From bbc3c0f3fd3f2b13b205adc9b93a9f839bf13df3 Mon Sep 17 00:00:00 2001 From: Wester Coenraads Date: Mon, 14 Apr 2025 14:04:23 +0000 Subject: [PATCH 01/75] Initial commit --- README.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae00656 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# s2-self-certification + + + +## Getting started + +To make it easy for you to get started with GitLab, here's a list of recommended next steps. + +Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! + +## Add your files + +- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files +- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: + +``` +cd existing_repo +git remote add origin https://ci.tno.nl/gitlab/s2/KIFLIN/s2-self-certification.git +git branch -M main +git push -uf origin main +``` + +## Integrate with your tools + +- [ ] [Set up project integrations](https://ci.tno.nl/gitlab/s2/KIFLIN/s2-self-certification/-/settings/integrations) + +## Collaborate with your team + +- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) +- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) +- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) +- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) +- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) + +## Test and Deploy + +Use the built-in continuous integration in GitLab. + +- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) +- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) +- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) +- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) + +*** + +# Editing this README + +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. + +## Suggestions for a good README + +Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. + +## Name +Choose a self-explaining name for your project. + +## Description +Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. + +## Badges +On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. + +## Visuals +Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. + +## Installation +Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. + +## Usage +Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. + +## Support +Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. + +## Roadmap +If you have ideas for releases in the future, it is a good idea to list them in the README. + +## Contributing +State if you are open to contributions and what your requirements are for accepting them. + +For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. + +You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. + +## Authors and acknowledgment +Show your appreciation to those who have contributed to the project. + +## License +For open source projects, say how it is licensed. + +## Project status +If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. From fe25ee068b334f5d2fcaa67a521e0099b15c83eb Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Wed, 23 Apr 2025 10:14:38 +0200 Subject: [PATCH 02/75] Shifted over current project code. Communication with RM working. WS connection and communication logic have been decoupled. Basic testing infrastructure is there but still needs work. --- .gitignore | 3 + .pylintrc | 7 + Logo-S2.svg | 1 + README.md | 93 +------ mypy.ini | 14 + requirements.txt | 4 + src/s2-self-certification/__init__.py | 0 src/s2-self-certification/config.py | 42 +++ src/s2-self-certification/config.yaml | 9 + src/s2-self-certification/connection.py | 217 +++++++++++++++ src/s2-self-certification/log.py | 25 ++ src/s2-self-certification/main.py | 59 ++++ src/s2-self-certification/message_handlers.py | 252 ++++++++++++++++++ src/s2-self-certification/orchestrator.py | 241 +++++++++++++++++ src/s2-self-certification/server.py | 62 +++++ src/s2-self-certification/test_suite.py | 164 ++++++++++++ src/s2-self-certification/util.py | 44 +++ uml_design.png | Bin 0 -> 112349 bytes 18 files changed, 1149 insertions(+), 88 deletions(-) create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 Logo-S2.svg create mode 100644 mypy.ini create mode 100644 requirements.txt create mode 100644 src/s2-self-certification/__init__.py create mode 100644 src/s2-self-certification/config.py create mode 100644 src/s2-self-certification/config.yaml create mode 100644 src/s2-self-certification/connection.py create mode 100644 src/s2-self-certification/log.py create mode 100644 src/s2-self-certification/main.py create mode 100644 src/s2-self-certification/message_handlers.py create mode 100644 src/s2-self-certification/orchestrator.py create mode 100644 src/s2-self-certification/server.py create mode 100644 src/s2-self-certification/test_suite.py create mode 100644 src/s2-self-certification/util.py create mode 100644 uml_design.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f10cfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +.vscode +__pycache__ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..84f8748 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,7 @@ +[Main] +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +disable=missing-class-docstring,missing-module-docstring,too-few-public-methods,missing-function-docstring,no-member,unsubscriptable-object,line-too-long diff --git a/Logo-S2.svg b/Logo-S2.svg new file mode 100644 index 0000000..be4d4bd --- /dev/null +++ b/Logo-S2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/README.md b/README.md index ae00656..c0be785 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,10 @@ # s2-self-certification - +
+ +
+
## Getting started -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://ci.tno.nl/gitlab/s2/KIFLIN/s2-self-certification.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](https://ci.tno.nl/gitlab/s2/KIFLIN/s2-self-certification/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +## Todo \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..1d4ec90 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,14 @@ +[mypy] +plugins = pydantic.mypy + +[mypy-s2python.*] +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[mypy-s2python.generated.*] +ignore_errors = true +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[mypy-unit.*] +check_untyped_defs = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bd901dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +websockets +s2-python +pydantic +PyYAML \ No newline at end of file diff --git a/src/s2-self-certification/__init__.py b/src/s2-self-certification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/s2-self-certification/config.py b/src/s2-self-certification/config.py new file mode 100644 index 0000000..ed99bd2 --- /dev/null +++ b/src/s2-self-certification/config.py @@ -0,0 +1,42 @@ +from typing import Optional + +import yaml +from pydantic import BaseModel + + +class NoSelectionConfig(BaseModel): + pass + + +class PEBCConfig(BaseModel): + status_update_frequency: int + status_update_frequency_buffer: int = 5 + + +class FRBCConfig(BaseModel): + pass + + +class ControlTypeDetails(BaseModel): + no_selection: Optional[NoSelectionConfig] + pebc: Optional[PEBCConfig] + frbc: Optional[FRBCConfig] + + +class DeviceDetails(BaseModel): + name: str + manufacturer: str + + +class Config(BaseModel): + device_details: DeviceDetails + control_types: ControlTypeDetails + + +def load_config(config_path) -> Config: + with open(config_path) as stream: + try: + config = yaml.safe_load(stream) + except yaml.YAMLError as exc: + print(exc) + return Config.model_validate(config) diff --git a/src/s2-self-certification/config.yaml b/src/s2-self-certification/config.yaml new file mode 100644 index 0000000..bb5b963 --- /dev/null +++ b/src/s2-self-certification/config.yaml @@ -0,0 +1,9 @@ + +device_details: + name: Some Device + manufacturer: ACME +control_types: + no_selection: null + pebc: + status_update_frequency: 10 + frbc: null \ No newline at end of file diff --git a/src/s2-self-certification/connection.py b/src/s2-self-certification/connection.py new file mode 100644 index 0000000..68f068e --- /dev/null +++ b/src/s2-self-certification/connection.py @@ -0,0 +1,217 @@ +import asyncio +import json +import logging +import threading +from typing import Type +import uuid + +import websockets +from websockets.asyncio.connection import Connection as WSConnection + +from s2python.common import ( + ReceptionStatusValues, + ReceptionStatus, +) +from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from s2python.message import S2Message + +logger = logging.getLogger(__name__) + + +class SendOkay: + """Mostly copied over from S2-Python library""" + + status_is_send: threading.Event + connection: "Connection" + subject_message_id: uuid.UUID + + def __init__(self, connection: "Connection", subject_message_id: uuid.UUID): + self.status_is_send = threading.Event() + self.connection = connection + self.subject_message_id = subject_message_id + + async def run_async(self) -> None: + self.status_is_send.set() + + await self.connection.respond_with_reception_status( + subject_message_id=self.subject_message_id, + status=ReceptionStatusValues.OK, + diagnostic_label="Processed okay.", + ) + + async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: + if not self.status_is_send.is_set(): + logger.warning( + "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " + "Sending it now.", + type_msg, + self.subject_message_id, + ) + await self.run_async() + + +class Connection: # pylint: disable=too-many-instance-attributes + """ + Manged the websocket connection to the RM. + Puts all received messages onto the message queue so they can be retrieved by other tasks. + Based on the S2Connection class is S2-Python library. + """ + + ws: WSConnection + s2_parser: S2Parser + + reception_status_awaiter: ReceptionStatusAwaiter + + message_queue: asyncio.Queue + + _stop_event: asyncio.Event + + def __init__(self, ws) -> None: # pylint: disable=too-many-arguments + self.ws = ws + self.reception_status_awaiter = ReceptionStatusAwaiter() + self.s2_parser = S2Parser() + + self._stop_event = asyncio.Event() + + self.message_queue = asyncio.Queue() + + async def _send_and_forget(self, s2_msg: S2Message) -> None: + if self.ws is None: + raise RuntimeError( + "Cannot send messages if websocket connection is not yet established." + ) + + json_msg = s2_msg.to_json() + logger.debug("Sending message %s", json_msg) + try: + await self.ws.send(json_msg) + except websockets.ConnectionClosedError as e: + logger.error("Unable to send message %s due to %s", s2_msg, str(e)) + + async def respond_with_reception_status( + self, + subject_message_id: uuid.UUID, + status: ReceptionStatusValues, + diagnostic_label: str, + ) -> None: + logger.debug( + "Responding to message %s with status %s", subject_message_id, status + ) + await self._send_and_forget( + ReceptionStatus( + subject_message_id=subject_message_id, + status=status, + diagnostic_label=diagnostic_label, + ) + ) + + async def send_msg_and_await_reception_status( + self, + s2_msg: S2Message, + timeout_reception_status: float = 5.0, + raise_on_error: bool = True, + ) -> ReceptionStatus: + await self._send_and_forget(s2_msg) + logger.debug( + "Waiting for ReceptionStatus for %s %s seconds", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + timeout_reception_status, + ) + try: + reception_status = await self.reception_status_awaiter.wait_for_reception_status( + s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] + ) + except TimeoutError: + logger.error( + "Did not receive a reception status on time for %s", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + ) + self._stop_event.set() + raise + + if reception_status.status != ReceptionStatusValues.OK and raise_on_error: + raise RuntimeError( + f"ReceptionStatus was not OK but rather {reception_status.status}" + ) + + return reception_status + + async def parse_received_message(self, message: str): + try: + s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) + except json.JSONDecodeError: + await self._send_and_forget( + ReceptionStatus( + subject_message_id=uuid.UUID( + "00000000-0000-0000-0000-000000000000" + ), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Not valid json.", + ) + ) + except S2ValidationError as e: + logger.exception("Problem whilst validating S2 Message.") + json_msg = json.loads(message) + message_id = json_msg.get("message_id") + if message_id: + await self.respond_with_reception_status( + subject_message_id=message_id, + status=ReceptionStatusValues.INVALID_MESSAGE, + diagnostic_label=str(e), + ) + else: + await self.respond_with_reception_status( + subject_message_id=uuid.UUID( + "00000000-0000-0000-0000-000000000000" + ), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Message appears valid json but could not find a message_id field.", + ) + except websockets.ConnectionClosedOK: + logger.info("Connection closed by remote. ") + await self.stop() + else: + logger.debug("Processing message: %s", s2_msg.to_json()) + + if isinstance(s2_msg, ReceptionStatus): + logger.debug( + "Message is a reception status for %s so registering in cache.", + s2_msg.subject_message_id, + ) + await self.reception_status_awaiter.receive_reception_status(s2_msg) + else: + await self.message_queue.put(s2_msg) + + async def receive_messages(self): + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + logger.debug("Connection has started to receive messages.") + + try: + while not self._stop_event.is_set(): + # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. + try: + message = await asyncio.wait_for(self.ws.recv(), timeout=1) + logger.debug("Received Message: %s", message) + except asyncio.TimeoutError: + continue + + await self.parse_received_message(str(message)) + except websockets.ConnectionClosedOK: + logger.info("Connection closed normally by remote.") + except websockets.ConnectionClosedError as e: + logger.error("Connection closed with error: %s", str(e)) + finally: + self._stop_event.set() + + async def get_next_message(self): + return await self.message_queue.get() + + async def stop(self): + self._stop_event.set() + + await self.ws.close() diff --git a/src/s2-self-certification/log.py b/src/s2-self-certification/log.py new file mode 100644 index 0000000..b96afcc --- /dev/null +++ b/src/s2-self-certification/log.py @@ -0,0 +1,25 @@ +import logging +import logging.config +from typing import Dict + +LOGGING_CONFIG: Dict = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "logging.Formatter", + "fmt": "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, + "s2_connection": {"handlers": ["console"], "level": "INFO", "propogate": False}, + }, +} diff --git a/src/s2-self-certification/main.py b/src/s2-self-certification/main.py new file mode 100644 index 0000000..b23d885 --- /dev/null +++ b/src/s2-self-certification/main.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python + +"""Echo server using the asyncio API.""" + +import argparse +import asyncio +import logging +import logging.config +import sys +from typing import Dict +from orchestrator import IntegrationTestOrchestrator +from server import S2Server + +from message_handlers import ( + Controller, + PEBCController, +) +from s2python.common import ControlType as ProtocolControlType +from test_suite import build_test_suite +from log import LOGGING_CONFIG +from config import Config, load_config + +logging.config.dictConfig(LOGGING_CONFIG) + +logging.getLogger("websockets").setLevel(logging.ERROR) + +logger = logging.getLogger(__name__) + +parser = argparse.ArgumentParser(prog="S2 Self Cert") +parser.add_argument("config") + + +async def main(): + logger.info("-" * 40) + logger.info("Starting...") + + args = parser.parse_args() + + config: Config = load_config(args.config) + + control_types: Dict[ProtocolControlType, Controller] = { + ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: PEBCController( + config.control_types.pebc + ), + # ProtocolControlType.FILL_RATE_BASED_CONTROL: create_frbc_handler_manager(), + } + + test_suite = build_test_suite() + + controller = IntegrationTestOrchestrator( + available_control_types=control_types, test_suite=test_suite + ) + + s2_server = S2Server("0.0.0.0", 8000, controller) + await s2_server.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/s2-self-certification/message_handlers.py b/src/s2-self-certification/message_handlers.py new file mode 100644 index 0000000..339046c --- /dev/null +++ b/src/s2-self-certification/message_handlers.py @@ -0,0 +1,252 @@ +import asyncio +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + List, + Optional, + Tuple, + Type, +) + +from config import PEBCConfig +from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole +from s2python.common import ( + ResourceManagerDetails, +) +from s2python.message import S2Message +from s2python.s2_connection import AssetDetails, SendOkay + +# from s2python.s2_control_type import PEBCControlType + +if TYPE_CHECKING: + from connection import Connection + +import logging + +logger = logging.getLogger(__name__) + +from s2python.version import S2_VERSION + + +@dataclass +class CEMAssetDetails(AssetDetails): # pylint: disable=too-many-instance-attributes + available_control_types: Optional[List["ProtocolControlType"]] = None + + @classmethod + def from_resource_manager_details(self, msg: ResourceManagerDetails): + return CEMAssetDetails( + currency=msg.currency, + firmware_version=msg.firmware_version, + instruction_processing_delay=msg.instruction_processing_delay, + manufacturer=msg.manufacturer, + model=msg.model, + name=msg.name, + provides_forecast=msg.provides_forecast, + provides_power_measurements=msg.provides_power_measurement_types, + resource_id=msg.resource_id, + roles=msg.roles, + serial_number=msg.serial_number, + available_control_types=msg.available_control_types, + ) + + +class MessageHandlerNotFoundError(Exception): + pass + + +class S2MessageAwaiter: + """ + Utility class which waits allows async functions on different threads + to wait for a message of a particular type to be received. + """ + + awaiting: Dict[Type[S2Message], Tuple[asyncio.Event, Optional[S2Message]]] + + def __init__(self): + self.awaiting = {} + + async def wait_for_message(self, message_type: Type[S2Message], timeout: float): + # Incase we have multiple tasks waiting for the same message. + # Not sure if this going to be a possible scenario, but worth covering anyways. + if message_type not in self.awaiting or self.awaiting[message_type][0].is_set(): + event = asyncio.Event() + self.awaiting[message_type] = (event, None) + else: + event = self.awaiting[message_type][0] + + await asyncio.wait_for(event.wait(), timeout) + + message = self.awaiting[message_type][1] + if message is None: + raise ValueError("Message not set.") + + return message + + def receive_message(self, message: S2Message): + if message.message_type in self.awaiting: + logger.debug("Received message that is being waited for. Setting event.") + awaiting = self.awaiting[message.message_type] + if self.awaiting: + # Set the message first before triggering the event to make sure that the + # waiting method gets the message. + awaiting[1] = message + awaiting[0].set() + else: + logger.debug("Received message but nothing waiting for it.") + + +class MessageHandler: + handlers: Dict[Type[S2Message], Callable] + + message_awaiter = S2MessageAwaiter() + + # When set to true messages without a handler won't thrown an error + # and the okay response will be sent. + _accept_unhandled_messages: bool = True + + def __init__(self): + self.handlers = {} + + self.message_awaiter = S2MessageAwaiter() + + def is_correct_message_type( + self, message: S2Message, message_type: Type[S2Message] + ): + if not isinstance(message, message_type): + logger.error( + "Handler for Handshake received a message of the wrong type: %s", + type(message), + ) + return False + return True + + def add_handler(self, msg_type: Type[S2Message], handler: Callable): + self.handlers[msg_type] = handler + + async def handle_message( + self, message: S2Message, connection: "Connection", *args, **kwargs + ): + try: + handler = self.handlers[type(message)] + + send_okay = SendOkay(connection, message.message_id) # type: ignore[attr-defined, union-attr] + + self.message_awaiter.receive_message(message) + result = await handler( + message, connection, send_okay.run_async(), *args, **kwargs + ) + + await send_okay.ensure_send_async(type(message)) + + return result + + except KeyError: + if self._accept_unhandled_messages: + send_okay = SendOkay(connection, message.message_id) # type: ignore[attr-defined, union-attr] + + await send_okay.run_async() + else: + raise MessageHandlerNotFoundError( + f"Command does not exist for message type '{ message.message_type}'" + ) + + +class Controller(MessageHandler): + control_type: ProtocolControlType + + def __init__(self): + super().__init__() + + +class ControlTypeBuilder: + def __init__(self): + self.control_type = Controller() + + def with_handler(self, msg_type: Type[S2Message], handler: Callable): + self.control_type.add_handler(msg_type, handler) + return self + + def build(self): + return self.control_type + + +from s2python.pebc import ( + PEBCEnergyConstraint, + PEBCPowerConstraints, + PEBCInstruction, + PEBCPowerEnvelope, + PEBCPowerEnvelopeConsequenceType, + PEBCPowerEnvelopeLimitType, + PEBCPowerEnvelopeElement, + PEBCAllowedLimitRange, +) +from s2python.frbc import ( + FRBCSystemDescription, + FRBCFillLevelTargetProfile, + FRBCStorageStatus, + FRBCActuatorStatus, +) + +ROLE = EnergyManagementRole.CEM + + +class PEBCController(Controller): + control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL + power_constraints: Optional[PEBCPowerConstraints] + config: Optional[PEBCConfig] + + _power_constraints_received = asyncio.Event() + + def __init__(self, config: Optional[PEBCConfig]): + super().__init__() + + self.config = config + + self.power_constraints = None + self._power_constraints_received = asyncio.Event() + + self.add_handler(PEBCPowerConstraints, self.handle_power_constraints_message) + # self.add_handler(PEBCEnergyConstraint, self.handle_energy_constraints_message) + # self.add_handler(PowerMeasurement, self.handle_power_measurement_message) + # self.add_handler(PowerForecast, self.handle_power_forecast_message) + # self.add_handler(InstructionStatusUpdate, self.handle_instruction_status_update) + + async def handle_power_constraints_message( + self, message: PEBCPowerConstraints, connection: "Connection", send_okay + ): + if not self.is_correct_message_type(message, PEBCPowerConstraints): + raise ValueError("Invalid Message Type.") + + logger.info("Received power constraints.") + self.power_constraints = message + self._power_constraints_received.set() + + await send_okay + + # async def handle_energy_constraints_message( + # self, message: S2Message, connection, send_okay + # ): + # if not self.is_correct_message_type(message, PEBCEnergyConstraint): + # logger.error( + # "Invalid Message Type. Expected %s but received %s", + # PEBCEnergyConstraint.message_type, + # message.message_type, + # ) + # raise ValueError("Invalid Message Type.") + + # await send_okay + + # async def handle_power_measurement_message(self, message, connection, send_okay): + # self.is_correct_message_type(message, PowerMeasurement) + # await send_okay + + # async def handle_power_forecast_message(self, message, connection, send_okay): + # self.is_correct_message_type(message, PowerForecast) + # await send_okay + + # async def handle_instruction_status_update( + # self, message: InstructionStatusUpdate, connection, send_okay + # ): + # await send_okay diff --git a/src/s2-self-certification/orchestrator.py b/src/s2-self-certification/orchestrator.py new file mode 100644 index 0000000..0085fb5 --- /dev/null +++ b/src/s2-self-certification/orchestrator.py @@ -0,0 +1,241 @@ +import asyncio +import logging +from types import CoroutineType +from typing import Any, Optional, List, Type, Dict, Callable, Awaitable, Union +import uuid + +from s2python.common import ( + ReceptionStatusValues, + ReceptionStatus, + Handshake, + EnergyManagementRole, + Role, + HandshakeResponse, + ResourceManagerDetails, + Duration, + Currency, + SelectControlType, +) +from s2python.common import ControlType as ProtocolControlType +from s2python.generated.gen_s2 import CommodityQuantity +from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_control_type import S2ControlType +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from s2python.message import S2Message +from s2python.s2_connection import AssetDetails, MessageHandlers +from s2python.s2_control_type import PEBCControlType +from s2python.common import ControlType as ProtocolControlType + +# Import just to set log settings +from message_handlers import ( + Controller, + CEMAssetDetails, +) +from s2python.version import S2_VERSION +from test_suite import TestSuite +from connection import Connection, SendOkay +from util import wait_for_event_or_stop + + +logger = logging.getLogger(__name__) + + +class IntegrationTestOrchestrator: + role: EnergyManagementRole = EnergyManagementRole.CEM + + connection: "Connection" + + asset_details: CEMAssetDetails + + controller: Optional[Controller] = None + controllers: Dict[ProtocolControlType, Controller] + + message_queue: asyncio.Queue + _tasks = set() + + # When set the main loop of Connection will trigger the stopping of all `_tasks` + _stop_event: asyncio.Event + + # The functions which handle the Handshake messages. All other messages should be handled by the controllers. + handshake_message_handlers: Dict[ + Type[S2Message], Callable[[S2Message, Awaitable[None]], CoroutineType] + ] + + running = False + + def __init__( + self, + available_control_types: Dict[ProtocolControlType, Controller], + test_suite: TestSuite, + ) -> None: # pylint: disable=too-many-arguments + + self.controllers = available_control_types + + self.test_suite = test_suite + + self.handshake_message_handlers = { # type: ignore + Handshake: self.handle_handshake, + ResourceManagerDetails: self.handle_rm_details, + } + + self._stop_event = asyncio.Event() + + def set_control_type(self, control_type: ProtocolControlType): + self.controller = self.controllers[control_type] + + async def process_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + msg: S2Message = await asyncio.wait_for( + self.connection.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + # If Handshake message then use the handshake message handlers + if type(msg) in self.handshake_message_handlers: + send_okay = SendOkay(self.connection, msg.message_id) # type: ignore + await self.handshake_message_handlers[type(msg)]( + msg, send_okay.run_async() + ) + await send_okay.ensure_send_async(type(msg)) + elif self.controller is not None: + await self.controller.handle_message(msg, self.connection) # type: ignore + else: + logger.warning("No handler available for %s", msg.message_type) + + except asyncio.CancelledError: + logger.info("Message receiver cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + self._stop_event.set() + + async def execute_test_suite(self): + # Wait until the handshake is complete before starting the testing. + # TODO: Figure out how to include the handshake process in the testing. + if self.controller: + await self.test_suite.execute(self.connection, self.controller) + + async def main_loop(self): + await self.initiate_handshake() + + if not await wait_for_event_or_stop(self._handshake_complete, self._stop_event): + return + + logger.info("Handshake Complete") + + await self.send_select_control_type() + + logger.info("-" * 40) + logger.info("Starting tests!") + + await self.execute_test_suite() + + async def run(self, connection: Connection): + self.running = True + self.connection = connection + + self._handshake_complete = asyncio.Event() + + self._stop_event = asyncio.Event() + + # Receives messages and puts them onto the queue. + self._tasks.add(asyncio.create_task(self.connection.receive_messages())) + self._tasks.add(asyncio.create_task(self.process_received_messages())) + + self._tasks.add(asyncio.create_task(self.main_loop())) + # self._tasks.add(asyncio.create_task(self.execute_test_suites())) + + logger.info("Started tasks") + + await self._stop_event.wait() + + for task in self._tasks: + task.cancel() + + await asyncio.gather(*self._tasks) + + await self.connection.stop() + + self._tasks.clear() + + self.running = False + + async def stop(self): + logger.info("Stopping.") + self._stop_event.set() + + def is_running(self): + return self.running + + async def initiate_handshake(self): + await self.connection.send_msg_and_await_reception_status( + Handshake( + message_id=uuid.uuid4(), # type: ignore + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + + async def handle_handshake( + self, + message: Handshake, + send_okay: Awaitable[None], + ) -> None: + logger.debug( + "%s supports S2 protocol versions: %s", + message.role, + message.supported_protocol_versions, + ) + if message.supported_protocol_versions is None: + raise ValueError( + "Missing supported protocol versions in handshake message." + ) + + await send_okay + + await self.connection.send_msg_and_await_reception_status( + HandshakeResponse( + message_id=uuid.uuid4(), + selected_protocol_version=message.supported_protocol_versions[0], + ) + ) + + async def send_select_control_type(self): + # TODO: Select the control type in a better way. + logger.info("Selecting Control Type.") + if ( + self.asset_details is None + or self.asset_details.available_control_types is None + ): + raise Exception("Missing Asset Details.") + + selected = self.asset_details.available_control_types[0] + + if selected is None: + logger.error("No suitable control type found.") + + logger.info("Selecting control type %s", selected) + + self.set_control_type(selected) + + await self.connection.send_msg_and_await_reception_status( + SelectControlType(message_id=uuid.uuid4(), control_type=selected) + ) + + async def handle_rm_details( + self, + message: ResourceManagerDetails, + send_okay: Awaitable[None], + ): + + self.asset_details = CEMAssetDetails.from_resource_manager_details(message) + + await send_okay + + self._handshake_complete.set() diff --git a/src/s2-self-certification/server.py b/src/s2-self-certification/server.py new file mode 100644 index 0000000..b500a52 --- /dev/null +++ b/src/s2-self-certification/server.py @@ -0,0 +1,62 @@ +import asyncio +import logging +import signal +from websockets.asyncio.connection import Connection as WSConnection +from websockets.asyncio.server import serve as ws_serve + +from connection import Connection +from orchestrator import IntegrationTestOrchestrator + +logger = logging.getLogger(__name__) + + +class S2Server: + # Receives incoming S2 Resource Manager WebSocket Connections + orchestrator: IntegrationTestOrchestrator + + _exit_event: asyncio.Event + + def __init__(self, host, port, orchestrator: IntegrationTestOrchestrator): + self._host = host + self._port = port + self._exit_event = asyncio.Event() + self._connection_tasks = set() + + self.orchestrator = orchestrator + + async def handle_incoming_connection(self, websocket: WSConnection): + """ + On WS connect it creates the connection instance and adds it to the Orchestrator. + If the orchestrator already has a connection then it discards the new connection. + This system is only meant to handle one connected device. + """ + if not self.orchestrator.is_running(): + logger.info("Connection to RM opened.") + connection = Connection(websocket) + await self.orchestrator.run(connection) + + logger.info("Connection closed.") + else: + logger.warning("This application only accepts one connection.") + await websocket.close() + + async def stop(self): + logger.info("Stopping server...") + for task in self._connection_tasks: + task.cancel() + await asyncio.gather(*self._connection_tasks, return_exceptions=True) + self._exit_event.set() + await self.orchestrator.stop() + + async def start(self): + loop = asyncio.get_event_loop() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) + + async with ws_serve( + self.handle_incoming_connection, self._host, self._port + ) as ws_server: + logger.info(f"Websocket server started at ws://{self._host}:{self._port}") + await self._exit_event.wait() + logger.info(f"Server stopped.") diff --git a/src/s2-self-certification/test_suite.py b/src/s2-self-certification/test_suite.py new file mode 100644 index 0000000..3aa3682 --- /dev/null +++ b/src/s2-self-certification/test_suite.py @@ -0,0 +1,164 @@ +import abc +import datetime +from typing import TYPE_CHECKING, Dict, List, Type +import uuid +from message_handlers import Controller, PEBCController +from s2python.common import PowerMeasurement +from s2python.common import ControlType as ProtocolControlType + +from s2python.pebc import ( + PEBCAllowedLimitRange, + PEBCInstruction, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, +) +from connection import Connection + + +import logging + +logger = logging.getLogger(__name__) + + +class S2TestCase(abc.ABC): + control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION + + def __init__(self, connection: Connection, controller: Controller): + self.connection = connection + self.controller = controller + + @abc.abstractmethod + def execute(self): + pass + + +class TestSuite: + test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]] + + def __init__(self): + self.test_cases = {} + + def add_test_case(self, test_case: Type[S2TestCase]): + if self.test_cases.get(test_case.control_type, None) is None: + self.test_cases[test_case.control_type] = [test_case] + else: + self.test_cases[test_case.control_type].append(test_case) + + async def execute(self, connection: Connection, controller: Controller): + self.controller = controller + self.connection = connection + + test_cases = self.test_cases[controller.control_type] + + for TestCase in test_cases: + test_case = TestCase(connection, controller) + test_case.execute() + + +class TestSuiteBuilder: + def __init__(self): + self.test_suite = TestSuite() + + def with_test_case(self, test_case): + self.test_suite.add_test_case(test_case) + return self + + def build(self): + return self.test_suite + + +class PEBCTestCase(S2TestCase): + controller: PEBCController + + async def wait_until_control_type_attrs_set(self): + power_constraints = self.controller.power_constraints + if ( + power_constraints is None + and not self.controller._power_constraints_received.is_set() + ): + logger.info( + "Waiting. %s, %s", + power_constraints, + self.controller._power_constraints_received, + ) + await self.controller._power_constraints_received.wait() + logger.info("Power Constraints is set.") + + async def test_set_limit_ranges_instruction(self): + # for limit_ranges in self.power_constraints.allowed_limit_ranges: + logger.info("Testing set limit range") + await self.wait_until_control_type_attrs_set() + + power_constraints = self.controller.power_constraints + + if power_constraints is None: + raise ValueError("Power Constraints not set.") + + limit_range: PEBCAllowedLimitRange = power_constraints.allowed_limit_ranges[1] + logger.info(power_constraints) + logger.info("Sending Instruction.") + + # exec_time = datetime.datetime.now() + exec_time = datetime.datetime.fromisoformat("2025-04-22T09:30:00+00:00") + # logger.info(exec_time.replace(tzinfo=datetime.timezone.utc)) + logger.info(exec_time) + + instruction = PEBCInstruction( + message_id=uuid.uuid4(), + id=power_constraints.id, + power_constraints_id=power_constraints.id, + power_envelopes=[ + PEBCPowerEnvelope( + id="pe_test", + commodity_quantity=limit_range.commodity_quantity, + power_envelope_elements=[ + PEBCPowerEnvelopeElement( + lower_limit=-2000.00, + upper_limit=0, + duration=3600000, + ) + ], + ) + ], + # Make it timezone-aware (UTC) + execution_time=exec_time.replace(tzinfo=datetime.timezone.utc), + abnormal_condition=False, + ) + logger.info(instruction) + await self.connection.send_msg_and_await_reception_status( + instruction, raise_on_error=True + ) + logger.info("Instruction sent") + + async def test_receives_interval_power_readings(self): + + if ( + self.controller.config is None + or self.controller.config.status_update_frequency is None + ): + raise ValueError("Status Update Frequency required to test status updates.") + + logger.info("Waiting for power reading.") + try: + await self.controller.message_awaiter.wait_for_message( + PowerMeasurement, + timeout=float(self.controller.config.status_update_frequency), + ) + logger.info("Power reading received.") + await self.controller.message_awaiter.wait_for_message( + PowerMeasurement, + timeout=float( + self.controller.config.status_update_frequency + + self.controller.config.status_update_frequency_buffer + ), + ) + logger.info("Power readings test passed.") + except Exception: + logger.exception("Did not receive power reading within allowed window.") + + async def execute(self): + await self.test_receives_interval_power_readings() + + +def build_test_suite(): + return TestSuiteBuilder().with_test_case(PEBCTestCase).build() diff --git a/src/s2-self-certification/util.py b/src/s2-self-certification/util.py new file mode 100644 index 0000000..b8cb2d7 --- /dev/null +++ b/src/s2-self-certification/util.py @@ -0,0 +1,44 @@ +import asyncio +import logging + +logger = logging.getLogger(__name__) + +TIMEOUT = 1 + + +async def wait_for_event_or_stop( + event: asyncio.Event, + stop_event: asyncio.Event, + timeout: float = TIMEOUT, + description: str = "Event", +): + """Waits for either the event or the stop event to be set. + + + Args: + event (asyncio.Event): Event to wait for. + stop_event (asyncio.Event): Stop event to wait for. + timeout (float): Timeout between stop checks + description (str, optional): Description for logging purposes. + + Returns: + bool: If event is set then returns True. If return due to exit event then False. + """ + while not (event.is_set() or stop_event.is_set()): + try: + await asyncio.wait_for(asyncio.shield(event.wait()), timeout=timeout) + + if stop_event.is_set(): + logger.debug(f"Stop event set while waiting for {description}.") + return False # Indicate stop event was triggered + logger.debug(f"{description} occurred.") + return True # Indicate event was triggered + + except asyncio.TimeoutError: + if stop_event.is_set(): + logger.info(f"Stop event set while waiting for {description}.") + return False # Indicate stop event was triggered + + except asyncio.CancelledError: + logger.info(f"Task cancelled while waiting for {description}.") + return False diff --git a/uml_design.png b/uml_design.png new file mode 100644 index 0000000000000000000000000000000000000000..7b4edd713127eb582a6aa289a53c5322d3947c46 GIT binary patch literal 112349 zcmeFZc{JPW_b;9vr`1+fhf}JGPAG~RQ}gLSYi^AtLaCWZYYrjpQB+m6W@4x+Y6yx* zC6ctpnyNKNNQ^NC5n_mN({s+}e!lnq^IPk#&-bpoe(PAv@P6m<4Ex#7-mks)>-~)gXT<|Y6@s3ZXJ9S!<~F`{>sCQQfxNst z8jbGh>9Mu7WwY69YimtSO}@Uq^YimbNlCLOPqrccXw=a991P32XEms$&EDBD)4aLM zX8-8z*SK!D_4Vu0@`|s2XhdY}imU6=y?dO6g^^LJu{mg7T6(*`pEfgFha;AjSG`J1 zXM}{TBay7q(w*_~=GJx=Yh!n9Z4-+{=H^Gn#512iU-9-{OHa1}yJ+7sSx{A-6%=GV zdSnH9SX@@QsINbJ>ePa=GV|FpWbS*qvGHva>m@t8jI6x53l{+U_Ay_)Sk%^L1OzNQ zIgR0Pmw9-myuDw)evQN7Mn*;+xcYcN`~zT!P{gyWx9^kFvNyK2?43NdZW_NYD#^|( z06+A?U@+a?-O9?!p`oEmOH0$!(<~M%E-o%JGgDt*-^Rwq)6>oJz^-M$5QzF+hX z_u|l_JGTA+z-i>q?|lY=k;&Yfya5I#dc1SLo;Z5-l;L5?E8NWl7~HyU88W=cIYeAE zji7F*2EDDM_#}->sNZ~?rk;Nsb;53H(K?R1&pXKZ45v09LdTwQT4Vk$v@s)9N#3`v5EL^}}H&p8#u4 z6>+Z)9)1-U5)qg~#~%U!wBog(EXuEAat1m6FT??WnCp7ae>YO!4>&hg`TX=PYTBDEbD zhWPC9_xOZ&U8`d$mkJTG>Q{>OH{O*jniYKvRmEo;agFbqDO1ZMMNJriw|`H^_QH7A&U?RL=+Jvh8T7^RWOL5_qa2qA+u}KudG{8F zj5p9I#H^#m_Nz}H#TFy)W!&`;5_Ux$NeaMuakrTV)bA6_9gIOaDwz9-;^a~y635|AXu3pD9r_GgFp>kG8S1Rmcp)DYWT)qC> zDdXMibU;3`+q_vIi_$jntdg3!JQWBY!G$5@` zqY2X@mz#qZN&T0wf+%e3cJzUEd;ix49!7L#MKea_nU-wERN{W-FG5s7@97(TLihes z0Ky`C5t-@!v?HxIs$P&fu;9^f%g-M!V(r}=_^-Kx9AS3tQM?6A%BEUbDAibid^bS| z4`tdt7X6p_5+7m8Zpf>B7#+fxt`4y$$XYDOtm}&XOyk=GC?51~65DI@uPt|m4ermz zbXvVH(M+**DCpR9E59V__#EmxPqizzPM;#8GAGGX0~$H=SGuF|x+~0ge{Xa1L{5>C z6w;WSTBrH;8$%9kvR3;F%1UENvJ(4N8xq1rM)8&%mDanzDLBsn{!~|Ye6o`kO6I&b3WYd3PpW-<#!nH`)8-)ihnEPts{SL!*QO! zlti!T$$zfNYo2@O2>SqIi{W<4d-x~V=+8X~E=s&1I>c{p^hyVcJD7>y? z;m@G}z_qi2|1FyN&#wIkckMsEN4MS<{AEOM{hFx--Jl!*0CMzF2g9JhtfZ)eId<}8pZ`1fE5QK14(7?LOFOm=BLqLW|E0aBz0>E%2`^b<3$00 zvz^O(`&RgOSHg(Q^<~6N!1fYiWjdI+cU$*&pNLPvy*z+Ted-ovu!6XyJtndwHhw3x$nv8@EIV2IY_A9&<6~C2}W=*5pg>Q0LQo4M5YMWyFJO? z!BaT`KONlX7|!ehbeS6(2QkAOcgHE*6vXBS#ENDZbl($E&XV{tem~$z!geHgg8KnM zYaHF-Fg^f4To2B3l%7B!ZU_PZIxjX4oE;YUFBfOHPHW;Ow@Ew~j5E#?_QmMKd7f+@ zqdvKLk+AP~_;cvzyyH~BIh`ip*mtf3kBLu+Jo4Z=zvluB{%a~+Y}Uku@n`?(<{#Po zqc{H?hJOsjKSuN)&+vcbHScZ3>we#DM7Zp-xt^czNep{k++#0hFMqeYVi_+DcoMw* zV{q*DUmxWwQ1(2RP4)rm2N6@fWJJwIDj=kuC^SLldVg-)jqOH+PcK<_w-Jx|_4Co) zZXE(0f1cY1o1e;V`3@X=zB`*cw8k>qpd15yW^zK^W3A;b zJ^+?v5~@6fq`>a%IYsD0S>=ud35a(-`OVG!|=3&Msv zf&)X@X<^V`0b>t$`v)ubyp|z=N+e=?I(O#*_fG)46Y9?$rOMHL_!!`MH}U5*9#A=} zxjzdATDp%U#6p z&*fX+sf^g1q;md<*+Pl$tcW3+{gwrP?Uo+Jg~y5q@|h)sM%>XI((FFlodDglF=p16 zoP1DhP^zV{;p^mld$hqnKS#Vjfe7Df6W=?Quo|Yj+m@yP%w=3^^Y32*C$BuS%<*?{ zJvlF!Bfh@#JYsXYW1}wk%fs-!jxLm_E}*5kK55-R5t~fm|QG z@#}{f-nHott`Zz-Wi~1}`obGBp9+LrHzEy1b7BtO?G~72HCC<^E>qwY7J-Zt?Ja7H zy4(bBs8$n3`xtfQYH~be2*E9;tc5&>hMgmfJ6#qbSMD9-X`f>pqq2U7H#Xi?t99Bt ze;~Pc@;rh5bTc|8&&Q{6_a--O+uG7U;S-{(%pGDx>CReGd;fPRIIjE))jF?e+PiS0 z{vFcN`ong6KuloH=ttr61jrkgI#oJe;@&k&(kC7k_0+@h`7va?zj#qncy%CJP5v;IV+gG|-dl;2kaZf>^h^li4>gtl#TOxGgr~@>V zAj*KmZ7p{Stq1?zaegPj03@A|#v?+=Nm4;f1${ zM2roXQKPxC2lAar%zmMPDlg1S;eL8IuUEiw_Qw^_EC#qoIB`B4ZWP#aCCM!W=KmaO zb|ZoH%gYjIMO_xriHy?KP*11WYr$D#R_#|K3Yy-T)izLvhjHr z_n?IG!0tYie~2QD2c}e}im#i6lLeGsx>?emJZK@#z8Fd<&?@%+U`Vf$f&XCL-fs~z z?Q_T7;k-hxEMMf}u;1NASt;nNIPP3Ahffp&@vkQ=wvy~(EzM&c3cWBUC9>}#-gZ{T z-Wcp`6T2hD8SzM!b0w)ONyxirg!*(lwWq6Ls=nkhR3bgB<|cQ<_;TX+12wc+K?Vje@M={jqg?r62Q3vq$b+ zW0n=aU%ggZz?4mM?W~Tpr1~iypeqp!yvJ?8?6f(nj(wqJJKIeoV#ytOA-?ZXz5#{^ zzd#kDO8ng-|Ia%&a57uV@TJ;rhclNj3l0LWECam{Ip+lj-p`U3;N?5n)s@k&|BeDr z)q3N69?^-{ZD~CF*BTm{U2i`lpodZW^)K_IzlSl&+pC`=;Nr4x~Eb+6G~jZU2t z7aJWM|C;lmz=-yM!{~C5^{kgDb|ll{uw$OCrkfvykJq1^@RxobdITv^re z6+^g@4~Wth9LTOBR5VOjA-qa}yt8MIam|bGsY|QX=gacx7w)lOt_u&Gcrq(K&`4KI zLjC(A9n`vRnq`4+KZl|gj6O#8PrDZMAzU9iQ!{~8qgw>+tGuw38hp5?D|TNO`eR`* zwW2mSdvjp~^Bn2~l9`+LF0rY2KIBxP5VZHS#5WClCA)TYBTX0Xnz_*wzK21OThdMs z+I~0Mk*7P2Ups20w)BJ(EdSWCh-aEGG%RUL8nGsyu6+r0%V-PCwp!i4UoIw&Y}VVyX75+^OZBB$QQ;DVcwZi9kOU6{f|N(u3zP>e zX2N!rba|d~dNbE_+Qh%H+D^F%OP?sv1p1%ml@$TD?rH}AU|ZKgP1-T6LJ9hS<5T}Z zf(-D4Bv?Mot_8?`(GYZSsyyH1!1v8e`>CCK@|8xfW*qR)NXDQ ze(UI64Q^F7LIttj#-HFHcc}>oKf+#a*lzEgnreN=zx*89LBX)R;L2vEc#zKzc4~!} zl7CEAUY1yQc)1q+<|WziWwI;-5?Z~{b>*EtW~gMN3vOUB{vB=?gY&!Bo0PZ8@$p56 z+eX*LR1NTcV{z5vx)av5^g5g!6uYP6JBcKkqL@Uc=8e6&a7;NODoay}Fn)6gA>={< zT2y2EJ%G&pxDZ)F0rXY4vMTPheK!6y+s(Qsaa8`EX~GPRNkChDB5*N>CjK}z^&u6z zz%bDVeOh)}_y{FXci|Ku@a|IIFf`VnH)r_5hLCbbziHgKpY4L#2FqpwH1VyIQr3Tt z0NF01wco2-xH0`M(7mv@*cuP@Gih`lG4?l54j23X(13wA|f0!=ki$y(?dBfM= z&fK)dDVPxyf&Bg{0q!$e){HJNNC%rTYt^BLDkesE_PdmEUHc3z82r^ousSU!*mE-| zNRZmoyW=C(QCmQlBA%g+)I;!yq7w1Zl%irSh!_be6_et&YMeby8p}K)KBpAss8BbI zH=dgEoXdQxFCQ$x9R%a$;N;*iEo_ss2%o&B3V6Xk;wKI4ywiS~iv_pnQn{ubT@e*-!?sfK;C`SB8L!cFoL#@r+G1)&q=nHCy0V1!7DkG5v35&;ccTE6S+Ac@kGI91O$`3q(o!;;v&tPm*y z|D+-@5+UBV7hNhi4fg4H;Ja~R0?9dZ{~M}B1mhmD;}HDHDiCLBm2qY#-hVsqgLy`T z?GkK0%_|wlp9yPzU*((_-p3~Ai8X(0Z=rwOD_6wGboTugWTI`Ek_mm|v5UrtYoSf- zKD6j61w?gO*4eSjmaNB-X`Toi_U>-HJ~)X!D)3SNr_($TOX(|<9*JKvlvwArw8-nw z>sK|KP1^>&z%?#>`oM5%Lh^f{eC2j3hI`ItiU89FoeN@pA4CtU9fBy&1>d1RIn;YL z9po}^Vre^_s~uQiC{j1>=MlOVZRJO_8ugmOc&k2#hMfM1SBWtqGtl-dsc?m#h*9g# zh{1w;wEbwZj&&1I>xk<^5f(MlBoe2ca2+02!Svf6C1N%N$0-xfp?Kqt1uXeFH0@f` zn8cq*S3oVnUHv$f0<|(FvKRI?6!x|#pl4xm7YV029=kVTjLtf*{0WkYt%;t$NbWt~ zWI6b&tCCE9#wo{_0tVthGM0| z9bQu%Zd4PxI2+UteO&_uO_sqJJEsas_D!3kR(bO#ry%Bd>a9{DiC0%^e(Pu8cPto= z^vOrksNY$pbrw_?i;<_$&gW2tceYfT8e2iyv7AfOs}KvG#uS}#Bvz#nK@#G2;TdEf zl8bM17f9i<d!w-BK_i*fID**qs{_t z*Q~ZME<~f<&%rUwkGTiBw$(5sgwB4jzZf-fha5J!CX|0^E~VUEA^OAW&C+0_w9t4f zTOkc1{@$`9*|8_!l73DREf*Ph;*Zb>E$FYYtML`Lf6IWba;4N7uTgIhIqC)riZfBy zT^T9#zrg||x?M6nywhmu~RvZDjIE7nZN=Cj#KlKG zrKriR8msO`#kb3{MO?)RM0I2vDuESC^^j*OOI`& zR$5wm-XlGkxY@~DY1Xzfxwpi>4A->tZm{T=KDoYbuf@E0IITLuyrf1T`3%n7;LusC0@>IzU2`ve6P0%%*(G_&RoGY1e3`|n z_I{bIFGsF>_vG2u&pkr%kf9JcD-)uH?pNW&79X3ck>7;73VW`;xDGX&SJ-E_)6#-b z=^m*F;D~n%{vZzwL?(LaX2^I%cc7@DRU#3`siCSvt??vjw}gHfx*$^J-cxEUSEdyQ zVjTw8+F!|g^}JijwDOB=#=vWOighQ{OiME1a*!S`-`%6* z;|xgU^`fh~2`S-MhlC1QZ?+uUw36ec6exj$}z2C`c*Klo!a7Kaz(1gYINjXi=z z#@_0wpYk60=@fRxU3eMugJIGZX=!bKh0+8?z>ZU+n+4OhTri6(k@=nj7?}57O5A+k z(YQC++qs?n(M>wp>CUzW^v4CClolLXF_mRi3tH=iH|BqnNO@Z|O&X}~9C%em92vf4 zo?5O{RiefpC|O2HaFcys8(jXv0OGjI6_B1g=^T$&DDJsm-1~_8StzujgA>)wu5UDM zQw>}3xTv8ofEqDW@dyRBVGKObJjgvT^=B?MycxFn^5twS2=sUz^YN~gg5$;3#R1hr zoCzV8=3v$M2SIwvbuA4-N+D@^%^}yc=W`+0k@WE8R=yfIqm6oJPahFA(0Z1Myd{>L z;-bSjS4u8?&0Ef2yJr~zDF%N;H6tQ63B9d8hRxS&%3DV9ciZj7HsC43;UFOp$f!#Z zc~x~ayMuVm$I#uYw79P4iIAjJ|MmhlqGLtG;-my2aE8wO1pf&CppO9ekkuvK)<#37 zQC)=&Ia+dyLN%uIwP#It928H12Qn2a*R?;)v@*S3&jy&7CEd#W@D3PbW-)CD?!Ik- zrj=+UHTu3g`Mb;5;mfldjmjcpFN)yDhy zN$~EsRSTqtxr(PJ=Pxpx{mz?YuHPE&Z=f+0Wm^K_rC^+|Ssa!);Wg5w63lP^(z0|h zEWpoL2J#Iftnkqll@e?=BH1CiMHjDHy+Wun%lI1VQ?I;_F&nSTwM)=NV!Ne`1tnnR zaX~E;eVq_?#Qe&PEMOw^>~7oaY<+5Rl3`=OOgT8W}4QlE7Q(L zrj!W$@*J9jv4Q|?Km@YCW`ZZNc#DE1}v{Mn=9AN+^? zG8WyhbG_?AV5wu!GFHrc&~x#RY`OgN1ZhmqFmY#k(`_LJ<*XxYIO5s4b!T$5!yx@0 zq$*)VAGd6=gpAjaBlH?fmkNr~Cg*`RVbq=DU))Lqxhs~l#Rs~w*X7H#QeGi|WepIt zNnEIVySs56b_dKoH`PZ^%s`Ujpz*rNM)?o*(~d65d}dxq%8K_jG!%a1ZJpS5{~*)F zT|VUMdr-Y1^uCzq6uB{s+CH5z_x%sSS`a;IGquygyQF#`OqklRgIkV;(rq6f>ZtQY zr%fO;F%q^jA6?;mxmA}4azDzT)f5CChkA;r&y1Q(!3G|uE*fgu3|cRrmOQE``onj~ zHbk5$Hr>IbU~Buq7irdIJ6pHWFQ6>-IhfW1)gvm91DN_Iv74l}8*{K8{U5@>jzUm?9wqdMT*SU^YBS!8kle z;@eh6-1-1kXp8x}mhVav<_D+Bk9ZKe9C1lNRsvp1A7+v=(5M^-IhkKoKn ze>-LEIPnh?tuytu5r2lAT1GI%SD_)@S&1LC(p{XyzO*}lfsrun%k~$Z$WqPuuQjk@I7Rr)mN{D z>F4vZ43z)=7J;NUrL$9_V3%qmp;c*+CiD=aOVhFo8@U1Q2P5Yv&a?+TvI(r10J&CF z)-?N7p-Yn#}f4^#ZUQLhWm9^PH=*qMc z=9!7;l_T(GQv<(%p~z05q8!?$wSrm`5gi`s&FJd$-LU#$qE2daU(YX)3PiFmB7*vu zlQ3n1fnLyHaRTWr^23cA860b?Y{x#-V$?xD{F?<}ML}U9ORQa~tNQ`shntL|qBB8j z{j!)SwR3L1CWu+cEik0Pd`q~eD}w^{=&XmLYAAIj7MhQzH=GK>+tI}OZEaqZ2)&3L? zkd@o1b}codg1_AuSyTUOub`>&m<=u5|E7?!qDjw4cVe}jt9P2Vf$W0a{kj7hY{E+{9)5WzCB_y(H zRLsPjJXW|s3(#3cmOn{4I|E|oXRx|Vj#*2&1$CHaR{0oiH<;|VT%*lg1dHyj;N(BS z5~Y~c$fw%gf(rE~s5_{mKHGjtO=N$Olq?>M5e`PHo^pmPWe;LLv667I!Gb!-qR0pd z!j@}|Vf-zKBJ zP(OWnNyQWyaI_7S!gA#hxD!JD@- z+HPLJ4v>QypwEfNn~#Mqj;6|)wOQ`Ac|OkHPgKre(SI6hruFL&e!u`~?NXw!ZH1%W z1f|KxPn<$HE2+yWDlO8vg3_HH8^GI<^F&L%Mkz>kg>Z??h{z{v+vz1^FX)xfGjs?q zneuXLxly%r^u3=e^sGnfBb#^T^I1Mj5}j!JpHsXI(M+F={sS4Aj> zt-gifMhd@j_}3*jLFY5+j177F9Z8?Ze}HU?N_}S2E8FbaLgaexdfudkfFPDf^GGEOIUVld% zULAA&yBUY&SVD7R=c*FoOEQ-Y%63Y-Y}JLl2m%dP+)CGV9nP@PPfmsxoq zd_RBF{4W^~Hl27W<2F6GZKl3rY~Ei^_o~iw?g9C4D#QL~G9V!hn=Jt|xu>|!^50cs zEl2pm;lK?5PzH+K06FPb6HK0QGXgrHF{pCWjtGW_pX_XV)e$qy_Vr*kN=|5bCo=%c z2}4{so@(K93r=oiHe)2a10u8QtIvmbbu8TRTjm5)myCwr$9oK5Vju0fZav!mDte23 zqr}x!*CW3(@|~J4QbT0vGIeHSNXR6|eVO##jdnB51iDlA=0Hm01LC5L#LR8|f-_8U&Ynzn zvWBMmkD~UYA&POa*VjweXQ?m2#Xa||gCL>$aKi+ zh2anV6@FAL4ii+Y@lyjd(?O+&9)*UEsWv!iQVXu!?fIuSQh z)1yaTTB^l^oraA>CXC5LlAaD(@xcQ|N8@#TEm7+C@8EK<>8U9Z+S~4UbJqjvxu|^R zq~}Ojw`?jpt5Y#AmB-=-@+NZh1m|tpYUfJ}`BjxXtKnxqgZN2MYJo=Sk-DA92@8jU z6n-;Es*vk>D#Vy1X55rvYEH9KA9~{oaU~pbg-KStX1L8Y#~Om~#d%hqDoG3llF(|B z%js)s=^GiQYJ?~XrpbeFpti>`19F%4j5qBGNo(5S=h_uaThlE-j zR`&UJWdytx;w!wtFy^_MIdn=VXRW?}HO~j@J=%OC$~N8<+VA2(BGE3c5$5nEHt+T< z$n-&W7~9I`F_$m%GZD;g2D*}a&EKW_-U%B$ntAbQytNRIQ7BuLnB-=jnCf|rU72G* zkj9NGMxMnDEL>X%_fL}#kP<%-#!LX?J*RoYo@*M&QnF67gLr8m!S4JPDDRqxX{}7r$`bECrWiC&dxz{*-ddV4FS08#f zSehn3#jLX;KRqWaKOMD1ZR)ao(0GmkQT}-xUP6;Nt-o1vMkNnXApys8X}+|TXYRK@ z8ayhtS52Y-spNhfWoezybd~N|lTvBe#RI%%5;6MenMy0rv2Lo*<%CXW$>Z6cE#vq7 z)tX!}5LW9C3)`t=mN^Tf50v z73-}W)UeBY^`v;bIyvLnRp*$r`7vi=1bBBG+me-_qT2B6la85*l%0?>jQ;R*ja>5~ z8J$VSo2qod-(aY5rjo=DOswick5|M%R*^B2(EGFq!*OhT9rc2nUTCg5ELE{)5!kq6 zz)RFK7b)QR@oT{)M@0PyqTX2kp@&d*NIrFVs&9L}y|{0^)o9&_E`(`$6{(#2bxqou zrSiq{nO^q<*;(Sni3n~6{y6s(;^M>ksCG$B@Hy3+h0GN>w&7a)h!Fi&x%h13mm94F z7l&?OGjMd)lob#=7qH@;zd@({+!p54T*_uD*4IsQLl5PLIAkUTYl6Ef*(fIm&aB4M zbuxY69Xq8qP#^hone9IYp6ge1AAy&y%BN_!w5$pgeDhDkL%k^JgA>R(KZ|kHNNitI zG@(ApS4o^2-I3r9&3d3qIS2LDq0Bmb$0ej0-6l^=x}J~buQJ~f>;pk$-``#o%dASMsuTMwmHLr>0ugtaRNcufW?| zO7kW(b8zX<;Dul>>|!_w_3-f}jW{SR`({vb|Aw2`ICGP=dx-S-Cl(V=fJU-4oC`~| z*Znqv!9ztBAmp4NL_D?Q_X6DMw|>DNs`A@>DpGTw4Q4L9*a`Y-vWzZoXeVTdbY_-M zAY-o)S|q18&Ed@4M+egiVwOW8=1_0RyS0#BCUzfh^MQA;PoyH#&C6lyXhjUoj(=1*xH+0>&?;QCnQy7 zN}WmlBHF`FJrcJ`(_feuC?Q^}Ee=>c{y)`n5-cPM?7LNdzH70Izx3)vSvzyx%06Et zONkU?wduSxaBG_kO&MH$io(sqMhEK!a|yt@mN&ypA~=OceFgEASL zlB5#9$vWd8X(bVj&Xk*kP?yz*ZjOU@YTLZR+AAN$p@^9Dx+m3I{-ZwGc=-l zlqzf;V0Pq8x25VtSJr`p=LuFF7U|=ENDBQBwAue@IDJ^~bC-}yYK0Ogp3|zVi`<$_ zW{0GV+Ohv=`qR7^_RKi@{>Faj#T=Q9IXvIG%`+1hd()-H-dQ2IWF+i=E_G#SbAWx1 zpwuEE{K7t?S1cPB>p8Ow^NJ{so7g)gn08$)X*WW9>yAw~@EjysMtm`O-;!97uPDml z_NJF^Ngc0~CB-H8wB=@|V`-V;bWp6XToFZH)imlI8^XsmorLp*>=OnLlC)br^HOFN<_z@1ioJfYf4ud50;UsxQKdN? zyoq}brCXA80{!<&3{CwUmcx~k77gva=~8tsp}wCpb33NmTNfHyMMhGJ77~E`;WNaH zs02)UW!KS(M@e5Yj0zX4X%{Uy6FvL4vS__2i3tnMrn=YuvOwJR7k&%HQz}1QMs$Zi z6UpP2M@2TMe#aUJdTD#T zANV~H(sUs^*jswg=x-R=O~C*IODVvnS6tY~yGZnXol~RM(d{)GKyeZ+U=<|9h;R-1 zPgte-D!lOrm!S*$yt~k)eLJc6K6QGn_v)ZgiouqxE+vUuG5B{G9(j9FH<s8lSBFh18DIoOtUc68^eh72j5klxT2}gD)8VO?u!~j|7$+ zZ0q6YGwWxj-`#;kTGL2;*YCCh3qnQA>ILfKV5os2(u6fL7G}A&I$*f>TdMdf2smtL zWwUV)S}WDv-odih$-#!avD<|u#)ZE{`cH^B2QF`>CifgMhB3UJcqvh_0Wiu4T($&* z>?nsj#%)9BpED{tGLECOf#lACSH`QE=!_RLz{|)!5)yNL3Rxvt)GsW5S2Bd z*v4_G-H^1c84&pacet3Uk6lU4K&Ef@+_TVe$RNWcS={7u#FmViroUU0TCS%~z==6* zQam&XR3Bjpmo;ex1)nK$9i;jpW1-g)Z#QFX=Im6rGOecO$O@Yu6uYTskPONuO{c$B z#_TUvI?N0XI$F8f_}Pwm69Y%ta*42ZdpCqrrdvGG6)y{!BE5nqifLc$A5QE_uMXEU z(3^tMr5Wd)vu#VjYH^d-h;ci;xa(42H!M|S@VQ_WL#AT2Q_f*7x4+rYv~;v;RFQ>mkS{wD_?wwXb66gUo=!uA@;9Gq7e$d4#(CnnkISp_YDr*ivpYhH)+f9R9*$h?zcwv<%@XV)@J zYV-YCBi(k-LF95R)$pR7ZD!Lt`p}l|s`7ynG%d*RW<23J)bn)Xh}-HteTm$#BV-?& zDasA&_miMv8)T5LQKOq+1m*|;Ox@0+em3*qQJNf2Y1*nJU@N5-vm_vt*S#fw3rXumW zSV;=KZD-7mS?yDV6Lani#pOl4{MFV6PrXW=Pboi?wL|N`xt61t^zpInm6J@@?+TWr zK*%QRSibnWZrDJsCK{7 zKErjqXG4ibh7vbraSNkOGlzljuCq5XrptF1d>#_=NP-uRpyd|xJLTr**S`acQ3;YK zO~T!KK*Q+@P?qu2h_}?Lc5~)8wH{a3cO!3)3_h~z`8v!mX9LQeHTL)>m(_}?S6X&; zP)ska9FhKoDyHVUSq?XmW5Pdn<@>lP3LJwytbpF^Z#LpSdQvY;_2Yz_-2P~%5!G3C z8xKtmP*T#?M(q^Nn}IVGCb(6!u3}Wde&d}%9aq0-2eQ`vji^oQZ{a>kYw~kYQu7?Q z$e6vLkhD*x=QIZ5DT1~urx^!Oe9SsNLSa90!NX@-2r1?3z2c}1dRQUR7Z{{zzHwa% z_pIJYM%yEHs@A}ILhZ|<0KV^a7PbeB3z8sY?@1(cp9I&j3b$*)n5Ql*UOg+J9ZviG z`)2kDHH$j*61c(lC%Wg-jL{3@iRBx&upLuvm+UdV#DbLFUka{gBF*j24bmm|(#4>? zi6oszuCjY7!4EPJ2guEC@?n^`%qXw0aI5oHI>B;e7aT`9_~`kRz_!Aoa$#koL9cll znoR-X@=8&T8@o|0_v&u<+)(K`k;|>!`N#&ekIaatikp$}uN~{E_Kq_6L%5kjLFfh{ z1L2e3JDju8u4?GdXN4o=IK6_(^OEFuzW;D_?31Ui&bL{Fsi&&gpfqz8ORB;-JyF-U z-!3+D3c{=|mUjBwOdVQhPfPgHykL^SojWN-1rcvJe%WGcrxg(<>_^iY2WBr_KJ zyx;cqRXK}Pn^IQW`c+ap%|E5fT%#i;=mI|)`!%4X*@HXx_EVU5qY)ysdS~aOoiIrs7;!q)5$U_?Yr2p!ZVf;L>=Rx z6m2>HcF6X7l~nX6sdj-gBWZ3iLkxoGDYreL=;ga0PwZKJL{1_znd{epsHer95SX>?r$rjm=#28T7u?9$!@`q!T{ZuwBzy%7FC9RV;YQ&PWDTZFr=!Pj#O#sq^r!{y{9M z$u-m`ZdS`iNN{$pU#h7ytvAIkuNoIjdo>muub^?9sB35=p(c-k;=;8ngbc;5UAZ-7 zC!fHX-X$+(?j{fXxyf&eN?FoK+0WN<&5R+-pp0j17XC&qX@SH6zqgaxe?`VQB zTfd+C_Pclp1qi7SwN9B%5*b7YQVTnhihBL#Nme|r(OFg%1hN-5!d8`B?B6WpF$1wB z)$L)nIc6{CXoCka{_BqQF|R3$w^gJb%7)oCtWM4K3pmR&&896n-kWvsW2DUbLtDDJ z&q!1XS!Z3!EAY!`oef@@?}?I8muvan$^N`t-v86flm^F#gKPl0EIDoD!$J46K!e+U z$En+}4>$MB;j)c!>`IS?UtGA^t!%ap{sh%WD7$!^v%2#K^?UySJ5FRo&3gGIG?d)Z zmjh;{8MlStmwcX6kYV8>CGrk8NJBIpM%UiwDu8X9}r>j*c>_mYU`1pN59z>sL*o2Rl_o(Wjxp()fP4af5>>oW)Fkz;V zjOIZR$1?0c^^YsO+%M)bA&D6eDto)PP2TF&v?-#abxqvzZ6NCx2zAHXxvyI^`7tMd zEF(BA*D`%G`ab?5p-vHuA_f&z`I}?Mi-Q7|zIO_@#-~)T7uLpgENcut7&^rzRhZm) zaPxkjRd0|gtLs&RDiJ3}#n#L&E+_TAb6*Ltqjkb5p?fCZUyX_~gIj?3MCp!EFS?pp zRh_TcsNwBT zaMA-QDmvSu-2t?6176B(2v3ru$96T*KDXtR(`{^WSZdyZK}$(iq5dX$8G_2|xj#B{ z`?5yLer=nGG-h2rKk6Mc26pD<5*Dlz6ww!rjjt`de+YrC1_ZL4m6Z7~MIV!sWP)@~ zs0)X3A3h?-1Gl+!7X{3;bL!+5*0&UU*o&`R(PaEMJm(=f^H{meb$V8#xywi2HEpwW ze3YLr!03B>GFd82Qni!q=4+Lfe%`Zpu3%HmQL6-bNg0%ojk_JM^mt=+%Ci7iqTDF! zFdN>Ba`yAG9~^&haXP&V(nT6rSoX2)HLy_5D4wKBF7Xy zx6}rZGp0oVGF(Anow`<-^r);BoBvQHC9#p|4gWM00q+;h1? zsWgFJ!SNz?OVUq*M5N&b2@$b6r^fL6t^w>F=zWFiQ}p_}g2SX{Yt4@1xFR!X4KgZu zQ{u|omG^-$Lal8=pWgbTx30Q^^y{9l#Dn%<+O~I=eWgnB$9#E-qzs66k>V2ir^9Tby z(p-m1$BBq^Kk z&a*4V$ZpwRvBHL!1{U)kFWY+9IS4Onl8NUE)G`*OmK(=Cv5AQdO0SZQC*G=VYLVc{ z_V*uHok&W1Ut=DDSm;yBYGOz1sK7m+Lk;U0uO$gIcW3#=S}l}S#n^<)+OJK8m2F+s4`eqbWPHn9h_ska;;j8=)G~OinGMm21pHlFQ2v;1f&t3{AZv%Y&)}XiHZHPsBpF>l1q1i-hv83=l&omzrKG4n7jgBOm zcyQC~Iyne|UPV=-x>tZrsQ~B6#u^R;n&EXmr=OH(?m0sX1f7&j5T)0W|K7vgSV_}4 z;N`y@TYGeRd318!4Tk50N2}E;+lZ(sS*M4*THq`Ss9g2anyDc|cVVBm{n&kT zR2>`BfW5ovH(bQEk zaB??~b&*sPm?IZjt!RV%O-rBi4K;x-j<2%5BuRwFa4xfYcm*a${f3BUDhq!y)U?L7lev8^Q(-&!CLXXb46IIM+| zI^KndxOHXE-ENo8L$XMI#g^4WDl4C%?K(Xl7eDC`>KasKtZ%ERr6TGH-vvZ5J@IY_ zJC2;14L95PVmhr-DJ<6itAXH*pvMky!-BIu7><9lz&DSLr{cijDRrPg%VtCGf`FQp z>AKZXl2hp#=hWvI)Yf=)(N4hfdYV=)+%Kx^UUocxmr|_@D=}QZj_;m-fWyAIbM&to zxz?8cNz29Iw3VoZb3ltwWl6-v&e~tU(wP>Lp~@5iBQ!J;qTO~=*N-O<86Fofmg>A% zEnh_TtK=kxdt^j<(#(;~(|Tg`aTSCZ$;xNZV;3F_*~d-G5uT%OVO`i_;J!|Muv2+1 zh^?-bh)iZ z93nF<=pW-V7CL&bkRq0qTLp6wXcWaBAn&Q9{g`gK_QeG07PIK}OiN67O=o4&tZh!2 z6>eaoITQR?&rG2>y4*A5cvU`LZ8>7CtInmXkm%sSO4i1`xvF25YEoJ@?JBl zXB4|twkL%qSu1Ssu^rf6eS=(6ZjPJ2-5y9tSPA3*bbqIq7QCEe8OgMBi%K;HcL7l9sO^GowJfO&B`LBP(+a~My9lW#Fz?S_S|CS`@LLCP3{fO@ zA8_VkRA-h_cjA}nnZ=x(NZr}n=9Bc^5VN8C(3N$Hsq1U!jRpGSxnmBF)$kk9Q~eU> z`EZzw60Cupbzf)rO5W63T}q&o_mgRAOpU-TP`CHFv(5Fm!EC3D3q?I@O{0;^u2pwT-Sj0Dyj(FK)DpeI%Ob=d z(ekPn*URPbiIQBQVOFStJBWTq=NZgovGn>_e|aX^>svr@(t}r9^5lJ8OQ9D^*7!*q zGPaS*8P?P|y{mMIB>%L8K!;Y&tc-z5Up#ui0{+P~u4IZ(g_rR1t}H+m?NgT$lRYVd z2f2msl3ILsP|U;MEp`-*b|M@^efDVbT!q?pYMc6L5}Px3cI1hjMm`;0o-u&(#=o{p=&Fg~L*gg|xR7OxZ;{ny(tlCPt3e zzHeE;cENa(4#~D+rN2=(H4a|^kMOmxU&&wA=oU!NNt;0U>HNVho?#?hxpGR!L7_oO zZNCXuzPa=3Y0rlH`@D^BDjAngtUpvd2KQdT6=9!mXb^uwf@ zt|t%O-o(+H%#*O_#AQ^h=-Bu5R0(UcJb|4Y@GT&aEe3zePYeZtTuOU@fAlj8V=?45l3SapkgIk01k$zl)`ykA+Di%d~xy(h@AW__lCAH`6uX@=+XO0N5@ zi19uBrZh}>6r4wWYU6^h1kwaYc^ch00804Oz|*2SLaWchE%Mkkcl~mKBjEn7zV33f zI>J||=A;62`<$=ErtxZhhw)3pw|LlHik*C~B#=jr@QC3}nkhFCb=NOj-;`<~d=Gqy zMr}UcWi{~K{wiRUrG@fma5drRm%yScq4F`u?(=ajr5l1Q(^n+;MN52qbBxzqW6^x6 zqe)TirF)9k>!Gi0uz{X~G&hUyAG8DvOKuD79I4U`_+q=5@gr@jzXQNYPP9kq{4{Al z6S-(?xAFSiTFm6|+cg$VY6!jNO4s~$6Kgs?jPCoD5^*k9wH1w*u6o zcyEpH5lE$FU({pcVJ?|0l>=Ax@hnZdT4TW`M>!*Yqn=?zc^L|+8hFSUx*~$6`DntX z323N+PAG-*54jgZKy>VcnE#$-nvrgq<)Qs z&zN7|C!JGHmo#{_w#qEPHwf>Mla-AMowFmkxL0|E(}k0|>G*!2>yF7r-Ek|n>7V#2 zk)N>GQqUDKmpqJKYyFk<9?@Y8_V4p!9SC}U)5hC}mbsk@)f zXopVDCb&XQbX@TDdJ57tzI1G9p?$v$WpI=oS5_`G7OtKJJ>BHzpX1(E|2z``^RH_yQG~T zJn@c=U8)%iZHT++f3%YQ@Td^dY%gd+N|7N(Kd$A9P+Hp z?7&8|$R2AbUnQ=(e`8cTHUG&>vBJBKWm$>Q}pYzqcecb}-&ASbn zrM#3VHH;ou5rib`d!N^&mOqg9nsKMY!?gr6E^jX5rRqUyix;aJevj?jO&Lzzw)2ti z?pdZr%>QbTvT;H5ELND-r%oB;;P(DG##|~7*2;7gC@cxXD8V`6~v(4FULQt;NqtCPya`$ImFtz!>5F8wlu~2IO zZ6WPCovNhX1#;x;#+g_9xD_Yf?W_B0n`!wjunHMi4n(kNo@7#Y`E-QRgUWX~t*W2G zw2J#i9}xQsO^{5hw`B;jo!rBUgJ0aSHOgO7pCz@j!t@;m?Oqv4IsHm9dV}?IYi3;N ztZKu6N(PH7?PyZMLlkcFxmxQm5oLpQCWS03W9jzw{>SZ@knSH`r;Y&u=xkM)!;LF0sXgEnZ=a zDiE~CY>R+4wIO0KHw{i61Hzhuknd}rwF~>~8NWv&Goz`K)zKMx-0Q;03fHfUv<{=& z;M)m3{RHNmef^$3xR=v`CZgi>fa{b9+(v>@hrrtXm=vg4{oVFBF=LLKGb|8^gvcd& zn74e>y~ABFy6}94pUz`+2iyPJ-rxFxsi94{IqTZU<&Lt|a^%)!X^DdDpNC$3LFMw6 zXQdG;En&T)q_sA|n$P(v={Cm6U%weDT#B*1+1RIgIO}=}98R%cwB6+Q1b7}HhC}Rc zUN@hn(!*C-NLmhrdFI!{5&P=wsvCn|E0o(Gwie^#7d))3Jbg1-g&UUA#fjq0%>ke> z9dsq`JIMUAd{q`=rJJS3&LrTB&Md`K202CzY`LkEV&1aE+;r=AFcULfzpo7d5qNBQ zb+p#3FGPPLGBX=0CF)&!?Pj=h;XByC(8InId8yZnfhR~-N9NpXUw4bT_fcQTS$+S| zrIu1%AILeI>Uy_Mk4fs=vdfM;-*n$93>HEMdn5B>OZo%BS2{MQJ`nTZ4Fu`3=047h zc8B-o)B}TMtKA8C+Z=L<>+|7|JwGnZa=8bbW$9v8r&H+4h zo(5alfQu#SpUIJ;OXDxkT_1oBfdJG*Hvq@3<4<)5IwaJKGu#K1n=h;%XwzwjY;;?g zSvL=1Hd4QKrOYb5qLk(@kV$t$r^~|BTSR*5O_SzF%)@IfI{LuXH0z}S>*R$qGv>c| zhba(&vv>f?2@J%`$waMhKhlg=B~4MSpRQym>WR#v+bE7jJp@k#NJ5ae{E*;7G0$P3sCt0*-=U{F@sZr`ReGp zd=-$n-F^{b>pSs6S-yJ=#CIc7XZy|zoR$*2@hgz8RvMy+a0IVdO8e7&g1UXZ0nD_!AL7I4hYsL#Orf0vnsoR!Nv)8PsH+a{^ zy{6t=_l{rgrB~%Uc_77*5CC6^^hJKUEorbD<4oQDYNHRTY#!Qt)7v6|Z+VbcciMRN zsKuCms^l?Lxqp<)t5z;lNAbQ-M~yx-PP&2OzID3Dp0BHW(>I$HgRxR0ruA_1tAtn~ z=p$cpB!?kl-jE-;DbjP0tHT9V)8bp!VLDklF@v}zY>V7MM_ty*-@vy}Z#_b+tkjg$ zpq9`%{;NB1N9Z2!OQmSB5r#)&3zf`TWFy$Va_@cA)*fnZbkZE+Wgry3Ha=eRG4VL8 zR4qZNygxAdN9U1I$Q=iI;5mj6Y4nW)p?msOz?!0AZDUBS5asCUysYwf{%0Pwme%MG zkyqpTCKN|3pTZ==Vbexsu`ebI7@orqrp@69t-zMC)lsiUbB@iHTvwgryw9HZ zyR7mi$4*0Mvc}3<%-R|GtB$5Wdx!67OHm1b2bBEJl)MKCiZ>efm~u5`2TiCO4)Dny1D_v7m+g=-7d~_^r%>nZJGWNW8v^a8 z(X@zr4nl28kuWdN9@$rOmr043;>&8ap<}2Yf-wO+ev%4_&yAqEk0RXxaw(>rW@USG zLpu?1$v((#uwU=UB;tql01rb*1;vh{NXu?mGU8LGrABhRc<6>{;3{cf*U2#v%h{N_ zswL)CRh8Y)G?hnP(5<>j@6zTfNgrq-eDtQ2m{O$WPQ9)X%uHeaHNb#tg}lOQu06yk zycc}OnW(A{#hyhRoWSO=ufoh;Si|-eLMT*yt0Gi zLem8tld)>){U(32WzB1=3mw|D;QkZ8#bugA*<+u`!Iq`6Xp&z9o2@C1*GoNrY0 z)AI5YV-h=k^;(xb#RoAa5`6B5s;`HYmy$R`@_ZqmO7sKq75J8?j;=K$G z@HGbUXN>!~iTMNF1A@0_gT1#>F^N)(m6A=aRs)`JzbEPgqnAWyKfP|;w4{*ZR$NU5 z>Qr26vmJm$bp&V3%yG$A-~L*tu@`^!4}V0T%$FPXWCv@FD46w0r(HzViLD)B;MB~{ zi|XbZU`GYStoR{TKkYThlQx<#NQv2uG4Q1gb*6Z`!7?$a4@TPJP@hlp!3Xzh%!Los zZ03vUuN`hO4aT_DcgY9PyOR*pIe_!9Vx0znrh|oK9b(ZVc=v43OSJ!XFF#eIRp3B76 zl-Ub(lID%Qxm8G&3mgJ76hv(oLJrZYYC?ndA5U7wLW_1r8|7174$X3Gnkb~RU%0*- zvunKg9wN#dn(oI6g7~$3I1K6uN@6WxJ@$F2j5tM5HH-PJ7_zOXcDt{)OM*o|bAR7m zXn22o(FpvZRvD!15Jg^7F-PVFE$j#KZE;b?#%YjFxkEPO{%BA?@lt0cuEXTf>leMhBm^j!CLhZml`7siPxz zr!z$r(Lj&^OYIo0`Ess(+<}aBg{*Kv-#P~VlORXbgTSfnpAdnH+_bmKZ5}~|1BU3q>@u;80dRggkl)>G;BF_C>KZ)|E(^|e1GvePU{|FT8l6K*iCrZm+^gH{QX_Q z@mET>6Em&R zJL2!4*U(sZIAFm&x~no=XXci_cT+1)tqolRZyiv+Ov+_yb1eyEHZ7&;??mJ&8@b9^ zC9e}L1Jss9TMkcq8Jb(?xD@T(3hhG#1G1OP|0wPP=>u$|<$6L|Vzj}i)`gL#5o{~} z3K`$BPmwkC>i*W%Z{|g(2izqU=D&&|!ezp`>ShnqR^K+L3_Jx&7DTK=M7C{Tt&1WbNOqv)2 zY(g&&6p9y~d>rht-kL|X#C+(D9%+i%e0&n(r4<|ea2Zq1HKtLODd2^`-nn4{s!^y` z^+J3nG|u+c-<4BiVWXazHp9GvUrq4Sqj|yapU#g_gxEg;y2VL7A$P}Qx~>h#WlcpU z>aZsUYNaYC3bk6pf3i;gm@e>WUR9_tHccpv?`6yRmReP5jQ{R8FLA%CC++Evu9{)U zr_=sGm zZ!_~mqT-AA*=Mwb7wuz-%7sj=f`fSxx_&3@C!P!rC6!%OnqWS+)>n+1bp2C174I-p$2f=qGI znQQ=Rk~$~B-12&J;y>ee>f*1fmy;ihW5=VgWOWo0iZlYE1o z|9V*}wjhz5qq~Jhj`nTCiHpW=Xq`A8B~nX%_VVpDWzu!lOw8{#!apM&*sST0gp3I5 zq#Hz;bk=$Aue>Ru@dz7TZ~)ysAxGD^;vC)m7=6>SF^gkvC3RJZS@JG%BFKBA+c!U} zXBcu_pMNoI&W5%2UxPB>!+ei$Z)#Ed=@0Gqq!C&k@11QY2UuRyXfIox#O-PmB**a7 z^imDL7s%cn9@8i2q7&WjSBsZfkxu(8;0i1c=?+rzsM@WPBa?49|I-4n{fDn|&j@Kb z!o?gGBaNia-&pUJT9MIZfc?)%IS9Xrv3%Ief3a=i^TTpXc`UVXHqb~WGI<--auKvn zN*&^+FZp2n>BfnstXQ8oG_+l{?RD{uTAy^#isOyOToeD~pdskbEB}#78;iJjy~(Vc z%B3wvuC8io>2~u%pB*(#;5eLP;;%h?Wr&iHJmAw}?QZzvg|Ej6=ir&WV9D<@vfxaOM}PFQ z{ly-CKjtvqnfKZC;>#a9z|j%)na3{SazIaeG0 z2$WthGq(Rx5JS>1C34GZ0FG&0-t;L8qU}JZn`x3z<_qG_E}P;0*mQhONxZgk8nMj@ zv&y<1mfY&tP5AL&ogQ`3Ky~z%r2m)up>3k?SB1VE)4(Z3@c*at^v}=*iGib2dKqD` zY4xV~oj>diCN~uS z=p3mn@-@l|*!XBupT9k7-t7LRY{sKGy`8VqigIbJ%1ozuAfTv2KhS!>?_1jL4g;Wa z^eT~-=bVG4lx4M>f=}$y4@tf|xeQR6U>gW)!Xw25UpDuM)=ZQ_K4SRVoiqgZ@j4c} zc-RVbjy^Z{JY;Y$wN{Ofq#d2vW2#um*)%0aL6X8>S1#*1fCq!a7V*k0g;2SVk=|OA~M0F`Rz~?HeF37|6K^1_ z_oq-kzv=c@hB{L;gHpU|Ch*5R<>OhdwCd0MO}<2f!AqiT{B99pg* z1_7R9s*@EKsWn08%}|0*meK9hd%k?0XKZT^f1B& zAUDF&hania8KM6~K=VP+>$xTWwp2qPUkN;**YNv`)f2)tK~IL|t#d1R={iPA?=oY( zPZFy?!5>@c0KU3fjTy%0DjNwu!>>#IwFu4SLU5NgQ7+nNi?XKgq+tCf9FY04%y2DN z$|Dvk1f2T)nh4zaE~_Y*7OVEBM7_)O>T}_5Gq=*;nEn&3Ebq5%s}bd>9}nD-YjX zaR@#lVdhFu+&mu#OQ%9j8mq6kRypKCCQ990Dg|~salSczZmx!tojEW^-+s5O#C5$= z7pv6a8kvse*L73PW}S(CV3c&NNBMQU?5c#B~*snLGpjh0cqpl{+UpGO9| z0sdE?=XyBE_Wp<)?TbxdPst56$E{Zu*U+-PSjEN1=}Sh72Pv3TmE;H~Z&rMVm<)WGqo=r6dtp_KvrUW8ZoVooit zX_OQ-#%elGdJ_&8V@JK^geJ9kDX3LHnn5;vx-%gHs@J?4*P8;Pe?{&q+o)nHUA}HW z07Iu~h}T>7fb8Newbez2u-djt!0!tqRcf2VX~?z3VF;deJ~Tm-#7T%zd_2@I?ND3f zM}!?9$CS-LIdXNc`#zxVGIlivTo0}_UD-0aIrjh#m`pb+GU?YN>Il&N2v4hfKQ=+-Rp zKAYdqJa5CB6?oepz|T9L86`Zl4aNj%RDcrP*WFcbN=u*XWL{Zc;8pI$>Zgzhp%;BJ zh~@1mAB$$53~_O!%%~r76C~Z+rNXTwE0-MvN7}F5zw_2hSUR+MRp^(i;&pup(JZDH zoO!Z5vmrlq7&2k~3oS2XJB+I%SbqW!AwJo!nu(zqn4uq*rxw-cGt;Er1*>R|N11-B z_7$~Z)3r(UP_qoN-5B%X8?xzgZfSKe(X1FBN~w!QVgX`9n?538J} zEsQ9`&U9aY+xk%a)SH9Ln;(K!L+Jg0XGr7R_9|D;daB8Q|15^6+Zo{s*hN1V+SZaq z_}_7s$oGxe_Gb_0-w&*tP8)4HZ_tdcK99Zqj)o?rcVGqK(>PXV{DQ8X!Hr*im;kIK zsZrKYLl`dqy4S=T7F$y?TRk?nX%-W#%2K!V)dg!S7i<%bBANU*y40 zduyuS!W_paghRs&WCd!cms}c!T7BI%TBY_qggPd-3?TOT?phI}#4#P_l(f|#Hl;&J zG(y{p-9G7*K>X6T9Y!y%!EhnSLayI6%8}^@Vn-oEBud@-knG)3ha(eo#-J zAO`ELg~xA(-q?6~Z=5x$9vwr#EnP|U&+r*|*Np0IJ~@)q<{(V7$LmYaSp%Km&r+nx zv#=&mE2jmC6#C^-Q_Vw!@Wgk`+zXtaWBlodQ*nvY3ACRZj{2Cmd>WyJljv8%Wem<% z*0>7{1Vu`);~nZ{{pH{>ifrOpD1tj%wSA);pdMN4lF!^9YThfgOqy6l-I%%j&Qi%= zRPCzYd_N%no?P!Ubw-~ztq^PJ%r_Qv!{4n#JFVZlQh_8Nj!J-*H(5blD;tZcoJ78L z!KDxhi!)`L{66H+Ex;0$P4n(;V<}7jbCTI9I$#W~B$uYmFh@d2btoF4Ue~U`7t@C1 zJ3Fa(JPRJ#R^+5^eFi{dlOrCQTl#C|1ADe-lxi!21-x%;DZK~=kRpENRlb3N8%PpD zsVOlQp8ky70)ybk+5<5_c96j+KWdwLgYRp73wdRbP;aRhm54{G-cAWFl8rZ8eW=u= zTq~)k889=-lWn=t->8c6c^+BV_aD(s(@-mfZ*Fnu9q zDU-ScPpBNsqv6eXeWmUU+9B45x98$iLyo zXfg`kbIV4jIkWuCC!o#_siuPw0CnW?14WPPg3R(*fG-m4F(p&;N>TfWe|qhrv7t3& z0_CIbV6Bu?2GUB6KT#&>)hCO-XFV?1C9n60zh+ zFi!bAhhmWuvOm4L)BBvn2SqqF-FzFuni_^P>X7|}1iIC3%f)PP-S0WxR)2R5_p zSHTnq@1xMnd|7y4uJVl2qh>LO`a^NW+yFSFhgp=!cQ=_wK(MVn;K$bCJt!YWT^7e8 zk8g#~ZIDZ&D~&+Yi2zNiy?Oa-vV)6}mAcQM($IHfaasgqYSwCVlok424{cphCE4r7 zH(k1Cz-xQ&-CdnmcqjJP0M}>Q=`XZv>4j%urCqpSc~alta^I7)bPIUL!Lo{}7fCSJ z!gHhDWVP1>gYBc3Dz%W>+_zP>F9zPM9r3{^Vqb5ztxV-jSFHl_YxGE_M8*q`{-tU`V{oS>xUUm3uGqJTgHNMLH$k0#E3X$z2f z9>8^X!pa0BVlpW;CV=@O_ryanr;{+(MCF$*P0dYH@j)WUPwjS>YNs7vUxTd1@zIkm zQ?BC>oA}Dor05vK%IyP=+b43t8O1AViFtODBi0{h6OHD;rlv05Ni9R$xk1t(Dd<={ zpXj^05akKs!E%Fa&3x%B2q+hs)37W&ZHO{)ot)laXF~8h*JtjHI&qwxy2K=F8}gv4 z#&T)0>l1~V9`uCgXUo|OE-YdE_I0=;mGr@m*&i-bO-)#ac63WqXL!*xa)l>AZ6eFK zxc@}hw%M#-K?hRtyOTOFW_&{O(8-*tMc{%!S`;aC7a2ij(Lbq8aiRO#Rtjtm5$*(f ze0`^kj|pAU>;t(-!T8bybCm=jflsppemSNQWFEFXzcz4*wC4GRx*)e3xKQ19umN-^ zzVgsLug2{BT6_ax^U{^CTEP+H_wF-=4Vsl~%zn}pFU*GRHt>Z736TvWc-j@t3vYmz zz>H;sm4upP6+gkvm#yx~HDWemlw>ozPz5r`89+8@|`+zH|!5B=Pyesql;R9F#3PCeR~Z) zO%DCh=?5ICPQ5w#=ca$sfwy9FD*wps@4|Hte7gI;y5(rhY;E>tHDL!yDBe_FdA6F# zIJG+c=R!1mx;At3=Xv_fki%M357h$AfTGcIRxcBR#s^Z$*Ncj}AC)e9Zvp9pD*W!~t9aC6-NMeq_NfNz$CkNOQH!&O3EU_qP-6Iu{-!8LbT& zO^nT<6(A0vyAcHQw3<4%t$G2eMy7tp)_veb0jkUaxS131jL)i0$b5JHj`ZAX9ijv) z&n$nr3Tdas*fxd0ps`hjBx;UMKnpa6PNe|}R37Z&daA%w)Qy8|22HB^VlOFLOuISV zHx$e}*yGelmVl;n?o4>Tm+XaBU(H+6IWxYJfCru7#k)1pYS((>m+h{ht+Tx|ru*%;Bb4Atl_l9&!?20R%_9bv?%ijE zAdr(@TO|<960<`}`OSm~MX-HDO(s+5xT{Uxf?BkenxE4C`@r;{s5OUJaB z7Yj&kJP5E_AzozJwM9W(HJ~pN3SSAY?OOG3O+86}cJ1m~18HaYV#|li7R$Q0>07=$ zVZIl{$e*!y;$pd<4<5BQ60eU#dA$cFyPIpi1~%0$k9n~D{hK#qmdkV3h)IGlIn?Hz z%Z-9Cvj=aZx`=6%esSYvR?4(vE-mmy7g-7~{h z3lbgacb)cg-{}``Yd2KOacC3;3t1~z&6 zD3{7xCMu=Hyek&wI3Mg>!}{{h+Pit2cN`l9Kc_pbH4v9f^~Ho~KM+e%DaunD`E6+8 zun2aEZ!?Pda&jZLqaFJ&xK$HVQxF4cR|ET96%xs6LXLb7d{JDvXu?Dapa<*}tm1T* zR9l2(wc4P!B~ssNw}W48^MxZoK4VJ+Q^V040hkIu z$WnZA0HX&A03&0hA2yp@_7&Q9_F&kBCXGvItj>xth~W)z(_LeOTU!SiLN8aN9@P|p zik?eH1!1j&44krS^-XDdL|0vq(PNuO1TCq)r2SXX4}IDnJ6iYllZ#V)U2657%qHh< zf2bJ{mp!Am#+{eqGhrGu8KvitDtuF?+4t(G}Ejc{Br!In3hH3l+p$A%*t(R?rl@n_xU9kJqYkLW>+1G z;D%3mf)|AmurDP)43n0yG+=|>;$kHkCFsZ=EuthxQ+*M;Q;h%c zQLe&(<@L$aB6(Rn?tB3jlJfl~T1S+rmoeq%CiqRz);`!q^Sb#&Ii!JhR20%;Seq^0t(({UfZw~Q28z?(my0cijj^A#sAq*-$k=Z)f$`zhv%Fx<@ zb6I9-o2qt~X(~G4+O*?b$sw(gB}p5hI_@#G#-2Qf)L&|S4e)&wb&p}!*qUtVL+L^n zd3(=wnW336VdkOd4dRpFT8k}lVJ!vziSg@Sj-KU6D}rA#q8S^08j)r!`F^jf@#>H6SugXzOJcFHX1MwV z2>{cXQm(!<4P!|4yb)O7yfs2%+5~T{+XOq!VLv;0`k5tHDU2N*s(Rus=uACYszACr zYZCrJW>?qRjSp8dAw!b5X+nl1wV+UruV!Dc9abKGghv-HkR!%HQrE`7@w@|8Tbs7) z(q`?|xl4VTU!!!*@bwne)eaW$gq|1F3Aj=_=mtV-Im;l|j59!7jW zGggu$0saJ1lh-7(7M4RapJbdbjE@VOx=^BGhoyY%5;ikyNu{8}R5nv5Z6aYt)@!Hz zf?BM4pBF#R?-f4}b6SXq!LZFg>S^QbU%DUk4OvW6<&^v;?Ozx)0r-s;qr6LTO9wEDXC^~qYCWzZ97gl-)hbnir~F?RD2 zQ5k(x_B_tVj5yJ{bfm@OtSjRBtHdH)QIF8eV=nbw%i$X4eTjHIlPMvYiA-&5Y~m{o z;vG|5oGz8>?HE^OIGS3*H=_D{0s^5W-oD?JYroXNxGJ*&wb67MYF;4gfWUCfuWS{E zgY>cpEA^|lJ0-0)wxFu(=Ex1@^f4y#BwJcyQ}m*i_cA@mY8AFZdw@I$<_eFoNL#eL zw^vpTdUZz$pwy@-W>~c7IHopzWh-Watm;9D84BKysrJ^874OINhlX*1fOjnQ{ITMG zz2lY@@y+M9N}W&w>o9BO0!fKNSe}}$0_=`i7`i}@*5iXd?Wiyyt(`J(u&=eDOS%=jmQx96O$&OEzIi5r+nYQD zgp~S{{7E0kE@WZRrYE|$)M7JsE_xN^ZS5Z$9=}gqvYp?k1l=}htORsoFbd?a3dt7U z*EI|%e6J@s^S-uwtrXf;Z$h%t9LM>9Sxj7Vn6k3HaFet(3>ddYyrBb{#gqkxjTMnr zkE^wOh2eb{9uE&sYhad1=s+*_xmNKIudbQxbS!==3y+snz$n=a50xL;=9bHM>Br5W z!iu~^|ZT~UO1_ld4;s+&G&o2Rt7c#qJGZc3a>c=E%~a~cVeTjEFV(U zkSt#y*26GK-pNM3Mn?ptx~K!Q*C)>lbjc3BHBQPsgqizdb;3!-u%~tNZ&o{b{Nq^5 zPaJ|`zYM&v19x#S%fDai_d4?1xpjzAwmYXdE?nS2fb4=B-n8@d%!=6Rq+#e&ir*o6 zd-z*!lB{;C;eL9-w#Y-vWLCMtt)QcHaI|WL*HiJp6Y>02mLV`}+Fs!b-wph43C1nV zFm?-7;O9B9uSw+D1Mwls&c;;!wi*H^db!^A1OQ~%`{pi)Uc9ZpLk?c?db%;UO-`Gw z$$fJs^d8`Ed*tFB{0OL>Phqg|4d9Ek=lgiw=KX+w%ez+df&Kv2`{vu_-Mz$dO#WKL z(rfGu@yiEejo6|pqj&#&_xGRq;k#G*{aq91t%+Lm2B75f;lE3EO99TV*zk)XHokw#Ld)Smu10`k#jo@x>5o~I=o##A&W8AyP zwzqreQ67i4&0t%KO=*fdQlcV605dQgvH> zx77WB9opfPGzqIT?E`H5z_@*?TD!A6rDmqaJhpqm@7=pHotT-I@X80+ZU7%J=Y4R# zWmbMa_OQKwyLwO=Jq2%E|Lf+3yH89EN}O1$%VB%=?J{tuQ4r8jWVMPRP;JmwL=AzH zZT={5R?Hf$hxfWa;Kh9x=m2sgY2_`@A--(Sgl_@A&&r=-d-6PF-+W6>I@>c2e*R}p z_c;%+S^oabrY!4vjqUedcJ|+i2I_m*cyHWbYkPO@2wTPHFMFOkc0OeLe1MDVvuwue zy=;I&*>O;EXsEv ztbgf>iWv`BZ)dlbD2;-KC{~_-n!I<`@AfAnqDc)3QCTcr#TyKnG)ay|iulucau{x% z&CL@8{jZR-=H5j*QDYqwvE%n(r3lkEFnHZR4TwGvCdtt_fep$Z>ALH;vzi?&mR`WY z!OPoRdT)ln;QF7>J=~~wFU1tx@v}*32!eJl+ZK^mE5*Ril8avjA((SwC)yhb#=>M7 zm)G&2&{dwR|Fq+P=mR;SGR;FWYr4fohN7r`Plp&oBT8l-DAL$A&ZH)=T<3o>efHH7 zL2wz>ml9E&+wYtpnDrG^TrP2#T;Mamk5U=%(5tl+bi1T|>Us(+hGOSH_*J7Zx1HbO zRHQyv>Ge>3t(AYRu;zXKKb;QGzjz&PE3e>F7Q=VgDOEmE>w;66PY?r;yYl-mZY){< z5r{uzk4rG~aa+wxc~?J2pls3-h9N}$x8Mr6^H#^CT#`-Ft?sLuGPOT(TH8u#RWl{= z2ODQ9dp!SrH2_26&NYz^k#{erMhH-6%d+<{g$HZehcYR%fW51ef$kHn<=PpqH)t+X zk2Bsq8=OT8ST4SgS1hE|#&?c+BV!(bmj*Tu19TDQ!|K`d8$+Im^WcB`+4T0E=wB}; z`kR^z`oppMOav%8|_+*5nAdjU#0TEWdqoo^=y9^k?i94sZ@rT;E2t`zD+|`7D;~ zk#XMRFH7To-xSVvA|PayNH=CtT^`#wT3a{1P-rK|tFNBwt`Ihy;E~&7P+M0)>!zi^ zQ6+_VfRpI)n~bx+NE-OJxn{dI95WJT`1y3@@~DQcr|Bbo?J2PZj5W6fY*bmMpFe{~ zy2jT^tml%mjQFe^rqB>~^I_BLYGXq{{_5!o`Hf4=vAaNc00(enY{af>z zdeT}$Ik)?X2tdG@=x{ai>d7yF(NhP)G-*c%<9kaxj{7?bQwQs)ld6}-6cT-g3NVG5 z-obh-z|d8)d%0C{{6fip_3f-T!$sh-`2>OvCuL* ztUs|%UB;M{M#h#)X;0vl`aHcQpOgbU!n1-PiSA{&|L)CQe_mAbkT^hu(Sv;_FnS5I z0Z7?jSfBF&Q$?`tf|<2y81y4Gx6HfjsxHEn1M#{G>t}H^zGH!$GCKpb=l7IE5199W z$ihtPzb`qnO442-{Hhn$Weg`&^A0xQ5>y`J0Et1p?Eq*;qaIURN+w(5IAvXi3-br9 zPqjcU#%WT#?O>yq$o|`dU+?qY3V9y@4rn z(sh^uv{Lj$d$ebp2TawvtQ+M2o%pOi%!Ao0tP8ts#GfdLX#CKhNG`RAUoKvr<_9V= zio4Q+(}@OOHP8FU!Zv8=QOR3rB3=>V3Nyj)U`=^ddXJmu-9wh}-ir19%&!N!jjR&; zcm9!iuoTnO*%=zD9+fLYWp-}nQ)>ZdS{Y@xw4YL_bcY)2AF7J|lt_!9U87kK72|EN zM{cp5D|PDuub8eOinHwesUC%xpwLb(>#4~*xuyyR4ya&OCrZ?#<;bRrzQZ?BB7O2k zE|n&`+{e24&yv_H8Fz6I_eQ!CI~!P-X2uH0{HM2gH9m|xSXlQA+(&+!`mcl6;)YuR zdCz|q#hFisPL5iHi2H_6fTR2VS%!uVowSl^JyvhOc1_E3dfEA(Y2y9GB6imLzpKOl ze?MnA{DAiTcb%L${}>2S{51aQ&npk6pa0jo_J4mqui*khc-UvZ{b4GFJ$v|h7C3wV z>YbZ^g>RG-f~?Qh^bw5ORv!(`_8Sevf`4KS*djA+2guH|&6EGV90UZx*MaboQgcEu zpsRM>E33Xn?e;ssQ{(}1gW0XJ&a_z{+Zl?V@hVc=@m?ti>fdrt-3qS(3Ea3b#8)@~ zSf{o$gKbjB8fj@4?|&!g`XJqQ7RS_f;6iV<7&cp$YI15@OW<`(?KU4lXQQOHJ2EJn zN!1VDX4Y<@HprXe&uW19e&#QwUyt1iHoYuQ+nJeIf~)cF%=)ZnZB5|+2Y2rk)zsF- zkD{ok2q-A1NKxrkKnNWH0jZH*LQ#5;^cKK|4e5klq<4@SYOo-^haMmT3B5?D0RsPx zdOYWR=iaA#AMUtMj11OZd#<@=`^~lXoW^rw{L5d>9>Aiq0<%(yOCLplJ!DEj-H?x0 z;oPu@qSc)k%|Qp`p4oV$ zIZO`d!o=Ae;rS2cY^r52)u&023;_362bncz<&N%-rk;svA5#nH9*c@(8mJt;TxR*0 zX*1aD0n`+#ule9w&h@?ReqQeM0=*d!aWpO3lGTV zUgE%Jp2>TEZda?&RCQC2oT`{5EeXTiZgQeqV$8%sCf24QoS*;)@6B1}zJ+?HD!@?B zmP-QmVk29|Qro>j=+f<2>2e~_`fkDIXf>yqAJic8l=oONmzX1>%~N=qfch%QW6Q>q z*X8=f!=O$n!}zc1s?O1Kd*9Mm-SbVOO#O}B2VlCx`l2i*y+j=55&px)6r+LYz);1!drAPXf14x-cObp-qaB zYsb=NVu9&o7H-)tVzsj3GpQciubNjXz^7|;J_Jel7!QM8gY07G8X(Cg-i02|3yyn` z0s;5T#4tx>w_#SK#_w?u35Yx~x00ZU!jTdrMpar=m#E<(R?v7Ui4 zmGFr>z~z_6KgYj^3Z^Pqz5W_P=uM6UI?{Dpn^d!SWCI=PLKdvJox+TGbBs}Hg2sDhdtjt^UJ^i*RC19zPo>zXDI?oo;FIx`EHcIwa`w;$c{ z1B<#g!-$WRI^!?K#)*9wZ?lg-RFBXvIwjpuJMFlQ=?VEW)#-C?a5?i5kxDP4v-*s! zJ;{W}TPFkMPj9%{2lB8ij6&{?pt99PFm`EfGEBJ}A-kStGJ62{CHd5GERxVOZv(EX zeY~!k#pcD1DrOwO?v2+PW&P~>)o)em!u6_lMb#%x&3>Nfq}?H#)c`R`&94~sQ<4N4 zJp4h7Uct<1dK*u_JN+}ja2D#Gq2oXXg?Zhc)Hql@$HclvgTCFTXnaPQ!(ugVcnk~{S6N17{h)&atZT9-Y!Mo zJba{75(Y=*52*DLUV4|1MY0HS1(Cg zit%|9X?A4$5N?hQQJ7v_qunzmo z2O!k@!1GTG?qA@@X({)5FUB>EW&ntj_?co`SigAeTN}B7EY<6uDY$>W_4iZqzjp2o zw3^{(-fh$S?}dE*nH^-L`ojv(P+x;(_|^8q7yQfZY54;KPOw1YB+*Wb95q>o&10xc zV!ZaR!8~py*Jbdv;%@wjlSXCyX2Gv&YRK!_=?4CwzbTox4siD0doh$lrW2}@SC#(l4% z%JGHnTglDlrYQKXbyX&1fe3%Win39Sew~B^I%sOh2gt_Ncu3m2;D-b2}m`(FyRhi1)YPg-2f z*I#Lv@pSzUgItjAkLMlfAVXoE%t>vAy8ZE`4$1dx@|M)*hB^3+JOU}$OhSMUlvSry zqS{X11#PRR@a~!Fr9~pkCj0@YXZWFV&Prlm5V3gwR`PXpK)_(%jsJ+lg>_!Hj1EPw zy>iJ);IkX|$T>3fl+`VhYu(jnYF6$zU_IV;IK42jzl7-&%y#Q4w5u@j(4R(gOnAd|E!>orL>I4N;JVeHzX_4#W9PXOrJu~0dkX$nZxa-92dWl zY4&1z+$rQhsJ}$eMp^Lg&}%CV>Y0JTUF%7Q@X)GBc4a>Wi`P9R-W(;x zKiZL;!AR?`G-j&}Z#*R{h?;M%v#M+=^tmtw-27{j;p>FbcwK3-f~jh_F$(qQ17G|& zeKB+br|rciV?$zTnpPU-wq5>1vOp4X{Y&dz?-LSflY#UHD6s4P@M;5Q`@dq)H{l_BrZH=KPfKFHD4J|= z-uh*Ml@`MDj(%9TmkG!CWkxpNtmL|cO+lp9%q?VAS>d>48qlIjcw?M-TX3k`#D{Al zU_x0YKf7UPT>8JN!MEzOID5V=xHH5*D#AKw=`jC*<%!_iwds_cS>Klek}BZ2b#=Ch zuGw9Yo=Z6zKs(|n%ySfw6NtjRJNU4%&G;XgGHyaFU@Z(aA9$aa?O47ZSn{%SHd}mS zBc%1~q|}WuV4KJKfZTcVbwpupz;I9$=0Z)?V11`^^!SSo_oKHNlOjw1R4`tV#v_@7 zup?B|9#xpH!CeroFdJwE5@nt5$ev{x8D^a?2(J~BTTILEp9Ej+hs$eHOzCA#WR~56 zcYtvsSM5oA8lE^cIOc4_GX;?WvK7DENQO&) z?c_Hc_^59<@JL3cAaE1@b(+RSAGp*gKg&Eq-?EX*+}9AOoaOAl{xC14P(FFrQ@;t3 zd$7=WWOmr-2mQ(|$|D`37hgbs-Ver%EGh+s&dJKA_w*0b&7Z-@q)Z-Wv#X~}@Lbx{ zJ`WrP$^prNX^75Y-x2-@a5<)5AFtbuENM(EXilTA`^^AdDOe)4d~4Z{;0vb(k-7nk zL*Yh#06xpq zB+zN3q`%J&zn<5MLYV!~oIppNXaE5%@k^h{goTR?Yukm~biMg|QZ>3Vb?j6}GnouVFWLkq})$_(ZrmYKry@u3WZt$W9Ee_VD>f3-< zUgZxCal5Hn5d$*ld)eq6^&LQsgV06&8`F^L3X>xZ)Bem2ktHx_bGVU_8ngOtmp1C? z?d};zMW8q)x2#+;)nzx7p!KSvn`g131iIUH4Z>rW-^uP*GS#PnG^h+PUvBdHqQ1cs zzUPG1;PM$v9uwIN2nxhr&!{gGE4{Gc36#MflcidaRJAZq?bKab%Yc=6VNmpAqo9}_ zrt(Q=TUOFxS_pJl_mzg|=p!QYR$}TapC+&yOm$-#TjhI~ysa|=E~V+1Q_$J(a3b`l zM8zE=HXx4JNZHMn54As&_;NX9R_#aRBsUQ{Hg9R6_5R#Lc}(1<-oV>X*ROf%?w$#9q~(?Ffr)C40zMfNU6ck!V8vAvttc5A7PEvs&z-Ym@4zW*%`(OtI}zR-LuG zn$@(3U(M4csL9+1DL6IY3r$ex-TSq#DEqlx3&hPHQYw!Ue#ah*{?_tVB3KJaC7 z^K`8EN3t8nI@X$4v4VxSB}*W6YyRa8w!iuY>}HSOR(>g6)CF|>PD~QqA&~8?i*Kw) zqkZ%hCocWmZs4>HX!Nz|J$VTe-Ju%Q#`9JpD37MYGQf)I!AGv)Sg#>!7XW^FXu*1<(l1uJR90{u|828zMNk1=#qHd%1sKI< zsWSTbn&K}PF>Hj+hNNQNIVa~%`t8+_l1`vd|FMFr9m9ZV)QlK8wKd-uPf2I;>KC{8 zV7LAq?C;9>uiS-s`2^M5KTlYDU&F{Dqgrv=>D(T4>qUul!iW)W-1Ku)Sv1i8XL(_8 zSWSGik&G)j8h}fKq2rZvd9c5ebNN-Q!ZE$Fw}cjK3GAHzva zjj%bM2c1Iw`QO~y4&((>Wq7)2sn&~PWcLRZVD$LUe9SI$MZ4B?l~R`NrsSpXpiWaM zFu+Sa>Dd;7wOcvu}OqrX|vdjdrggdnuNdh8pQtgWbF=6J2eeF=6VIwFR4oJra7+) z-09HP!cA$tA;@>HcR~GK*T)u;*NcWz^AM>^^*co_OGwu`AOJDBgfRzl3eL}VJ@2kv z-=}R}D``e%*|;t5y|;Ls8-JLC`ZfZ3E0~=06mYiV_`@4TJY&||vOjCHBfMFWqkQ5t z_a}g^SQI8s4UN^Goh-Jxce4>rH|FuFcf#ZVB&5&P6U0B1Il<%q60vN-^;7Y zNMZy!N?4PDPoUko0mxt3X}# zF868ym1m`S_50TkNSh6qXpijfZJV~NT>Q!DpQON<`(kareVR~^omJb|0kx7X6xc79 zWVYBxyn8kr+%U@ZZ;3Sl3L5B`W=6`ZrQ2zFb?_W< zC+-~htAkBUEam;nyr_hC&KG@M+e^zoljwQCpbkHe;1#y78M+6QyZh3KG4e>t=W<$B z8OO7iU;B1<8#o~OW^)t`v~26VxjKC^nW^PxUF^ah=@L-2<5wv#xFc*9QKP>RR1=ka z07#o_A{h9Fay6w8Q!%^_+=LVOOOrBW%{|V|NmUr~4L{3YDM-*QF;V&$k$T`Iwl)C) zz@P#w_}p=J6_6h=9Y!OnhU9zII`2AzrPssPM)EK}+J9I~*m{w6euE2)Th~7)Bw2@c zRT=!h1G*Zz)WIo}>a)dP@4SKbJm01L%dAOXXBorZw8~U(Ug7q6$`aqx?zx7s{+A&A z0gc~Y0*+5f>~HA#hsM7E+Mj9v0y(^FO`t~-0LJ|bzy7giH-BZ<|6a!*`1%)f;@FJJ1`!#vflEz^LE>vk|q zu=bg3`?|*{Wo0KlzG4wT`Y(p{06!sn#@)qS!^-+t)Cwr8Z3EQvUHal*vKg{C`&A*y zG_RQ`%IT$Lp%yHujkEF_+1^uie;k+wYKz~zoz;xvqHFBCUMIc7W=~j}lx8xr`XE&ON8I29|A4ThZ zGEV9h$#S61uaCiPs0GB%h1Bae`j$kQ71dW446kLSE`u$29d2~gCYlY52v-nRdRiyl zN-W3$&l7qHBNth7t8HU&W*u%Nqlow%?;uYco)JWpZGZ=N&D=esZg^CYw+})!R=zXp zX4ESq4?GrL39!fe6~pWbK3-d2@t!?E9aj{!O%LZ2ueqEmeV3wr z_%Wo=SeQwzQhj;ahZXZ#W#7M~)G2OXN&mn|4~psdSv7aXk9ah?U(Plf&nrdM^!*9R zYFxMOwVDtfpaL#OLIhXT`YV|a{08;Q&!ip{t|f`fC+CY>QL|PKOavnRDJVrRKI?2{ zh`)BWX@6vA<-&au=^%+8&3Ie~PEjlLAbSb7{m_LE)~C}Aihr1}(5Fs(rbp&=g0QVt z{|Z(PJMU-ZKCYu z(FbdE+5KUa{qE%@ymg}BQk}2)I^P4BF{WW&FPk2a_AK(8?bGW>3s8DB}# z|K{WJ&ZAoPrQ{jyzEoq^YhSt^tWs@n#Fht`6uj7f3iD%Q#vrwj@Y3B|(O)S1FNR@# zXDr4G6bi$HeF}6jNlSf-bl( zzQDz5bKTY1G1!+GCs;ccjg=j$3%DR7_7xJ~+WdCY&>^c&*VTFR;>Kn=)trdDR9)1= z?BLi|#+ZPU`dZf;gcbW@pa;sv(PklC&=@E(NdbKa{KgpvX*4$7pr2a#JP^QnW%tza zMj(Qka^>qSoUC-64UNdcmt=c_PtJa4c9qZDy#t3jr!*DDNGO%5q;cze#dj`p7HKr# z4VdOlOUFG~N)q^b`&;>yJTeMU67rOUZSi|ZDViQWO!fdxVdM*CKAoM=Q8*9Pj&Q>L zvpeZ}e!@8@7IOguNzFsHlv5?zp`aOelBtR?HiF$4O$~TTDtJu4o)PQ z#Jx92mDiEQSbUHTNQh_w68YQ(_tl z?d^7rs&ex(ens^eAS)@xNMUT0#F$nHWGY3TkzUHoHeZ_Gmg`Hfe4I@QF}Xp+VL)f6R#CeV{ty7B9DVXju$t z;vt8LbE~RKbOHb18S7zb7PK*a85t$hYppWnB z7d$6XD@CAnhACWO7rf)MC-+luSTU|Ob6wiuKJ^|E*Mq%^L|Fy;o>jYc;?+BK;fI&` z-0eN!7ST`|%#&|ltSYZou()p%E zvSr-Lvy6e7m-AXg)+cXzwoks^O)W#>)>ocg2>&A>;q#l@%&J$@r&0q2n&Iim>a_9i zZ0yHpb-=@^c5NlyAu!cJ$brA@pkZ;EL|h7gImx=YN8QyOHgH{b@32pmTdJ6C!3&9> z@(s{g1TCQN;8ENK;m~*|&&Adq($^0KGphYZHXEAUa86sd^*yYrmt04in_f4W5*1Vf zcn+;G2=2!}&US|sG2(|w7(KG>D;jOu*`y`B;| z*`kwWV)wbXPvJ27;YJn@je!Isy`f<|N;^v3AM?B-!>zrc zO?#{|O>j}|y?VLn*;a(jbPW!@yg#(BFjg2dV+MzU2*^Gs?gQX!8RJN!g4x;*^yp;& z=`Wf;JQBabsf;H*8k$FmaqJFI3N)k(XmCT9=8QvY8Bx%9X>$EIcPx`@=g##=fGG~> zd#8A5M=yWj_I>D4v>W93e(!&zC_(d zpqP=1jx^G3bC|Z82{HKDKT4+Fb!d-RST7CtP_Hh;0Di#AADBJ zM--m&*?3VbiL+E}5K@iY9|93VIu*Fp)g5STO)XY5s7V(B9EVT8`_%0H5B9EtF*llsB8S_w#eyD_QbuV{4m)}!88P6SR?dZ;p zuD$|quCJ`;h>mo|631rM2`Nfzg_(|*RVswt#VA}H_D*wsALtK#Q9GAsQh#8(|JD1E zeroTH2fL4@<~`pmsm*Z5*o9J@ATGWHtk%Y{)snu&vb=La7*xW0bQkZ_{{8A-6h(C) zszaZ$%M+Q&)Lqlw3E8k1XHO0*o-;C>9fb>j_{QU~7*_{p_~5bgkXE|6-VXwC&hStZ z;P+T5Y!n^lS>@_TdQ-??Lb<$1LqVs+g)*}i57xDb8OvtZ`K`___Xy22$*J6!cUI^a zc@n~Q)^ERAHZDHg^!7RONq76R)mwuLj@FP1KB6G;B7*gk7t2o-Y&P8nsyOR~G&H)o z(}&3>F3O)T??7J_Oa>YGXV?;9$Pf*F;$^|v7j|$vrPtGratbPrjtjELJ(d~PLxBdT z?1h5Q>Mq)^9kNdD0FJ`<#zUl?snYq)_0hi;kLo~xA3F?CAJk6`#ExH_7*zYLeLNC& z{{a@Wt7=Dh=BWq-Q!f-9w&uA~T;;LiV!h_Pv6@V;gY_OHZ=%B4F?TZwv(!01gfn=A zki=P5$WGy9^oMB_PxobI{H2gu4{QUu^1WUfT!acct+5LLarKZ}FC zfcb?@M$49XIJ%RoWyi=2W}s)6p8n5!t7RX&FV%PVp*vLGnaTiR6*QQF;k^3XM$T&T zC3CuGO(Z8!3({?Awi@otdl`tEbroyVeu zP7uy~B7R6kGOnFHXBIVk{z$XuBbzOsZw6c2P#=0zhT-ZVM^Ez)#VZ@bncMR$TW4s2sVa3i$ifIE%hss=#fzr?Bq zEtf7-cIam5bOj4hRie4p+HB|?QQ0|POUmz-ccrd83-9z$S1)3W93L_I@Dgd9R%O-?@w)Y=*{9yD(<>JXsbg*ht{@h&H8t6ZxeBWWo;Z3W9Naa7AaO*4FTz zsY^RqP^dvn%ELc!VOKsBKSt?tYFw&%9 zJ=(<^j4yImX>3J&s8L(VHs{9FvT#7X)RF6v+6gMW=4|Sn`!D|?e5ogZIo7Mf<6e}w z+^G`vY3HU%u#K}YAI?*krBkDe%2`~Y4cvpw0{LkH?u-q=q!snP0kYkPP(q?pN@?+>DnJ=p`9+OyB(KQ;VI)d19W zgMr2C#sqIGf@{v;W<_U(;FYe|vdTVVxeP;X`QB!`K4KlPCf#JoWxpdnakAZ}YS8`i$lZd)^YWv7$ni&W5r1 zGSr18nHt1E1Mb|7=Sn5KZQy3F^_fji^-MBLM(9O!ZI>OW#KkGGI$0`yV&2Msn)i5d|C$BxWsVox zwkt10tMjEiVFyI$Cz4^tkW@2m0_Uwy@geZU{gisfQ~xWdCvfyVRea^8HmCmgncH&d zUhJMq`~O)xGYjJhBjUeaKq@zV>BC^-FR$=>W&i&e_DM$eFIR(>wOyX!8Z+FVUzCyH zM}a?Ic-cg{3k}#R2*l3|CS5y@2T$R<>OA-LWOi{g`9}sZHN@W21U;`6GYr}pbj4z` zyfoFZ(QmS6-IRV{_Jz|akz?0)v{iDruS7SswOe3Dj1A-al6M@XYcONZYwvAIBnxQw z^7!yEB^F(6$EcyM??g}=SwI^mSM;;*ytv#Ts5VvJ^Qe(RM4?!QwQd;L`JdB}U44#B zJo-@(NSwcwbp0>NT=whT8P7>IV)>wa;x->(y zRn?v5SDLioYro^@3~h<}2kGo1u{%}=HSgh2~5AnWQ)aS(@5 zvTk-|id(62ZOz{K5ht-BU0YXl*t1vgd_|XvP<_s_H3yMLlo|Mqa9mPW<4r}QA-5s2xx=BkatQ&l6X;e zjD!(h+{eaJzq%~K)ledq+j=_F=X&v5wq~;I#oWbGiV51N#ag5B>hb2C!zH3S^%Mzu*OaceFX{H-S@67P%k0lH>A&4_ zx<$Brxy!m3mCzrpz#GY(TjKg5%1EQ??v=NU)Yc5=*ys-DrY~pn$>FVHjls9N$cW#D zLGkT~+{p*xK~z4Y-#C;_-*EH?Y~2a&vgeGYAmBeUE5wkRKD{?q{zPZc*^V^dqL_8I zwmSTtNi>(5kZ9us8Q>T2;6sXCwma&?tz$m2M?zVWpHD8#=-PYE$RxjPwpCziBa`Ln zQ?qpF97bZw0n^@^ zRju+1u^Mo7)6`}O8_(l0hIc%C5}ksx&)vcjTshmBc^^lKJW28|%JWG{zA|cP;-vFK z05EkVV5(!ux4#5#x#Emmy(motq<<$ zZg;t&YO%h^hZIH!NZT4_j7~}4DI0b>xZ9Pxv8PL_1bTc5+pQ`e8x$LD8PjuIE4coV zwWV4IiObhk(irUWY}DZUHWuUucxw_#sbl4cY6H>dhL9ie(Ux;y3_eu)KDmi`zS_3J zoS-`ClHK~&?smd-dmmSOQ$0IB!g2fI%;gOO!75j)O3s|>PmdjX6H2_YHMbPmU1rUt zQ9Oz2yOol~mQOP2Eo$MmHNI_@cSzZr+q<%&KIg%fX6(hN6&+{^RQ6s)5vwo5GiUo)^qaJ)@DDp)Rx>=; zo2{*thuy;xrraNxy0IIq=r)v(RPL2>qsZ08Pg%lwgNeMVTAeswc;0CS1($P#ZB8H% zCrL>G*dR7k&XC9+1%(=7b{~O}S1RtHLY-MLX9^0-j5%)`eYi7%YzT+?R9Go64Agx< zwZr`Uy-}mtVfd~E*!-jXI3FdWa3?#vP2=n0<@b{&qoB@t%J6R95?tcg_Q)mZZA?<9 z)Qi+pc;MT|O&)v~)fvh@vbi=aed*kOGnoXQS3`cNpba%;9|N(vU$Re55Xm3FD>Lzr zR8`d??}X}@(zAnKcuq$aBJ$o@BYdl#2}ci0%0f&s>%_12FPOzLX5x!N4t=t899c13 z?+5CB?PED?$^OObFMVPN*hV~8oZNs0FwMdXFIg}~6o zKBt*}?QLNU-#sKl5_>ch;)z2WL{B|vqHPe-;`${Z9iH4uwW^pPgNp%(j4OCpGImmaMXSzu)Es zN*Z1ndSqOj8&$;AJSaKqZ9$6l^JutPDZrO;TISqO{{8gVd>Cg-ccB?#9`OQE-iDui zx#kJ`AEkT>lQ@HfJyIxrGUaTVm3opy0h?wH~KqI;_dqrO8-Qc zmjOk{#CKVA?7blUg|2{S0Zn|C@%PXF`>8&aN1|fhJteHqpS%&&&Uh3<`FoBZnIlTv zr=O@%Hqu@Cg{aSea?`r14H6^abka=xN~ZDb7a%#|t>S8t6zG2Q(+QW?jRo?*5IKn? z>(D?L-$_vxr1ah<=U+2uYcUY7Q7|f4v;)D7)2~=mn?zn$uR9d6|1=cx9 zz~^4^jm*sZlj46yq^G}ge(ohQuk6lK!|4AxtWEiKG4x%&`?tB_A`q@)9+9y_(fW(s zPjj$I@}QECA9njSvqN~wRk#2u!(qqdMg#PBHUe0C&W+lJ_&jM&W%~Z~#sKeR_2z$) z9VtXUl(xIyJR>b zEa97Kab@?F21a+!C)(XPeU6Tz!Wr)Kx1r>7j*O_*=J4jxB%qVs-%JADHu0fCW!>lY zO}hKK`m8BWrl377TO21#Y%EL^QVGgA#)fZ&51w&Cq(fST=(O5s=W5$juXjZ@`}~WQ z8lvvRZ_#$ft)JS1PsQ8wN0|EFXG3s$#5>H$E+7D};=i=4aD8s-UrU+lWfdFDhK6H| z;5(J`+%YaiygsII55vAlNBT0yOqmP>EInrpR4jYu|a_e7< zprM?YbmYR__w^BIj40xvk%x5m2lF_Qk_&Vkxw?lxlS%mUj5irLUl7JtItFy;B6Mva z6HoprJV{UHO${(QjuFUwLT+#QeWb8qG)%S$&i61X+d;3b5DO_rm8YQEJ%%>jgSEr{ z$wA=dj5k)`8Hwm3T{F?j!8mF2mS+>Y@we8rFY7~$72zsrAwy^Zs= zbJ-lHGM`L~e?b`tN6&u`chwok@lJgP`RxHzU=I*1d8z?|~i;n$Y=%pd`Fo5wHK&$Rzjqz3g0-iKu8Q6B8)ej&z!b~QYa z>U41_8nrP|9po&7Hr|f!5Ff*4R&go9%;Ht#S4Axn2%GKox-WVj@z_FV()P#iceoz( zo=$E$+jw)zJ}0wa)n(lz$ZN4Df4r|njO07lUsNH4sUo1HP{%I<1v8YaW!FCT*$%N_ ztg{3XN@AcJrktp`O!m?nGCY{+?Y6a>_#&^^EPo}WkA-x{f#;?RB}w)9zigLN*KKTK z{#mnxw4DrI+FL7csQY-<&PtUV6@}n2~s(16Qc8pkMmkufMk|6#rZv)7(e?}X(|D77vuGAeMI zyT?G43kYEP6eXrPlBWf~{Sk0NeaQ&H*U zsnGtprFnnq3$p$cHB`eCk&1&+He;DNhB5Li=gw<~u#lf8e7Fi}^Wy31wJzE%DV?R> zQatIYgl|e36$>`>bNV~l zB%cI)D(;ae&5j_pa$=#5v5)E8F;tdG5kVw1Bd@U^29-bAM?h=XjGb<%BX zVw#mzZ(gJ4-OFTunH`qh%~Q8}XXgqLCfF8gax7m-Z4w{ zZB*FQV3#vo-p@)nXhcR|?Lg?w8HtsCP&wat`Ef?8WUx*_ZQ-0=Zp9h8V19mDv!+^c z70W?sP116Tg1)s6SxXkCrrL2dp||)gqOu3Y2V%=}YH#RwS{$rB5)EP#7$oGXmKRY+ z8Lo&bEoL4%U&_h%LiZTeJ^80eoT$5356r$^&(GGMm3QX*`D_+ z1rD%WlxSCFczoDC7qTdG$T4#$srd zt0@yGX-fhkp)r|fI@sAyx@+8*LROr`-lp~6QC_b#N+|60T-rTp!G)}y97`RDiDT^0 z?|pEnykE#w#|17;pj5{~q65>q#&6XW7q$-?&geNhMSD~yC4A0H=mcT5creXuL!!ZQ zF~I?b?FicQm_hIf67b1iHXc|I`pOdI7N*I+A)h~50XEJ4On$t3+4I7UTfkAnxT@Dp zjP<4Tgl7!xBc~8C(8ZAwe8^SY*K2+^Z?;Zn^G_6JK0th&dbjeB3!^98d+P@^WlE6M zZPS(|nne|(v~)`1d8S6 z2A32`avI6xAX^cgogMdVqQF;T`FIGPuKwBhqE3f+9>*`LS#_HS*JrMy%uBznn`;W* z!pjZV`a_19q3xnQ3vPmEtD^6#tEabT4RNVZWP1~`)eEe|J)ThLrVnz#p>2rp1Fe(= ziY0U|+z+)k#4D%@qbQ;f=fkG7eB&@GErO9}U1qu;0qtj8{&=+rs*L)&L-CZ3?3d#L z5<^1;w{?9K4BwovQ}M?XD`lFr(NyYh#o~F-^r^GC+GV4l6j}#%1^BK=K{u`O?R093 z!o_KmMn~UI_z=BE^IYe*{e{p^>?|s;M)`7?C+%XN-}U{R`E3;9HrnaL$E}xfCcnwc z5sci*JMT2jS6tMz7T3taG$-WXqB0%@?TgYV7}c(hifBY%<<5-K5y5xGy2!q6Mc8xt zZu+ZA4E!+Mf6m~aL`qWp8`_Ab!j|~`G1IPXz7f!j_BZLq1#uxT3tdgKcEpzfvD9y0 zvI+;YXA#m~wT^e&L0sc3XbUT29n&B~WzBK>;h<_R?eK?AE_9q4_R5w&O3ykz(pHum z)28q%yBFiBZ7T#85qCaK>xRtd7irGt%zkIA$l71Kt*Ga-+J?}O%YSKo)v_q(lw@9+ zZNYoR&f1ys2iI0R5WXTOT;UCzs-<6)Z`2!)<(d$Xa0dOz^Gg|Pi7%^#(nlr~P>rRR zR&utFZSCh@Vb{U8qr-XX`Yt6lu!B$q@rl*K$!lec$cG>S`k6UBb%-Nk%eXCy`7AG# z=yMZe>)azOJ9l5X7oqK=D>7Rp5)9LhG76X5y08CH@!D)_okyRx=QP|Z{~HR7g^YhS ziL~diHInFh9v<9VZV+G1AU-Nu88WrNao}unJpx*Bd#G2WR@D3oQk4C5&9kA0KWwlv zN*;p`p?-Mv=hd1%cR5V#=shOg1}J?+6rkL#nmtw!Ld?}uE#IU$FKM`sDwU+&o3C3P z#&{^X5X2KWsLUM1IuN}iZsB51FS8a(mrj!KQv%vd>OUhbeG9W3B(X1<)!coMt?Xse zJAXj;Sg1dXysYvIybZ+@t4kr3TAau|UreryEEB#L(TY&p)Vi&&bG=QT?YxJ~b6dK1 z4?4}-D}-D~{Sv#j;Dpc8hEWnc#e}ki3>~@#zFb=E1)i1MecEuf>%%mq$pJcTi8aeT z4?RW`@w_pqX8U%AUY~;8)*UBSK4+mjRf8g*vZDmac~mqCJqAwRTF*poMPL5vV%20< z>^R-7O+yD~aq*waZb``@sTTzTSgOsCeeWD%VN?5j;Y$MWG3Wl|?1qFPV{G1bH*g8t z*^SuR#+K%sO*Oo=cQbW>);Ya{<3xj6d1tNpd_&6QR**w~WD~5VO})rCeb~!fV`=%` zX=;1n6P_Z`pWlt(N|!-lBG6Z8$fUX0Ars(8N)gTce;a zALS*rIgx*L=6)$mK1BXzW>VS|r$b}AqjAC^YF0y_`-Hc~u}_?*-&to^L4>ldlSn%e zXC!j((13lPJmO4F_O=+TWu$%M zZcG#D!@zz1?1Bs5@GIju`;IT0G7;a|QI70ojY-oRu1!KfO7<9+tXKNa#9|I+y;5Vx zFfe=j)UOWji$v-7Dt}PFYBv@Ts{xnGO*}JE&?dy(Sjo}d7vAF}$sq+E1keoARY>X^ z))hUvSgtk^nTRnP6eKY4R@h16<==Bt;8zfV{VkJg0R%GtEwl`AVW-0o_=GLBU%3xM z#5+i@#F_=rnJ%$69-(A-@GD-82a&{>Zv4?j88g1UO5@Favj753!}=2N$A8=7k)uDC zG}9XRl<{)_HEADPqFrAC?cq@u|I}7Vp!~tj`n?IJg+WYS^hThPQs8^fN?gU0QOxVu zK&FA=wWAom-F*(=rnLJQW>;o0Z$Tk2gD%MOD_oj|2v?+e&->%Tht!iler9)&#>i5g z{g9r39eN+4M9L{*oy@zj_T4wqyYymvE3QWM+hAA9jXK>jo`Zl5^p-+nU+`f>$%QYg z&{PDGWcNld_;=FBj1P0TiU>S!zc@Tva-ctBh``Vl6Hh$kM#${bAX zG$vCxbl7D!9d4pOv>&G6MoShZ6p+MOu%qQ|`AHe;qaPiZ139^swcT_xY&r%dGs=V< zF{88~mn5Cf0a883@QyzgDw|0x>P*=)JCf6@c%LnKv>F>|(7m_9f3&M47WCtYIme2p z1*jkX{r1c_6AfXTJ%N@O(sL*)t-`mLVH$nff5)%qt)$cbi63Hr^TGnRux5}*l+6(? zUj_?q5FEtjz=Ef*hJ>T~h99TY0VA5^N#v4Hq({R{e_+7&c5m z7b=0LRPZQA=0JdtlNmCOIru80NNCO>I%hNuG;MqN;a3(FEK~NsV(@!GIZiSp(qx;* zX}M#Nh-d>1nevndD5cmph6$xj&uxP=k-RyqD$8E=_oloWN28#yJ}3guyN|{ktcSTg zYjUe%B1t6$p!4UI)J1#BcX?kgp*TP1Jp1on`V3mV$*g$2g3gO5C=d3DXP!`nNJ zqj2aj|E&xLULWu2ZPM><_hf#i=T7^<5@EN|qtL_cHD!HbT(mkv&|S*nB$cV~5p`pkL! zG~FI{2R5`;VzwD}Sr;3RUaK3KFe-n7Y3P|t%RkC}iP-7i7gGYr#_c{&b5Q5ySFr$2 zYrU}cA$5ncYkU088Tz;;#1`i8D^r`*s)J7s5fJNF;G@oN>51q-u!ZQsVeKw(TS3PY zGT%^Iz!I_G3c@v7%q&5RuCZ3?+M$yn6m|!uZ9Ru9g zy=3!EYLdu2XaX`Xkv5JF!5(^OOzKrxv+BOQ`bMsUjtcp`32v`hTs`+&IP_QSV zJET9_FLj_zZQH;ijmcg~q)+UT=JPSxX)+;sgNw99Pj;~93EL}4J5`5Njf5_yqit@4 zgjI?L(nQ-1S#v1MF4GUQ%jbliDN?htPXuR0M>gp=Vy6pm==<$OrMeHnsp%MVcp{Z& z)L8T^8~G#EZ}r>$$lDk>%GB}jIR;>*3Ec8to%)Tdt^%`R;xyAEGge9YW%DQrh-(+K z%(rAa>}zvwuXWE{2lO}Njr61(z5apbbV1#;JfEfcg?s#4(jslqs3g6$=ZfwU?Udu+ z`k+gJ>y1a7S&c{i6J^obt%wGy>jHubjc=a4p5wUP0LtnC(uJ;`4}Ea4?T8O-R<0tK zx3pK!)H$tBT3Kbpv_f{T5k%q_g}CCZy_b)3HSO@Ur6I&+=!J7tiq>+L))ioOwYXb^ zE_6q-RFdKP3S<=&WjupzHf_C6CLseMyks|WKc9)E`m)6)O^THsdUF%1tXkslE|e0P zff+iQJHqv`dMEDSAT}mxGtG!7`s?fBPqc^VoU8VCC)H<+kXtC&lw5T;Xj9wT)t^q| ztOZA-C!?QENIA^mJbPol@m1&Pw4tEooS2Q&`H)!e0}FlPIJG@v zR8ecY*SiqXwkb$<0 zCZvr)O{C5f1cZ%kyB!}@CHJ7;d#6kOJ+4bh8txjAV_P2dL7d{8pO{@;BTpF}zOC7d zG}%K7Da%xN(B<>sg7N={z4wfYstMi)#{eprK!PNZELkKBAVIP?1j!i`$!W+E1~8Fx z4nvSEIWsW8phzBqB&oZUTV_si~n^5ER=+g)9^x~lrAr@3Lq zR#0k5?}NSNUx}%BV2;|!!5A!!+1K;YAFT0XS!+q9O1>=J0C}y0%pFXh$lH%chK~tH zgv@abjd$(4z**a9SV)1}PbuM0KUAQY(JVzOEvCau4O&zUz{s^|PixQiaLPa1Wq%0c z>7-{KgB?=*YRzVGdP31qiPN2`+oeTE`CXDPnjcfE+F~9@Kk+ob{}47?kI!cw0A*0!*0WaY)4|pM7e&Jj$q@pt%ttNx|57+tUO9U zjkkK`59wyEGb-c7E5mHi?bKTABDaf#W2)~2dSb44jP(jbzF4h)Hyig1^XuG#Nw-Q) znHviVMSW6ngRaii$1%#dDgILe8i^&`4w-DKYm~Nab!kS!!r>oMqFI1H5YiaUsY!jx z_oxit#RD!MV37>AMfyd%+OEjfCy%YJ^bB#XlHK`WK$8FR=di2meCbNw$j+6L*?eJD zWMqLCWN;Ng9z_7^1!R1ZCl6FaJzsVgTk{oA?6l@V_fJ8_TH;Fa7qPgx(pb7B21n#s3%|0_N^t?WVQ+AiADB|srmMlk3BaQCWel}3V)eVLa5YtJE#+@uYp8X?q1$Tml zMiCYdTIilysDFJ7_1dc(2={&Og;b8Eaxmr{aS9k%c=!$DbP$!J!jPVBCcoj*+qgfZ=X6;534Id0p*EQ9LPBTS+9${Fx<5GanC{`eeA|MP+nZ2r z`@w6z^Uvr@c^Y+7WQD`@l+@+E=?>3*v8arP#eU$r(w?+WN)^Y^pzipJs`shXV!!|WG^pTh6V$5*FN0~|-8A@?<+ z>R}4F$BzXc*Uu-wAN>C83~?psWInvEeP<|cyC#H1 zqKLGps(3b)0#A_$-1T9REb~m5>cMKTcCnUm^LQ`LJWOOnV{M^59tOdh@}KlCi80k( z_C>=4`^y%M2?cIwvdQu8JAWlAG#Z>DT#l*@lKhc#?1cZ?_9&<%@cb^6S>DC`mE@9T zO}uN!la$Jjt7IuUW9GK_JVw>FeB^{1CAIUxorq1=JEub$gF<=^5B<6(4i;I~BzxAM z==Ml38TyhBpM-Av#bigifIe$EL_;;|A~h}ceryVLF)93X>K31o9V`J1!3{*#GuhEF zqek$;z1kcKdP5wue)5HlIqE7EV%$cN@Ui_Vkdm5-;N0j{<99n3ZcAgjx%Q z5$k^?Z6i&TQ5|S?`#yeTxnCVQSEi8>@OH@bYab(~J@2Yyge<0)g(#&c^|%Hip3#GP zQFUBsx_lFv9@-D-Py58jjvTMxcr>j@;~Db?ZR20hoz-Z^Ug0>{ny6#ohw@eJaz3jl zFT7uby?=FPJPy7cR{*m$ZP3i>%;PlS%~m%&i)zXBwx9}8ZzI14=k-K+;zw~d!FKp- z20e1OO!VvogN1PQ5Y=JsF7^#dR-zg^p2P89(QZ<~88Q>}WEV^vo*re@q`GnMK?q#viwP}QGRxZ<> zRFBM&e4(#56O?18e7fIYCiP`;3_BP^yeq+b5CVhTEUZ4p&b3w~KwXGcR>02Y9v^7P zd^)rY1F+WZSr41W*jGb&MP?fb^-@0Klyxo-QB7XaHW3C+u~I!=32$3R(1 zQzTYIA_-}=J0jX|1RyXt&&ZHd$J#0Ez@@NCnI86~na{LYa6W3?)3e&V`xTXKkAz1W z!FZNTQ;ofdi)X6@*;-7-Qfqf`pAG|9PMC*`CadF`BFAo8M!w!gBB_5+#5_a3fZ^JInr0<>*nQbRiD2! z-bUV#b*H$mg>0&t3nIFW2R2e)Mtnm5O+5p=#nSuM!;RxwB}5m(xJv&7W$vP85m>&X zjxr|=9q9kkp`Xv$H`2we1syQfJxoo9G=3ApCZlztT=ez?G%(;hl3kQVr!4R~sHbcH zgZaXJt_D_2i0kQ>+x{i}JTQ!WNsbQ$H82yJTo=w7k}yc1rlV}Q$!Sta_>TS6d@w9F zqkJDWWjk86pzBEl>U4;MHW3~_aGTMs+gGwTU>-ZR5s%hyugMM9O9)NSaDX()s4ZV& zbGj%js2@~Tggv_C?t5=e`iw_U3S&3e%}(|u8@?0w#{~1xcc&m2gkiht-4|7`Aw;Eb zmB_I@u4IT_PR1~~=KZv`z_Y7TO}~_au18brdJ^1DulY=YDTeu4S$U zSX7YJ@3fO43xdZbY&NQXjaX8hdPt)qnb-#mknQS$b zz<%u8H#qxfO9F)Rv79o9=5hInwOb1eqL`EG}F|Cvqr0mzXq}E5N#n52qXb zx_6Yq4Gx?C8$JBMlFF=xeJWz~LUp=t8ekI3dZSKiLq_-HNx^dc-s$SbQW#D2 zLY}7ML%uig`0VFY``H1$PGtH1uskym-8X?@pE`!i&$>ojiY^7IfRo=#U#ZWJ9-}dVk9y zJay7HikiARIMz3|%_CHgn!x)eAw>ki`HS!ta^=eGaEE{tgWwnkJT!)4qfz7g*)GAK z8X_6gjqVzZ*MR7{|FN?>p&jj3+gi^!z7xo?#@?XGU3={3b2??O9v{(2|XraGt;wQWu?K{QR| zSxa&Ix-y>P5IfJf3hnTu_Ly~JIE!$VnCe9ET=R|TX+2nN?Vw-W3ETu`zsldPe3jE) zs3623L;{hr`QW7Fp9emwJQ!EXel?*cyU@xM`PrA6nB>E$bt3*fXRor_^!9kc^U?}_ zk*)Y{B^ApDCrL_P)Tc-6U;A?vUz4Pz7R;&S*-Kq8YY4b#wV(3&Mg?g$gu#otm&2Lz?dKOGM&sk* zl##VEMym&(Dj4do7G0WE6O7B(x+P&l%q@H5Lp8~mKI3Au;;-K1daa|}jOX!d?6F** zMGKm@-j?IoF?BV?JtfK_d)L)uT*OZ&(ckWxfzN?{$sarRpQl^EWF^=mnJ>@HQWoQmbMMzZWde`b#aNl;*>CsYR(^*r|2#yxgyWt{8rc-zU|L9gw@SQSI!yO~ z$2I?CA#KVpggu=6keh5Os_pfE+H7Q!ZPF~~0zmns>7cBYuu|bF<#5J*3*o<#B)>p28-ktoexS!wo>Kpl`S?GFZunhN z^Jo6-pO~57>6`zXAr|!X$eD$d%@d;W{r8-U8&0r!q8VISB6r=%)~w668{qiR{ROSU zFMp%soPGDrC-%)6^J9(OQW4*%OFWh9{&ydgXZMXc?oEmQg@(Zm041uGR-uDR7>&l3 zq)ceQAerlMV77aqvhtO_;Zc23SBz#t;`}ng?H3NKJ^NR}5azh--X1jrAhZxkjiTJf zOcSF47Ha$M^I_SMKbdWA!bAb@;};+eT*8;s4ElbpeuHF)LJlnhBkj$Rw#lsW85NYF ziR?h#rQUf~Rf}fpKo-&GdB~5E`7hF{+{Jz%Cem0TJ{B80QF<9*mjJD5MZ+_fAG3$u zU~y-%oVRn0pxJonHnDiQyng`OF@QC&P61$SZw`~7l|YtvuA6C+s=+s#gq~mB_!ZB^ zPXZgOanr(R2kWdBul;ASv?1G%n7i1LV&pVTa1+`pNeh=V%Mvi}M&p8GjS@q@Qw#ri zfT;|zR3+}JTxxMl@Z5{M(;?D@vcZ1nqBTktOV37p1Kyb_e(TL84d~V(@XGIm_(cf+ zp42AuZZ74)!SrQu?QwP5ftC)fp<9f3x*Zd##bz2bdzx+eaOcXJm%EqK9*)CQ6gBDZ zl-5^Ad#c4$!8qAz&??U4s+EfYv0GLVW3};d?5WSeau2?LVk0}C8S9r|iGm)IAd|ga zsX!igwjv~YE`Y}BrBHhAJm{jA5<_ERKFPP!HQhQQta36^QY1@tgXCt|G}4u|T=%-3 zY~9FHQh6TIdfd)hWuwnoC*2nbgy!2JF>xO)+k_>BY!d=&({Lj3^>;0j zY+TGO_}V=5(>TgZo|52dTkdg}r}`rUTK0AeXL+o_Jymu#KDp{2>0in#*6HFOPI5GL z?5UOZI$@p(_F-#30o(zefyMNqoVFr8K{a;wQdL1vAX^WvFS&Q>DnWUEhLJ>GUdrN% z5O2Q#YOuREfUZ-qpqxsXeUSBrGpt^hRg{>qqK+IJ)0rxx+7&n2p>;W|Nsh%8YX?dl zfp;SY^@5j+0y*)ET~w+G8g(paTHJ=veT>AVjQ9QXen08yjoxXz4fX-0C-yR>1h|@q zY;SdAxH39}?!RiP&zgODI~e)O53G-Tu{%(THTY&M_uNs?_F>TixZp!Yn0%A-XuJcq zSsp@}(pbfbmxtTkU?E0Hmq0J*VjKWaENxixPs;N-UV0-!5J>nU;_}UxMEf>tWk}@W zae<|GO5aBnz;~A@vzik$X+o5diY~b(xJT8HN;UNo(*UFmgnlkGc-FDYKdd#bNOU6L z07mo5yyWT8PR6}Idn^_szrnHwDlI~Ia$BmdxUF8*0ZxhG!k2^C(w5xr6h~M}AmpAl zt`ml*j#v-Z%Qlr@4f<_$t+{0xF}QbQh+555HT!^#Z%}l)LIBq-DWw4^3R_&@9O$BS&rPX*8 zUz7EgpxxjaCh*Gm=_+6|x3*idCWkR%p0c>d5vM(c*F>PW1syxHr%tJW$#l&1`~BQE zIX~J}NW2IF-TWM{u;+e>(?OGiiQ$!CqlfGw=CVJ%hM*K_&%yCnwBh{qu)`-#3h`IVK{8 zOTgMs!M!oNO5(0KuYk};EK6#jI%g~InDp$>mWa`3n#e;N3Y(V!0qXiHASeh=7}1(h zf!+8uuMBY{GYGWpxalFAKOp&{Uz8yV`u*uoG%y;ygwrbEA8a-RnD(uu2!*}&bVN48T=hVp?*p(wCTiRUP*V8z(oUK0PvUgJ(<;B6c@uFY7tp^I zQ2Toi=vnw@o0#=6F9~pE5=df&WMlW06S+dkOwZR&hNd>OcjsZmV9PxgJsZ}{JV=+BAfr(gHa>XBbA0KC!8dkO*`w7>Db zWnY^OxxuH9-%UjS(d5zPrUOh~$2s@AKoH0{^&c;xs;$ahtJeg9Jj5f;f(Rpj!-+Sv zjzR$;H0(bw~o35dIR8pBdxZD ze{bH#@7Eg4PC0)M=+Qq0L~Uqdfiwyu0)cqj`X#dbOcRS6D|{qO=&mWeN`Jkp_u~c3 zH5>Fx=5PEC<~tt;Y9 z#pG-=Je0Thewn_ujR9UeMPF6G0)WC_1y5tM5(hR9<>-jFR*Ob8EW-5YcFAalE#QR@L@e2SBdZ=@6z#+YhteCw^i+dUQahT96 zO%A_k`^v^L>u56fVcV()CuaTQXnnU5!NQaj@X0CwFmMnk3BCZQ>U5_N&>1jCJ9h2bawfM!8&>VZPpo~4gBZKR6j z@Y>x4uTnzbuih;v^4RW9lM1&!3qUa|Cn@K3-^WB;{3RCvnhW|2SbnqHMM}~66)xQU z34^V9J#P+jrgc$n?WyeL+CZ-$MOi7$Jv*7AJ8j@T5>LudL+jB^SIGC($4Kvvyr46REeZ%FgDyIo<6JjRSP2{NUl(7PdinQsdzmH) zZRT5=L+gJ97pK#CV3%am>DxpyF($E4yD6Hd1i*R|0ENSUPO$X;ltPj27McztiLeq9@*(yn<{2%F!iOu3HR5C&{THh?$=fqH<_ zl)#M=SFQJ($j^bf6NXOyd780?1q=&}E&)<>)EU6qT={$8KS-L^O-B;>G^ZK?5VWX9 zQ_<`@(fN&JmtxN^>K7mq+nipKW9&>v4hh79dksCqu}(XZe&d1OK4F$15h>c=Vw>u zA$!Hg)x}|~FRlBhHa;OS*tj7MYEg@h4~20K9zs7;LHyQJ55DvE)#nX{IDdeh{N|GQ z{pN@Vm`52i4=|#S0{17ZATs*aX%Z_X(k6!%(a_wNyR*GLxNy>C+!sUk39a~1bAWvS zs8RiMox&rnFf3Q_eJ2AsZbqmQ>`0A^4r_%Rs_7*=jZ$)lSqQ*Zrj@5Dlgw|fR0ejl z1&S;0-f7UM#F%s;p1e|`FQZ#(w1{8S!PX!&=Rf|fBJm2YP%spqC6vb_>sKvj{cPtP z*BjMn;y+f4p1Ynm!%Ur{IbiJobEuMdO|)f=EKbx!?L*x8aXNsp zDAg2hSad(jW%Gf*y`Y-iUT>Z-gZ8Q*^0N2otJPulMJ{@91zqV_6}uw6u)yhxbK z(5jZtpt|aB)B9UH4LxNWO7@6|I-)ojn&R2Y-dD0ijIKQ42>ki&-LcaT*wV3pe8-^U1cX!tp^h@Q_!%Ky7UFHn|=5(FX)vpFM$@Xtm|F(J4_c5#* zgld8bKWmu^Ug=d4G!2>gzL=*$q{IpOfYld4yn+hW) zW=TG&@@K+Pss2av<=`&?b6E1wqN$OSjC7IG3>XHKS6{W&2ve69}GY=w3jEQkGc8g}-fs6bhfN`eQ;N?Tpqym6au3#%?8CYW>A* z5N@&)VeCS6v~}Oq&c|U29c-S^jSy%E+HYVn(oT=DsFZ>QM=_F9jwEs!eACn7l>0#> zdiME{&Js-zHmrl-wFe&81XZZj(EBYdw@ zP4N7@3P4)-6?-rdtGX5T&}%#GXP!E2s=L_Km-zDRTnhbg;BZ_sJYU1{pYLn{HLNyR z`}S9rQVkCjGYweSpg3N0wIDTPk~wj`1w)CJI#oruaP`6&|9$)T83OkPpB8`ED{SYc z14sF)hzsBJz99imIzn@xQm+f_Er;4!4TNch3w!F@YS=HpwZFPe{Gt>2bMFUp8-2gl z<30ggnEbXIAy_sdz&eq?Cyi1@N>^*G8TdIlK)Zd7JEH)4H}i)EoKwzDTutVOl@Ppq zC|RNTVPPr-l61e(Y6OuR>?AAtDAtohc`nzT0Yj7$y}`3PhrI<6RL3WD#c?F?z*qtD zs9!~rpod#%goE+x&kZ-r5e8peBAKXS-w7A4P*=Qnt9;qwzXey)Qdxy8^7NG~g%<$l ze2}UJY4XHO_pI&Gr-hROuo_x&wVH5e4mp}kxtdF!hXW_#!Dum#9zN?xy`XT@`cPB5 zJ#^sesE!@hAfH>y;up8gpFYI&3!fsr*abVMwO6eOFJq~0{_Li!=@HqXVVnl3jp85B z$1yIpt>c2~)6sW56BGH&jGCgM*6z_}Sa$NloSG|SSL%98yTL^#+^vF_hYX9}oecy* z6E44CVs~dIPFsLXP?END_eBy0>p^gh^D=!Rwx@Fb?gkiyVzKAfSo@Cze@h;q4$&Yo zC?G0SGSclZWeKXZ$?7E)MRg8qsJYx0(7*j~e~mfp8hr>XI}&eLn@Z3oRKxs^@bR~6 z6BgK&)U=p(9}Ql?e7-jYLh(J}NKeF(ie#upfm6x4Y~I7c9Bl|)Ayvrp$_VBTAqBj? z$S0z+RJu!FN%Ez5-U=FhbO-Z?GlQ=f7;zbGGX!>NDn-aY>cczd+!xKd)hQqK*wQiA!nxExxt8%5%Xi0**)!&G`9}4xQLR33cwGNv zn3>zVyfS7auGbx>`AknW@~l=8@K66|-S&buso|Cq^of^P5WS6&OXE|%qdp~cI_eyT z7VqWf(n?BG;c5%Fr;Z(^^Nx4)-D8&W92rVjQHlK@V4{i)r?G6^nkIo9t%oq%6RU~c z`Wlzk`qFD#uVc0gA1FIZ^{;%GtF3;olNUcST~$$`T00NaUJC!=vcP7TyqGYbhn)7H zHvvuvz#{~K7T4|#a6;~SNCSb_z#sqfk5J@~ZT=ro&Yz9`|3`kgKmWg6erwSG2b2M| zPhxL>Sbyyx!MZ)tXmmf;AP$!+D^p=31@$vgD&L-T;;C|JalR1y`PXuT0A;ekEbYt7)&4^GY4Hc`DTt9oS8tHhzDGSG1WaUCSz?ekGd`Z;=Hy;Hk$R zic#iqrQ5zE2ht+H3b;Wp9DHS8=lG@EZ+}`DP0R6gD5}#rPe4Xv;B7yR2DX0b&T3a> z5C_|FenTf$sx7V&tv_HqA!|_~#%w`k5R#guL~Kx0Yf8=j_9Kq_Q#Qk^eeDV3(D6Xn zlp~h8t)xpPg2;)F>Di;AB+X_uqf|GT9+yd9H`-k<)9r`8VJEokriVJ6%r!vgGIYg#ja7$6 z!Hr6HTBM@4A|?!r)^ZP*8dfvnkZ9cV(YVqmEy=So6Ck;6?f^djMiklsFbDkUSFI_y zCi3gFe8OAp*)-@yMBVFB#Fg7wd*U3UpF5@g4QjNrv6rM|pT zz+a%Uk&5XO1}G0zH%>wOZW* zP1haFLUZNzlVWX8foiouv#q&t+F%L;hEYU8n1qAg*f&}hmd67h3e3PedI<@PM*sw+ zp;(KtIH6B^U{##@OhRyM!<7-VRj? z+|(&~7AUg)1*nMPDitDc0hY#K{?FJ0t9N*AVbx~jcn^M9=7jd^14qCO--G`Cu> zNIWPi3muvI=c{^)h;5UbX|1D|K2;XJjGdOp4Kr@bG6~vL$LiK6;*?KUi1_1oZGxnE zX)Oz|fo71x{Xqgsh^1wyjIrbs)2X}CD!fu>hzM7)GQ2>AkLcWN@ z0BE1*!e~6DWHW8q@>a$nBH$d8#6rYBg$>6Vs79EfN_5+9wAYCx zZd=)Q4XGW?n!kOMzXHLGP3c-oQ}t=FXcdlxOwSlf*o0MoEC>*yVfhqTevMWg>p4py zSk&55$7f4?jnXjYDGf_!AU}2(UrR=?D*TSOEs#?&O|QG0Gb}xMm?f_4z-Tp#axe!e z05IoKJmPkXNIu;ZlMC6Uts{S=dJL3y{ON=pj2rulVq!g3IYeHt z)Fg;iqou33u6}ExHj`Qss$%qw8sXznbV$wF5Qr-)?@U)7pS?={a#*@z+Geqh2tL=| zdq=ZrCE_ZngM&t5FdB>oPh6BvR5P5}txg)#{XlHqaBbo_r@epv%L3i3*_$~);_lD1 z&ukpj$F*)L{*ridt80daB!Qe}@Njaf@TV>cO(PA0Ys39TIt`fsrT#n>J~bz|jew7I zl1zu|GG5-ge+p7lDllTx$2TKj8xvot!t|k9PPm?m4$^`8wDN-8f$MRaLZFcT&V$jX zf_OCYDPBNal$AfjHimz+tW$ry-MyFE(va)uu6`y@I)@c>5PbHka2CL@{&R4lPHRJR z@e?dT^2hEZCE9owfBC6z{h`dY1}@@E70{m@^ac@`|7tN%=O#!x-9ON;nD?k8Ep%rX zr~G<}v5yIJwtf!e()sqMnolL~8C~3-`6H23FO<)2*54_Sx|tFMVPf6xD{$-DtjO3 zs`+dwaO(WeUjZ75@vlEm&e4&Gqw}AB=SvDZ(DU6$KjQs^cfSV@^l{*!iaO0W^E*dp zA4#6E9W^yE|8^OGT$cDvaZe&+(%)Od!t|EUze z9#+5z5I6qeef;Vw0%b1l@u$nehabh_t2P5a%N-u(yF7Nm97Ucxu*{xa`2^nn#XkXh zgVzQKJ#kFRFfR8;mF?ROIrZB==3GlQwrn4&;wsR092%oS&q@Yex;FdI>4s^9FaF`> z)G~Uw^baaqoN3&6X*~y={huVo3)Z;6Dxdp&y6ovWB{xG89u*6#X8qCLKGGVT(gFolt8^a|#&Q!_~& zvs8$Pj(=-VM$sHN$^yIw28RCR_s~W1VGZiVC9v#f6gNi0bWNZ;isM9-nUl^{E3%A%@*n$KMVaUlS=rBmr}&G z9vz$9Sv3P(GM|(cXT`Ct{%57dP?s3>xug+!)Q;JzVOpzO=~jYzUm558LfprO5D(Ly zAWrVwUC#;u>HP30g~WOY-rf6@o*g6GEY8EXG>|aFrMaBsA1fCKKuqV@u1yn=U5|z->vIZstU*eeg!mXVl za}gtYI&u1)NhAIM>0f+Tc{;^Y90?q|1rs-kruQ3%( zL2Ln@t#OD>pw1lTFGw&PP%CXH?hohqKcirlO8THG+VSkE59aBm=xX8^G*(Y1$Pl@C zvgpSCMDAMDdp+S*+w_rY*zK4+azcWFPZe_)uQnAV=(Q8$M|U18E0*#4U_!+D;rHvE z`;rHvoju$GiFK4yTZYYND`C`gVFjcr*TcWdFp;I+Pa7KH;@KFynl`LKliAFQaVRjl}< zqJy^Jb1qzX|6PIPw-Sw&u0Pt3C5=%RwqN|#cRk50!Fv<&Zn3XYKMxHZl`SPJ<+rF{ z134(wxKvJ&`Xj;8?|jp2YE5xdb=z=H<#Y!()_S zb@j*1p<*Ev)IK-iYgQ^}pDnkIv1oZn$zvU_zU-h}gv+K5)<6@*M8#P~aI?PT3~glRxZMn*W*JO_{F*`|^?aDW)ixqRT-4((~61xbSs@lHyiJIgO{8 zO<3|$0I|8)tdIQcE&ORHPV9@FC}-@#^#^kHk!JI;J-ye z`&%RujjmsmXW9n+<6*C8v^05ubm&LslgizfZ7YmL7qO%UE>21$j(5QVY z0}yFGdk{5#b?frY`ie7pd?l(L3fcyM5WoTP?oN4=-PKSMTNXPgBj#OYb{f^9k>rN& zxUrB^*CwG9(aPm{+S=N%m$uOJs^DyGHy1jxk1awuDY;5 zp|T*YL)tRdTHd5ZJ|9|b-Wa4c&-$z1%9yXSz2VQTm<(@CdE1^cU_PV&*nSnjz0Rxt z{Y6hWDO7Fo^4<#MU3nWPaEmVW55ip>F+s4U2OFM44@jTqr7tkg_I9&+$0h_rC;ee) zXLUICGt0dzH6;LG_SOD_23uwI<$)b}y$$ta=jTi&;3U_bHH#rGb_vL%$&p#s@Ncck z4F|Qw%>}=7apVtO>{Y0Sunf=`jG{vgU*~PPA`Uvo+qqh(gu>gT6uSjA3`e^l^vL0t zlBx%h^iyu*QF{*}!ywVHhMa{V?lo+W*^SF-!jz(_IFn~(@yfRvgz_?j@kcwgB#Yc@ zOZ+i9h*5gnjYi==c2`-H4C6q(S@o0Xv*9RZ1+1604ff4Fg zjr<0d@NY>%b^!${AGud&He5g;gj0%wz>#a;AQ)FCb{FJ$8#NV6`DW-X-{k{g2&Npl zMgaQ$;D~xCA2i*jcW>bPt_TG4X*(74-AVvCa2@o0Nf^yU4U@23p+RErg1!s4mgHRk zNp}3tqrwnzkTA);dD1rxpKo9EPR)g;=H3IPUJHYOHZRItU3W@AIVE_399bXoT&N?y z&QRW=cigVGdf{{O=eo<^c^-cFd=Kc_vfv8H^5Xy1PIkz`BB+X6CtZm3MmzeC@p~4| z?d0ekD}E}-m8n5$A{r& zHhM~G@NoZ5Yn@(#!{vYr2LEjcaB2?w>Y%!WEV}Ahrlv^0j5Nop3bfV(ZS-#cCV zZ1m#lTX8_xed>|%o*5hKd6~F$VmUspPS4jEQ;OkZ-bjjsC zjD~$IN?Xnkqj(c0c{dR>O%QNQPGRQ&eJ>c>) zaAr1d+cb;+@#DHLm3xeSwRWk~vMo?XwEhOGCnDJ@YjmnbBv~!ozjOR!M*t$YY3tMN zAO2f{6%$>hH!>)<^nQNMfA^p9zR*^w;loD2Y_YWY^1>v};wIg(ghG*KpTDsZlhhFs5odDYlM+qu=bl&kGbEZYKHn%8L zEVwO;#Rk{8mSUFIugLmDc8U(-$wJD;Sls+vCCqIdt&oL z3-uFtu1n?NOHTAN+v}-tZ#m@+aNUFyHW~(>-f~xTC1-9uCUw1(LBsr=94}8=5~n0> zY8bL61eT5mx6n|!f7)F#`Hr-4)M4su?D6moS*Xz#bTk_eCNMH!<)X${#pc~94(_849|ehH$2S*?g_A{tB@dSmyczy0vd)Eqjhe19;6@>t zhFYAa$^lnPsR8ByYY-bOW8`FnQhS6-K(BXOQT7#-;QseIVD1jCh`kJjr3}!yyS+x6mD`06opr&XvF1jEsQ+d80j`)$$CkH@8ns@^Fo zNBPkISIj(E17_7jF>{)6i{!61{Tk1*n<&uASc8gIA2?Y5cAlLAO3_OA=8eh;pZHxC zb{$5Ct;4tfQTFfhc%hyASL7q`K7)4tZeKTHb4r|joIfGNy(!=a-OqH`{Bk2U)sbm6 z{I!vz3)L6I+eN~E7_1JBC-5lNsjm(Gd~5q7W|q8F7S6fwNv!kZ;gN?>IZ>6qI!4ty zn{K;qk+$vA-Ix$xf~Ug#hm?NoTXzBRu0-=DsVZ@f|fP-LKg3lj8NDtd|0 zC_P(MjQZbA{-3ee|3^muF6i?Gk&aV>H&@;={N9~B_GE}aj^QrTCIF(&MCe1i!r1j` zw4OuR-}}W;A;U0yE@6oEH7I$hgfkp|^LflGr-Z4szxNoF%~j<*95{9H=EHZp>Rfp9 z-Q^hCu&Qa}e)U_RzRT?_pw~O*GM}2{n&_lZHB$8>wPj!FPnIibL;wBEcLrAwUK@_g zZ?5Oh^LBlCv+Z9asq?uik|>x}JDKI|BT%|8r={mcvpYKCpEK6LzLpyMe%o}~c*$OC z&zBS9H5MsnB>-R!GK@&ffuL-7)SJ!dn^ zfknu{7+k=h$t%^+9vjxn#xP?eTJ`-gdSjn!AM{=6h2cd&(ODUR`f~8JnLb`0M?a9aC zr?~`jI*0ZQS8CWprxWt_I55<{iu7b$wT76-(FO!YmTRHkBok))i8TUTzS0YYDO(5h z)rpmfkeH-c+r0~-W^Wpd*!W!bRpiG-gJ*L;iU$|vtP4eA?_UVSe-s!nOAJrNJj-!g zpj04mnplkGTuBfA_X3gN+of|kv+Ik@+`fk7-S->{oy7gThemA2WwWf}3ywIEG@(Q;d8{?635_f36 z_r*SEZ}DHg+?`)GqD`R~3bTmb)Lj~EB9CcWx457UH)a#tz!i{RqEkchVy10E#rh@e zb6GC1LCA#H6*`FTb;n1Cjp(8beT*#P(F>No6|OIR_RrXs7Pt^&Rl}Fw@c*{Ju+~H6 z5~y40QOSNuP)5&1yGNJKn~|j_QTz6fMq*xmMpWBqxj$X@Qg@{D&QeY-O*Oo==#ninYcBFjAOEjdF; zKN#OSrdcp>p;CUWH0pg&O~g3nI((=+JpsgN_`by=A43KGFw)m%QN+~b)bYewmZLll(n)(!T`Mt8_;rJ{^TCzi}|Y`7Ajo=oP0xEiN;r^Sdp=B~Yq zF%_8_nI#(7id;S%6sNJQZ%Y3wV3_M=7@m46xwS`S#fG@N{xMt$Ubke;-+F0sH?G3r zfFyPeCWhdcv~zw1wlsY_xhqgF$~s8FvuU4#9c>fW_Ia!&!!+lZYGc_T;Sf8yKVt}n z2I8xgRE@`~YWkE<*IC8{c@`4ZlSKEN42HM68WUH|k!u6^0j z%hnqH23tyCI{a5ItBJzQCHuQ)F_D;pwg+<&sigm&QZ#RQ+NvIEV+_- zMoYi7TsiCH$umLPo*`bT#U}fxw8i$zu1t!cb?s)Uc_-YHQDtHYRtFyOUK91kOUq}0 z1UIh#75+%%-*b78$_XT_j@s+#UnS7y4l~Hp2wVydlXt(G`DPGlGdcKpYyr+k+0Y2w z5*5PkBy4w{01I`xtDIIA`vbcIMI&+@fUl7 zjkR;Q6Pn(6YxHOOz^nsCBb5vIn#Yngj&E>3T@_Lc(6;I*7_{H7b2=6iih$iiD@|>w zh4|Mh7n>Ty6&iL$>4eor*rxXlp57v+zWZ0K3|u=J8>c@&#zFaQWh#wt(R;#`(43y4#Jgjmr?mVW$)f4uKx(F zVSm|3?`G2{BIwMzI6049S^>3We>Jwc#6V*EeKRMA#x1)y`k`s~Y2oaZ6}fLW(n`7~ zf=g~Bt85Wcr;f-gOXrvr&gpKGWeE@t1+jBk#=kZ~38H^4$Gcl+v4rKDV4|S=sUDmh z?5s>J>*9DJ@Uy63-;j635rcz+5gcP!1O1b*wR`rjNgYqJoTL-9x!s{%U~5?uRtqVW z<^K-Wd;jU-aQMjqg>&ZS(3%EbC~jSJq$ry_IGPSG^fYBo*+N;1EkY0JQtoY8kZqKA zPBTK%s;1qwzE}!F$7;5wgP{REybk3kw_|y^ z_^9`$H8R~1Fk(5#kV=G?{@8P_nslvV9mpgcXU|xXI0{jhfE-G4S-tox*Ri7_&gH4( z7GB)sDy4>PQbATgwZL5qDQR?ttJXbT`mgLPA-=0yO6l?i>cKM9EOcfRw(mbS1T=DX zyGrwCC=#>TyuabWY2TVx7O^Hj5y}9$JmQAeUcQV~Y3Q7`bj_avvb(sqBe?_xZd-Kt zQEd+Ewp87<1q%fpA77C}B+Q`S1lm06HoxHPMK6wIN!gJhO^E?wa{dB*5{yVp@ z;nk{F6~OgUl>X`HeN=hEC9f1-9QPRi(-gcu-KmbXJO;eex}~WU)R=T6J!0Rxek~!F zQz|hoYch(x_t8YW&M?u|bxRp4U2XjGo|Y&B96NOvmr=W+-ldmn#jEcUBN|vF5ZpWH zuPGh8FhID%G16RPi0D%4CL1nory=<|Mb<{U@?!p2*sq}v77ZPV`JN<{rfC^GWE9P1 zM!q~=dKdt+Wz%jCe(Ayl`9k@Y@bBPj&1(T}1l!$vRdn`}ySd<)w-J@t>GWVH{>MPIuKK{&MOxU`q~1;ZyauPhc1TFur@ zUY;Gl+!tkjTa=|;OY z%;($Wu=f_@=&am)IgADmnU01sJ`lNzj%wAi)@;nkKB1=&cnEs^XCR|H_SIonF1GuP z$W0ytv%MA=H)h&dI`8I*H?HKBo4Tz)n25ZAOdb!34bs&$QEor#pimofmAWh4&LR6j z0((M*zQs#RhT0n^wU=)OpIK@Nd1rIKm4VepL$zvs#jm*9<`9IkEOrz!8s-%YU$35X zGbOcSKMLy6DC2Jy^_;Z7lsT-Zkx&U5voZzvJFRN1zaAfO|1z1O{nXNFHS{7r)jHXq z-ZTAj?x#i(BVp6)l5Nr}_ITD{cN--Gtnihanb+jBWZgac^GM7v_rQ;qbjQ^~!S=Rt zMXx51V;}a4AnaLAm`Pj;%)qDFjwGxq8Om*xgh?M(2wQw%k0~+O&SKPOO@8jMbexZV z=|guTUMEzqc-8U!7ChK`vlC2IkdG@-C7RQQJo@~{VmsWEHafG2dVkBF#%xUh>Dit1 zYUs;X$6q7dU)G>6e5uF&4aJ5@@xaqKVUF+935W)az&uE#8}zLU#*-JCMwoMqcrD@Jf{0CJb2(Y12srhWEUb;2wdHl zV{xnPr7V3Q-~j+!xuq9dz%z`rUKG**4X;%{yTEEIt1=yzKv_XZr;WtAHkjJQ8s=-e z^6p!&Xm`i9Z8%kGj#f&Y7a&4neYDX6!XdG%&Qtue?=%=(#LP*Avs{2juIgxG`zE`Z zSug=Li$-&eibD~Q`Je(mR{mt?$wX+q@#11`K6Tr|{}{|rh0gztxVMaIYi-*_S6zV8 zqQy&#YjKBC+}*9kJ;e(ISOqG$1cC%9u7RRKLtC5>+%3f&O3)zfnO$A)yB6R1an9Id z?7jaoU{2=q%sjd8>%K&Y+2heT?aNrutzY$v&xTfn1x5L78XFty)sYhRI8YR8cXbWN zjl>V2rqBZ*^4-yhOLazVBs>Dl%$pURB^Q5qeb)r@TFq7^@P`+^j$rDV8kz2UbuM!P zYIldgY2<^pmk&6N0T)3_u;$jhzRX9kCNgU4PF=D%6~PhJ+tr;iysUY_z+gQV5VOrF z>DMN%U^3|hUNZ?$i6-)auwhgO)q71l8qPP1ns@4JT+T_i{MjQe z4$ap_i&&a4J8B_)S9sx&0yT2t^;VstKQ(5wgn=$N-7l6(U%D|o-YwF5th(I~opZ1q zowncTNfr9N&Zn~bR391l;uC-Xl@?hJQLZ64rW~pWM;NJaD-Lh$)bOGGj&U_=%l-Tnq1|0O+)%$G#AD_(2OM}E zcZHIQTn+AdHVCwn;B6t6Gx^H7k?rby5kllr@IKSX*R3YlDC5{eCzG!fL)JuXi#pmY8^`hAA$nYg`t5?uwKrUmLzj@l?Il zG@*<&Ex@OL_}o%Bl74!mI(<4>*$Re(xBHEvJ8E7Dqu*`~cNq3+)45&8t60rSTob_C?|-#80bQoZq4WZltc?hi*4DUnm*}mJHhrB5W0{ox95)o z6DvpZGpkgJgh!Gc6LN@asdjTlBu!5x(ca&+C7ky)VOME5c;yTFFq1?v?L(Y0E#Z zehx7t%-V5mQ?m^`{jOnR)Sb9BFN0MZopyAzR`>PNUeHu6E?) zLQq8-G3Au;swb`x&z9nuy?OT_;33fb2bfB1FqBTx12fq7^Bl+If6%r8t+Wp3YNVx{ zN$rYO&h*;K@$ouapMMHL+>^+)zfR)TjX??>bOj)6{X?OKiOjCyKFCfUZ}bPhOsJLdYY zC!4G(b&XzM460J83ZNLrInFyLOqbyev(&Bn<^UriF{NX!s_;Z zu!{o(iOD19zIDO&j107>*|Q=;O;m=gc=jzqxc%{U?pf&Ik$@}t0H?C)$;++}FF_r?E0 z`=`1t+V#9^^4o*U|EbXXf$FJ_2Tczz_1piu2Xy~mfbajxsPN_=ub%a}{W@;`ZhvVn zVz2&Tx=8)&mA(Go!P$R$>2m(i260}=1{RGkm%8sj$JL)V?AR~gdscK(5*UvuKT z8%MVz?jGx2`uR!M{SChi=oYqzL{x672CmA5O>=N+Y`Vjm56I=KAiW(K{gfedPM%0U zP&rz6#eIP{rfB_r(;~$@F=rZpm~QvJ2HYMZJeu4p4I*Sfp`r4uO2&2Lm%)n}$FIM?OmuuN-dQ2k((U0A(~h=1;NM-*GPhJe{5#6>-Qupv7#ERX+S@4kAk zqt16Rvo5yWZ*{^s`q@KESakS-n{q^D7edpp+kw}sRB6<|ER1Ts@FC=DGsC5KXkmq( zL{`V_I_I$UFUezYK7AtJ^;um%sM^8K+Sdw?3-?*DH*H^;qX!d6;tc|3xY~5rYM6O-JPU=X1LuW`jDXNT#5W#Y_6=-h89I@4c#6LNeoZ{v2 zmzVlIbdvJ3i)~cq&4JVSUl6|A{(UyZzDUyP;t?3!vYxM!hocLTsxx&M;L$O7?x?nY zGIZdKWm|dd>3k2bvP`3#AZ(gzA8?z}7a}!(TV%qe(at)+Y(`VPET4;GQ|ce8W`oF; zP#MjOvZ)W=$WvNKVAJ#}T{~BpS`EFlwROI?6CZz_C?zF-66wrZvc?kA;G}_EPKD(4 zD-fwkn|i4{psavtWk%jA7vt5(fNPDP( zS`k$+c~xP7yL_JoTijS2h@zi|3k#>~sndR`@8XYjG2Zsc#POk;&UH^ff zl&b9{6Br-#u{LpIhBW+?<9~LuL2fsi_E*M&n2XN-nZZat4aL%PvFYK)AVch_3-UNJ zs}*d+Rgf>eQwB*^RW)Uexy#@)_FD<6=ex#&Uj(Y3JzopbM&gF5CqK{mkaCuNsDFl( z>(f5|7n1CJTcGll?)8*%wCqaN^b+i}mhmLmz$ahs5wQg%LudU|^CkX}i{+QZi2a!c zB02~tYetS~UZ$;xD}wY-F!7~}&4`D(LxH(GcW^2B`=B5m--f*xuq3YUZG{Ej>nPoP zzD0O0v%a4_95uEiU_0f14zf8F_3KePSLd^pV#m1a)K>C-Eq=K4qS*vDW?SBK>AR}V z>$aDCj(b9r-$&}jDRh}3N(iK_Lq>0Ri`1azoH=*k!)Sy7p><7>uv+-Z3hMBlNlx8# zR9<0C$4CQ-K-%0s9g#UZZmirgCerl*#N;LU5Rr*Rm)ApFfBTXH%Z{VqapJF1>YquF z5Fx#js>H~`8JVo!x}lg}^w-0DY!t?f^I6xj^WA9^9cR;?r=}}6pU9W9Hc7>K^M(u; zBJrvhvWoH9#mC;U?XDffYpZ1Y+fx8(5Ja`(d3sORr|^w`ep@w2jJWba2REiuy`T`J zM0oj-1Wq4o4R#|AWfSu#qfRFZrbZ}e@snADvO%=lHXH2Qf%Y6;goV!Md*&!j=ZImQ z+U1W-pDd4j_tT!+K$Pt%rr0KHN?4N2>T0oY6a2R#+*vf%XXUy`U$*m68r}Z>ON;v2 z<92<`9L2N^ty==Yf`6)H9?jBX-|o5V9DKp=x)T4!t?rD2ZYOU4fiuD4PyCW5f=3Zo zH{&lBWgLxZXR`yjWRjeXGF1b-ePxL$_Lqmt(vLsU9RkpBZE28e-#>%F2e_jwYXj`I zjPo+p>-(2ZzW@Kz1L?tL5vO6DDFy_et_4Q_ zu1X(1e-Tr45Jk1|60M)4&gLG1dvY;@%^9 zhJQ5uLhfhf74Ogem;VVBi7CAGdDdqy@$FvY_NGzUbo3bcs4&I2GBb=0f4jyV-zv;a zC*^$+_Xkb4e;06n6M1|_o?!GGIw2rPQFYg~Orj;z!(kV19ja2++Q#K8-^nihvcX2& zO=qUI@!tAUx`ht9zNnOvNVl?>{rBlK%67g8b7ng06hs+vf-Q6@DYLj?(Dd|s!&)=b zG}+-mpNN6pFc9gRn#DEIB{%*))FqNyqTYQC_Y4R~yD7?T`cxKXz<735Y?d^cS!?nf zO0FltZ`?mOZCIx1&|@90HW;dESq~O|g;vxl@Su8yHBu>3%7AQkvL;ycx^ph@2gkg+ zv@dzIu2wrlK(~j-6=tsU*R&9hzsVHlky~u(~)7fzzzU zR_rbFq)VR)K&mT9r{?!54%B0>JF6tRP-fu_r8FefT6gSygW-F8U}z(EF!HV0qM|24Y2vjFvv18aZ2ZG@^{?*f?^4uChmf2_jpj%C%5gT>3Z1aqgP3WUYE#nm%USR=eeL72 zAO2X`#&VpTUIi(O*UGw5oY~eR*HNlXF1%&2t4eBP{!+QGX~Fs+e*0>$;5#d-wSB#Y zl4}J5MQf*ex94<`ayW8jQ;x@mn+IQ(YrnPs?!x|t9VbRwdpR7R^ax2#KtOPgD=fsg z+T0p4H78vA<{v#9&yk0F#cwqBYnR^M4)ky4cv`5798|tz)Yf2~(Vr9vTdNHM+kWL=S?j(fzw%9y^C+H3{sqUzTb_qen~tNuszr$3ARK zNcp{yO}(~!Gi!wYxRf&-q#ask5ezCA>S_XP_=R2S=Ta(#+#cAI@*PJ^qI>t@bM@X8 z-{i+M+0Nk-z1Bt%d5$@3v zg^j9KEf9I zvzx{`NoaNrnCH90a4^cN!a>UfnV@~tgzx1V0)&DIJy`79DT8{#7K4t#Xs=>J%q;Ji0G~Ga|8S42P9`|0sfT2~V^`@6Vo8p^s?0RZ%v48kz zZ5JYz^F|;$enDw%W1Vu%{Ikz;!Yw0jbK>8l=Hfw1=Yj{@43#5puZ5Ud6Q*ub9;<1? zD&b(K3Ok9DzfX2oy609p6mzs)TxC-vUz<}$Vy0H)x&z0loaM6As@mLa7wS`7S)yF) zh-n!-+f0t?q6~1!xyjJl=&V}hK<7geZ{6DSI~sM8310E``ri}IjQWOL4K@a+o!mD8 z*MkcT9;r#?{4+hY2}7NYe{i~lO1k`Y`fwE-w89fl=0iCG6&yZ#DA{bjWMU9M+ef%g z6#4M{&+0cD2ygVzhSemX0z_S666)ER)%_% zS!v#SuxF*plN<0<(MqNR*k(`mrnObN^kckF->_TR!^+>hRXhYF>DsLP^zxOCZZ&YY z6x3cZ*BT#ggCJ^d<_pQ}z_b(Tov%uD1GN=KC+2(}mHi$)Mu<^qsS3aL=jLy03TSY` z9&Vf*1MgCux%v!ZmRjP47y0gzD3Lvw$!O?q>axR#PCkQ2`pyIXU4;awgEBO$k5JKV zj9w;lRmF+t^blM`+?b}yB#pe_2P&vQ4GB@PVTt?= zH_-z9Q6IogUX@HYAKdTEvfchn-Adt^_P|M`U+k!DKpS$?W3`ISYy{EbNe@h3ZZ7V~ zJjM>?c6xHte|P&0kj&-1ih&`w(ptaX`2N7$78sZdl)l$o5O03F@@_+#Nj}}rxFj7; zlq7Jce$GrSZf$gDtV+LRm}S?Ac>{=me-bC>mHFL#@jVl>!ypdE zZyHqs+;1|!Tq>u59;HYHmRx^xZnJZ|!;2&!DPS@foO9Cb+kEF-AFwAke_nYboiR8< zJ6puq!tCaKP6MUtlEi|NAwd!+^^lt$SP_k#vL%xkSX;k46e{))X9R%SK2#TR%NpHK z(lxpxZb3rnJD$RuHs9+=$p_K9A*b^&ot<-q*xrKH$>un-1`M-5%Xl5o5`CtRj??11 zV<*L({8WZGoY@USgj4TiW7YOA?et*X7j5k@5cQC+wP45e=+AJs$J}Uo=J5E^b(w~w{kyq z#pTcZNI{8opTR5%|23O&5;@{12SF-kz?g2_?de<~+X^0ED+iPt3raO?j~7WzNV$N%2}_x%H{{hxZAYoEDR z>;6qj8oYXiI>qDPSkvF-)nD3&zxZXXQk-tDb1tDWiQCpK*(ME=*7e{ zS%70yU|_+C@f&hYh*+0ml-OqRy{M>Hc8!7cm}q}BX!s6{;)Rx(1*b1J(D0si4I#cV5 znDAdCk-s;(P8#WTfi2z2UWF7Gbyb+HeO}FVTErw)Kp*m)pM>)aq_~*;>iTY?W+lmh1-w`DTK7lzbc7 zLR=5GBjJGpNs+5T6SjQ~?yA4_lz{ywHB#LkN z?R|-N5EB~GqSt71%@%3qNd$=mm4 zED7@s)ljnv*>}F3O3PSeUJBAZe)aU39EE5t9fk4}@fPL^JvH!PE_p>**rvczYD1bH zcT}wPj$ikn8*3hYU0X3PkOj)UIS~Y37$YL*^-cFx;T#t&qQI4>WR2%9K<#D1psLM@ z)h1j9_Go(Lq9Ii_Ox;XKVFUQ%<8Z>Dej8Y>J&=-N3AEVQ8>Nn)PFGes5Zb&&@9UP{ zV#&_~P;WSeAG9p#74|HJ^f19onEIe%NeTS+LmSEwd|41*qhTIpRA{h~!=Lh}8l?{z zd((s2<+n>Yr7@;z7AYF^YZbF` zGE(bz8m6?f4~mMz+zZz!BpXB-9t?eY#}9dews$;r?jZy2R=lEoM87>H!euO1`NBmN zzyBommEyU1e-i~YJ z0ZWQhqB)f8fi?m5v&O~-OEB-O6ElPS=-C}*w3oMzZLG^yoTs2(^sZ;qbR=~wTPlQOIn%@= z&j(D;gqE$H_Khs61S_`G62<(mJx@dkHS~M(autGNuh2Kw+8$9=8h|SoM32(l8dRefqZF=yq-PQzpSfz}!})UM}I)di|*j#kOhqvYiAcW2}^f zg{BsxV_$xLiHZ5o35T5oO<{$p(s_>!$^GVP)BA?C5psHOI}8%r^Ea%DkRbIab$(1z ziCo`AJyPP1JU7x({E4vs$ajzCbrt&+?k_eK=$f0X!$&l%4T-jHrZr`@xW&5L>5CfA z&DDgH>+l24d+hbzM1}XcdyEXd&Ci=o9M?~Fmm!={VoF!%iZbvmx)+RXMt=@+slrC~?$yq<6PXlwqQ1O7C)z(VJ=&QV&1& zsp)(UBOz(x0O>D`lV{C2(%tbz}2I%zNsK>e-> z7z=GEO#;$($#LxukvmD*PQwN&B{VNyOEm%R*+PGRjeALq&~-D??R#)#(ydrYQY4C; z*Vzyc=2qrdEKSQgUBBx6I+AzSfM4RU9hwM%{Z~6R47P1u8q$z8<*+(bMJjjD02aeK8^-(n5%yO&_B zcc$NCT9TQ$#rX+Fcn=4IavO#Bf#Am5gDTNg`+xBsFan{j{fs_4RRO$|J~K}VZ>{_y z6fK+J6RHGJnqRxW3^SjHzV(}Z9MWc!X5<^W%##bniZqNq>DCuNcB~%!te*y-lMe{GGwnM%#{a zVe!K9iD@2+7)fj?(X(0)`17hsQ>eZ4C-f%&W?dsgtc6^h4g-8oga$)$Igx z7tcXJ72?A%tQz}SGr+|m!a%TgnIXU=eq=bDrQud{^Ll1_yXG+|WAR*4Xig*N1nbiDu$#$*d0_PE&?m-jv#zvFNPz)y zB&6VnJC@&6XX5+!qkLDdmooKiJvQ@Y>KSC@00#Dm{NgRxNk`*QXvu_$UVSX*TR4!` zuFsL5q@{RIUo_;Tx4wOuy{}e1X+1_ut+TD5{b@(aQ1PAG1*J(8Uh9L4^N8Z2mnd^~ zC$1d(wCd%uu}8dIlVk}U-dIlsqzyaY92!yplySU?HWQE-Ye}!fuhjxRdGfZ2jew!G zibVmWV9^oFe=$ZY`TMwomD4`oqsQ8EWh5P-T%1qCU&*4+#M+jG!g&PeVt@MmAZWCc z`qdXoJ;IEf51FZF$a5dHz2`s*961TR3Rn*=6{1$#UaL2zxPzihQg|i%ZEGsWG>MC3 z5%3z2MMhBz^_7&VT#Qa!Plj8s3gA{4*FY7lUr2Fea=`^0Nhp?^oHg$Xh!i}0;N<*% zL}&fLV9e|APpF=drU9bVlczk?5pN0()oZNuly)Ve7W7#fuzjM^(3G-SeCVpnuRrDP zK)@BUBAU}^vP?Z`-RSvJ1KAF@t{2}QdK+wzp>9-L<@r(p=~eYGfs|?pRp#6@HG3lUYzbN?`^qEy+pG zK%Oq;O+g~dHkdD*Z8VD$VmZ>JXaFsa)=K~xAMn5G;zvwf5tNYltBiNExs9f3>OFZZ zp2ct^j^;U%!I#zG64En09&JP>TJ*cT9^ShU=(s;BGE(YhQ*@6RLbad0RQSzTpo6Mj z^eoa9J3r&gY05XU{OC%X%oU~#+V5kDUV~0W;MaT!Yz#(@^I)lj#4?!(SLSrGgy4~K zWsN=gZc?VcEcZ78lWEcCx||VH*7uFA(XZ+2=RRRx;LmA_)B7$zF*kB5Z?2Kp_j0W11AUJW zcZDfR0w>?op#&IjnMeZqIIg$sIb1x6=42yEBErNWLt6Y6gZE}1nO~qD<4wgNhu|9~ z-JcclO=cY)8TDlW?p?Xg9AbDCA`O*A@7Yc--Y#@eV~gi1>7)mwR3_#OLk@SYRdNRp z3eLUTo`rz0oO;FO#Ujq!2A@2C_)W~^PS=F_xHj7x1Wpzu$>$watABk^fJOJH6O&k` zhyjXYGs5W-IFtZ(KM2$R8wO>US+kVWHs?ZBuaT$CL^7Vlf_3+4h6Vy=&}E3_x~VVB z5&l2cpt60cd1X`kfk^I6(bF^#U&mVb`{!jPOSJ~C;?0vR9Fp}U^Jr0PdnkjrF9hb@nx z0l6I#I4J3sAQL4@V7ixknX0{5z-_nifOpQnbV(w`nqKY!YCvsy;H=I8y0yjNF4w%* z+{13pmkOj1P+AK^(}4v0s7mWEq37BQah&kPG1xt`6HfA$9>=0DwnYh!X!;$khBHdh^MJOV0UuV4mJ=%8^e=R2kG727) zO({a?4Q@ene11^V=Cs3Kz%$hnRl6y-yh(@Rfhd6%O2JEJ6YW?ob;V=MS!PpU=6K_W zRNTO(7ai5<>|Bf}PYisV&!%AQINe~hy5 zP(~!3{vo7~MED1Vi|=tO*z8n%qoW3lr+3HKZL}j6IcJ>%rm}m_5~c7dLBw>^&o5l! zRA{H*D5_v7-EXZ)Z-#p2*eznuR|U6b*x#oO_9N#UNq6lV&gwb2pQNK%GSKjO?Hl0!%=I1sH2=HviBu zITs&XHL&|~E9t4t;3g+(x(?RZZ+D)yer{)87zgClNYu`5Y+D=ihT&I^e4LXfyu;aG zTr|^h-BM-0bAM%TpX|UzdmPLvE9~KeI<0@+y1=^tnF&bM!+v3*;bDCzNI3pF0pzDz zTbijNf-wI1ol`@@;}^3Hu!Hv`AUta5r~f?OIa*0vn?C+l(a`oGz1WW--$0ljhfwyEAEPe|Wr|01Z?? zl2+U)ysmV@A^_=snD`Y{3xLkg$ws$?ILnlKgb3?>4aH~|MGPAqnHa@>g~~4iI(oWvi^u#vt2Ge zDxd5YFgJ#4guG<+341{FFFJ)KUntaK4n@o|=Pk0OODn4^ZML zA-87eCwDP^cdO*(cGQe?{@FBL*q*0x10xMisM1N2`1iygnnhF(e*V3$$cMV$l224okloa!(B z9?TLz3I+p7gy3=ApQTGdI+DB6{0eJ%oVix_OJa;M`M&GV5!SS!acC>!infp<81PuG zPrh{BEj&AeXE$(SIiZp*b+vU`fEvDP{X9kurMjK#5HuBNhWShH zzF5B*!1GE1F1>%l|BGo72$fk3(P^wBaI_o4MwvNGNnj9V(32L*8a^(A!|pG|2dH;% zAPoLFU>M7SX#q%EmRZXF&~Xw~;MWA{)^NY1@O8a2tujbQ#oQ3J1Y>3Krs|xBvpzM} zaS#@m{q$$PXaV3uqPex7Kitpqjnbv9mjbxG<3$f^;p+z)oT{VMa`om7nkiWY-GdUF zFg)_*+Gw6>W>(|nq~J1T4SEzS^{DI>q4fI6Q+fJ@TLD$7#R_iUBgCm`UcQ_Lm^lCT zE-1s#nsc3tf4EGvl@4w=d3N-Uvc%?niZ8%g_8H)qp61`HVs%?-;lk+JFL!hc-sN$G zalBJphHY(YY5-*xdy;m^1>gw$7^9!JYHuP}TH5Hfe$|9x`n9(9N?o1|uDT3Va7lq% zOFu$V7E7a`H0T-Sw8ChLUb=gSGIRRjd@1Klf}MVDpD^k6xshwSj@LuIY*PA(_4Vc* z)$OluxtP|@UfPQ)wRNb;$?&|BY)a`TFaLSs?cT6zPke)F*Q%N%L4__U#|!<4s!&~L z6PIR~j|(?knc=$G9BEshFb=tn*>ypDP zKCu@5EPgg81$*^#B4Cr}UlvDyLEZx9hr>J8Dp{`4WD%N4KRwmjdBS9MP9&-I2!1}x z9+0jYT0Yl1-lbbFPi)@wQ^x(etu?^CW$zM}&fbGS*kaFA zw7$_AXX+nu?&YR95+j!J%6q=wn_kAYbtcfLw^WPQhdT3T3j9%xIG{8AxwHD=+wMR@ z!uP}>KryvNUbgO+`EPm*K^KAmD7-p*5YKzxNUK&$ZkB$iAB$yiP?@ZV2{q$r##!Rz zDRct_r?kD#8(|YBlJ>n$k;4}!b0Gu}DSC|P>^zR=5&$>z7&BBbQRjZ!7-#v*SEXr3 zqB>9v-J&A3SKrcQ{hptoMHIsvbF!mM0^?~>eW_C~i`)k0YqaG{HO;-4SAE68j3#5? zqA}La6@)aCE~iT4jP5={5jLFSq?FGoW{$Qut3~_p@O9~Pa!@XoF4YB@`F;9fx$x!f z1ZMXoYvTH?eW=OwDV|ybE}rKwcxNHht=qDPw&T)To?%_s)Qr0LTs)ZonPwi}eSDDU@TP1hy{u%8!%hopB*DOai}7_}G=_anjx`WR|WCf>>(6yL;-B5@(Sao2UxoOMXOAQ4y+@b*^5E zN)s6lVAE41Y#8AOE|ad=4H2q65q?~-5fjwp+vf&RTuw=prte57$z!Ud^IeOwRJ#Kn z+pA0|8cnkl&ILLUZ9K|Cc%rT#x@Q`S;}n$P89w-`r*o+T!{C{acP6$Yca~CrPUNX* zevRgHn+8oS(#G9F1kV?BLN( z4$ZzO8%MedrS*56$-61&GxN~%Mix;>uB>RzrK4%&()Gt91$9LL7z0~Dvn4U?Km&iI+pyO zi)GbVx?0p&#_)t#VnIHzb~Ip24f_i2pu{lA3!v`p{o{P5Tus|zCdJBZ{=0DTcX+(Z zxbNKzIQ8r+&%c_{-ywYbaw>5cLYDiY=s~=@NS^fL5W-;N2yM6CF7t^#NCC8|PQ1Xt z)0!h8VkKBkflmN`uR3pbLkkrXM8tFL=XyO;)&TNB`|_GaeWcy9ZeyDw-yZK;3b-Rh zst5^;5@KvKbe`YlO}bYp%nR7CQ7DRYb21 z7yL{0I^nQ>^n0?zYz_DO+FMTW1PXcHD6*mR_AWtrsUVw>K%1!sD7)-MS6JWza`g;5 zm3?z=z27bWtsvic<6yWz2GUt5$bSeeGyD#V3)5iaH1pIwc$_gn;o?U&{Ugrm{C^BW zI*1jqAGWOXs}rJp+La0tL!K)kCtR^2 z=$AGhD04%RlkR{S;+JM-98Lemp2S923y0GZ}_`Z1be7E8ucF4vYJruDnhW?Z?2PX`>T@+hk|lQaryhG z44X=@zqZfC)Tyfuh#+|Zpczq4;GGS7Qz(?nf$Dw$iLp5!iEX$O$6N>8j@0*y-va_* z%58pPi>eKOC+m*t`C*1@I@sPSmWJBFN*gB}G)iCP3k8ZBuUi);=}^-dz4!0qI?7AJ zuT!EDnvcT zdU*OFIIqv#5W^3a!9)P*qTOKGu@9naW=!^y1NwE9O`)JIka(!H%$d6&tWeb1M#D12 zgCsH5bgV*x?aL~o*^8v2ez2BeNiUxl!4heKa@G(lp|46<5rG_1&+))|TLzPy$;~$2 z!2w68KYWKr!|kl*7+aYL+Jn)dA7`vR(y6kle%ZboIc}QF9|tfO3p(`6Cy$zQHxfz8 zNAZpM_~{+W)xM&&0RV#4X)7Pg7?Wy*bPx~)&%`hOetry3sN}=R#;VLfg>_C>61r6Y zfcBDbUhqo@EOmWW?=>jEB6_oeWQf@d%3=1(mMI;}4n^#HQ3_r#3~Q{^i?ZH=c|px? ze>iy4Y~TJ|J0T)nePu}bjcXsb(VrXBMd1|F5j}FEt1404~Tq%Id zHIykN@hu)%`Gy{ypKxxlV_g=m1?eI4DDVN{L%T6@kGJ>ba;!l9VEqbnWpe^(Yn;)e z5(t?5^tBRbKqAwJRh!Lk<7&ZGFqFG1*{Z-Z6M;`bW2Pu~IKB3R+@-4J;>>G4_W61I z0KqY(euohO>-{>m-7x3DH^nYssOQs6=|N^X3iu+ZE}I~=4~oDrA<=I}n*q2n?rz_q z3cWfoK75tgf!J|*N+1avl~i9>6c1`p4tM2(NwP~lR6gPRTR)g6(}mqQC-0iiB28i6T=+I~-YKd+{ayYJ9kEklr8K+#zE zh@9&%7n9m+53qWjZ8}V7fRVwzP&~kxy=e?rex*zz8+unfxppC8`g`pvuMN2h`3X!P zXS;y!#((HyOn?OaAxkDD&TVa^IH-azZ(q_nz?TMXIwU@;_SSP4W%ham;h9^PEkE5Ix6C(a zPPmRvRPE-n*i4OgHmCXc(xZNdQ-kQ#t$)&j0~Blo>3D%b@Uk}#Fmvp+FVKdsvm#Hulb7^Y4?T;S|j z`q(nJ-Hmuds`6xzAs;hK ztmrCSvr2g=#5g>b;LHbGtzU17W`yOAB=SbEKENoql=;+su93WXZ;(>e^mQ~zleN4} zqG2~2oJnKCD7UuLEyR9T?{SIYUBXHZPgXm~&QQ!dHOw%J$G!n=h+LIlnrih|rIHWt zl?<5MUrd&1ZGuf7xfG{Gn0NN-!^vpYX$j79Y03#H-d{6Bqe=-=bj8Dx1J9gexL0yz0Nzx2^HoB7J;+&Q0py@_=Eq(S3s!X0{?tcKgN z@b#*8v+R#v#7nUnyWS*c2Kj`Y$*eUpJVAQYMM*?Oa#QzoeFn2_UcT3EAJX2fE6g<< z{}b`lu>0XrM9!?x%EaURSE(IHaL&+MY(iTL9PPxBgd z`;e%{KYM(>w%;i=&GhknL3A7?a|ABh{EN_j(QB3c0>Xul`HXL>C8w3;(n@MHXoUPUgdG zi^f-ulHOb&<96|mlZW9K*nBPZsz&RQ+=SP!m6$_i)|S$4XE7=1(|zMrE*2+4a{)(N z&TO}ic{N*>FFE%}mjEzaTCGkfOz`b-N3wUtmM>%vS3?8iD^i@+aYO3k!QWFuz%{*3 zGFvAY`G(A0|L}_JBnNT36W%9YOBgW`L{pv`hAcH2c1}jnjh?n|jF+BSZ}$jYK-p5X ze7X(fZ@4T@db6%K>@vDOF?*cVzNzgJr<3qnu5`i_|CDUQaH6|gXj@na*A358HQCN; zaqR!g8=#3VbdAY)g7?P=q0|PzTwGtjVuLzqjSk%NZYo#N2z0g*9Ku(U%$HJIFL=G} z1)&j3EXizYFb_%vjnJeOz@XgC`A3lSY_mouehuPig3mV1;?rmy?P*JSNt~Gra<;I`2)y>&ZKa2HJkA; z+U7&5V3Bl<*-hzo6C~Xa$>!Q>3uJTsspoOiS7=kM_KIH|1PSd_x(MHyqD11Rl3oI&K$gncKGv1 z(;F~q&#Z#{bkV^hH)i^yp~<$Yq3tB*Q>%8lTu9-j4jR<)EthDv=|zbGdAh?|YO*-~eV%rCW2_KjVhtA{+_+^R0D zY3g^Sg@@-dyKZ@aO-B&FX#$|mw(!mN1zsT|#3-_+zG- zx>ri5v(Dm>w{^e~t8zue(-G;7-s6!q5F(eaF-}+iAnplTVO|El0Cx*JE!j^C@5Nq7 z1+cdWg6B|%8&DFuL0k6eaprF|(D9t}APv)w?gVRSDi^7>&xg8L46?})ycJybyJ7?> zlrcPfGqd#Cft(X=Idmc?>l)Dm0NsVVltvf?wz0cy(YGd+UY;YBJHdL^_(Kg( z`9of*i{Sx$7Y==&518@(UrwL5$~&n))(nT-@`D8Fa3W4XgN4YU$EwpB*mA?|AM*Ba;=4h{u+p!M zV~jPDbP|5F3J#aE%s#lgmrfl3-0{^M?Q;)%_t_egCz3!G#+DN`gg!|C=5rK&sAelZ z?KCE&4chbh#_P-0nWsK2sxFuas`kw4Zk~pK=*{Ll+!Qfa5tj+>zy$;vlAA!CQza)5<{sGU9#`WYFp@tTZ`T|=r5 zShj!A;@AGSy@k*t~(%>QOYc4>Cv1P7$h> z&*2_EXT;b&(&3FPl0&{lvD_sJ8q-Rsi*6chfP?W0NKx6%;>IWz@uzaii4Py?teydR zNfwlwVrW2>f|p7Dq}_P*Q*Mmui z)XgIS9n;Zy{XMt7A=`f~jn8d? z=%eQO_E%#QuEi=;1|5U%-lz-mFGtC>OBTMk`5qCO=(V9cl z=9=xvW07~U(B z>-PvVDK9o)UmG$nTipVXD3qYue%@o*q;blObmp=NrSL~EYWDji7TvJ&L)IKnpZ;5g z{|uM!o9@2(@qZX7073^ZOkD{o1hIH+()ZU}(TSR03@`N9xA8jf4i#HP+F%6#%OxOyNaS5*R0w_8#fTOO$o@0Vf?nK%Rd@znbgb|9;d{AOcUj(O$-8e$DI4OV3zyeYVnfAv6n zBckjy1H9khbx7{LmwfmjxxU6$UE?OneV%|x0QfCTJlN{J>%A!1W{C*P6( zKjnR8RNGy*Cp9V*cqrBag+g)n;1noD6C4UbiaWtIKm{pKptuBSaR^d0XrQ>e2M87% zibK%J^FHsrckaxZPqWs1nS4oBa!$_w*gn6#&))l&S@G#mjeE$hU!7C9%M1G$H(hk; zL)UEo+&2B&)qEid;;2Dv2iXT|i1$%`bLvg~nVRL5fbbY8#efC+DY=8Tp(fgu8kT8> zlKUf!0a}sgv=|q+@4Y9kPTtvtley`0(?pit?SOlZ{KdiU9?*15V(gS`jTZDJKzvD6 zmc41uLQlKgA}Urmttw){;%6+qt%Mr%!!hkt0G|m?Gp< zGK}fyTAkcrKq8IQB-Ekk=btPA)7c+prm(XHYOdY0EEuIbQ|k3@hj70ukixn6MD9jq0&Scq<;B;A5q?GTNu!o~~otxAlLdl;UT8-PMst zz_e?mp886tHZq@Ag$N<8kwSJPo&_@UnG=)jeV@nO%6DdgeKlU`+U&X^)K2vqT)yR_ z8pGg?^jy%Wq&5f`@v?y<&$Fww#4Dn`Ts7k*JV^d9ZE!~qo{$rC_Q7a~9F8yCgP0cb zg@RbkMXP5ISzP$hn9BzrSw1N|np-jI>4osFlc5pk18oY4$4f{YG^F>62w~NezwAVGwCBM}tw85DK0-dS{{GGf`tsDE!2=LVIM*mgY)$lEn7z5i* zEKleWe#`gHUXz{3P@FrxKgzx3&qfJ+72Ih3GW14bYQ+Eg*Rh#{TGNQ#expG*ck=l zMD>fe*(*hKhBzse{v=A?YaD}`je=FJMevV)ruU`0)=a|ask2Y+Ra>$XDz&u$Cn9sq zCEy5;oJ&t6bCF?@RFX|Dz8ay}76tmdFqi%N3s+2W!FKZZ;%*HiQIb(94gDIF&6wsq zzn`b?SA%yAbxZbU_o}j7l?ew?p)00c8olHZrA?fKjVifNhuCrFt_249zWI4It^Nvu z@=AH6#%~Jcfj-uqCff;*6~iMTM^=^BPZ`qnT|2@(+VbQQpDn0Ta#$DGmXtlLA@g^P zg=Xst%-RVG-n3L+7$I@O?U{E_i{yf(1C$l*o&R;$Kkw67cDpC4yC|~w?u(QW8mD-pnRmMqdiYaA!1_!*MT)7BZv&fyGYzAz8cr0ccZb?5RRV=|+2fbn4NOs@ zJmnAKFwfh>t68vC9+Ka5!q(@dmE7Ev9uI8Z>$P5_)V4f%RckBnNXuiOof9;jukjdA z;vk@D>&`SVwwGqRDO~MPS>cUI7cO8UOjT|3R*Dh^aCBBlZJBlYZy|F=&owEkMme=q zydq?#-gS&PWmNOjiJGg`x)It9CfaGSiJkGANEbL2QE{In##Q239t;26mBd|S7nD)iES;lFLm za@tM-m6HQdpq*c4p`OrhErWbgp1{=zsETP%NL=oBcFELON@e3jb+Odv9-BdWuG5bK zRjsty3!Eq4v=pImmzMTDj)HgTdc%szyNf= zEf)$S4pcTEML$bW7pATn%=CQiRZq{hI5R=Gb+jC_)(-yobG2C?`;9+c^l>Q(C7&(r zjks>^g1myB!`$8az3F`eL)ZF752QpNB1edY#edXZ(mUD^Kd44_0?bzCV;6M@l&2-P z?Wb~8p>87=`;enn{FIkypMu>h1o+T42D1K1a$No_U-xgn)#ersDcP#*5B-_^cJUIDL z8NLs{35&M+Jeic@rn?kII(-clp`6?)d0cS>(CiowTVzC^%U?-ggjYEXdw;8sXpS0@ zaLOPIAQ%)jT4@tJ&*TA5%*&0OUM=C}a{OhuoMX(S>A*@jTQIIu^V3bf?=2r|h+CR1 zi)Q5%11;&M?Gh;H`L4d#4A}HY2H~LfeHK%LFLYByCY&?4Wp|*xnFn7GyWfsuOVTVY zq{m%5OoSL>)TX*=i(IzfD&^ZpYVYVG9or1>PxIu_C!fvX*8?fRe-5wjw#1!(QLw>I zKlV3(VpDqhUInhy6VV=Uo0QR&B6Y+RRGa<$UZW2=->@JMo-ciDA0h~Uo{)F3+MpyJ!;$tuPi+T>bygj zTsY7~Kwq1717xnH8n(+HE1WQHWfv#4Aocb|O_%^XfwvQ?5(?wN_cS0mUe~>Zm7%?` z0e2RauKE-db%(9aG`?rWG!Hdc84p##B@T4$wYqvtzYVA$SLm%UMqD@U2uNv^?)?FrYsC|chir80_+g+wfr z<{QMA7nT}CHekx`9{PP_0s-RlvLxGz_(D(^Ze-#(N|*eTr=2BCLk~Tv8Qk4S23c_Y z`ee}coWzqvX`pbvxS=jL*lukUPe6XB8@x6jxhPhG)M+|dnLuD(@nk2XWNAzqs69&` zl&BD`sehAjy=bDEM!+GVx@~K0cx6M)&>+EG%$IxLdyecK9dE)DAe%P?F>Jr3myD3c z&9Bh0X&aDLF#mm&Jqod1863Nd^0dFC$VU%{gQ$)@t_3h|hg{?J?||?`*X^n;cCvNU zq)+i!%{T)KLgt=-x?#St^ zq(rwaLVS|Srg*vmk(ELxX;Hji-ylyl>a)}t|csE}U z9jmvhOfYG5(K1r>or5Uhf(B=UWn!5bD4l{swt{DZoaz!FfTdk047g$u0T0V9H#jJH z(Eu{2P}4oDGGYO6R+Q)qZ443?Eff_7$5uITWU9~aM>!U`=shjtJ+!hxb&!EKsSIAh zJ`0WBi4glb>YE{AwC5(^GXvPknZaeHC8m;-(L1g=ojf|ufQldM9!Sj3V3^<{Clcuf zClXDs(QC!ckz(`_JT6yl&D`TKOKF_FdRaCzx-h)~JTx|H*I0Dc5xPVD4|-T$QLq8y zH(BIe2e}kVD=+AG3XZX!+M_DE(PV7wj$FXb^}v`ff#D9NChO)m{;WTh%Ka`J7BJf< z9I$+vdy$EshYllO`*8tW(5+^tmA}|bt2O5kp8Z|lyx5piOV@m!Js=Z~v|du{0Vrp` z>{oQ3pf-bw1yi1g58M(#iY6P$tyHfDwj`$^oG1aJdnhqY zhG!qzAlVxm!fPtlYR8;vW`HtodHslMoAd#peeHYCMAmrSJ&8Vyp0WpkT+n+xh|!!` zq$y2~e-_1dlnc5M$W){Wr@SA!%+yxzI62%#WfZORijEi@zQp8UOtv@CQUj1L1MdoV z&I@Fy;I)`b`35ms)LN!ctaGLkjhgb(yqQy$$UFDoF?nGI8-eJgc@CKIEDdcYy&mBB zc{-qnO*AV3oboUze16#2H?@rm%zA$2HIFl4fQ4^Lh1JIB@2r#Zj@c4!jz4%Dgzou8 zd&r*FEh_0DL-v~mgVL2BhA?`}yiSKFUq(vQwaFlv)*paH0W_;%G4c;KgX}>WJA3a3 zz}Q6gJ`@a$nfht{;uu(iOnL?oP@>o_qtZ_2%(u{`%c)A()$7%6);k`501P@)nL30> z(lCUz%nev9$nbQcsM2IxHx$@tdk@Bgah#&V;LVQ2Cx9F+N_U!_0USDiTKj~r)@^2d z+eh)4+SWp_<~LF7Pv;W%58HV2a4pI$UHiaw^(kWSq(78+8SM|F{~CCBypr5}B;g~d z@$GXJTWjdnhUy#ohtsq(r5w$5!$VTd+Rb4`!+$weZtzu~}zqMTmB+X+;r}p|AUjG>3){9NAq|hW~^1OlqXgk zro>mh{CDAbdsh_)&&HpoHB(f26?$R4*Jz5}YQ^~Qujv1tOUe_GGURno+ZM`R1NY%c zL)u8dZox5t-)y_2UU81K`-Xf6(uTbX4Fuf+JURXW7~V870h|XbXUkJV_)QU&G)y+* z-q}nqDtGqYA#*dg*=l1`9qmB`ke)-YcQ+l5#*p2m+i!pOZrk@t8+k1)L*Zds?bYg0 zyhKd%swbt^MBED(huC`A{a)gPAo<~Lu>N#n^O2gwYlzt`K5F4x>`e3Lv-rSIgmZzd4NzPv zVn$kNS)6C;*eK)SfUa;mTgtilEPnHj7mmNY(WnOy8LZ8z{>$ednOe5Zmm>XMz?Ux1 z#peRsiTcQW>GluMJ~$`o&4+KS6>_dbSNIr|ZRS5(r828*nvnpO1IE@0kw3nx`_@K= zr1i7N{dwQ!{jVLvjUzEiZ*hCv5`8}GVagiV^loio!sC+{1@p~(>h<$!F(+kMzWJcp zfQCw&qlh0;HGN3+G0mZC2*kXr8Bi3EYWn zCrdfLYHqyf3|ybEed+|C<^IIKY zHWBIB+oTJQx_|aJKJdcPDE_&Rlk@@YdbISnfWiAXsC_e1$F+Fp@5TII!|W}C?PSsM zayIl9``>=RHxBw=TE~#^H+z7D&eisDDEaop*WuQYa%W~m4^7I;Gy7u#Z}K@dr3=9R;2sOq zuz;yWHFP+gJu5hw0osXvT?iGF<_J})u zOrJ|qfk-oiJtFQ^U!dWy1RdAHrI@{DTxp{!!sbEt?Seo&4Lx?!E`>I2XJ*z*zzy%q^&Z9y(mA? zgxw^Y^0$XW9OS?$oIdD=S5`pnQVpr_9@RdiqGkZRcfHH-YX>oww4`GPynZ#}5Y1We zPFN1s>8Yr%m7OhpYN7~?D9es6NKfszwV`{R!MKQEeumpCk2dD#(x1Sn|m0= zLh_t0O2$LPI1(JGdz%Z>r~x;HI>g6H$Evms0yWx2CY*sUo{a0~*@ejY6oilhOnwmc z8lfEcgyuJnJ?yNDsYP2XPj`l+ROJ};zAe1BA=G8B!2~d2bwl*2R&~b8nv1RiGynUT z{)AHZ{MFZMY`_tsAt*;hNg+HHi&79{9OSNsu6$W^@T=g8K09~_2W`A$sRLB4wmxiq zFv4F^A~Ne*;uawuKlzy1$cnNqkTNhNC2lXoO)At*&`{D^VN`uEU9~jp(V~6QOkzW| zYVp0i(m4xFWx`CG{xhWnEM60n^9<%kQ-Jk6e>jyus2vk&W3b+Q?IZTwH0o7+(b%%H zw6E)j$Hm$rE_H?xlLtZqCgYKZ8uLX$=Az0zk4=^RH;Q9IUW)eU8hdkp*$JoZ6*wZ$ zR4_L9RF=5Li67wdVTS+LAm-OqU};6n=UuV;QRLY{a;25y3xhgC)!-Map?P%1635to z{?GaJE-Kew#96N zA;UmP516>7)tCBaAV&M-zJFh}`);26;~Tq^NNxgpw4xGsn){C&|K3m#F>TH-dY$Y| z^8{UX%)}Nff40lef%4Wpi-XuQoN`C|EJk3DiAfjo5?t0JG$#zIxp$A85!;u?gQBPsm9?2VNapK#RDCE zRzgWI-G>v~88g@T(_ZEgx$hOBp#kT<8UU zG->q4YZUvp&5&lBkJ<_@wf4dYk!nF7DgSvy|NFX0naT^ll*%UVhTVK1dv=V}>P;1W zPi$lo%o8wN@f^aDM;;q2-zWd|uaqV&4PgqfjKDb8Kp0vT+r2zn>_eo4#AKz9PATtH zsCRAs-pHDeWvB1B{thVe=(LSpF5#@OQi_;m76l*s94OAFpPk5B0-M8nO|i_5iY4^& zsv11CuV?Lg`X~LGZT&p+wh*%vOLIkt0ewYzy6TPe$;zG*PJ_a+L+8_`N$0=haSF-Q z6+TiSq6%%(9seaB8fUc!-jbj2oj|&Ap^v{Pb^TCjn1072BJ6LjGS$TJ-zEQh;%eO| z6I~KLmX?)VRXl5b;>ETG=|No#XoD%gEXlAS#z5~F;vUv$k!2FYSh#6jxI#u`MymolN$T7g+q~jm`=6~882PZ2%u6CCW=*%^vNkLowOx(pM zx10N#>X8s|6#Jm;en$g>jXgwi5Y+^ZjgV1Zam_jdT2^#iI*M_@Tp=Q1L3Py~Kp6>v z>5Ut$!+|7s^T&Wvt3|4C(xBQ-yXVHXuo}BwS3n8xO9at6_ckg2eyR$IA|()~f_`-~ z67n$GkTM_eWg=27Z>ty%y{2};NE1Kv<&^^ z@v(Iw`0MlX5h?fWPSL0cdA%#mj?_3PN*4ylj1|$Y`;Hvrn7JE;{=+KHy$9>Bu#njc z<-$3`%_Pm*VJCesAIcMR?+?J4%2pfYxLJTB znnHj^!+OL$a%K{FrRU-7g0xy{pjo1+imgpl1xB+pkV)bmV`e z8yLe>;Sk4CDkI77KSm5i*7mq*@5AX>CiF&owhQrDz^w(*u20D>cppw}(Dfn;xrd<7 z)w?DTl97c6F;MF8!C4!Z^5_{g7Y7FpmN!n%HOto+(X2b=CTl+^ykk>KR>AYfWjA=u z-UoQz9=L-*Tonh8kgw3A{d4o(2tj?K>Rpt_hY*W`cN)AK(hN`wIeX`AT68k&U8N~T zjzZ0NSi8lkv344;bXEfAHw!$8V6?i(N}p}}Y_=qeMsVO8a@po9#W{J@Ik7vA zy_693R1#5mt+HlA4+jjO>VrEX$VM`RMVXS{Exf6+2+rTle49P@;m`P+6qPx$8b_7+ z`ptI-IBqJM))E@y@xzh{5}J$bI z_KKUXHcj(8;ag=ziZNkAqi3};x;k&4h3X>CW7icpnE?u5kMPx(9<(N}OOQo>&4!kw z;JdEpG-gqq+B=1EvngsA?^HF^!l-CwvYkN&ZbO4*TdmF}$b`fGnKsjkm&CzBXJg;5 z!45}nkmTa>b5>3dEJQR2QVY!+*o5Hz|#h_F7P(!wJ`Gj8ZBNx&~?+w zuit(n#@_wcEZEsCa2j*co-R^;-t^kyGshvHMEjuQ7@!<3s9;91uT}IQe3RdjC7>YMyJ!! zuZTsamfamlxJTMu3rZTZa7y=6YE%9(1si4J(NfY#Os4i_rnnGlpCImN zkH~k(p207>s{u~e`Qui&sJ-A(MS1tyjjpi08{BCC>-Ciu6cd#*Jx=*XO{A6uO@!$o zVtL1|82MT+y2kaJ=+k0fX~gV{wT^CmBSKwAcu#IP#_VZqfxG37o{Q1-b(BAh(Cdb9#6eUJB7J?SYCE@Q|5lxD(vyMB0>O~su?<0agKLGC7s=&4Xj3emu< z0em5b#F*4z;whUv`Q{jzlt$EbDGS|5;Ck$P5)#smm|rFMV3pQXis_vqUxy!YPZ-~R z)o$Ausw@8@$rJimP_rC~!VU32Y-g2h?)xShKZ z)-~3y+{F>|G`Cvd6?nr3+`AEh13A)onT2r_Y_fr4Q|A7ITb0ReH#-cCG`IzWhex}Q zOY*v7@`mS%5A|zLt>#C1r_r@QOWlJ;d1({%?z4W%9zBr6_3M)<81KL;q=0Dw!GS-O6Di1yjfu z*ljLi$U9|{?^$8?e$qr;LzCvBOQ%tU&pz=6ThKTRVCw{UOUb*qf6e z)wL_Z>e6dnc^vT&?pYVV*e*v@J~G*x{~}uDaXJ#;amULVf-io+BO{M%e}`wSJ{Ko z#gp-ObrrEz)SEhFOrDX-B?%niqpx(1)cV9VoD!dy1u8UezHw}>ovdz9dm;roFCxX= z_5jyU>Bb-8I#at`hCw}q>LC52Z>6!qVIN>`i{BoMk2NrGxtm7a*J1a#EW?2Nf0*Q1 z(WY$&F#U$~rY}^Ru(o)uqFw>76;5!Iw}1PaWfj#J&z0>n(&uyQLfzLnR@HBft-&4e zzzT>G*yuk)}91nA*i>^sFqs^PouhbrJxr z`kbx9V%Ge#XqSrwr1T~C5+%6lqkxkRX|nc$u(fCk#c3)BNU;bRFWUD$EoTI%~&$cavQWnaSd#y3!di2sx zeQ!Y7Wh(eh{UL9aphs!HE^&gKkKhWIuH_Gu>%@S1zURd1T#iOvZ)!G$cjA5KU3k0{ z<;r-N0VCC+7L}hqGC^~2(BB$Gb4d92%9e_Uyxw)*q`;W<7_xm$(3svxs3+?8gN`^> zLj{gqaE$Ok{c|zqzSH;D+yrl;tXf(!FF6738c|1x-M3?mrlSw2!R{w8;|9uz($<>M zc~fpcA4SHtd=JgDahfAZp-`3Oh}<&QY|2R$Ty&ZzWQ!AA+FCdAMPTmLsGTXtz{|Hj z<4iAn)-|3^c!Iojxavon6-NH5kS@$%D4DJ<3B|l#$-UNFkR&VxYTAXJ#Bf+|QrUiN z%BS49!-tq_FpP{`0QrmJG8LwZ^(Rn`7yBsLZCP&XsxDb9wnycR>rq^2n4KEJnZ4ZE zqmRXe5}&5Mxfem|Shw`>rSo{+>1bCl4T0{U4&{;oi5md{xFnfk#dU69Jx`Z7&$a@J zJ7I21I&A(C2smScN9iBKjB(Bvppmzf>+iSgjR)o*rmhuNCP1k%B}bDQv5+Q4`01K| zpRl6euTo+e-OtNafzNPs5Gsk_jWj}+Li1}oLaGbKQ+zr@Q*=Oi?Y6e%JP znvft~O!A*X(H?~HVNLv*s_Z8xb)`jL<%OhGSTYl(lL%$0o6!|#QPyxF9~E2W^sCWU z9VKR1EZ3yHAwwd`conELmU-c~u+C6!p`gAVr$AwpmqE&v$=+wBAtGr{GeP9%flpes7b z$$+LAR~ZY;_bpIQUwG0&m52(n$@Ywy^nP{3hK2rR*u=c~-j~GM_@KDddrblnG}P~f zzU_30A{ql;M95;o2WBVcRSn!kO5}VrxXZ<2`Tm^wl&C4Bs4#H$Jk@hwJH}V}Q(PKH zzlf!BB;4gK`XpVA?kM1pPx?8bV*b^s;xI!|)nGr4znwCEYJB@Ef9x+o1=RsX*w&j9 zoD&X?p~Az~n=`iWT^|qrvrmS6FP%!=m$_H+R#cSSlt+@>^&Kuo?a+b&I4_jHMJLYp z3-n%sl<6^V{9l9;+k{({)42H81uP9bWxlU* zhR{(ZCX5#^5L>#Hr&yYJCDK<|CCE4ZQ6ao=+o^=vP{}ALDb!IpjtUNX`u?VF1S=s| zEoXB@djas?`APW-O4_^%d(PdSTiQ8LIH(T+#Abbgj=8+{Uv}5d%Ssy5!3Hc1?mzzb zp(O&e;Vo77?-$TW`TT7rol`YT%@i4a`Ez{Gq_ z<)6ZHMHp0Ud}>19Y((n8tdS;H!uqB|_hBiHoxz2Q7?42#I0U+r>Gb4C9Hp*UB+4CH zn5v}_W>vM;T^L3u@L1k4ObVh%+A4a%kL1_BY>MI5aA`w;^&9(|cq<>k1|B=q6h%Ga z9tD;Zk$~j3s>QaeGi`2bPL;lvbob;4ak~Ing+xRh7Lt$hPY(^;K^9XC4mt{8kaN!A z(}(`XJ%}7bO#YV`$0?3XEApIsCcimc96r}}c#T4$BJrI?`T&LH4x0H!`jh@^!^g1_ zI81~0(PUi?#~5Kj>>!=F3R@rvH%pCxYVq7(2MJUcBI?Jrzn%oOLk2E7;>X8O{*Jsl zeSl0W@B%OJ=-B7(T*N2;Scui1*vj@?DEv<$^`_@1P;pZ|UG{kqgxHMJ@yT)-3c5R{HTSBD6iN!rQw-Oe+#I+a}V==)(;3<|nbf(qip9M53o zyIPSINK>CJBB5I8E%%dO8W_*#2BDNXK8aNfP*n}gm?2dwjo2|>3CTMPv|H9ED#-K- zt4b7Q36Na87f^XtXV8<%%Ld@P&sBnt#mB>2Ji{8!Aq4$83LQ-9hk6iFtaP|E!fF-x zF&pH!sz@P$zHb5Se1Sdf(33WQY~r41j$16GqPFeOQKA=-5eHDfCK^~u=lI}^5d;}3 zX7Ar=!4RvDmlP5pTitLfhWVyBZZGV(h?_1Xmd%k7myvz;SB>)9%bp6wGzm&;3cvEw zm$PzeA~BFqDsAm@2S6p$QKecpqDVqI31XC}&(Tb^G0TVSZpf%Jdx-UX4p2fy>Lamo zV@WaxO#fc91c$(qu{fPTp%P=K3}Xd39R4TlBShh>D@q*!wnxWNPn1&5n1ioGM3qhqno+h8aGAm4J5=>=C%}0BNW$*SQx9q8cJod(maF@694!dRaG4= zb!h%c3h@2g%_O3}@}&N%(^v!^t?A%8ne(G_cRXHlZZwQbTyCRQi6A9(V61)uN%`x= zKL`P5IT;UjLe5LPbo~}I>ml=bIOB7`d@rJB|FGl7+Sx;Fp73fGe(d&VlbKX_-%O?v zB{v3kzk?}s8d|w9IjL3YEAdq_&|huISqW4BOC#Jbt`80mZeSkLXNF%?o~;K%Ri1Ty zA3*&)Hs)x^adtAV0In3YgM^+^=IW2nfAVK@&}YVsQHp6K0|(? zsanTYPtY~C3TM7wro=MsonNh?d;K`j0iH&za@+e19>;?H3EQ&!19DTrIC3^Mg8XH} zdX2Y~+u@Et*cU-=1T{s@h^s6ShZJgfnH&Vt;;2h=pi7v97=@Pj3SNI{H zhnIw!N<|S#``2P%xkQz=|C2jQKG|ybLKVM;x<0|MId|tmNrQP&TUIG~lqnyRc9a{2 z$c|+aqkAREPneARf494u+zYjtds;r7Z&|_2CiYC4jKos3Yqa(|q;eYJ$XQnuyV3fJ z&Vy;)NaNg~4KO{gezi&_Vqr!vXqqU$MqI^OPiO*!--ejb;GGYk1sfv>yv=UXnX>HUU2$+^P)muPCsB>sJzhwp;Z zh}rUrn_?gmn3Kf2owTi|r%P*!hYWisj$>fIsA$OG(SwkHmcQK(-_ngCr>KgY)? zKQ?sMak;kdBp2C+=PI80r!Ry6v*EW^z9nPHtvBv!KauTPO^!q;l%A=db=WLNk6Yrq zof26wCZsbxI;UQHVDo+Kr*N((F^mu8cdMb7toSF6rO~;`b@eqVU&5VV&69DLx^k&| z5fF|Tww}5&i~~4=JIRAV59xD$HMu*mU28cl9uaLsu3}^ChG`h^w(1^%q*X{lwWb&N ztD;solR}NeRHlu?nh_?UyON!zg9L@<!zeC2@yX+6vxlrzwSYZwCkN_=x zfr{pAhlUqYX+NY;N>PA%`^!#ySzpgsl1eVmS5NK0piE-zIQFiK3|W6APCWU|-O#P! z;(!?DzqW6Hu~YAn%d)eXXJe(SFzu$3rJ;om1j! zlWjlsb;ee@eQ`UcdFbKTTdAMm(o~AOPZC&# zelth9cJrq%2PkTC6xZoaa5cHH`rDTIh*s;7JdI;ZvLq;;d0IE1nr9)FFI{LdaKd*2 z^l+DWxsw4|gdGOBmCA%Sdp2>h1@E59b>eBbWg8BzsTNKqe^(U91i%d0q$Q^V( zA$+x+a^J^a{d{gYr+Pt)j-M4Z(M?4*h~@2=|68u}=SXXrTOH1qh4nsuu$cfBi{8u%Xdl5bOEFXHd=k#n19uqSNoEe{B|cAB_M`3Hk5l*lqbT0u$$vJ2=PkK!coz@P8!zuMo-y(NtKv!Kt^d(YJa5r= z>v`5mIL*9e+iZS+hXD^Ss;bn_lLWWG8hJ~$4+Q8wPGx!B%*f-u*l&Hl-dfp*hgXn% z*MaJ9q1`S%g?O#Ii0A#7S>SlEdWai`B|c8&dEk9ax(m+7m5^#V-ibhSohKaO`pe`l|K8og)ZJ3d+|?5IhR4s#FT}+w z$i*k3%_k@(C?dws%fZVl#>=~=a?7u2FLM&{peE!cBk{%63;3}~GXAADOj+Soj arjE}4=Q=Gf_q6|>1todV+fvyNpZ^EWfmb2` literal 0 HcmV?d00001 From 2c81228ccaceadbe9e84694f5e3239e697670a41 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Wed, 23 Apr 2025 12:20:17 +0200 Subject: [PATCH 03/75] Cleaned up codebase a bit --- README.md | 10 +- src/s2-self-certification/config.py | 36 ++++-- src/s2-self-certification/connection.py | 25 ++-- .../controllers/__init__.py | 4 + .../controllers/builder.py | 15 +++ .../controllers/controller.py | 12 ++ .../controllers/frbc_controller.py | 9 ++ .../controllers/pebc_controller.py | 72 +++++++++++ src/s2-self-certification/log.py | 3 +- src/s2-self-certification/main.py | 36 +++--- src/s2-self-certification/message_handlers.py | 121 ++---------------- src/s2-self-certification/orchestrator.py | 76 ++++++----- src/s2-self-certification/server.py | 14 +- .../test_suite/__init__.py | 2 + .../test_suite/frbc_test_cases.py | 31 +++++ .../pebc_test_cases.py} | 91 ++++--------- .../test_suite/test_suite.py | 71 ++++++++++ 17 files changed, 365 insertions(+), 263 deletions(-) create mode 100644 src/s2-self-certification/controllers/__init__.py create mode 100644 src/s2-self-certification/controllers/builder.py create mode 100644 src/s2-self-certification/controllers/controller.py create mode 100644 src/s2-self-certification/controllers/frbc_controller.py create mode 100644 src/s2-self-certification/controllers/pebc_controller.py create mode 100644 src/s2-self-certification/test_suite/__init__.py create mode 100644 src/s2-self-certification/test_suite/frbc_test_cases.py rename src/s2-self-certification/{test_suite.py => test_suite/pebc_test_cases.py} (62%) create mode 100644 src/s2-self-certification/test_suite/test_suite.py diff --git a/README.md b/README.md index c0be785..7b523ed 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,12 @@ ## Getting started -## Todo \ No newline at end of file +## Todo + +## Development + +### Executing with Hot Reload + +```bash +watchmedo auto-restart --pattern "*.py" --recursive --signal SIGTERM python ./src/s2-self-certification/main.py ./src/s2-self-certification/config.yaml +``` diff --git a/src/s2-self-certification/config.py b/src/s2-self-certification/config.py index ed99bd2..0b49f15 100644 --- a/src/s2-self-certification/config.py +++ b/src/s2-self-certification/config.py @@ -1,26 +1,43 @@ +import logging from typing import Optional import yaml from pydantic import BaseModel +from s2python.common import ControlType as ProtocolControlType +logger = logging.getLogger(__name__) -class NoSelectionConfig(BaseModel): + +class BaseTestConfig(BaseModel): pass -class PEBCConfig(BaseModel): +class NoSelectionTestConfig(BaseTestConfig): + pass + + +class PEBCTestConfig(BaseTestConfig): status_update_frequency: int status_update_frequency_buffer: int = 5 -class FRBCConfig(BaseModel): +class FRBCTestConfig(BaseTestConfig): pass -class ControlTypeDetails(BaseModel): - no_selection: Optional[NoSelectionConfig] - pebc: Optional[PEBCConfig] - frbc: Optional[FRBCConfig] +class ControlTypeTestConfig(BaseModel): + no_selection: Optional[NoSelectionTestConfig] + pebc: Optional[PEBCTestConfig] + frbc: Optional[FRBCTestConfig] + + def get_control_type_config( + self, control_type: ProtocolControlType + ) -> NoSelectionTestConfig | PEBCTestConfig | FRBCTestConfig: + return { + ProtocolControlType.NO_SELECTION: self.no_selection, + ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: self.pebc, + ProtocolControlType.FILL_RATE_BASED_CONTROL: self.frbc, + }[control_type] class DeviceDetails(BaseModel): @@ -30,7 +47,7 @@ class DeviceDetails(BaseModel): class Config(BaseModel): device_details: DeviceDetails - control_types: ControlTypeDetails + control_types: ControlTypeTestConfig def load_config(config_path) -> Config: @@ -38,5 +55,6 @@ def load_config(config_path) -> Config: try: config = yaml.safe_load(stream) except yaml.YAMLError as exc: - print(exc) + logger.error("Failed to load yaml config file.") + raise return Config.model_validate(config) diff --git a/src/s2-self-certification/connection.py b/src/s2-self-certification/connection.py index 68f068e..60bdff4 100644 --- a/src/s2-self-certification/connection.py +++ b/src/s2-self-certification/connection.py @@ -2,20 +2,16 @@ import json import logging import threading -from typing import Type import uuid +from typing import Type import websockets -from websockets.asyncio.connection import Connection as WSConnection - -from s2python.common import ( - ReceptionStatusValues, - ReceptionStatus, -) +from s2python.common import ReceptionStatus, ReceptionStatusValues +from s2python.message import S2Message from s2python.reception_status_awaiter import ReceptionStatusAwaiter from s2python.s2_parser import S2Parser from s2python.s2_validation_error import S2ValidationError -from s2python.message import S2Message +from websockets.asyncio.connection import Connection as WSConnection logger = logging.getLogger(__name__) @@ -88,7 +84,7 @@ async def _send_and_forget(self, s2_msg: S2Message) -> None: try: await self.ws.send(json_msg) except websockets.ConnectionClosedError as e: - logger.error("Unable to send message %s due to %s", s2_msg, str(e)) + logger.error("Unable to send message %s.", s2_msg.message_type) async def respond_with_reception_status( self, @@ -152,7 +148,6 @@ async def parse_received_message(self, message: str): ) ) except S2ValidationError as e: - logger.exception("Problem whilst validating S2 Message.") json_msg = json.loads(message) message_id = json_msg.get("message_id") if message_id: @@ -169,6 +164,9 @@ async def parse_received_message(self, message: str): status=ReceptionStatusValues.INVALID_DATA, diagnostic_label="Message appears valid json but could not find a message_id field.", ) + + # Raise the error so that we can handle it in the orchestrator + raise e except websockets.ConnectionClosedOK: logger.info("Connection closed by remote. ") await self.stop() @@ -203,10 +201,13 @@ async def receive_messages(self): await self.parse_received_message(str(message)) except websockets.ConnectionClosedOK: logger.info("Connection closed normally by remote.") + self._handle_ws_close() except websockets.ConnectionClosedError as e: logger.error("Connection closed with error: %s", str(e)) - finally: - self._stop_event.set() + self._handle_ws_close() + + def _handle_ws_close(self): + self._stop_event.set() async def get_next_message(self): return await self.message_queue.get() diff --git a/src/s2-self-certification/controllers/__init__.py b/src/s2-self-certification/controllers/__init__.py new file mode 100644 index 0000000..48d60b4 --- /dev/null +++ b/src/s2-self-certification/controllers/__init__.py @@ -0,0 +1,4 @@ +from .builder import ControlTypeBuilder +from .controller import Controller +from .frbc_controller import FRBCController +from .pebc_controller import PEBCController diff --git a/src/s2-self-certification/controllers/builder.py b/src/s2-self-certification/controllers/builder.py new file mode 100644 index 0000000..9faab71 --- /dev/null +++ b/src/s2-self-certification/controllers/builder.py @@ -0,0 +1,15 @@ +from typing import Callable, Type +from controllers.controller import Controller +from s2python.message import S2Message + + +class ControlTypeBuilder: + def __init__(self): + self.control_type = Controller() + + def with_handler(self, msg_type: Type[S2Message], handler: Callable): + self.control_type.add_handler(msg_type, handler) + return self + + def build(self): + return self.control_type diff --git a/src/s2-self-certification/controllers/controller.py b/src/s2-self-certification/controllers/controller.py new file mode 100644 index 0000000..df7ca18 --- /dev/null +++ b/src/s2-self-certification/controllers/controller.py @@ -0,0 +1,12 @@ +from typing import Callable, Type +from message_handlers import MessageHandler +from s2python.common import ControlType as ProtocolControlType +from s2python.message import S2Message + + +class Controller(MessageHandler): + control_type: ProtocolControlType + + def __init__(self): + super().__init__() + diff --git a/src/s2-self-certification/controllers/frbc_controller.py b/src/s2-self-certification/controllers/frbc_controller.py new file mode 100644 index 0000000..68a474e --- /dev/null +++ b/src/s2-self-certification/controllers/frbc_controller.py @@ -0,0 +1,9 @@ +from s2python.common import ControlType as ProtocolControlType +from .controller import Controller + + +class FRBCController(Controller): + control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL + + def __init__(self): + super().__init__() diff --git a/src/s2-self-certification/controllers/pebc_controller.py b/src/s2-self-certification/controllers/pebc_controller.py new file mode 100644 index 0000000..026e0c9 --- /dev/null +++ b/src/s2-self-certification/controllers/pebc_controller.py @@ -0,0 +1,72 @@ +import asyncio +from typing import TYPE_CHECKING, Optional + +from s2python.common import ControlType as ProtocolControlType +from s2python.pebc import ( + PEBCPowerConstraints, +) +from .controller import Controller + +if TYPE_CHECKING: + from connection import Connection + +import logging + +logger = logging.getLogger(__name__) + + +class PEBCController(Controller): + control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL + power_constraints: Optional[PEBCPowerConstraints] + + _power_constraints_received = asyncio.Event() + + def __init__(self): + super().__init__() + + self.power_constraints = None + self._power_constraints_received = asyncio.Event() + + self.add_handler(PEBCPowerConstraints, self.handle_power_constraints_message) + # self.add_handler(PEBCEnergyConstraint, self.handle_energy_constraints_message) + # self.add_handler(PowerMeasurement, self.handle_power_measurement_message) + # self.add_handler(PowerForecast, self.handle_power_forecast_message) + # self.add_handler(InstructionStatusUpdate, self.handle_instruction_status_update) + + async def handle_power_constraints_message( + self, message: PEBCPowerConstraints, connection: "Connection", send_okay + ): + if not self.is_correct_message_type(message, PEBCPowerConstraints): + raise ValueError("Invalid Message Type.") + + logger.info("Received power constraints.") + self.power_constraints = message + self._power_constraints_received.set() + + await send_okay + + # async def handle_energy_constraints_message( + # self, message: S2Message, connection, send_okay + # ): + # if not self.is_correct_message_type(message, PEBCEnergyConstraint): + # logger.error( + # "Invalid Message Type. Expected %s but received %s", + # PEBCEnergyConstraint.message_type, + # message.message_type, + # ) + # raise ValueError("Invalid Message Type.") + + # await send_okay + + # async def handle_power_measurement_message(self, message, connection, send_okay): + # self.is_correct_message_type(message, PowerMeasurement) + # await send_okay + + # async def handle_power_forecast_message(self, message, connection, send_okay): + # self.is_correct_message_type(message, PowerForecast) + # await send_okay + + # async def handle_instruction_status_update( + # self, message: InstructionStatusUpdate, connection, send_okay + # ): + # await send_okay diff --git a/src/s2-self-certification/log.py b/src/s2-self-certification/log.py index b96afcc..0ac2e20 100644 --- a/src/s2-self-certification/log.py +++ b/src/s2-self-certification/log.py @@ -20,6 +20,7 @@ }, "loggers": { "": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, - "s2_connection": {"handlers": ["console"], "level": "INFO", "propogate": False}, + "connection": {"handlers": ["console"], "level": "INFO", "propogate": False}, + "orchestrator": {"handlers": ["console"], "level": "DEBUG", "propogate": False}, }, } diff --git a/src/s2-self-certification/main.py b/src/s2-self-certification/main.py index b23d885..c275317 100644 --- a/src/s2-self-certification/main.py +++ b/src/s2-self-certification/main.py @@ -6,19 +6,16 @@ import asyncio import logging import logging.config -import sys from typing import Dict -from orchestrator import IntegrationTestOrchestrator -from server import S2Server -from message_handlers import ( - Controller, - PEBCController, -) -from s2python.common import ControlType as ProtocolControlType -from test_suite import build_test_suite -from log import LOGGING_CONFIG from config import Config, load_config +from controllers import Controller, PEBCController, FRBCController +from log import LOGGING_CONFIG +from orchestrator import IntegrationTestOrchestrator +from s2python.common import ControlType as ProtocolControlType +from server import S2Server +from test_suite import PEBCTestCase, TestSuiteBuilder +from test_suite.frbc_test_cases import FRBCTestCase logging.config.dictConfig(LOGGING_CONFIG) @@ -39,19 +36,22 @@ async def main(): config: Config = load_config(args.config) control_types: Dict[ProtocolControlType, Controller] = { - ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: PEBCController( - config.control_types.pebc - ), - # ProtocolControlType.FILL_RATE_BASED_CONTROL: create_frbc_handler_manager(), + ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: PEBCController(), + ProtocolControlType.FILL_RATE_BASED_CONTROL: FRBCController(), } - test_suite = build_test_suite() + test_suite = ( + TestSuiteBuilder(config.control_types) + .with_test_case(PEBCTestCase) + .with_test_case(FRBCTestCase) + .build() + ) - controller = IntegrationTestOrchestrator( - available_control_types=control_types, test_suite=test_suite + orchestrator = IntegrationTestOrchestrator( + available_control_types=control_types, test_suites=test_suite ) - s2_server = S2Server("0.0.0.0", 8000, controller) + s2_server = S2Server("0.0.0.0", 8000, orchestrator) await s2_server.start() diff --git a/src/s2-self-certification/message_handlers.py b/src/s2-self-certification/message_handlers.py index 339046c..a7b4634 100644 --- a/src/s2-self-certification/message_handlers.py +++ b/src/s2-self-certification/message_handlers.py @@ -1,25 +1,15 @@ import asyncio from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Callable, - Dict, - List, - Optional, - Tuple, - Type, -) +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type -from config import PEBCConfig -from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole -from s2python.common import ( - ResourceManagerDetails, -) +from s2python.common import ControlType as ProtocolControlType +from s2python.common import EnergyManagementRole, ResourceManagerDetails from s2python.message import S2Message +from s2python.pebc import ( + PEBCPowerConstraints, +) from s2python.s2_connection import AssetDetails, SendOkay -# from s2python.s2_control_type import PEBCControlType - if TYPE_CHECKING: from connection import Connection @@ -35,8 +25,8 @@ class CEMAssetDetails(AssetDetails): # pylint: disable=too-many-instance-attrib available_control_types: Optional[List["ProtocolControlType"]] = None @classmethod - def from_resource_manager_details(self, msg: ResourceManagerDetails): - return CEMAssetDetails( + def from_resource_manager_details(cls, msg: ResourceManagerDetails): + return cls( currency=msg.currency, firmware_version=msg.firmware_version, instruction_processing_delay=msg.instruction_processing_delay, @@ -91,7 +81,7 @@ def receive_message(self, message: S2Message): if self.awaiting: # Set the message first before triggering the event to make sure that the # waiting method gets the message. - awaiting[1] = message + awaiting[1] = message # type: ignore awaiting[0].set() else: logger.debug("Received message but nothing waiting for it.") @@ -153,100 +143,7 @@ async def handle_message( ) -class Controller(MessageHandler): - control_type: ProtocolControlType - - def __init__(self): - super().__init__() - - -class ControlTypeBuilder: - def __init__(self): - self.control_type = Controller() - - def with_handler(self, msg_type: Type[S2Message], handler: Callable): - self.control_type.add_handler(msg_type, handler) - return self - - def build(self): - return self.control_type - - -from s2python.pebc import ( - PEBCEnergyConstraint, - PEBCPowerConstraints, - PEBCInstruction, - PEBCPowerEnvelope, - PEBCPowerEnvelopeConsequenceType, - PEBCPowerEnvelopeLimitType, - PEBCPowerEnvelopeElement, - PEBCAllowedLimitRange, -) -from s2python.frbc import ( - FRBCSystemDescription, - FRBCFillLevelTargetProfile, - FRBCStorageStatus, - FRBCActuatorStatus, -) ROLE = EnergyManagementRole.CEM -class PEBCController(Controller): - control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL - power_constraints: Optional[PEBCPowerConstraints] - config: Optional[PEBCConfig] - - _power_constraints_received = asyncio.Event() - - def __init__(self, config: Optional[PEBCConfig]): - super().__init__() - - self.config = config - - self.power_constraints = None - self._power_constraints_received = asyncio.Event() - - self.add_handler(PEBCPowerConstraints, self.handle_power_constraints_message) - # self.add_handler(PEBCEnergyConstraint, self.handle_energy_constraints_message) - # self.add_handler(PowerMeasurement, self.handle_power_measurement_message) - # self.add_handler(PowerForecast, self.handle_power_forecast_message) - # self.add_handler(InstructionStatusUpdate, self.handle_instruction_status_update) - - async def handle_power_constraints_message( - self, message: PEBCPowerConstraints, connection: "Connection", send_okay - ): - if not self.is_correct_message_type(message, PEBCPowerConstraints): - raise ValueError("Invalid Message Type.") - - logger.info("Received power constraints.") - self.power_constraints = message - self._power_constraints_received.set() - - await send_okay - - # async def handle_energy_constraints_message( - # self, message: S2Message, connection, send_okay - # ): - # if not self.is_correct_message_type(message, PEBCEnergyConstraint): - # logger.error( - # "Invalid Message Type. Expected %s but received %s", - # PEBCEnergyConstraint.message_type, - # message.message_type, - # ) - # raise ValueError("Invalid Message Type.") - - # await send_okay - - # async def handle_power_measurement_message(self, message, connection, send_okay): - # self.is_correct_message_type(message, PowerMeasurement) - # await send_okay - - # async def handle_power_forecast_message(self, message, connection, send_okay): - # self.is_correct_message_type(message, PowerForecast) - # await send_okay - - # async def handle_instruction_status_update( - # self, message: InstructionStatusUpdate, connection, send_okay - # ): - # await send_okay diff --git a/src/s2-self-certification/orchestrator.py b/src/s2-self-certification/orchestrator.py index 0085fb5..99f6c02 100644 --- a/src/s2-self-certification/orchestrator.py +++ b/src/s2-self-certification/orchestrator.py @@ -1,43 +1,28 @@ import asyncio import logging -from types import CoroutineType -from typing import Any, Optional, List, Type, Dict, Callable, Awaitable, Union import uuid +from types import CoroutineType +from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type +from s2python.common import ControlType as ProtocolControlType from s2python.common import ( - ReceptionStatusValues, - ReceptionStatus, - Handshake, EnergyManagementRole, - Role, + Handshake, HandshakeResponse, ResourceManagerDetails, - Duration, - Currency, SelectControlType, ) -from s2python.common import ControlType as ProtocolControlType -from s2python.generated.gen_s2 import CommodityQuantity -from s2python.reception_status_awaiter import ReceptionStatusAwaiter -from s2python.s2_control_type import S2ControlType -from s2python.s2_parser import S2Parser -from s2python.s2_validation_error import S2ValidationError from s2python.message import S2Message -from s2python.s2_connection import AssetDetails, MessageHandlers -from s2python.s2_control_type import PEBCControlType -from s2python.common import ControlType as ProtocolControlType - -# Import just to set log settings -from message_handlers import ( - Controller, - CEMAssetDetails, -) +from s2python.s2_validation_error import S2ValidationError from s2python.version import S2_VERSION -from test_suite import TestSuite + + from connection import Connection, SendOkay +from message_handlers import CEMAssetDetails +from controllers import Controller +from test_suite.test_suite import TestSuite from util import wait_for_event_or_stop - logger = logging.getLogger(__name__) @@ -51,7 +36,6 @@ class IntegrationTestOrchestrator: controller: Optional[Controller] = None controllers: Dict[ProtocolControlType, Controller] - message_queue: asyncio.Queue _tasks = set() # When set the main loop of Connection will trigger the stopping of all `_tasks` @@ -67,12 +51,12 @@ class IntegrationTestOrchestrator: def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], - test_suite: TestSuite, + test_suites: TestSuite, ) -> None: # pylint: disable=too-many-arguments self.controllers = available_control_types - self.test_suite = test_suite + self.test_suites = test_suites self.handshake_message_handlers = { # type: ignore Handshake: self.handle_handshake, @@ -82,6 +66,7 @@ def __init__( self._stop_event = asyncio.Event() def set_control_type(self, control_type: ProtocolControlType): + logger.info(self.controllers) self.controller = self.controllers[control_type] async def process_received_messages(self): @@ -119,7 +104,7 @@ async def execute_test_suite(self): # Wait until the handshake is complete before starting the testing. # TODO: Figure out how to include the handshake process in the testing. if self.controller: - await self.test_suite.execute(self.connection, self.controller) + await self.test_suites.execute(self.connection, self.controller) async def main_loop(self): await self.initiate_handshake() @@ -136,6 +121,29 @@ async def main_loop(self): await self.execute_test_suite() + async def connection_receive_messages(self): + """Wrapping the receive messages method to allow catching of validation errors.""" + try: + await self.connection.receive_messages() + except S2ValidationError as e: + logger.error("S2 Validation Error encountered: %s", e) + except: + logger.exception("An error occurred whilst receiving messages.") + + async def task_wrapper(self, task: Coroutine): + """Uncaught exceptions don't get logged reliably in tasks. This catches all exceptions to log them and kill the controller. + + TODO: Maybe handle the exceptions in a better way... + """ + try: + await task + except: + logger.exception("Exception in task!") + self.stop() + + def create_task(self, task: Coroutine): + self._tasks.add(asyncio.create_task(self.task_wrapper(task))) + async def run(self, connection: Connection): self.running = True self.connection = connection @@ -145,11 +153,9 @@ async def run(self, connection: Connection): self._stop_event = asyncio.Event() # Receives messages and puts them onto the queue. - self._tasks.add(asyncio.create_task(self.connection.receive_messages())) - self._tasks.add(asyncio.create_task(self.process_received_messages())) - - self._tasks.add(asyncio.create_task(self.main_loop())) - # self._tasks.add(asyncio.create_task(self.execute_test_suites())) + self.create_task(self.connection_receive_messages()) + self.create_task(self.process_received_messages()) + self.create_task(self.main_loop()) logger.info("Started tasks") @@ -166,7 +172,7 @@ async def run(self, connection: Connection): self.running = False - async def stop(self): + def stop(self): logger.info("Stopping.") self._stop_event.set() diff --git a/src/s2-self-certification/server.py b/src/s2-self-certification/server.py index b500a52..df2edfe 100644 --- a/src/s2-self-certification/server.py +++ b/src/s2-self-certification/server.py @@ -1,11 +1,11 @@ import asyncio import logging import signal -from websockets.asyncio.connection import Connection as WSConnection -from websockets.asyncio.server import serve as ws_serve from connection import Connection from orchestrator import IntegrationTestOrchestrator +from websockets.asyncio.connection import Connection as WSConnection +from websockets.asyncio.server import serve as ws_serve logger = logging.getLogger(__name__) @@ -42,11 +42,7 @@ async def handle_incoming_connection(self, websocket: WSConnection): async def stop(self): logger.info("Stopping server...") - for task in self._connection_tasks: - task.cancel() - await asyncio.gather(*self._connection_tasks, return_exceptions=True) self._exit_event.set() - await self.orchestrator.stop() async def start(self): loop = asyncio.get_event_loop() @@ -60,3 +56,9 @@ async def start(self): logger.info(f"Websocket server started at ws://{self._host}:{self._port}") await self._exit_event.wait() logger.info(f"Server stopped.") + + self.orchestrator.stop() + for task in self._connection_tasks: + task.cancel() + + await asyncio.gather(*self._connection_tasks, return_exceptions=True) diff --git a/src/s2-self-certification/test_suite/__init__.py b/src/s2-self-certification/test_suite/__init__.py new file mode 100644 index 0000000..8416f3b --- /dev/null +++ b/src/s2-self-certification/test_suite/__init__.py @@ -0,0 +1,2 @@ +from .pebc_test_cases import PEBCTestCase +from .test_suite import S2TestCase, TestSuite, TestSuiteBuilder diff --git a/src/s2-self-certification/test_suite/frbc_test_cases.py b/src/s2-self-certification/test_suite/frbc_test_cases.py new file mode 100644 index 0000000..f7ec407 --- /dev/null +++ b/src/s2-self-certification/test_suite/frbc_test_cases.py @@ -0,0 +1,31 @@ +import datetime +import logging +import uuid + +from config import FRBCTestConfig, PEBCTestConfig +from connection import Connection +from controllers.frbc_controller import FRBCController +from s2python.common import PowerMeasurement +from s2python.pebc import ( + PEBCAllowedLimitRange, + PEBCInstruction, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, +) +from test_suite.test_suite import S2TestCase + +logger = logging.getLogger(__name__) + + +class FRBCTestCase(S2TestCase): + + controller: FRBCController + config: FRBCTestConfig + + def __init__( + self, + config: FRBCTestConfig, + connection: Connection, + controller: FRBCController, + ): + super().__init__(config, connection, controller) diff --git a/src/s2-self-certification/test_suite.py b/src/s2-self-certification/test_suite/pebc_test_cases.py similarity index 62% rename from src/s2-self-certification/test_suite.py rename to src/s2-self-certification/test_suite/pebc_test_cases.py index 3aa3682..4774a4c 100644 --- a/src/s2-self-certification/test_suite.py +++ b/src/s2-self-certification/test_suite/pebc_test_cases.py @@ -1,74 +1,34 @@ -import abc import datetime -from typing import TYPE_CHECKING, Dict, List, Type +import logging import uuid -from message_handlers import Controller, PEBCController -from s2python.common import PowerMeasurement -from s2python.common import ControlType as ProtocolControlType +from config import BaseTestConfig, PEBCTestConfig +from connection import Connection +from controllers.controller import Controller +from controllers.pebc_controller import PEBCController +from s2python.common import PowerMeasurement from s2python.pebc import ( PEBCAllowedLimitRange, PEBCInstruction, PEBCPowerEnvelope, PEBCPowerEnvelopeElement, ) -from connection import Connection - - -import logging +from test_suite.test_suite import S2TestCase logger = logging.getLogger(__name__) -class S2TestCase(abc.ABC): - control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION - - def __init__(self, connection: Connection, controller: Controller): - self.connection = connection - self.controller = controller - - @abc.abstractmethod - def execute(self): - pass - - -class TestSuite: - test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]] - - def __init__(self): - self.test_cases = {} - - def add_test_case(self, test_case: Type[S2TestCase]): - if self.test_cases.get(test_case.control_type, None) is None: - self.test_cases[test_case.control_type] = [test_case] - else: - self.test_cases[test_case.control_type].append(test_case) - - async def execute(self, connection: Connection, controller: Controller): - self.controller = controller - self.connection = connection - - test_cases = self.test_cases[controller.control_type] - - for TestCase in test_cases: - test_case = TestCase(connection, controller) - test_case.execute() - - -class TestSuiteBuilder: - def __init__(self): - self.test_suite = TestSuite() - - def with_test_case(self, test_case): - self.test_suite.add_test_case(test_case) - return self - - def build(self): - return self.test_suite - - class PEBCTestCase(S2TestCase): controller: PEBCController + config: PEBCTestConfig + + def __init__( + self, + config: PEBCTestConfig, + connection: Connection, + controller: PEBCController, + ): + super().__init__(config, connection, controller) async def wait_until_control_type_attrs_set(self): power_constraints = self.controller.power_constraints @@ -109,13 +69,13 @@ async def test_set_limit_ranges_instruction(self): power_constraints_id=power_constraints.id, power_envelopes=[ PEBCPowerEnvelope( - id="pe_test", + id="pe_test", # type: ignore commodity_quantity=limit_range.commodity_quantity, power_envelope_elements=[ PEBCPowerEnvelopeElement( lower_limit=-2000.00, upper_limit=0, - duration=3600000, + duration=3600000, # type: ignore ) ], ) @@ -132,24 +92,21 @@ async def test_set_limit_ranges_instruction(self): async def test_receives_interval_power_readings(self): - if ( - self.controller.config is None - or self.controller.config.status_update_frequency is None - ): + if self.config is None or self.config.status_update_frequency is None: raise ValueError("Status Update Frequency required to test status updates.") logger.info("Waiting for power reading.") try: await self.controller.message_awaiter.wait_for_message( PowerMeasurement, - timeout=float(self.controller.config.status_update_frequency), + timeout=float(self.config.status_update_frequency), ) logger.info("Power reading received.") await self.controller.message_awaiter.wait_for_message( PowerMeasurement, timeout=float( - self.controller.config.status_update_frequency - + self.controller.config.status_update_frequency_buffer + self.config.status_update_frequency + + self.config.status_update_frequency_buffer ), ) logger.info("Power readings test passed.") @@ -158,7 +115,3 @@ async def test_receives_interval_power_readings(self): async def execute(self): await self.test_receives_interval_power_readings() - - -def build_test_suite(): - return TestSuiteBuilder().with_test_case(PEBCTestCase).build() diff --git a/src/s2-self-certification/test_suite/test_suite.py b/src/s2-self-certification/test_suite/test_suite.py new file mode 100644 index 0000000..0192cab --- /dev/null +++ b/src/s2-self-certification/test_suite/test_suite.py @@ -0,0 +1,71 @@ +import abc +import logging +from typing import TYPE_CHECKING, Dict, List, Type + +from config import BaseTestConfig, ControlTypeTestConfig +from connection import Connection +from controllers.controller import Controller +from s2python.common import ControlType as ProtocolControlType + +logger = logging.getLogger(__name__) + + +class S2TestCase(abc.ABC): + control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION + config: BaseTestConfig + + def __init__( + self, + config: BaseTestConfig, + connection: Connection, + controller: Controller, + ): + self.connection = connection + self.controller = controller + self.config = config + + @abc.abstractmethod + def execute(self): + pass + + +class TestSuite: + config: ControlTypeTestConfig + test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]] + + def __init__(self, config: ControlTypeTestConfig): + self.test_cases = {} + self.config = config + + def add_test_case(self, test_case: Type[S2TestCase]): + if test_case.control_type in self.test_cases: + self.test_cases[test_case.control_type].append(test_case) + else: + self.test_cases[test_case.control_type] = [test_case] + + async def execute(self, connection: Connection, controller: Controller): + self.controller = controller + self.connection = connection + + control_type = controller.control_type + + for TestCase in self.test_cases.get(control_type, []): + control_type = TestCase.control_type + test_case = TestCase( + self.config.get_control_type_config(control_type), + connection, + controller, + ) + test_case.execute() + + +class TestSuiteBuilder: + def __init__(self, config: ControlTypeTestConfig): + self.test_suite = TestSuite(config) + + def with_test_case(self, test_case): + self.test_suite.add_test_case(test_case) + return self + + def build(self): + return self.test_suite From 915930464e06c954f649a88cc4365e8c2b76ab49 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Thu, 24 Apr 2025 09:36:19 +0200 Subject: [PATCH 04/75] Implemented way to enable and disable specific control types through config file --- src/s2-self-certification/config.py | 1 + src/s2-self-certification/config.yaml | 6 ++- src/s2-self-certification/connection.py | 24 +++++---- .../controllers/controller.py | 15 ++++++ .../controllers/frbc_controller.py | 23 ++++++++ src/s2-self-certification/log.py | 13 +++-- src/s2-self-certification/main.py | 23 +++++--- src/s2-self-certification/message_handlers.py | 33 +----------- src/s2-self-certification/orchestrator.py | 53 +++++++++++++------ .../test_suite/frbc_test_cases.py | 7 +++ .../test_suite/pebc_test_cases.py | 1 + 11 files changed, 126 insertions(+), 73 deletions(-) diff --git a/src/s2-self-certification/config.py b/src/s2-self-certification/config.py index 0b49f15..fd49d63 100644 --- a/src/s2-self-certification/config.py +++ b/src/s2-self-certification/config.py @@ -9,6 +9,7 @@ class BaseTestConfig(BaseModel): + enabled: bool = True pass diff --git a/src/s2-self-certification/config.yaml b/src/s2-self-certification/config.yaml index bb5b963..615b032 100644 --- a/src/s2-self-certification/config.yaml +++ b/src/s2-self-certification/config.yaml @@ -5,5 +5,7 @@ device_details: control_types: no_selection: null pebc: - status_update_frequency: 10 - frbc: null \ No newline at end of file + enabled : true + status_update_frequency: 10 + frbc: + enabled: true \ No newline at end of file diff --git a/src/s2-self-certification/connection.py b/src/s2-self-certification/connection.py index 60bdff4..5c47bed 100644 --- a/src/s2-self-certification/connection.py +++ b/src/s2-self-certification/connection.py @@ -80,7 +80,6 @@ async def _send_and_forget(self, s2_msg: S2Message) -> None: ) json_msg = s2_msg.to_json() - logger.debug("Sending message %s", json_msg) try: await self.ws.send(json_msg) except websockets.ConnectionClosedError as e: @@ -95,13 +94,12 @@ async def respond_with_reception_status( logger.debug( "Responding to message %s with status %s", subject_message_id, status ) - await self._send_and_forget( - ReceptionStatus( - subject_message_id=subject_message_id, - status=status, - diagnostic_label=diagnostic_label, - ) + msg = ReceptionStatus( + subject_message_id=subject_message_id, + status=status, + diagnostic_label=diagnostic_label, ) + await self._send_and_forget(msg) async def send_msg_and_await_reception_status( self, @@ -153,16 +151,20 @@ async def parse_received_message(self, message: str): if message_id: await self.respond_with_reception_status( subject_message_id=message_id, - status=ReceptionStatusValues.INVALID_MESSAGE, - diagnostic_label=str(e), + status=ReceptionStatusValues.OK, # TODO: Put this back to the correct error. + diagnostic_label="", + # status=ReceptionStatusValues.INVALID_MESSAGE, + # diagnostic_label=str(e), ) else: await self.respond_with_reception_status( subject_message_id=uuid.UUID( "00000000-0000-0000-0000-000000000000" ), - status=ReceptionStatusValues.INVALID_DATA, - diagnostic_label="Message appears valid json but could not find a message_id field.", + status=ReceptionStatusValues.OK, # TODO: Put this back to the correct error. + diagnostic_label="", + # status=ReceptionStatusValues.INVALID_DATA, + # diagnostic_label="Message appears valid json but could not find a message_id field.", ) # Raise the error so that we can handle it in the orchestrator diff --git a/src/s2-self-certification/controllers/controller.py b/src/s2-self-certification/controllers/controller.py index df7ca18..2066d0d 100644 --- a/src/s2-self-certification/controllers/controller.py +++ b/src/s2-self-certification/controllers/controller.py @@ -2,6 +2,11 @@ from message_handlers import MessageHandler from s2python.common import ControlType as ProtocolControlType from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError + +import logging + +logger = logging.getLogger(__name__) class Controller(MessageHandler): @@ -10,3 +15,13 @@ class Controller(MessageHandler): def __init__(self): super().__init__() + def handle_s2_validation_exception(self, e: S2ValidationError): + logger.error("Failed to validate S2 Message.") + logger.error(e.pydantic_validation_error) + + +class BaseController(Controller): + control_type = ProtocolControlType.NOT_CONTROLABLE + + def __init__(self): + super().__init__() diff --git a/src/s2-self-certification/controllers/frbc_controller.py b/src/s2-self-certification/controllers/frbc_controller.py index 68a474e..c0fc684 100644 --- a/src/s2-self-certification/controllers/frbc_controller.py +++ b/src/s2-self-certification/controllers/frbc_controller.py @@ -1,9 +1,32 @@ +import asyncio +import logging +from typing import Optional +from connection import Connection from s2python.common import ControlType as ProtocolControlType +from s2python.frbc import FRBCSystemDescription from .controller import Controller +logger = logging.getLogger(__name__) + class FRBCController(Controller): control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL + system_description: FRBCSystemDescription + + _system_description_received: asyncio.Event def __init__(self): super().__init__() + self._system_description_received = asyncio.Event() + + async def handle_power_constraints_message( + self, message: FRBCSystemDescription, connection: "Connection", send_okay + ): + if not self.is_correct_message_type(message, FRBCSystemDescription): + raise ValueError("Invalid Message Type.") + + logger.info("Received FRBC System Description.") + self.system_description = message + self._system_description_received.set() + + await send_okay diff --git a/src/s2-self-certification/log.py b/src/s2-self-certification/log.py index 0ac2e20..57a578d 100644 --- a/src/s2-self-certification/log.py +++ b/src/s2-self-certification/log.py @@ -10,17 +10,22 @@ "()": "logging.Formatter", "fmt": "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", }, + "short": { + "()": "logging.Formatter", + "fmt": "%(name)s:%(lineno)d - %(levelname)s - %(message)s", + }, }, "handlers": { "console": { "class": "logging.StreamHandler", - "formatter": "default", + "formatter": "short", "stream": "ext://sys.stdout", }, }, "loggers": { - "": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, - "connection": {"handlers": ["console"], "level": "INFO", "propogate": False}, - "orchestrator": {"handlers": ["console"], "level": "DEBUG", "propogate": False}, + "": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, + "connection": {"handlers": ["console"], "level": "INFO", "propagate": False}, + "orchestrator": {"handlers": ["console"], "level": "INFO", "propagate": False}, + "websockets": {"handlers": ["console"], "level": "WARNING", "propagate": True}, }, } diff --git a/src/s2-self-certification/main.py b/src/s2-self-certification/main.py index c275317..fb303bb 100644 --- a/src/s2-self-certification/main.py +++ b/src/s2-self-certification/main.py @@ -8,7 +8,7 @@ import logging.config from typing import Dict -from config import Config, load_config +from config import Config, ControlTypeTestConfig, load_config from controllers import Controller, PEBCController, FRBCController from log import LOGGING_CONFIG from orchestrator import IntegrationTestOrchestrator @@ -19,14 +19,24 @@ logging.config.dictConfig(LOGGING_CONFIG) -logging.getLogger("websockets").setLevel(logging.ERROR) - logger = logging.getLogger(__name__) parser = argparse.ArgumentParser(prog="S2 Self Cert") parser.add_argument("config") +def create_controllers_dict(config: Config) -> Dict[ProtocolControlType, Controller]: + controllers: Dict[ProtocolControlType, Controller] = {} + + if config.control_types.frbc and config.control_types.frbc.enabled: + controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() + + if config.control_types.pebc and config.control_types.pebc.enabled: + controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = PEBCController() + + return controllers + + async def main(): logger.info("-" * 40) logger.info("Starting...") @@ -35,10 +45,7 @@ async def main(): config: Config = load_config(args.config) - control_types: Dict[ProtocolControlType, Controller] = { - ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: PEBCController(), - ProtocolControlType.FILL_RATE_BASED_CONTROL: FRBCController(), - } + controllers = create_controllers_dict(config) test_suite = ( TestSuiteBuilder(config.control_types) @@ -48,7 +55,7 @@ async def main(): ) orchestrator = IntegrationTestOrchestrator( - available_control_types=control_types, test_suites=test_suite + available_control_types=controllers, test_suite=test_suite ) s2_server = S2Server("0.0.0.0", 8000, orchestrator) diff --git a/src/s2-self-certification/message_handlers.py b/src/s2-self-certification/message_handlers.py index a7b4634..fe44457 100644 --- a/src/s2-self-certification/message_handlers.py +++ b/src/s2-self-certification/message_handlers.py @@ -5,10 +5,7 @@ from s2python.common import ControlType as ProtocolControlType from s2python.common import EnergyManagementRole, ResourceManagerDetails from s2python.message import S2Message -from s2python.pebc import ( - PEBCPowerConstraints, -) -from s2python.s2_connection import AssetDetails, SendOkay +from s2python.s2_connection import SendOkay if TYPE_CHECKING: from connection import Connection @@ -17,31 +14,6 @@ logger = logging.getLogger(__name__) -from s2python.version import S2_VERSION - - -@dataclass -class CEMAssetDetails(AssetDetails): # pylint: disable=too-many-instance-attributes - available_control_types: Optional[List["ProtocolControlType"]] = None - - @classmethod - def from_resource_manager_details(cls, msg: ResourceManagerDetails): - return cls( - currency=msg.currency, - firmware_version=msg.firmware_version, - instruction_processing_delay=msg.instruction_processing_delay, - manufacturer=msg.manufacturer, - model=msg.model, - name=msg.name, - provides_forecast=msg.provides_forecast, - provides_power_measurements=msg.provides_power_measurement_types, - resource_id=msg.resource_id, - roles=msg.roles, - serial_number=msg.serial_number, - available_control_types=msg.available_control_types, - ) - - class MessageHandlerNotFoundError(Exception): pass @@ -143,7 +115,4 @@ async def handle_message( ) - ROLE = EnergyManagementRole.CEM - - diff --git a/src/s2-self-certification/orchestrator.py b/src/s2-self-certification/orchestrator.py index 99f6c02..80a5201 100644 --- a/src/s2-self-certification/orchestrator.py +++ b/src/s2-self-certification/orchestrator.py @@ -18,7 +18,6 @@ from connection import Connection, SendOkay -from message_handlers import CEMAssetDetails from controllers import Controller from test_suite.test_suite import TestSuite from util import wait_for_event_or_stop @@ -31,7 +30,7 @@ class IntegrationTestOrchestrator: connection: "Connection" - asset_details: CEMAssetDetails + resource_manager_details: ResourceManagerDetails controller: Optional[Controller] = None controllers: Dict[ProtocolControlType, Controller] @@ -46,17 +45,19 @@ class IntegrationTestOrchestrator: Type[S2Message], Callable[[S2Message, Awaitable[None]], CoroutineType] ] + test_suite: TestSuite + running = False def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], - test_suites: TestSuite, + test_suite: TestSuite, ) -> None: # pylint: disable=too-many-arguments self.controllers = available_control_types - self.test_suites = test_suites + self.test_suite = test_suite self.handshake_message_handlers = { # type: ignore Handshake: self.handle_handshake, @@ -104,7 +105,7 @@ async def execute_test_suite(self): # Wait until the handshake is complete before starting the testing. # TODO: Figure out how to include the handshake process in the testing. if self.controller: - await self.test_suites.execute(self.connection, self.controller) + await self.test_suite.execute(self.connection, self.controller) async def main_loop(self): await self.initiate_handshake() @@ -126,7 +127,10 @@ async def connection_receive_messages(self): try: await self.connection.receive_messages() except S2ValidationError as e: - logger.error("S2 Validation Error encountered: %s", e) + if self.controller is not None: + self.controller.handle_s2_validation_exception(e) + else: + logger.error("S2 Validation Error encountered: %s", e) except: logger.exception("An error occurred whilst receiving messages.") @@ -216,22 +220,37 @@ async def send_select_control_type(self): # TODO: Select the control type in a better way. logger.info("Selecting Control Type.") if ( - self.asset_details is None - or self.asset_details.available_control_types is None + self.resource_manager_details is None + or self.resource_manager_details.available_control_types is None ): - raise Exception("Missing Asset Details.") + raise Exception("Missing Resource Details.") - selected = self.asset_details.available_control_types[0] + controller: Optional[Controller] = None - if selected is None: - logger.error("No suitable control type found.") + while ( + controller is None + and len(self.resource_manager_details.available_control_types) > 0 + ): + control_type = self.resource_manager_details.available_control_types.pop() + if control_type in self.controllers: + logger.info( + "Getting controller %s from %s", control_type, self.controllers + ) + controller = self.controllers[control_type] + + if controller is None: + logger.warning("No suitable control types available. Exiting...") + self.stop() + return - logger.info("Selecting control type %s", selected) + logger.info("Selecting control type %s", controller) - self.set_control_type(selected) + self.set_control_type(controller.control_type) await self.connection.send_msg_and_await_reception_status( - SelectControlType(message_id=uuid.uuid4(), control_type=selected) + SelectControlType( + message_id=uuid.uuid4(), control_type=controller.control_type + ) ) async def handle_rm_details( @@ -239,8 +258,10 @@ async def handle_rm_details( message: ResourceManagerDetails, send_okay: Awaitable[None], ): + if type(message) != ResourceManagerDetails: + raise ValueError("Expected a ResourceManagerDetails instance.") - self.asset_details = CEMAssetDetails.from_resource_manager_details(message) + self.resource_manager_details = message await send_okay diff --git a/src/s2-self-certification/test_suite/frbc_test_cases.py b/src/s2-self-certification/test_suite/frbc_test_cases.py index f7ec407..8978ecf 100644 --- a/src/s2-self-certification/test_suite/frbc_test_cases.py +++ b/src/s2-self-certification/test_suite/frbc_test_cases.py @@ -29,3 +29,10 @@ def __init__( controller: FRBCController, ): super().__init__(config, connection, controller) + + async def wait_for_system_description(self): + await self.controller._system_description_received.wait() + + # TODO: Validate system description + + logger.info(self.controller.system_description) diff --git a/src/s2-self-certification/test_suite/pebc_test_cases.py b/src/s2-self-certification/test_suite/pebc_test_cases.py index 4774a4c..5afd4c7 100644 --- a/src/s2-self-certification/test_suite/pebc_test_cases.py +++ b/src/s2-self-certification/test_suite/pebc_test_cases.py @@ -29,6 +29,7 @@ def __init__( controller: PEBCController, ): super().__init__(config, connection, controller) + async def wait_until_control_type_attrs_set(self): power_constraints = self.controller.power_constraints From 95773c15660ac565f900ab84b3503f037dfc49fb Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Thu, 24 Apr 2025 09:36:31 +0200 Subject: [PATCH 05/75] Setup Uvicorn for package management --- .python-version | 1 + pyproject.toml | 23 +++ uv.lock | 528 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 552 insertions(+) create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d2a2def --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "s2-self-certification" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.11.3", + "s2-python", + "watchdog>=6.0.0", + "websockets>=13.1", +] + +[dependency-groups] +dev = [ + "black>=25.1.0", + "isort>=6.0.1", + "mypy>=1.15.0", + "pyyaml>=6.0.2", +] + +[tool.uv.sources] +s2-python = { path = "../s2-python", editable = true } diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6fd82f5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,528 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload_time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload_time = "2025-01-29T05:37:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload_time = "2025-01-29T05:37:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload_time = "2025-01-29T04:18:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload_time = "2025-01-29T04:19:04.296Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload_time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload_time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload_time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload_time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload_time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload_time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload_time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload_time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload_time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload_time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload_time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload_time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload_time = "2025-01-29T04:15:38.082Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload_time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload_time = "2025-02-26T21:13:14.911Z" }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload_time = "2025-02-05T03:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433, upload_time = "2025-02-05T03:49:29.145Z" }, + { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472, upload_time = "2025-02-05T03:49:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424, upload_time = "2025-02-05T03:49:46.908Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450, upload_time = "2025-02-05T03:50:05.89Z" }, + { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765, upload_time = "2025-02-05T03:49:33.56Z" }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload_time = "2025-02-05T03:49:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338, upload_time = "2025-02-05T03:50:17.287Z" }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540, upload_time = "2025-02-05T03:49:51.21Z" }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051, upload_time = "2025-02-05T03:50:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751, upload_time = "2025-02-05T03:49:42.408Z" }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783, upload_time = "2025-02-05T03:49:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload_time = "2025-02-05T03:49:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload_time = "2025-02-05T03:50:28.25Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload_time = "2025-02-05T03:50:13.411Z" }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload_time = "2025-02-05T03:50:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload_time = "2025-02-05T03:48:48.705Z" }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload_time = "2025-02-05T03:49:03.628Z" }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload_time = "2025-02-05T03:50:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload_time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload_time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload_time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload_time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload_time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload_time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload_time = "2025-02-05T03:50:08.348Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload_time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload_time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload_time = "2025-03-19T20:36:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload_time = "2025-03-19T20:36:09.038Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload_time = "2025-04-08T13:27:06.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload_time = "2025-04-08T13:27:03.789Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload_time = "2025-04-02T09:49:41.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021, upload_time = "2025-04-02T09:46:45.065Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742, upload_time = "2025-04-02T09:46:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414, upload_time = "2025-04-02T09:46:48.263Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848, upload_time = "2025-04-02T09:46:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055, upload_time = "2025-04-02T09:46:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806, upload_time = "2025-04-02T09:46:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777, upload_time = "2025-04-02T09:46:53.675Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803, upload_time = "2025-04-02T09:46:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755, upload_time = "2025-04-02T09:46:56.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358, upload_time = "2025-04-02T09:46:58.445Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916, upload_time = "2025-04-02T09:46:59.726Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823, upload_time = "2025-04-02T09:47:01.278Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494, upload_time = "2025-04-02T09:47:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224, upload_time = "2025-04-02T09:47:04.199Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845, upload_time = "2025-04-02T09:47:05.686Z" }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029, upload_time = "2025-04-02T09:47:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784, upload_time = "2025-04-02T09:47:08.63Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075, upload_time = "2025-04-02T09:47:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849, upload_time = "2025-04-02T09:47:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794, upload_time = "2025-04-02T09:47:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237, upload_time = "2025-04-02T09:47:14.355Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351, upload_time = "2025-04-02T09:47:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914, upload_time = "2025-04-02T09:47:17Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385, upload_time = "2025-04-02T09:47:18.631Z" }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765, upload_time = "2025-04-02T09:47:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688, upload_time = "2025-04-02T09:47:22.029Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185, upload_time = "2025-04-02T09:47:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640, upload_time = "2025-04-02T09:47:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649, upload_time = "2025-04-02T09:47:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472, upload_time = "2025-04-02T09:47:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509, upload_time = "2025-04-02T09:47:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702, upload_time = "2025-04-02T09:47:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428, upload_time = "2025-04-02T09:47:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753, upload_time = "2025-04-02T09:47:39.013Z" }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849, upload_time = "2025-04-02T09:47:40.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541, upload_time = "2025-04-02T09:47:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225, upload_time = "2025-04-02T09:47:43.425Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373, upload_time = "2025-04-02T09:47:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034, upload_time = "2025-04-02T09:47:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848, upload_time = "2025-04-02T09:47:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986, upload_time = "2025-04-02T09:47:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551, upload_time = "2025-04-02T09:47:51.648Z" }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785, upload_time = "2025-04-02T09:47:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758, upload_time = "2025-04-02T09:47:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109, upload_time = "2025-04-02T09:47:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159, upload_time = "2025-04-02T09:47:58.088Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222, upload_time = "2025-04-02T09:47:59.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980, upload_time = "2025-04-02T09:48:01.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840, upload_time = "2025-04-02T09:48:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518, upload_time = "2025-04-02T09:48:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025, upload_time = "2025-04-02T09:48:06.226Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991, upload_time = "2025-04-02T09:48:08.114Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262, upload_time = "2025-04-02T09:48:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626, upload_time = "2025-04-02T09:48:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590, upload_time = "2025-04-02T09:48:12.861Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload_time = "2025-04-02T09:48:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload_time = "2025-04-02T09:48:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload_time = "2025-04-02T09:48:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659, upload_time = "2025-04-02T09:48:45.342Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294, upload_time = "2025-04-02T09:48:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771, upload_time = "2025-04-02T09:48:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558, upload_time = "2025-04-02T09:48:51.409Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038, upload_time = "2025-04-02T09:48:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315, upload_time = "2025-04-02T09:48:55.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063, upload_time = "2025-04-02T09:48:57.479Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631, upload_time = "2025-04-02T09:48:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877, upload_time = "2025-04-02T09:49:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858, upload_time = "2025-04-02T09:49:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745, upload_time = "2025-04-02T09:49:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188, upload_time = "2025-04-02T09:49:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479, upload_time = "2025-04-02T09:49:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415, upload_time = "2025-04-02T09:49:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623, upload_time = "2025-04-02T09:49:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175, upload_time = "2025-04-02T09:49:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674, upload_time = "2025-04-02T09:49:17.61Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951, upload_time = "2025-04-02T09:49:19.559Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "s2-python" +version = "0.5.0" +source = { editable = "../s2-python" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "datamodel-code-generator", marker = "extra == 'development'" }, + { name = "mypy", marker = "extra == 'testing'" }, + { name = "pip-tools", marker = "extra == 'development'" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pylint", marker = "extra == 'testing'" }, + { name = "pyright", marker = "extra == 'testing'" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-coverage", marker = "extra == 'testing'" }, + { name = "pytest-timer", marker = "extra == 'testing'" }, + { name = "pytz" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "sphinx-tabs", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'development'" }, + { name = "types-pytz", marker = "extra == 'testing'" }, + { name = "websockets", specifier = "~=13.1" }, +] +provides-extras = ["testing", "development", "docs"] + +[[package]] +name = "s2-self-certification" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "s2-python" }, + { name = "watchdog" }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "s2-python", editable = "../s2-python" }, + { name = "watchdog", specifier = ">=6.0.0" }, + { name = "websockets", specifier = ">=13.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "isort", specifier = ">=6.0.1" }, + { name = "mypy", specifier = ">=1.15.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload_time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload_time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload_time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload_time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload_time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload_time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload_time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload_time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload_time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload_time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload_time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload_time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload_time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload_time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload_time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload_time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload_time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload_time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload_time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload_time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload_time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload_time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload_time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload_time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload_time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload_time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload_time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload_time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload_time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload_time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload_time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload_time = "2025-02-25T17:27:59.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload_time = "2025-02-25T17:27:57.754Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload_time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload_time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload_time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload_time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload_time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload_time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload_time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload_time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload_time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload_time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload_time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload_time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload_time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload_time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload_time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload_time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload_time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload_time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload_time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload_time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload_time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload_time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload_time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload_time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload_time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload_time = "2024-09-21T17:34:21.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload_time = "2024-09-21T17:32:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload_time = "2024-09-21T17:32:28.428Z" }, + { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload_time = "2024-09-21T17:32:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload_time = "2024-09-21T17:32:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload_time = "2024-09-21T17:32:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload_time = "2024-09-21T17:32:33.398Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload_time = "2024-09-21T17:32:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload_time = "2024-09-21T17:32:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload_time = "2024-09-21T17:32:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload_time = "2024-09-21T17:32:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload_time = "2024-09-21T17:32:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload_time = "2024-09-21T17:32:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload_time = "2024-09-21T17:32:43.858Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload_time = "2024-09-21T17:32:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload_time = "2024-09-21T17:32:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload_time = "2024-09-21T17:32:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload_time = "2024-09-21T17:32:48.046Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload_time = "2024-09-21T17:32:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload_time = "2024-09-21T17:32:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload_time = "2024-09-21T17:32:52.223Z" }, + { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload_time = "2024-09-21T17:32:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload_time = "2024-09-21T17:32:54.721Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload_time = "2024-09-21T17:32:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload_time = "2024-09-21T17:32:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload_time = "2024-09-21T17:32:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload_time = "2024-09-21T17:33:00.495Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload_time = "2024-09-21T17:33:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload_time = "2024-09-21T17:33:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload_time = "2024-09-21T17:33:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload_time = "2024-09-21T17:33:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload_time = "2024-09-21T17:33:06.823Z" }, + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload_time = "2024-09-21T17:33:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload_time = "2024-09-21T17:33:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload_time = "2024-09-21T17:33:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload_time = "2024-09-21T17:33:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload_time = "2024-09-21T17:33:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload_time = "2024-09-21T17:33:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload_time = "2024-09-21T17:33:17.113Z" }, + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload_time = "2024-09-21T17:33:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload_time = "2024-09-21T17:33:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload_time = "2024-09-21T17:33:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload_time = "2024-09-21T17:33:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload_time = "2024-09-21T17:33:24.196Z" }, + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload_time = "2024-09-21T17:33:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload_time = "2024-09-21T17:33:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload_time = "2024-09-21T17:33:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload_time = "2024-09-21T17:33:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload_time = "2024-09-21T17:33:59.061Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload_time = "2024-09-21T17:34:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload_time = "2024-09-21T17:34:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload_time = "2024-09-21T17:34:19.904Z" }, +] From 58e156aaff8676af2902fefdc93c980e351542bf Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Thu, 24 Apr 2025 16:54:52 +0200 Subject: [PATCH 06/75] Created cert framework --- cert.yaml | 2 + .../certificate/certificate.py | 67 ++++++++++++++ .../controllers/controller.py | 30 ++++++- .../controllers/pebc_controller.py | 88 +++++++++++-------- src/s2-self-certification/log.py | 1 + src/s2-self-certification/main.py | 17 +++- src/s2-self-certification/message_handlers.py | 7 +- src/s2-self-certification/orchestrator.py | 65 ++++++++++---- src/s2-self-certification/server.py | 15 ++-- .../test_suite/frbc_test_cases.py | 40 ++++++++- .../test_suite/pebc_test_cases.py | 38 ++++++-- .../test_suite/test_suite.py | 29 +++--- 12 files changed, 311 insertions(+), 88 deletions(-) create mode 100644 cert.yaml create mode 100644 src/s2-self-certification/certificate/certificate.py diff --git a/cert.yaml b/cert.yaml new file mode 100644 index 0000000..27d3b79 --- /dev/null +++ b/cert.yaml @@ -0,0 +1,2 @@ +findings: [] +timestamp: 2025-04-24 16:51:58.485070 diff --git a/src/s2-self-certification/certificate/certificate.py b/src/s2-self-certification/certificate/certificate.py new file mode 100644 index 0000000..c50235f --- /dev/null +++ b/src/s2-self-certification/certificate/certificate.py @@ -0,0 +1,67 @@ +from email import message +import logging +from datetime import datetime +import json +from typing import List, Type +from enum import Enum + +from black.output import out +from pydantic import BaseModel, field_serializer +import yaml + +from s2python.message import S2Message + + +logger = logging.getLogger(__name__) + + +class ComplianceStatus(Enum): + PASS = "PASS" + FAIL = "FAIL" + N_A = "N/A" + + +class ComplianceParameter(BaseModel): + name: str + status: ComplianceStatus + + @field_serializer("status") + def serializer_status(self, status: ComplianceStatus): + return status.name + + +class ComplianceFinding(BaseModel): + message_type: Type[S2Message] + status: ComplianceStatus = ComplianceStatus.PASS + parameters: List[ComplianceParameter] = [] + + def add_parameter(self, param: ComplianceParameter): + self.parameters.append(param) + + if param.status == ComplianceStatus.FAIL: + self.status = ComplianceStatus.FAIL + + @field_serializer("status") + def serializer_status(self, status: ComplianceStatus): + return status.name + + @field_serializer("message_type") + def serializer_message_type(self, message_type: Type[S2Message]): + return message_type.__name__ + + +class ComplianceReport(BaseModel): + timestamp: datetime = datetime.now() + findings: List[ComplianceFinding] = [] + + def add_finding(self, finding: ComplianceFinding): + self.findings.append(finding) + + def generate_certificate_dict(self) -> dict: + return self.model_dump() + + def export(self, filename="cert.yaml"): + with open(filename, "w") as output: + logger.info("Exporting report to `%s`.", filename) + cert_data = self.generate_certificate_dict() + yaml.dump(cert_data, output, default_flow_style=False) diff --git a/src/s2-self-certification/controllers/controller.py b/src/s2-self-certification/controllers/controller.py index 2066d0d..30dbc86 100644 --- a/src/s2-self-certification/controllers/controller.py +++ b/src/s2-self-certification/controllers/controller.py @@ -1,6 +1,11 @@ -from typing import Callable, Type +from typing import Awaitable, Callable, Type +from connection import Connection from message_handlers import MessageHandler -from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + ControlType as ProtocolControlType, + PowerForecast, + PowerMeasurement, +) from s2python.message import S2Message from s2python.s2_validation_error import S2ValidationError @@ -25,3 +30,24 @@ class BaseController(Controller): def __init__(self): super().__init__() + + self.add_handler(PowerMeasurement, self.handle_power_measurement_message) + self.add_handler(PowerForecast, self.handle_power_forecast_message) + + async def handle_power_measurement_message( + self, + message: PowerMeasurement, + connection: "Connection", + send_okay: Awaitable, + ): + + await send_okay + + async def handle_power_forecast_message( + self, + message: PowerForecast, + connection: "Connection", + send_okay: Awaitable, + ): + + await send_okay diff --git a/src/s2-self-certification/controllers/pebc_controller.py b/src/s2-self-certification/controllers/pebc_controller.py index 026e0c9..927fe8e 100644 --- a/src/s2-self-certification/controllers/pebc_controller.py +++ b/src/s2-self-certification/controllers/pebc_controller.py @@ -1,8 +1,12 @@ import asyncio -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Awaitable, Optional -from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + ControlType as ProtocolControlType, + InstructionStatusUpdate, +) from s2python.pebc import ( + PEBCEnergyConstraint, PEBCPowerConstraints, ) from .controller import Controller @@ -28,45 +32,59 @@ def __init__(self): self._power_constraints_received = asyncio.Event() self.add_handler(PEBCPowerConstraints, self.handle_power_constraints_message) - # self.add_handler(PEBCEnergyConstraint, self.handle_energy_constraints_message) - # self.add_handler(PowerMeasurement, self.handle_power_measurement_message) - # self.add_handler(PowerForecast, self.handle_power_forecast_message) - # self.add_handler(InstructionStatusUpdate, self.handle_instruction_status_update) + self.add_handler(PEBCEnergyConstraint, self.handle_energy_constraints_message) + self.add_handler(InstructionStatusUpdate, self.handle_instruction_status_update) async def handle_power_constraints_message( - self, message: PEBCPowerConstraints, connection: "Connection", send_okay + self, + message: PEBCPowerConstraints, + connection: "Connection", + send_okay: Awaitable, ): - if not self.is_correct_message_type(message, PEBCPowerConstraints): - raise ValueError("Invalid Message Type.") - logger.info("Received power constraints.") self.power_constraints = message self._power_constraints_received.set() await send_okay - # async def handle_energy_constraints_message( - # self, message: S2Message, connection, send_okay - # ): - # if not self.is_correct_message_type(message, PEBCEnergyConstraint): - # logger.error( - # "Invalid Message Type. Expected %s but received %s", - # PEBCEnergyConstraint.message_type, - # message.message_type, - # ) - # raise ValueError("Invalid Message Type.") - - # await send_okay - - # async def handle_power_measurement_message(self, message, connection, send_okay): - # self.is_correct_message_type(message, PowerMeasurement) - # await send_okay - - # async def handle_power_forecast_message(self, message, connection, send_okay): - # self.is_correct_message_type(message, PowerForecast) - # await send_okay - - # async def handle_instruction_status_update( - # self, message: InstructionStatusUpdate, connection, send_okay - # ): - # await send_okay + async def handle_energy_constraints_message( + self, + message: PEBCEnergyConstraint, + connection: "Connection", + send_okay: Awaitable, + ): + await send_okay + + async def handle_instruction_status_update( + self, + message: InstructionStatusUpdate, + connection: "Connection", + send_okay: Awaitable, + ): + await send_okay + + +# class PEBCComplianceReportController(PEBCController): + +# def __init__(self, compliance_report: ComplianceReport): +# super().__init__() + +# self.compliance_report: ComplianceReport = compliance_report + +# async def handle_power_constraints_message( +# self, +# message: PEBCPowerConstraints, +# connection: "Connection", +# send_okay: Awaitable, +# ): +# logger.info("TEST") +# await super().handle_power_constraints_message(message, connection, send_okay) + +# finding = ComplianceFinding(PEBCPowerConstraints) +# finding.add_parameter( +# ComplianceParameter("PEBCPowerConstraints Provided.", ComplianceStatus.PASS) +# ) + +# self.compliance_report.add_finding(finding) + +# logger.info() diff --git a/src/s2-self-certification/log.py b/src/s2-self-certification/log.py index 57a578d..bdc0f12 100644 --- a/src/s2-self-certification/log.py +++ b/src/s2-self-certification/log.py @@ -27,5 +27,6 @@ "connection": {"handlers": ["console"], "level": "INFO", "propagate": False}, "orchestrator": {"handlers": ["console"], "level": "INFO", "propagate": False}, "websockets": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "asyncio": {"handlers": ["console"], "level": "WARNING", "propagate": True}, }, } diff --git a/src/s2-self-certification/main.py b/src/s2-self-certification/main.py index fb303bb..fd394ba 100644 --- a/src/s2-self-certification/main.py +++ b/src/s2-self-certification/main.py @@ -4,10 +4,12 @@ import argparse import asyncio +from datetime import datetime import logging import logging.config from typing import Dict +from certificate.certificate import ComplianceReport from config import Config, ControlTypeTestConfig, load_config from controllers import Controller, PEBCController, FRBCController from log import LOGGING_CONFIG @@ -25,7 +27,9 @@ parser.add_argument("config") -def create_controllers_dict(config: Config) -> Dict[ProtocolControlType, Controller]: +def create_controllers_dict( + config: Config, report: ComplianceReport +) -> Dict[ProtocolControlType, Controller]: controllers: Dict[ProtocolControlType, Controller] = {} if config.control_types.frbc and config.control_types.frbc.enabled: @@ -45,22 +49,27 @@ async def main(): config: Config = load_config(args.config) - controllers = create_controllers_dict(config) + report = ComplianceReport(timestamp=datetime.now()) + + controllers = create_controllers_dict(config, report) test_suite = ( - TestSuiteBuilder(config.control_types) + TestSuiteBuilder(config.control_types, report) .with_test_case(PEBCTestCase) .with_test_case(FRBCTestCase) .build() ) orchestrator = IntegrationTestOrchestrator( - available_control_types=controllers, test_suite=test_suite + available_control_types=controllers, test_suite=test_suite, report=report ) s2_server = S2Server("0.0.0.0", 8000, orchestrator) + await s2_server.start() + report.export() + if __name__ == "__main__": asyncio.run(main()) diff --git a/src/s2-self-certification/message_handlers.py b/src/s2-self-certification/message_handlers.py index fe44457..83dd361 100644 --- a/src/s2-self-certification/message_handlers.py +++ b/src/s2-self-certification/message_handlers.py @@ -14,6 +14,7 @@ logger = logging.getLogger(__name__) + class MessageHandlerNotFoundError(Exception): pass @@ -74,13 +75,17 @@ def __init__(self): self.message_awaiter = S2MessageAwaiter() def is_correct_message_type( - self, message: S2Message, message_type: Type[S2Message] + self, message: S2Message, message_type: Type[S2Message], raise_exception=True ): if not isinstance(message, message_type): logger.error( "Handler for Handshake received a message of the wrong type: %s", type(message), ) + if raise_exception: + raise ValueError( + f"Incorrect message type. Expected {message_type} but received {message.message_type}." + ) return False return True diff --git a/src/s2-self-certification/orchestrator.py b/src/s2-self-certification/orchestrator.py index 80a5201..e1f93a4 100644 --- a/src/s2-self-certification/orchestrator.py +++ b/src/s2-self-certification/orchestrator.py @@ -1,9 +1,13 @@ import asyncio import logging +from tracemalloc import stop import uuid from types import CoroutineType from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type +from mypyc.ir.ops import Value + +from certificate.certificate import ComplianceReport from s2python.common import ControlType as ProtocolControlType from s2python.common import ( EnergyManagementRole, @@ -28,9 +32,9 @@ class IntegrationTestOrchestrator: role: EnergyManagementRole = EnergyManagementRole.CEM - connection: "Connection" + connection: Optional["Connection"] = None - resource_manager_details: ResourceManagerDetails + resource_manager_details: Optional[ResourceManagerDetails] = None controller: Optional[Controller] = None controllers: Dict[ProtocolControlType, Controller] @@ -53,6 +57,7 @@ def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], test_suite: TestSuite, + report: ComplianceReport, ) -> None: # pylint: disable=too-many-arguments self.controllers = available_control_types @@ -66,12 +71,18 @@ def __init__( self._stop_event = asyncio.Event() + self.report = report + def set_control_type(self, control_type: ProtocolControlType): logger.info(self.controllers) self.controller = self.controllers[control_type] async def process_received_messages(self): """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.connection is None: + raise ValueError("Connection not set.") + try: while not self._stop_event.is_set(): try: @@ -104,6 +115,10 @@ async def process_received_messages(self): async def execute_test_suite(self): # Wait until the handshake is complete before starting the testing. # TODO: Figure out how to include the handshake process in the testing. + + if self.connection is None: + raise ValueError("Connection not set.") + if self.controller: await self.test_suite.execute(self.connection, self.controller) @@ -113,17 +128,22 @@ async def main_loop(self): if not await wait_for_event_or_stop(self._handshake_complete, self._stop_event): return - logger.info("Handshake Complete") + logger.info("Handshake Complete!") await self.send_select_control_type() - logger.info("-" * 40) logger.info("Starting tests!") await self.execute_test_suite() + logger.info(self.report.generate_certificate_dict()) + async def connection_receive_messages(self): """Wrapping the receive messages method to allow catching of validation errors.""" + + if self.connection is None: + raise ValueError("Connection not set.") + try: await self.connection.receive_messages() except S2ValidationError as e: @@ -134,44 +154,43 @@ async def connection_receive_messages(self): except: logger.exception("An error occurred whilst receiving messages.") - async def task_wrapper(self, task: Coroutine): + async def task_wrapper(self, task: Coroutine, stop_on_complete): """Uncaught exceptions don't get logged reliably in tasks. This catches all exceptions to log them and kill the controller. TODO: Maybe handle the exceptions in a better way... """ try: await task + if stop_on_complete: + self.stop() except: logger.exception("Exception in task!") self.stop() - def create_task(self, task: Coroutine): - self._tasks.add(asyncio.create_task(self.task_wrapper(task))) + def create_task(self, task: Coroutine, stop_on_complete=False): + self._tasks.add(asyncio.create_task(self.task_wrapper(task, stop_on_complete))) async def run(self, connection: Connection): self.running = True self.connection = connection self._handshake_complete = asyncio.Event() - self._stop_event = asyncio.Event() # Receives messages and puts them onto the queue. - self.create_task(self.connection_receive_messages()) - self.create_task(self.process_received_messages()) - self.create_task(self.main_loop()) - - logger.info("Started tasks") + self.create_task(self.connection_receive_messages(), True) + self.create_task(self.process_received_messages(), True) + self.create_task(self.main_loop(), True) await self._stop_event.wait() for task in self._tasks: task.cancel() - await asyncio.gather(*self._tasks) - await self.connection.stop() + await asyncio.gather(*self._tasks) + self._tasks.clear() self.running = False @@ -184,6 +203,9 @@ def is_running(self): return self.running async def initiate_handshake(self): + if self.connection is None: + raise ValueError("Connection not set.") + await self.connection.send_msg_and_await_reception_status( Handshake( message_id=uuid.uuid4(), # type: ignore @@ -197,6 +219,10 @@ async def handle_handshake( message: Handshake, send_okay: Awaitable[None], ) -> None: + + if self.connection is None: + raise ValueError("Connection not set.") + logger.debug( "%s supports S2 protocol versions: %s", message.role, @@ -225,6 +251,9 @@ async def send_select_control_type(self): ): raise Exception("Missing Resource Details.") + if self.connection is None: + raise ValueError("Connection not set.") + controller: Optional[Controller] = None while ( @@ -233,9 +262,9 @@ async def send_select_control_type(self): ): control_type = self.resource_manager_details.available_control_types.pop() if control_type in self.controllers: - logger.info( - "Getting controller %s from %s", control_type, self.controllers - ) + # logger.info( + # "Getting controller %s from %s", control_type, self.controllers + # ) controller = self.controllers[control_type] if controller is None: diff --git a/src/s2-self-certification/server.py b/src/s2-self-certification/server.py index df2edfe..c078c4c 100644 --- a/src/s2-self-certification/server.py +++ b/src/s2-self-certification/server.py @@ -20,7 +20,6 @@ def __init__(self, host, port, orchestrator: IntegrationTestOrchestrator): self._host = host self._port = port self._exit_event = asyncio.Event() - self._connection_tasks = set() self.orchestrator = orchestrator @@ -36,11 +35,13 @@ async def handle_incoming_connection(self, websocket: WSConnection): await self.orchestrator.run(connection) logger.info("Connection closed.") + + self.stop() else: logger.warning("This application only accepts one connection.") await websocket.close() - async def stop(self): + def stop(self): logger.info("Stopping server...") self._exit_event.set() @@ -48,17 +49,15 @@ async def start(self): loop = asyncio.get_event_loop() for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) + loop.add_signal_handler(sig, lambda: self.stop()) async with ws_serve( self.handle_incoming_connection, self._host, self._port ) as ws_server: logger.info(f"Websocket server started at ws://{self._host}:{self._port}") + logger.info("Waiting for RM connection...") await self._exit_event.wait() - logger.info(f"Server stopped.") + logger.info(f"Server stopping.") self.orchestrator.stop() - for task in self._connection_tasks: - task.cancel() - - await asyncio.gather(*self._connection_tasks, return_exceptions=True) + logger.info(f"Server stop.") diff --git a/src/s2-self-certification/test_suite/frbc_test_cases.py b/src/s2-self-certification/test_suite/frbc_test_cases.py index 8978ecf..57d085f 100644 --- a/src/s2-self-certification/test_suite/frbc_test_cases.py +++ b/src/s2-self-certification/test_suite/frbc_test_cases.py @@ -2,6 +2,12 @@ import logging import uuid +from certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) from config import FRBCTestConfig, PEBCTestConfig from connection import Connection from controllers.frbc_controller import FRBCController @@ -9,6 +15,7 @@ from s2python.pebc import ( PEBCAllowedLimitRange, PEBCInstruction, + PEBCPowerConstraints, PEBCPowerEnvelope, PEBCPowerEnvelopeElement, ) @@ -27,12 +34,37 @@ def __init__( config: FRBCTestConfig, connection: Connection, controller: FRBCController, + report: ComplianceReport, ): - super().__init__(config, connection, controller) + super().__init__(config, connection, controller, report) async def wait_for_system_description(self): - await self.controller._system_description_received.wait() + system_description = self.controller.system_description + if ( + system_description is None + and not self.controller._system_description_received.is_set() + ): + logger.info( + "Waiting. %s, %s", + system_description, + self.controller._system_description_received, + ) + await self.controller._system_description_received.wait() + logger.info("Power Constraints is set.") + + async def validate_power_constraints_set(self): + await self.wait_for_system_description() + + finding = ComplianceFinding(message_type=PEBCPowerConstraints) + finding.add_parameter( + ComplianceParameter( + name="PEBCPowerConstraints Provided.", status=ComplianceStatus.PASS + ) + ) - # TODO: Validate system description + self.report.add_finding(finding) - logger.info(self.controller.system_description) + async def execute(self): + logger.info("Starting FRBC Test Case") + await self.validate_power_constraints_set() + logger.info("FRBC test case complete.") diff --git a/src/s2-self-certification/test_suite/pebc_test_cases.py b/src/s2-self-certification/test_suite/pebc_test_cases.py index 5afd4c7..ab33860 100644 --- a/src/s2-self-certification/test_suite/pebc_test_cases.py +++ b/src/s2-self-certification/test_suite/pebc_test_cases.py @@ -2,14 +2,24 @@ import logging import uuid +from certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) from config import BaseTestConfig, PEBCTestConfig from connection import Connection from controllers.controller import Controller from controllers.pebc_controller import PEBCController -from s2python.common import PowerMeasurement +from s2python.common import ( + ControlType as ProtocolControlType, + PowerMeasurement, +) from s2python.pebc import ( PEBCAllowedLimitRange, PEBCInstruction, + PEBCPowerConstraints, PEBCPowerEnvelope, PEBCPowerEnvelopeElement, ) @@ -19,6 +29,7 @@ class PEBCTestCase(S2TestCase): + control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL controller: PEBCController config: PEBCTestConfig @@ -27,11 +38,11 @@ def __init__( config: PEBCTestConfig, connection: Connection, controller: PEBCController, + report: ComplianceReport, ): - super().__init__(config, connection, controller) - + super().__init__(config, connection, controller, report) - async def wait_until_control_type_attrs_set(self): + async def wait_until_power_constraints_set(self): power_constraints = self.controller.power_constraints if ( power_constraints is None @@ -45,10 +56,22 @@ async def wait_until_control_type_attrs_set(self): await self.controller._power_constraints_received.wait() logger.info("Power Constraints is set.") + async def validate_power_constraints_set(self): + await self.wait_until_power_constraints_set() + + finding = ComplianceFinding(message_type=PEBCPowerConstraints) + finding.add_parameter( + ComplianceParameter( + name="PEBCPowerConstraints Provided.", status=ComplianceStatus.PASS + ) + ) + + self.report.add_finding(finding) + async def test_set_limit_ranges_instruction(self): # for limit_ranges in self.power_constraints.allowed_limit_ranges: logger.info("Testing set limit range") - await self.wait_until_control_type_attrs_set() + await self.wait_until_power_constraints_set() power_constraints = self.controller.power_constraints @@ -115,4 +138,7 @@ async def test_receives_interval_power_readings(self): logger.exception("Did not receive power reading within allowed window.") async def execute(self): - await self.test_receives_interval_power_readings() + logger.info("Starting PEBC Test Case") + await self.validate_power_constraints_set() + # await self.test_receives_interval_power_readings() + logger.info("Start") diff --git a/src/s2-self-certification/test_suite/test_suite.py b/src/s2-self-certification/test_suite/test_suite.py index 0192cab..9de514b 100644 --- a/src/s2-self-certification/test_suite/test_suite.py +++ b/src/s2-self-certification/test_suite/test_suite.py @@ -2,6 +2,7 @@ import logging from typing import TYPE_CHECKING, Dict, List, Type +from certificate.certificate import ComplianceReport from config import BaseTestConfig, ControlTypeTestConfig from connection import Connection from controllers.controller import Controller @@ -19,23 +20,27 @@ def __init__( config: BaseTestConfig, connection: Connection, controller: Controller, + report: ComplianceReport, ): self.connection = connection self.controller = controller self.config = config + self.report = report @abc.abstractmethod - def execute(self): + async def execute(self): pass class TestSuite: config: ControlTypeTestConfig test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]] + report: ComplianceReport - def __init__(self, config: ControlTypeTestConfig): + def __init__(self, config: ControlTypeTestConfig, report: ComplianceReport): self.test_cases = {} self.config = config + self.report = report def add_test_case(self, test_case: Type[S2TestCase]): if test_case.control_type in self.test_cases: @@ -44,24 +49,28 @@ def add_test_case(self, test_case: Type[S2TestCase]): self.test_cases[test_case.control_type] = [test_case] async def execute(self, connection: Connection, controller: Controller): - self.controller = controller - self.connection = connection - control_type = controller.control_type - - for TestCase in self.test_cases.get(control_type, []): + test_cases = self.test_cases.get(control_type, []) + logger.info(self.test_cases) + logger.info( + "Executing test suite for %s control type. %s test cases to execute.", + control_type, + len(test_cases), + ) + for TestCase in test_cases: control_type = TestCase.control_type test_case = TestCase( self.config.get_control_type_config(control_type), connection, controller, + self.report, ) - test_case.execute() + await test_case.execute() class TestSuiteBuilder: - def __init__(self, config: ControlTypeTestConfig): - self.test_suite = TestSuite(config) + def __init__(self, config: ControlTypeTestConfig, report: ComplianceReport): + self.test_suite = TestSuite(config, report) def with_test_case(self, test_case): self.test_suite.add_test_case(test_case) From f46cfa4abb7846bc62323d9f54ed9761ff6ded3e Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Fri, 25 Apr 2025 16:35:24 +0200 Subject: [PATCH 07/75] Implemented cert on FRBC and PEBC test cases Centralised no control type tests to a shared test case so that all test cases can be tested for the basic functions. --- cert.yaml | 19 ++- .../certificate/certificate.py | 15 +- src/s2-self-certification/connection.py | 2 + .../controllers/controller.py | 23 +++ .../controllers/frbc_controller.py | 11 +- .../controllers/pebc_controller.py | 8 +- src/s2-self-certification/message_handlers.py | 13 +- src/s2-self-certification/orchestrator.py | 1 - .../test_suite/base_test_case.py | 47 ++++++ .../test_suite/frbc_test_cases.py | 70 +++++--- .../test_suite/pebc_test_cases.py | 151 +++++++++--------- .../test_suite/test_suite.py | 85 +++++++++- 12 files changed, 328 insertions(+), 117 deletions(-) create mode 100644 src/s2-self-certification/test_suite/base_test_case.py diff --git a/cert.yaml b/cert.yaml index 27d3b79..020493a 100644 --- a/cert.yaml +++ b/cert.yaml @@ -1,2 +1,17 @@ -findings: [] -timestamp: 2025-04-24 16:51:58.485070 +findings: +- message_type: PowerForecast + parameters: + - name: Not Provided. + status: FAIL + status: FAIL +- message_type: PowerMeasurement + parameters: + - name: Provided. + status: PASS + status: PASS +- message_type: PEBCPowerConstraints + parameters: + - name: PEBCPowerConstraints Provided. + status: PASS + status: PASS +timestamp: 2025-04-25 16:34:03.628891 diff --git a/src/s2-self-certification/certificate/certificate.py b/src/s2-self-certification/certificate/certificate.py index c50235f..b33e446 100644 --- a/src/s2-self-certification/certificate/certificate.py +++ b/src/s2-self-certification/certificate/certificate.py @@ -2,7 +2,7 @@ import logging from datetime import datetime import json -from typing import List, Type +from typing import List, Optional, Type from enum import Enum from black.output import out @@ -35,7 +35,18 @@ class ComplianceFinding(BaseModel): status: ComplianceStatus = ComplianceStatus.PASS parameters: List[ComplianceParameter] = [] - def add_parameter(self, param: ComplianceParameter): + def add_parameter( + self, + name: Optional[str] = None, + status: Optional[ComplianceStatus] = None, + param: Optional[ComplianceParameter] = None, + ): + if param is None and name is not None and status is not None: + param = ComplianceParameter(name=name, status=status) + elif param is None: + raise ValueError("Either the param must be set or name and status.") + + self.parameters.append(param) if param.status == ComplianceStatus.FAIL: diff --git a/src/s2-self-certification/connection.py b/src/s2-self-certification/connection.py index 5c47bed..fef4e4e 100644 --- a/src/s2-self-certification/connection.py +++ b/src/s2-self-certification/connection.py @@ -207,6 +207,8 @@ async def receive_messages(self): except websockets.ConnectionClosedError as e: logger.error("Connection closed with error: %s", str(e)) self._handle_ws_close() + except asyncio.CancelledError: + pass def _handle_ws_close(self): self._stop_event.set() diff --git a/src/s2-self-certification/controllers/controller.py b/src/s2-self-certification/controllers/controller.py index 30dbc86..746b3d9 100644 --- a/src/s2-self-certification/controllers/controller.py +++ b/src/s2-self-certification/controllers/controller.py @@ -1,3 +1,4 @@ +from email import message from typing import Awaitable, Callable, Type from connection import Connection from message_handlers import MessageHandler @@ -17,13 +18,35 @@ class Controller(MessageHandler): control_type: ProtocolControlType + messages_received = [] + def __init__(self): super().__init__() + def handle_message( + self, message: S2Message, connection: Connection, *args, **kwargs + ): + try: + result = super().handle_message(message, connection, *args, **kwargs) + except: + raise + finally: + self.messages_received.append(message) + return result or None + def handle_s2_validation_exception(self, e: S2ValidationError): logger.error("Failed to validate S2 Message.") logger.error(e.pydantic_validation_error) + def get_received_messages(self, message_type: Type[S2Message]) -> list: + def filter_messages(m: S2Message): + logger.info("Filter: %s, %s", type(m), message_type) + return type(m) == message_type + + result = list(filter(filter_messages, self.messages_received)) + + return result + class BaseController(Controller): control_type = ProtocolControlType.NOT_CONTROLABLE diff --git a/src/s2-self-certification/controllers/frbc_controller.py b/src/s2-self-certification/controllers/frbc_controller.py index c0fc684..ef30a15 100644 --- a/src/s2-self-certification/controllers/frbc_controller.py +++ b/src/s2-self-certification/controllers/frbc_controller.py @@ -4,22 +4,25 @@ from connection import Connection from s2python.common import ControlType as ProtocolControlType from s2python.frbc import FRBCSystemDescription -from .controller import Controller +from .controller import BaseController logger = logging.getLogger(__name__) -class FRBCController(Controller): +class FRBCController(BaseController): control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL - system_description: FRBCSystemDescription + system_description: Optional[FRBCSystemDescription] = None _system_description_received: asyncio.Event def __init__(self): super().__init__() + self.system_description = None self._system_description_received = asyncio.Event() - async def handle_power_constraints_message( + self.add_handler(FRBCSystemDescription, self.handle_system_description_message) + + async def handle_system_description_message( self, message: FRBCSystemDescription, connection: "Connection", send_okay ): if not self.is_correct_message_type(message, FRBCSystemDescription): diff --git a/src/s2-self-certification/controllers/pebc_controller.py b/src/s2-self-certification/controllers/pebc_controller.py index 927fe8e..61990a9 100644 --- a/src/s2-self-certification/controllers/pebc_controller.py +++ b/src/s2-self-certification/controllers/pebc_controller.py @@ -9,7 +9,7 @@ PEBCEnergyConstraint, PEBCPowerConstraints, ) -from .controller import Controller +from .controller import BaseController, Controller if TYPE_CHECKING: from connection import Connection @@ -19,11 +19,11 @@ logger = logging.getLogger(__name__) -class PEBCController(Controller): +class PEBCController(BaseController): control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL - power_constraints: Optional[PEBCPowerConstraints] + power_constraints: Optional[PEBCPowerConstraints] = None - _power_constraints_received = asyncio.Event() + _power_constraints_received: asyncio.Event def __init__(self): super().__init__() diff --git a/src/s2-self-certification/message_handlers.py b/src/s2-self-certification/message_handlers.py index 83dd361..2cf1715 100644 --- a/src/s2-self-certification/message_handlers.py +++ b/src/s2-self-certification/message_handlers.py @@ -49,7 +49,10 @@ async def wait_for_message(self, message_type: Type[S2Message], timeout: float): def receive_message(self, message: S2Message): if message.message_type in self.awaiting: - logger.debug("Received message that is being waited for. Setting event.") + logger.debug( + "Received %s message that is being waited for. Setting event.", + message.message_type, + ) awaiting = self.awaiting[message.message_type] if self.awaiting: # Set the message first before triggering the event to make sure that the @@ -57,7 +60,9 @@ def receive_message(self, message: S2Message): awaiting[1] = message # type: ignore awaiting[0].set() else: - logger.debug("Received message but nothing waiting for it.") + logger.debug( + "Received %s message but nothing waiting for it.", message.message_type + ) class MessageHandler: @@ -96,11 +101,11 @@ async def handle_message( self, message: S2Message, connection: "Connection", *args, **kwargs ): try: + handler = self.handlers[type(message)] send_okay = SendOkay(connection, message.message_id) # type: ignore[attr-defined, union-attr] - self.message_awaiter.receive_message(message) result = await handler( message, connection, send_okay.run_async(), *args, **kwargs ) @@ -118,6 +123,8 @@ async def handle_message( raise MessageHandlerNotFoundError( f"Command does not exist for message type '{ message.message_type}'" ) + finally: + self.message_awaiter.receive_message(message) ROLE = EnergyManagementRole.CEM diff --git a/src/s2-self-certification/orchestrator.py b/src/s2-self-certification/orchestrator.py index e1f93a4..77e968b 100644 --- a/src/s2-self-certification/orchestrator.py +++ b/src/s2-self-certification/orchestrator.py @@ -196,7 +196,6 @@ async def run(self, connection: Connection): self.running = False def stop(self): - logger.info("Stopping.") self._stop_event.set() def is_running(self): diff --git a/src/s2-self-certification/test_suite/base_test_case.py b/src/s2-self-certification/test_suite/base_test_case.py new file mode 100644 index 0000000..d55fafd --- /dev/null +++ b/src/s2-self-certification/test_suite/base_test_case.py @@ -0,0 +1,47 @@ +from certificate.certificate import ComplianceFinding, ComplianceReport +from config import BaseTestConfig +from connection import Connection +from controllers.controller import BaseController +from test_suite.test_suite import S2TestCase + +from s2python.common import ( + PowerForecast, + PowerMeasurement, + ControlType as ProtocolControlType, +) + + +class NoSelectionTestCase(S2TestCase): + + control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL + + TIMEOUT = 5 + + controller: BaseController + config: BaseTestConfig + + def __init__( + self, + config: BaseTestConfig, + connection: Connection, + controller: BaseController, + report: ComplianceReport, + ): + super().__init__(config, connection, controller, report) + + @S2TestCase.test_case + async def test_receive_power_forecast(self): + + finding = ComplianceFinding(message_type=PowerForecast) + + message = await self.check_receive_message_type(PowerForecast, finding) + + self.report.add_finding(finding) + + @S2TestCase.test_case + async def test_receive_power_measurement(self): + finding = ComplianceFinding(message_type=PowerMeasurement) + + message = await self.check_receive_message_type(PowerMeasurement, finding) + + self.report.add_finding(finding) diff --git a/src/s2-self-certification/test_suite/frbc_test_cases.py b/src/s2-self-certification/test_suite/frbc_test_cases.py index 57d085f..a66d99a 100644 --- a/src/s2-self-certification/test_suite/frbc_test_cases.py +++ b/src/s2-self-certification/test_suite/frbc_test_cases.py @@ -1,4 +1,5 @@ import datetime +import json import logging import uuid @@ -11,20 +12,24 @@ from config import FRBCTestConfig, PEBCTestConfig from connection import Connection from controllers.frbc_controller import FRBCController -from s2python.common import PowerMeasurement -from s2python.pebc import ( - PEBCAllowedLimitRange, - PEBCInstruction, - PEBCPowerConstraints, - PEBCPowerEnvelope, - PEBCPowerEnvelopeElement, +from s2python.common import PowerMeasurement, ControlType as ProtocolControlType +from s2python.frbc import ( + FRBCActuatorStatus, + FRBCStorageDescription, + FRBCStorageStatus, + FRBCSystemDescription, + FRBCUsageForecast, ) +from test_suite.base_test_case import NoSelectionTestCase from test_suite.test_suite import S2TestCase logger = logging.getLogger(__name__) -class FRBCTestCase(S2TestCase): +class FRBCTestCase(NoSelectionTestCase): + control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL + + TIMEOUT = 5 controller: FRBCController config: FRBCTestConfig @@ -38,33 +43,56 @@ def __init__( ): super().__init__(config, connection, controller, report) + async def setup(self): + await self.controller._system_description_received.wait() + async def wait_for_system_description(self): system_description = self.controller.system_description if ( system_description is None and not self.controller._system_description_received.is_set() ): - logger.info( + logger.debug( "Waiting. %s, %s", system_description, self.controller._system_description_received, ) await self.controller._system_description_received.wait() - logger.info("Power Constraints is set.") + logger.debug("System description is set.") - async def validate_power_constraints_set(self): + @S2TestCase.test_case + async def test_receive_frbc_system_description(self): await self.wait_for_system_description() - finding = ComplianceFinding(message_type=PEBCPowerConstraints) - finding.add_parameter( - ComplianceParameter( - name="PEBCPowerConstraints Provided.", status=ComplianceStatus.PASS - ) - ) + finding = ComplianceFinding(message_type=FRBCSystemDescription) + + message = await self.check_receive_message_type(FRBCSystemDescription, finding) + + self.report.add_finding(finding) + + @S2TestCase.test_case + async def test_receive_actuator_status(self): + + finding = ComplianceFinding(message_type=FRBCActuatorStatus) + + message = await self.check_receive_message_type(FRBCActuatorStatus, finding) self.report.add_finding(finding) - async def execute(self): - logger.info("Starting FRBC Test Case") - await self.validate_power_constraints_set() - logger.info("FRBC test case complete.") + @S2TestCase.test_case + async def test_receive_storage_status(self): + + finding = ComplianceFinding(message_type=FRBCStorageStatus) + + message = await self.check_receive_message_type(FRBCStorageStatus, finding) + + self.report.add_finding(finding) + + @S2TestCase.test_case + async def test_receive_usage_forecast(self): + + finding = ComplianceFinding(message_type=FRBCUsageForecast) + + message = await self.check_receive_message_type(FRBCUsageForecast, finding) + + self.report.add_finding(finding) diff --git a/src/s2-self-certification/test_suite/pebc_test_cases.py b/src/s2-self-certification/test_suite/pebc_test_cases.py index ab33860..951ac79 100644 --- a/src/s2-self-certification/test_suite/pebc_test_cases.py +++ b/src/s2-self-certification/test_suite/pebc_test_cases.py @@ -23,12 +23,13 @@ PEBCPowerEnvelope, PEBCPowerEnvelopeElement, ) +from test_suite.base_test_case import NoSelectionTestCase from test_suite.test_suite import S2TestCase logger = logging.getLogger(__name__) -class PEBCTestCase(S2TestCase): +class PEBCTestCase(NoSelectionTestCase): control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL controller: PEBCController config: PEBCTestConfig @@ -42,6 +43,9 @@ def __init__( ): super().__init__(config, connection, controller, report) + async def setup(self): + await self.controller._power_constraints_received.wait() + async def wait_until_power_constraints_set(self): power_constraints = self.controller.power_constraints if ( @@ -56,89 +60,84 @@ async def wait_until_power_constraints_set(self): await self.controller._power_constraints_received.wait() logger.info("Power Constraints is set.") + @S2TestCase.test_case async def validate_power_constraints_set(self): await self.wait_until_power_constraints_set() finding = ComplianceFinding(message_type=PEBCPowerConstraints) finding.add_parameter( - ComplianceParameter( + param=ComplianceParameter( name="PEBCPowerConstraints Provided.", status=ComplianceStatus.PASS ) ) self.report.add_finding(finding) - async def test_set_limit_ranges_instruction(self): - # for limit_ranges in self.power_constraints.allowed_limit_ranges: - logger.info("Testing set limit range") - await self.wait_until_power_constraints_set() - - power_constraints = self.controller.power_constraints - - if power_constraints is None: - raise ValueError("Power Constraints not set.") - - limit_range: PEBCAllowedLimitRange = power_constraints.allowed_limit_ranges[1] - logger.info(power_constraints) - logger.info("Sending Instruction.") - - # exec_time = datetime.datetime.now() - exec_time = datetime.datetime.fromisoformat("2025-04-22T09:30:00+00:00") - # logger.info(exec_time.replace(tzinfo=datetime.timezone.utc)) - logger.info(exec_time) - - instruction = PEBCInstruction( - message_id=uuid.uuid4(), - id=power_constraints.id, - power_constraints_id=power_constraints.id, - power_envelopes=[ - PEBCPowerEnvelope( - id="pe_test", # type: ignore - commodity_quantity=limit_range.commodity_quantity, - power_envelope_elements=[ - PEBCPowerEnvelopeElement( - lower_limit=-2000.00, - upper_limit=0, - duration=3600000, # type: ignore - ) - ], - ) - ], - # Make it timezone-aware (UTC) - execution_time=exec_time.replace(tzinfo=datetime.timezone.utc), - abnormal_condition=False, - ) - logger.info(instruction) - await self.connection.send_msg_and_await_reception_status( - instruction, raise_on_error=True - ) - logger.info("Instruction sent") - - async def test_receives_interval_power_readings(self): - - if self.config is None or self.config.status_update_frequency is None: - raise ValueError("Status Update Frequency required to test status updates.") - - logger.info("Waiting for power reading.") - try: - await self.controller.message_awaiter.wait_for_message( - PowerMeasurement, - timeout=float(self.config.status_update_frequency), - ) - logger.info("Power reading received.") - await self.controller.message_awaiter.wait_for_message( - PowerMeasurement, - timeout=float( - self.config.status_update_frequency - + self.config.status_update_frequency_buffer - ), - ) - logger.info("Power readings test passed.") - except Exception: - logger.exception("Did not receive power reading within allowed window.") - - async def execute(self): - logger.info("Starting PEBC Test Case") - await self.validate_power_constraints_set() - # await self.test_receives_interval_power_readings() - logger.info("Start") + # async def test_set_limit_ranges_instruction(self): + # # for limit_ranges in self.power_constraints.allowed_limit_ranges: + # logger.info("Testing set limit range") + # await self.wait_until_power_constraints_set() + + # power_constraints = self.controller.power_constraints + + # if power_constraints is None: + # raise ValueError("Power Constraints not set.") + + # limit_range: PEBCAllowedLimitRange = power_constraints.allowed_limit_ranges[1] + # logger.info(power_constraints) + # logger.info("Sending Instruction.") + + # # exec_time = datetime.datetime.now() + # exec_time = datetime.datetime.fromisoformat("2025-04-22T09:30:00+00:00") + # # logger.info(exec_time.replace(tzinfo=datetime.timezone.utc)) + # logger.info(exec_time) + + # instruction = PEBCInstruction( + # message_id=uuid.uuid4(), + # id=power_constraints.id, + # power_constraints_id=power_constraints.id, + # power_envelopes=[ + # PEBCPowerEnvelope( + # id="pe_test", # type: ignore + # commodity_quantity=limit_range.commodity_quantity, + # power_envelope_elements=[ + # PEBCPowerEnvelopeElement( + # lower_limit=-2000.00, + # upper_limit=0, + # duration=3600000, # type: ignore + # ) + # ], + # ) + # ], + # # Make it timezone-aware (UTC) + # execution_time=exec_time.replace(tzinfo=datetime.timezone.utc), + # abnormal_condition=False, + # ) + # logger.info(instruction) + # await self.connection.send_msg_and_await_reception_status( + # instruction, raise_on_error=True + # ) + # logger.info("Instruction sent") + + # async def test_receives_interval_power_readings(self): + + # if self.config is None or self.config.status_update_frequency is None: + # raise ValueError("Status Update Frequency required to test status updates.") + + # logger.info("Waiting for power reading.") + # try: + # await self.controller.message_awaiter.wait_for_message( + # PowerMeasurement, + # timeout=float(self.config.status_update_frequency), + # ) + # logger.info("Power reading received.") + # await self.controller.message_awaiter.wait_for_message( + # PowerMeasurement, + # timeout=float( + # self.config.status_update_frequency + # + self.config.status_update_frequency_buffer + # ), + # ) + # logger.info("Power readings test passed.") + # except Exception: + # logger.exception("Did not receive power reading within allowed window.") diff --git a/src/s2-self-certification/test_suite/test_suite.py b/src/s2-self-certification/test_suite/test_suite.py index 9de514b..1d98443 100644 --- a/src/s2-self-certification/test_suite/test_suite.py +++ b/src/s2-self-certification/test_suite/test_suite.py @@ -1,12 +1,24 @@ import abc +import asyncio +import functools +import inspect import logging -from typing import TYPE_CHECKING, Dict, List, Type +from typing import TYPE_CHECKING, Dict, List, Optional, Type -from certificate.certificate import ComplianceReport +from click import Option +import test + +from certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) from config import BaseTestConfig, ControlTypeTestConfig from connection import Connection from controllers.controller import Controller from s2python.common import ControlType as ProtocolControlType +from s2python.message import S2Message logger = logging.getLogger(__name__) @@ -15,6 +27,8 @@ class S2TestCase(abc.ABC): control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION config: BaseTestConfig + TIMEOUT = 5 + def __init__( self, config: BaseTestConfig, @@ -27,10 +41,73 @@ def __init__( self.config = config self.report = report - @abc.abstractmethod - async def execute(self): + async def check_receive_message_type( + self, + message_type: Type[S2Message], + report_finding: Optional[ComplianceFinding] = None, + ): + logger.info("Checking for %s", message_type) + + if report_finding is None: + report_finding = ComplianceFinding(message_type=message_type) + + try: + messages: list = self.controller.get_received_messages(message_type) + if len(messages) < 1: + message = await self.controller.message_awaiter.wait_for_message( + message_type, self.TIMEOUT + ) + else: + message = messages[0] + + report_finding.add_parameter( + name=f"{message_type} Provided.", + status=ComplianceStatus.PASS, + ) + + return message + except asyncio.TimeoutError: + report_finding.add_parameter( + name=f"{message_type} Not Provided.", + status=ComplianceStatus.FAIL, + ) + return None + + @classmethod + def test_case(cls, func): + func._is_test_case = True + return func + + async def setup(self): + """Override in subclass for per-test setup.""" + pass + + async def teardown(self): + """Override in subclass for per-test teardown.""" pass + def get_test_cases(self): + test_cases = [] + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + if getattr(method, "_is_test_case", False): + test_cases.append((name, method)) + return test_cases + + async def execute(self): + logger.info( + "Executing test case %s. Has %s tests.", + self.__class__.__name__, + self.get_test_cases(), + ) + for name, method in self.get_test_cases(): + logger.info(f"Running test case: {name}") + await self.setup() + try: + await method() + finally: + await self.teardown() + logger.info("Test case %s complete.", self.__class__.__name__) + class TestSuite: config: ControlTypeTestConfig From 07b897d122661d04aafa37388c28b806e5045f35 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Tue, 29 Apr 2025 13:28:20 +0200 Subject: [PATCH 08/75] Added handshake process to testing --- cert.yaml | 10 +++---- .../controllers/controller.py | 25 +++++++++++++++++- src/s2-self-certification/orchestrator.py | 25 +++++++++++++----- .../test_suite/base_test_case.py | 26 ++++++++++++++++--- .../test_suite/frbc_test_cases.py | 8 +++--- .../test_suite/pebc_test_cases.py | 2 +- .../test_suite/test_suite.py | 22 +++++++++++----- 7 files changed, 90 insertions(+), 28 deletions(-) diff --git a/cert.yaml b/cert.yaml index 020493a..f78265c 100644 --- a/cert.yaml +++ b/cert.yaml @@ -1,12 +1,12 @@ findings: - message_type: PowerForecast parameters: - - name: Not Provided. - status: FAIL - status: FAIL + - name: PowerForecast Provided. + status: PASS + status: PASS - message_type: PowerMeasurement parameters: - - name: Provided. + - name: PowerMeasurement Provided. status: PASS status: PASS - message_type: PEBCPowerConstraints @@ -14,4 +14,4 @@ findings: - name: PEBCPowerConstraints Provided. status: PASS status: PASS -timestamp: 2025-04-25 16:34:03.628891 +timestamp: 2025-04-28 09:55:21.153769 diff --git a/src/s2-self-certification/controllers/controller.py b/src/s2-self-certification/controllers/controller.py index 746b3d9..8ffd2af 100644 --- a/src/s2-self-certification/controllers/controller.py +++ b/src/s2-self-certification/controllers/controller.py @@ -1,11 +1,13 @@ +import asyncio from email import message -from typing import Awaitable, Callable, Type +from typing import Awaitable, Callable, Optional, Type from connection import Connection from message_handlers import MessageHandler from s2python.common import ( ControlType as ProtocolControlType, PowerForecast, PowerMeasurement, + ResourceManagerDetails, ) from s2python.message import S2Message from s2python.s2_validation_error import S2ValidationError @@ -18,6 +20,8 @@ class Controller(MessageHandler): control_type: ProtocolControlType + resource_manager_details: Optional[ResourceManagerDetails] = None + messages_received = [] def __init__(self): @@ -47,6 +51,25 @@ def filter_messages(m: S2Message): return result + def handshake_acknowledged(self): + # The CEM sends a handshake message. Once the RM sends HandshakeResponse this method should be called. + pass + + def handshake_received(self): + # After the handshake message is sent by the RM and the CEM (this program) responds with a HandshakeResponse + # and receives a valid status response, then this method is called. + pass + + async def handle_rm_details( + self, + message: ResourceManagerDetails, + connection: "Connection", + send_okay: Awaitable, + ): + self.resource_manager_details = message + + await send_okay + class BaseController(Controller): control_type = ProtocolControlType.NOT_CONTROLABLE diff --git a/src/s2-self-certification/orchestrator.py b/src/s2-self-certification/orchestrator.py index 77e968b..c83692f 100644 --- a/src/s2-self-certification/orchestrator.py +++ b/src/s2-self-certification/orchestrator.py @@ -36,7 +36,7 @@ class IntegrationTestOrchestrator: resource_manager_details: Optional[ResourceManagerDetails] = None - controller: Optional[Controller] = None + controller: Controller controllers: Dict[ProtocolControlType, Controller] _tasks = set() @@ -62,6 +62,11 @@ def __init__( self.controllers = available_control_types + controller = available_control_types.get(ProtocolControlType.NO_SELECTION) + if controller is None: + raise ValueError("A NO_SELECTION controller must be provided.") + self.controller = controller + self.test_suite = test_suite self.handshake_message_handlers = { # type: ignore @@ -74,8 +79,10 @@ def __init__( self.report = report def set_control_type(self, control_type: ProtocolControlType): - logger.info(self.controllers) - self.controller = self.controllers[control_type] + controller = self.controllers[control_type] + # Put the RM Details into the new controller. + controller.resource_manager_details = controller.resource_manager_details + self.controller = controller async def process_received_messages(self): """AsyncIO task which pops messages off the queue and processes them using the control type.""" @@ -213,6 +220,8 @@ async def initiate_handshake(self): ) ) + self.controller.handshake_acknowledged() + async def handle_handshake( self, message: Handshake, @@ -241,6 +250,8 @@ async def handle_handshake( ) ) + self.controller.handshake_received() + async def send_select_control_type(self): # TODO: Select the control type in a better way. logger.info("Selecting Control Type.") @@ -286,11 +297,13 @@ async def handle_rm_details( message: ResourceManagerDetails, send_okay: Awaitable[None], ): - if type(message) != ResourceManagerDetails: - raise ValueError("Expected a ResourceManagerDetails instance.") self.resource_manager_details = message - await send_okay + if self.connection is None: + raise ValueError("Connection not set.") + + # Pass it to the no selection controller to use it as part of the test cases. + await self.controller.handle_message(message, self.connection, send_okay) self._handshake_complete.set() diff --git a/src/s2-self-certification/test_suite/base_test_case.py b/src/s2-self-certification/test_suite/base_test_case.py index d55fafd..6bb3ea0 100644 --- a/src/s2-self-certification/test_suite/base_test_case.py +++ b/src/s2-self-certification/test_suite/base_test_case.py @@ -1,4 +1,8 @@ -from certificate.certificate import ComplianceFinding, ComplianceReport +from certificate.certificate import ( + ComplianceFinding, + ComplianceReport, + ComplianceStatus, +) from config import BaseTestConfig from connection import Connection from controllers.controller import BaseController @@ -8,12 +12,13 @@ PowerForecast, PowerMeasurement, ControlType as ProtocolControlType, + ResourceManagerDetails, ) class NoSelectionTestCase(S2TestCase): - control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL + control_type = ProtocolControlType.NO_SELECTION TIMEOUT = 5 @@ -29,7 +34,20 @@ def __init__( ): super().__init__(config, connection, controller, report) - @S2TestCase.test_case + async def test_validate_rm_details_received(self): + finding = ComplianceFinding(message_type=ResourceManagerDetails) + + if self.controller.resource_manager_details is not None: + finding.add_parameter( + "ResourceManagerDetails Received.", ComplianceStatus.PASS + ) + finding.add_parameter( + "ResourceManagerDetails Valid.", ComplianceStatus.PASS + ) + + self.report.add_finding(finding) + + @S2TestCase.test async def test_receive_power_forecast(self): finding = ComplianceFinding(message_type=PowerForecast) @@ -38,7 +56,7 @@ async def test_receive_power_forecast(self): self.report.add_finding(finding) - @S2TestCase.test_case + @S2TestCase.test async def test_receive_power_measurement(self): finding = ComplianceFinding(message_type=PowerMeasurement) diff --git a/src/s2-self-certification/test_suite/frbc_test_cases.py b/src/s2-self-certification/test_suite/frbc_test_cases.py index a66d99a..9c1897c 100644 --- a/src/s2-self-certification/test_suite/frbc_test_cases.py +++ b/src/s2-self-certification/test_suite/frbc_test_cases.py @@ -60,7 +60,7 @@ async def wait_for_system_description(self): await self.controller._system_description_received.wait() logger.debug("System description is set.") - @S2TestCase.test_case + @S2TestCase.test async def test_receive_frbc_system_description(self): await self.wait_for_system_description() @@ -70,7 +70,7 @@ async def test_receive_frbc_system_description(self): self.report.add_finding(finding) - @S2TestCase.test_case + @S2TestCase.test async def test_receive_actuator_status(self): finding = ComplianceFinding(message_type=FRBCActuatorStatus) @@ -79,7 +79,7 @@ async def test_receive_actuator_status(self): self.report.add_finding(finding) - @S2TestCase.test_case + @S2TestCase.test async def test_receive_storage_status(self): finding = ComplianceFinding(message_type=FRBCStorageStatus) @@ -88,7 +88,7 @@ async def test_receive_storage_status(self): self.report.add_finding(finding) - @S2TestCase.test_case + @S2TestCase.test async def test_receive_usage_forecast(self): finding = ComplianceFinding(message_type=FRBCUsageForecast) diff --git a/src/s2-self-certification/test_suite/pebc_test_cases.py b/src/s2-self-certification/test_suite/pebc_test_cases.py index 951ac79..0d2ea76 100644 --- a/src/s2-self-certification/test_suite/pebc_test_cases.py +++ b/src/s2-self-certification/test_suite/pebc_test_cases.py @@ -60,7 +60,7 @@ async def wait_until_power_constraints_set(self): await self.controller._power_constraints_received.wait() logger.info("Power Constraints is set.") - @S2TestCase.test_case + @S2TestCase.test async def validate_power_constraints_set(self): await self.wait_until_power_constraints_set() diff --git a/src/s2-self-certification/test_suite/test_suite.py b/src/s2-self-certification/test_suite/test_suite.py index 1d98443..4a6d0a9 100644 --- a/src/s2-self-certification/test_suite/test_suite.py +++ b/src/s2-self-certification/test_suite/test_suite.py @@ -51,6 +51,7 @@ async def check_receive_message_type( if report_finding is None: report_finding = ComplianceFinding(message_type=message_type) + message = None try: messages: list = self.controller.get_received_messages(message_type) if len(messages) < 1: @@ -59,22 +60,29 @@ async def check_receive_message_type( ) else: message = messages[0] + except asyncio.TimeoutError: + message = None + + if message is None: + messages: list = self.controller.get_received_messages(message_type) + if len(messages) > 0: + message = messages[0] + if message is not None: report_finding.add_parameter( - name=f"{message_type} Provided.", + name=f"{message_type.__name__} Provided.", status=ComplianceStatus.PASS, ) - - return message - except asyncio.TimeoutError: + else: report_finding.add_parameter( - name=f"{message_type} Not Provided.", + name=f"{message_type.__name__} Not Provided.", status=ComplianceStatus.FAIL, ) - return None + + return message @classmethod - def test_case(cls, func): + def test(cls, func): func._is_test_case = True return func From b67583c71fb94e1c1ba0d589f4c391c0e4dfad64 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Tue, 29 Apr 2025 15:09:41 +0200 Subject: [PATCH 09/75] Broke up into 2 models 2 facilitate creation of server Self cert split into the s2-self-cert and s2-testing packages. s2-testing will be a shared package between the client and server --- cert.yaml | 2 +- .../__init__.py => s2-self-cert/README.md | 0 s2-self-cert/cert.yaml | 17 + s2-self-cert/pyproject.toml | 15 + s2-self-cert/src/__init__.py | 0 .../src}/config.yaml | 1 + s2-self-cert/src/connection.py | 73 ++ .../src}/log.py | 0 .../src}/main.py | 22 +- .../src}/orchestrator.py | 14 +- .../src}/server.py | 0 s2-self-cert/uv.lock | 339 +++++++++ s2-testing/README.md | 0 pyproject.toml => s2-testing/pyproject.toml | 14 +- s2-testing/src/__init__.py | 0 s2-testing/src/s2_testing.egg-info/PKG-INFO | 10 + .../src/s2_testing.egg-info/SOURCES.txt | 24 + .../s2_testing.egg-info/dependency_links.txt | 1 + .../src/s2_testing.egg-info/requires.txt | 4 + .../src/s2_testing.egg-info/top_level.txt | 2 + s2-testing/src/s2testing/__init__.py | 0 .../src/s2testing}/certificate/certificate.py | 1 - .../src/s2testing}/config.py | 3 +- .../src/s2testing}/connection.py | 65 +- .../src/s2testing}/controllers/__init__.py | 2 +- .../src/s2testing}/controllers/builder.py | 2 +- .../src/s2testing}/controllers/controller.py | 16 +- .../s2testing}/controllers/frbc_controller.py | 4 +- .../s2testing}/controllers/pebc_controller.py | 12 +- .../src/s2testing}/message_handlers.py | 0 .../src/s2testing}/test_suite/__init__.py | 0 .../s2testing}/test_suite/base_test_case.py | 12 +- .../s2testing}/test_suite/frbc_test_cases.py | 14 +- .../s2testing}/test_suite/pebc_test_cases.py | 16 +- .../src/s2testing}/test_suite/test_suite.py | 15 +- .../src/s2testing}/util.py | 0 s2-testing/uv.lock | 324 +++++++++ uv.lock | 686 ++++++++++++------ 38 files changed, 1387 insertions(+), 323 deletions(-) rename src/s2-self-certification/__init__.py => s2-self-cert/README.md (100%) create mode 100644 s2-self-cert/cert.yaml create mode 100644 s2-self-cert/pyproject.toml create mode 100644 s2-self-cert/src/__init__.py rename {src/s2-self-certification => s2-self-cert/src}/config.yaml (92%) create mode 100644 s2-self-cert/src/connection.py rename {src/s2-self-certification => s2-self-cert/src}/log.py (100%) rename {src/s2-self-certification => s2-self-cert/src}/main.py (75%) rename {src/s2-self-certification => s2-self-cert/src}/orchestrator.py (97%) rename {src/s2-self-certification => s2-self-cert/src}/server.py (100%) create mode 100644 s2-self-cert/uv.lock create mode 100644 s2-testing/README.md rename pyproject.toml => s2-testing/pyproject.toml (54%) create mode 100644 s2-testing/src/__init__.py create mode 100644 s2-testing/src/s2_testing.egg-info/PKG-INFO create mode 100644 s2-testing/src/s2_testing.egg-info/SOURCES.txt create mode 100644 s2-testing/src/s2_testing.egg-info/dependency_links.txt create mode 100644 s2-testing/src/s2_testing.egg-info/requires.txt create mode 100644 s2-testing/src/s2_testing.egg-info/top_level.txt create mode 100644 s2-testing/src/s2testing/__init__.py rename {src/s2-self-certification => s2-testing/src/s2testing}/certificate/certificate.py (98%) rename {src/s2-self-certification => s2-testing/src/s2testing}/config.py (94%) rename {src/s2-self-certification => s2-testing/src/s2testing}/connection.py (78%) rename {src/s2-self-certification => s2-testing/src/s2testing}/controllers/__init__.py (71%) rename {src/s2-self-certification => s2-testing/src/s2testing}/controllers/builder.py (89%) rename {src/s2-self-certification => s2-testing/src/s2testing}/controllers/controller.py (88%) rename {src/s2-self-certification => s2-testing/src/s2testing}/controllers/frbc_controller.py (88%) rename {src/s2-self-certification => s2-testing/src/s2testing}/controllers/pebc_controller.py (91%) rename {src/s2-self-certification => s2-testing/src/s2testing}/message_handlers.py (100%) rename {src/s2-self-certification => s2-testing/src/s2testing}/test_suite/__init__.py (100%) rename {src/s2-self-certification => s2-testing/src/s2testing}/test_suite/base_test_case.py (86%) rename {src/s2-self-certification => s2-testing/src/s2testing}/test_suite/frbc_test_cases.py (87%) rename {src/s2-self-certification => s2-testing/src/s2testing}/test_suite/pebc_test_cases.py (91%) rename {src/s2-self-certification => s2-testing/src/s2testing}/test_suite/test_suite.py (93%) rename {src/s2-self-certification => s2-testing/src/s2testing}/util.py (100%) create mode 100644 s2-testing/uv.lock diff --git a/cert.yaml b/cert.yaml index f78265c..3c9b6ec 100644 --- a/cert.yaml +++ b/cert.yaml @@ -14,4 +14,4 @@ findings: - name: PEBCPowerConstraints Provided. status: PASS status: PASS -timestamp: 2025-04-28 09:55:21.153769 +timestamp: 2025-04-29 15:07:56.715961 diff --git a/src/s2-self-certification/__init__.py b/s2-self-cert/README.md similarity index 100% rename from src/s2-self-certification/__init__.py rename to s2-self-cert/README.md diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml new file mode 100644 index 0000000..605ad7e --- /dev/null +++ b/s2-self-cert/cert.yaml @@ -0,0 +1,17 @@ +findings: +- message_type: PowerForecast + parameters: + - name: PowerForecast Provided. + status: PASS + status: PASS +- message_type: PowerMeasurement + parameters: + - name: PowerMeasurement Provided. + status: PASS + status: PASS +- message_type: PEBCPowerConstraints + parameters: + - name: PEBCPowerConstraints Provided. + status: PASS + status: PASS +timestamp: 2025-04-29 14:54:29.709989 diff --git a/s2-self-cert/pyproject.toml b/s2-self-cert/pyproject.toml new file mode 100644 index 0000000..db818cc --- /dev/null +++ b/s2-self-cert/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "s2-self-cert" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" + +dependencies = [ + "s2-python", + "s2-testing", +] + +[tool.uv.sources] +s2-python = { path = "../../s2-python", editable = true } +s2-testing = { path = "../s2-testing", editable = true } diff --git a/s2-self-cert/src/__init__.py b/s2-self-cert/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/s2-self-certification/config.yaml b/s2-self-cert/src/config.yaml similarity index 92% rename from src/s2-self-certification/config.yaml rename to s2-self-cert/src/config.yaml index 615b032..e9c145e 100644 --- a/src/s2-self-certification/config.yaml +++ b/s2-self-cert/src/config.yaml @@ -2,6 +2,7 @@ device_details: name: Some Device manufacturer: ACME +mode: testing control_types: no_selection: null pebc: diff --git a/s2-self-cert/src/connection.py b/s2-self-cert/src/connection.py new file mode 100644 index 0000000..ff260fd --- /dev/null +++ b/s2-self-cert/src/connection.py @@ -0,0 +1,73 @@ +import asyncio +import json +import logging +import threading +import uuid +from typing import Type + +import websockets +from s2python.common import ReceptionStatus, ReceptionStatusValues +from s2python.message import S2Message +from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from websockets.asyncio.connection import Connection as WSConnection +from s2testing.connection import BaseRMConnection + +logger = logging.getLogger(__name__) + + +class Connection(BaseRMConnection): # pylint: disable=too-many-instance-attributes + """ + Manged the websocket connection to the RM. + Puts all received messages onto the message queue so they can be retrieved by other tasks. + Based on the S2Connection class is S2-Python library. + """ + + ws: WSConnection + + def __init__(self, ws) -> None: # pylint: disable=too-many-arguments + super().__init__() + self.ws = ws + + async def send(self, message): + if self.ws is None: + raise RuntimeError( + "Cannot send messages if websocket connection is not yet established." + ) + + await self.ws.send(message) + + async def receive(self): + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + + return await self.ws.recv() + + async def receive_messages(self): + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + logger.debug("Connection has started to receive messages.") + + try: + await super().receive_messages() + except websockets.ConnectionClosedOK: + logger.info("Connection closed normally by remote.") + self._handle_ws_close() + except websockets.ConnectionClosedError as e: + logger.error("Connection closed with error: %s", str(e)) + self._handle_ws_close() + except asyncio.CancelledError: + pass + + def _handle_ws_close(self): + self._stop_event.set() + + async def stop(self): + super().stop() + + await self.ws.close() diff --git a/src/s2-self-certification/log.py b/s2-self-cert/src/log.py similarity index 100% rename from src/s2-self-certification/log.py rename to s2-self-cert/src/log.py diff --git a/src/s2-self-certification/main.py b/s2-self-cert/src/main.py similarity index 75% rename from src/s2-self-certification/main.py rename to s2-self-cert/src/main.py index fd394ba..4649d27 100644 --- a/src/s2-self-certification/main.py +++ b/s2-self-cert/src/main.py @@ -9,15 +9,20 @@ import logging.config from typing import Dict -from certificate.certificate import ComplianceReport -from config import Config, ControlTypeTestConfig, load_config -from controllers import Controller, PEBCController, FRBCController +from s2testing.certificate.certificate import ComplianceReport +from s2testing.config import Config, ControlTypeTestConfig, load_config +from s2testing.controllers import ( + Controller, + BaseController, + PEBCController, + FRBCController, +) from log import LOGGING_CONFIG from orchestrator import IntegrationTestOrchestrator from s2python.common import ControlType as ProtocolControlType from server import S2Server -from test_suite import PEBCTestCase, TestSuiteBuilder -from test_suite.frbc_test_cases import FRBCTestCase +from s2testing.test_suite import PEBCTestCase, TestSuiteBuilder +from s2testing.test_suite.frbc_test_cases import FRBCTestCase logging.config.dictConfig(LOGGING_CONFIG) @@ -32,6 +37,8 @@ def create_controllers_dict( ) -> Dict[ProtocolControlType, Controller]: controllers: Dict[ProtocolControlType, Controller] = {} + controllers[ProtocolControlType.NO_SELECTION] = BaseController() + if config.control_types.frbc and config.control_types.frbc.enabled: controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() @@ -42,13 +49,14 @@ def create_controllers_dict( async def main(): - logger.info("-" * 40) - logger.info("Starting...") args = parser.parse_args() config: Config = load_config(args.config) + logger.info("-" * 40) + logger.info(f"Starting in {config.mode} mode...") + report = ComplianceReport(timestamp=datetime.now()) controllers = create_controllers_dict(config, report) diff --git a/src/s2-self-certification/orchestrator.py b/s2-self-cert/src/orchestrator.py similarity index 97% rename from src/s2-self-certification/orchestrator.py rename to s2-self-cert/src/orchestrator.py index c83692f..a157cb8 100644 --- a/src/s2-self-certification/orchestrator.py +++ b/s2-self-cert/src/orchestrator.py @@ -1,13 +1,10 @@ import asyncio import logging -from tracemalloc import stop import uuid from types import CoroutineType from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type -from mypyc.ir.ops import Value - -from certificate.certificate import ComplianceReport +from s2testing.certificate.certificate import ComplianceReport from s2python.common import ControlType as ProtocolControlType from s2python.common import ( EnergyManagementRole, @@ -21,10 +18,11 @@ from s2python.version import S2_VERSION -from connection import Connection, SendOkay -from controllers import Controller -from test_suite.test_suite import TestSuite -from util import wait_for_event_or_stop +from connection import Connection +from s2testing.connection import SendOkay +from s2testing.controllers import Controller +from s2testing.test_suite.test_suite import TestSuite +from s2testing.util import wait_for_event_or_stop logger = logging.getLogger(__name__) diff --git a/src/s2-self-certification/server.py b/s2-self-cert/src/server.py similarity index 100% rename from src/s2-self-certification/server.py rename to s2-self-cert/src/server.py diff --git a/s2-self-cert/uv.lock b/s2-self-cert/uv.lock new file mode 100644 index 0000000..18458ae --- /dev/null +++ b/s2-self-cert/uv.lock @@ -0,0 +1,339 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload_time = "2025-04-08T13:27:06.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload_time = "2025-04-08T13:27:03.789Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload_time = "2025-04-02T09:49:41.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021, upload_time = "2025-04-02T09:46:45.065Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742, upload_time = "2025-04-02T09:46:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414, upload_time = "2025-04-02T09:46:48.263Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848, upload_time = "2025-04-02T09:46:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055, upload_time = "2025-04-02T09:46:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806, upload_time = "2025-04-02T09:46:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777, upload_time = "2025-04-02T09:46:53.675Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803, upload_time = "2025-04-02T09:46:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755, upload_time = "2025-04-02T09:46:56.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358, upload_time = "2025-04-02T09:46:58.445Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916, upload_time = "2025-04-02T09:46:59.726Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823, upload_time = "2025-04-02T09:47:01.278Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494, upload_time = "2025-04-02T09:47:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224, upload_time = "2025-04-02T09:47:04.199Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845, upload_time = "2025-04-02T09:47:05.686Z" }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029, upload_time = "2025-04-02T09:47:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784, upload_time = "2025-04-02T09:47:08.63Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075, upload_time = "2025-04-02T09:47:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849, upload_time = "2025-04-02T09:47:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794, upload_time = "2025-04-02T09:47:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237, upload_time = "2025-04-02T09:47:14.355Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351, upload_time = "2025-04-02T09:47:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914, upload_time = "2025-04-02T09:47:17Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385, upload_time = "2025-04-02T09:47:18.631Z" }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765, upload_time = "2025-04-02T09:47:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688, upload_time = "2025-04-02T09:47:22.029Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185, upload_time = "2025-04-02T09:47:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640, upload_time = "2025-04-02T09:47:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649, upload_time = "2025-04-02T09:47:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472, upload_time = "2025-04-02T09:47:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509, upload_time = "2025-04-02T09:47:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702, upload_time = "2025-04-02T09:47:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428, upload_time = "2025-04-02T09:47:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753, upload_time = "2025-04-02T09:47:39.013Z" }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849, upload_time = "2025-04-02T09:47:40.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541, upload_time = "2025-04-02T09:47:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225, upload_time = "2025-04-02T09:47:43.425Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373, upload_time = "2025-04-02T09:47:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034, upload_time = "2025-04-02T09:47:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848, upload_time = "2025-04-02T09:47:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986, upload_time = "2025-04-02T09:47:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551, upload_time = "2025-04-02T09:47:51.648Z" }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785, upload_time = "2025-04-02T09:47:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758, upload_time = "2025-04-02T09:47:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109, upload_time = "2025-04-02T09:47:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159, upload_time = "2025-04-02T09:47:58.088Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222, upload_time = "2025-04-02T09:47:59.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980, upload_time = "2025-04-02T09:48:01.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840, upload_time = "2025-04-02T09:48:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518, upload_time = "2025-04-02T09:48:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025, upload_time = "2025-04-02T09:48:06.226Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991, upload_time = "2025-04-02T09:48:08.114Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262, upload_time = "2025-04-02T09:48:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626, upload_time = "2025-04-02T09:48:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590, upload_time = "2025-04-02T09:48:12.861Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload_time = "2025-04-02T09:48:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload_time = "2025-04-02T09:48:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload_time = "2025-04-02T09:48:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659, upload_time = "2025-04-02T09:48:45.342Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294, upload_time = "2025-04-02T09:48:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771, upload_time = "2025-04-02T09:48:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558, upload_time = "2025-04-02T09:48:51.409Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038, upload_time = "2025-04-02T09:48:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315, upload_time = "2025-04-02T09:48:55.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063, upload_time = "2025-04-02T09:48:57.479Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631, upload_time = "2025-04-02T09:48:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877, upload_time = "2025-04-02T09:49:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858, upload_time = "2025-04-02T09:49:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745, upload_time = "2025-04-02T09:49:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188, upload_time = "2025-04-02T09:49:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479, upload_time = "2025-04-02T09:49:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415, upload_time = "2025-04-02T09:49:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623, upload_time = "2025-04-02T09:49:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175, upload_time = "2025-04-02T09:49:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674, upload_time = "2025-04-02T09:49:17.61Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951, upload_time = "2025-04-02T09:49:19.559Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "s2-python" +version = "0.5.0" +source = { editable = "../../s2-python" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "datamodel-code-generator", marker = "extra == 'development'" }, + { name = "mypy", marker = "extra == 'testing'" }, + { name = "pip-tools", marker = "extra == 'development'" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pylint", marker = "extra == 'testing'" }, + { name = "pyright", marker = "extra == 'testing'" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-coverage", marker = "extra == 'testing'" }, + { name = "pytest-timer", marker = "extra == 'testing'" }, + { name = "pytz" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "sphinx-tabs", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'development'" }, + { name = "types-pytz", marker = "extra == 'testing'" }, + { name = "websockets", specifier = "~=13.1" }, +] +provides-extras = ["testing", "development", "docs"] + +[[package]] +name = "s2-self-cert" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "s2-python" }, + { name = "s2-testing" }, +] + +[package.metadata] +requires-dist = [ + { name = "s2-python", editable = "../../s2-python" }, + { name = "s2-testing", editable = "../s2-testing" }, +] + +[[package]] +name = "s2-testing" +version = "0.1.0" +source = { editable = "../s2-testing" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../../s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload_time = "2025-02-25T17:27:59.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload_time = "2025-02-25T17:27:57.754Z" }, +] + +[[package]] +name = "websockets" +version = "13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload_time = "2024-09-21T17:34:21.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload_time = "2024-09-21T17:32:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload_time = "2024-09-21T17:32:28.428Z" }, + { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload_time = "2024-09-21T17:32:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload_time = "2024-09-21T17:32:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload_time = "2024-09-21T17:32:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload_time = "2024-09-21T17:32:33.398Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload_time = "2024-09-21T17:32:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload_time = "2024-09-21T17:32:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload_time = "2024-09-21T17:32:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload_time = "2024-09-21T17:32:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload_time = "2024-09-21T17:32:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload_time = "2024-09-21T17:32:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload_time = "2024-09-21T17:32:43.858Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload_time = "2024-09-21T17:32:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload_time = "2024-09-21T17:32:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload_time = "2024-09-21T17:32:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload_time = "2024-09-21T17:32:48.046Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload_time = "2024-09-21T17:32:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload_time = "2024-09-21T17:32:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload_time = "2024-09-21T17:32:52.223Z" }, + { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload_time = "2024-09-21T17:32:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload_time = "2024-09-21T17:32:54.721Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload_time = "2024-09-21T17:32:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload_time = "2024-09-21T17:32:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload_time = "2024-09-21T17:32:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload_time = "2024-09-21T17:33:00.495Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload_time = "2024-09-21T17:33:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload_time = "2024-09-21T17:33:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload_time = "2024-09-21T17:33:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload_time = "2024-09-21T17:33:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload_time = "2024-09-21T17:33:06.823Z" }, + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload_time = "2024-09-21T17:33:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload_time = "2024-09-21T17:33:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload_time = "2024-09-21T17:33:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload_time = "2024-09-21T17:33:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload_time = "2024-09-21T17:33:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload_time = "2024-09-21T17:33:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload_time = "2024-09-21T17:33:17.113Z" }, + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload_time = "2024-09-21T17:33:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload_time = "2024-09-21T17:33:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload_time = "2024-09-21T17:33:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload_time = "2024-09-21T17:33:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload_time = "2024-09-21T17:33:24.196Z" }, + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload_time = "2024-09-21T17:33:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload_time = "2024-09-21T17:33:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload_time = "2024-09-21T17:33:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload_time = "2024-09-21T17:33:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload_time = "2024-09-21T17:33:59.061Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload_time = "2024-09-21T17:34:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload_time = "2024-09-21T17:34:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload_time = "2024-09-21T17:34:19.904Z" }, +] diff --git a/s2-testing/README.md b/s2-testing/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/s2-testing/pyproject.toml similarity index 54% rename from pyproject.toml rename to s2-testing/pyproject.toml index d2a2def..793cdc1 100644 --- a/pyproject.toml +++ b/s2-testing/pyproject.toml @@ -1,23 +1,15 @@ [project] -name = "s2-self-certification" +name = "s2-testing" version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.10" dependencies = [ "pydantic>=2.11.3", + "pyyaml>=6.0.2", "s2-python", - "watchdog>=6.0.0", "websockets>=13.1", ] -[dependency-groups] -dev = [ - "black>=25.1.0", - "isort>=6.0.1", - "mypy>=1.15.0", - "pyyaml>=6.0.2", -] - [tool.uv.sources] -s2-python = { path = "../s2-python", editable = true } +s2-python = { path = "../../s2-python", editable = true } diff --git a/s2-testing/src/__init__.py b/s2-testing/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-testing/src/s2_testing.egg-info/PKG-INFO b/s2-testing/src/s2_testing.egg-info/PKG-INFO new file mode 100644 index 0000000..2f8fd90 --- /dev/null +++ b/s2-testing/src/s2_testing.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 2.4 +Name: s2-testing +Version: 0.1.0 +Summary: Add your description here +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: pydantic>=2.11.3 +Requires-Dist: pyyaml>=6.0.2 +Requires-Dist: s2-python +Requires-Dist: websockets>=13.1 diff --git a/s2-testing/src/s2_testing.egg-info/SOURCES.txt b/s2-testing/src/s2_testing.egg-info/SOURCES.txt new file mode 100644 index 0000000..465cd63 --- /dev/null +++ b/s2-testing/src/s2_testing.egg-info/SOURCES.txt @@ -0,0 +1,24 @@ +README.md +pyproject.toml +src/__init__.py +src/s2_testing.egg-info/PKG-INFO +src/s2_testing.egg-info/SOURCES.txt +src/s2_testing.egg-info/dependency_links.txt +src/s2_testing.egg-info/requires.txt +src/s2_testing.egg-info/top_level.txt +src/s2testing/__init__.py +src/s2testing/config.py +src/s2testing/connection.py +src/s2testing/message_handlers.py +src/s2testing/util.py +src/s2testing/certificate/certificate.py +src/s2testing/controllers/__init__.py +src/s2testing/controllers/builder.py +src/s2testing/controllers/controller.py +src/s2testing/controllers/frbc_controller.py +src/s2testing/controllers/pebc_controller.py +src/s2testing/test_suite/__init__.py +src/s2testing/test_suite/base_test_case.py +src/s2testing/test_suite/frbc_test_cases.py +src/s2testing/test_suite/pebc_test_cases.py +src/s2testing/test_suite/test_suite.py \ No newline at end of file diff --git a/s2-testing/src/s2_testing.egg-info/dependency_links.txt b/s2-testing/src/s2_testing.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/s2-testing/src/s2_testing.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/s2-testing/src/s2_testing.egg-info/requires.txt b/s2-testing/src/s2_testing.egg-info/requires.txt new file mode 100644 index 0000000..f633e80 --- /dev/null +++ b/s2-testing/src/s2_testing.egg-info/requires.txt @@ -0,0 +1,4 @@ +pydantic>=2.11.3 +pyyaml>=6.0.2 +s2-python +websockets>=13.1 diff --git a/s2-testing/src/s2_testing.egg-info/top_level.txt b/s2-testing/src/s2_testing.egg-info/top_level.txt new file mode 100644 index 0000000..e1e55dd --- /dev/null +++ b/s2-testing/src/s2_testing.egg-info/top_level.txt @@ -0,0 +1,2 @@ +__init__ +s2testing diff --git a/s2-testing/src/s2testing/__init__.py b/s2-testing/src/s2testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/s2-self-certification/certificate/certificate.py b/s2-testing/src/s2testing/certificate/certificate.py similarity index 98% rename from src/s2-self-certification/certificate/certificate.py rename to s2-testing/src/s2testing/certificate/certificate.py index b33e446..1b4aa8b 100644 --- a/src/s2-self-certification/certificate/certificate.py +++ b/s2-testing/src/s2testing/certificate/certificate.py @@ -5,7 +5,6 @@ from typing import List, Optional, Type from enum import Enum -from black.output import out from pydantic import BaseModel, field_serializer import yaml diff --git a/src/s2-self-certification/config.py b/s2-testing/src/s2testing/config.py similarity index 94% rename from src/s2-self-certification/config.py rename to s2-testing/src/s2testing/config.py index fd49d63..b4015e5 100644 --- a/src/s2-self-certification/config.py +++ b/s2-testing/src/s2testing/config.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Optional, Literal import yaml from pydantic import BaseModel @@ -47,6 +47,7 @@ class DeviceDetails(BaseModel): class Config(BaseModel): + mode: Literal["testing", "certification"] device_details: DeviceDetails control_types: ControlTypeTestConfig diff --git a/src/s2-self-certification/connection.py b/s2-testing/src/s2testing/connection.py similarity index 78% rename from src/s2-self-certification/connection.py rename to s2-testing/src/s2testing/connection.py index fef4e4e..95a6e01 100644 --- a/src/s2-self-certification/connection.py +++ b/s2-testing/src/s2testing/connection.py @@ -3,6 +3,7 @@ import logging import threading import uuid +import abc from typing import Type import websockets @@ -11,7 +12,6 @@ from s2python.reception_status_awaiter import ReceptionStatusAwaiter from s2python.s2_parser import S2Parser from s2python.s2_validation_error import S2ValidationError -from websockets.asyncio.connection import Connection as WSConnection logger = logging.getLogger(__name__) @@ -20,10 +20,10 @@ class SendOkay: """Mostly copied over from S2-Python library""" status_is_send: threading.Event - connection: "Connection" + connection: "BaseRMConnection" subject_message_id: uuid.UUID - def __init__(self, connection: "Connection", subject_message_id: uuid.UUID): + def __init__(self, connection: "BaseRMConnection", subject_message_id: uuid.UUID): self.status_is_send = threading.Event() self.connection = connection self.subject_message_id = subject_message_id @@ -48,14 +48,13 @@ async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: await self.run_async() -class Connection: # pylint: disable=too-many-instance-attributes +class BaseRMConnection(abc.ABC): # pylint: disable=too-many-instance-attributes """ Manged the websocket connection to the RM. Puts all received messages onto the message queue so they can be retrieved by other tasks. Based on the S2Connection class is S2-Python library. """ - ws: WSConnection s2_parser: S2Parser reception_status_awaiter: ReceptionStatusAwaiter @@ -64,8 +63,7 @@ class Connection: # pylint: disable=too-many-instance-attributes _stop_event: asyncio.Event - def __init__(self, ws) -> None: # pylint: disable=too-many-arguments - self.ws = ws + def __init__(self) -> None: # pylint: disable=too-many-arguments self.reception_status_awaiter = ReceptionStatusAwaiter() self.s2_parser = S2Parser() @@ -73,15 +71,18 @@ def __init__(self, ws) -> None: # pylint: disable=too-many-arguments self.message_queue = asyncio.Queue() - async def _send_and_forget(self, s2_msg: S2Message) -> None: - if self.ws is None: - raise RuntimeError( - "Cannot send messages if websocket connection is not yet established." - ) + @abc.abstractmethod + async def send(self, message: str): + pass + + @abc.abstractmethod + async def receive(self): + pass + async def _send_and_forget(self, s2_msg: S2Message) -> None: json_msg = s2_msg.to_json() try: - await self.ws.send(json_msg) + await self.send(json_msg) except websockets.ConnectionClosedError as e: logger.error("Unable to send message %s.", s2_msg.message_type) @@ -185,38 +186,18 @@ async def parse_received_message(self, message: str): await self.message_queue.put(s2_msg) async def receive_messages(self): - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - logger.debug("Connection has started to receive messages.") + while not self._stop_event.is_set(): + # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. + try: + message = await asyncio.wait_for(self.receive(), timeout=1) + logger.debug("Received Message: %s", message) + except asyncio.TimeoutError: + continue - try: - while not self._stop_event.is_set(): - # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. - try: - message = await asyncio.wait_for(self.ws.recv(), timeout=1) - logger.debug("Received Message: %s", message) - except asyncio.TimeoutError: - continue - - await self.parse_received_message(str(message)) - except websockets.ConnectionClosedOK: - logger.info("Connection closed normally by remote.") - self._handle_ws_close() - except websockets.ConnectionClosedError as e: - logger.error("Connection closed with error: %s", str(e)) - self._handle_ws_close() - except asyncio.CancelledError: - pass - - def _handle_ws_close(self): - self._stop_event.set() + await self.parse_received_message(str(message)) async def get_next_message(self): return await self.message_queue.get() - async def stop(self): + def stop(self): self._stop_event.set() - - await self.ws.close() diff --git a/src/s2-self-certification/controllers/__init__.py b/s2-testing/src/s2testing/controllers/__init__.py similarity index 71% rename from src/s2-self-certification/controllers/__init__.py rename to s2-testing/src/s2testing/controllers/__init__.py index 48d60b4..0442072 100644 --- a/src/s2-self-certification/controllers/__init__.py +++ b/s2-testing/src/s2testing/controllers/__init__.py @@ -1,4 +1,4 @@ from .builder import ControlTypeBuilder -from .controller import Controller +from .controller import Controller, BaseController from .frbc_controller import FRBCController from .pebc_controller import PEBCController diff --git a/src/s2-self-certification/controllers/builder.py b/s2-testing/src/s2testing/controllers/builder.py similarity index 89% rename from src/s2-self-certification/controllers/builder.py rename to s2-testing/src/s2testing/controllers/builder.py index 9faab71..826fc45 100644 --- a/src/s2-self-certification/controllers/builder.py +++ b/s2-testing/src/s2testing/controllers/builder.py @@ -1,5 +1,5 @@ from typing import Callable, Type -from controllers.controller import Controller +from .controller import Controller from s2python.message import S2Message diff --git a/src/s2-self-certification/controllers/controller.py b/s2-testing/src/s2testing/controllers/controller.py similarity index 88% rename from src/s2-self-certification/controllers/controller.py rename to s2-testing/src/s2testing/controllers/controller.py index 8ffd2af..21c6791 100644 --- a/src/s2-self-certification/controllers/controller.py +++ b/s2-testing/src/s2testing/controllers/controller.py @@ -1,8 +1,8 @@ import asyncio from email import message from typing import Awaitable, Callable, Optional, Type -from connection import Connection -from message_handlers import MessageHandler +from ..connection import BaseRMConnection +from s2testing.message_handlers import MessageHandler from s2python.common import ( ControlType as ProtocolControlType, PowerForecast, @@ -28,7 +28,11 @@ def __init__(self): super().__init__() def handle_message( - self, message: S2Message, connection: Connection, *args, **kwargs + self, + message: S2Message, + connection: "BaseRMConnection", + *args, + **kwargs ): try: result = super().handle_message(message, connection, *args, **kwargs) @@ -63,7 +67,7 @@ def handshake_received(self): async def handle_rm_details( self, message: ResourceManagerDetails, - connection: "Connection", + connection: "BaseRMConnection", send_okay: Awaitable, ): self.resource_manager_details = message @@ -83,7 +87,7 @@ def __init__(self): async def handle_power_measurement_message( self, message: PowerMeasurement, - connection: "Connection", + connection: "BaseRMConnection", send_okay: Awaitable, ): @@ -92,7 +96,7 @@ async def handle_power_measurement_message( async def handle_power_forecast_message( self, message: PowerForecast, - connection: "Connection", + connection: "BaseRMConnection", send_okay: Awaitable, ): diff --git a/src/s2-self-certification/controllers/frbc_controller.py b/s2-testing/src/s2testing/controllers/frbc_controller.py similarity index 88% rename from src/s2-self-certification/controllers/frbc_controller.py rename to s2-testing/src/s2testing/controllers/frbc_controller.py index ef30a15..b2106e8 100644 --- a/src/s2-self-certification/controllers/frbc_controller.py +++ b/s2-testing/src/s2testing/controllers/frbc_controller.py @@ -1,7 +1,7 @@ import asyncio import logging from typing import Optional -from connection import Connection +from s2testing.connection import BaseRMConnection from s2python.common import ControlType as ProtocolControlType from s2python.frbc import FRBCSystemDescription from .controller import BaseController @@ -23,7 +23,7 @@ def __init__(self): self.add_handler(FRBCSystemDescription, self.handle_system_description_message) async def handle_system_description_message( - self, message: FRBCSystemDescription, connection: "Connection", send_okay + self, message: FRBCSystemDescription, connection: "BaseRMConnection", send_okay ): if not self.is_correct_message_type(message, FRBCSystemDescription): raise ValueError("Invalid Message Type.") diff --git a/src/s2-self-certification/controllers/pebc_controller.py b/s2-testing/src/s2testing/controllers/pebc_controller.py similarity index 91% rename from src/s2-self-certification/controllers/pebc_controller.py rename to s2-testing/src/s2testing/controllers/pebc_controller.py index 61990a9..498e759 100644 --- a/src/s2-self-certification/controllers/pebc_controller.py +++ b/s2-testing/src/s2testing/controllers/pebc_controller.py @@ -9,10 +9,8 @@ PEBCEnergyConstraint, PEBCPowerConstraints, ) -from .controller import BaseController, Controller - -if TYPE_CHECKING: - from connection import Connection +from .controller import BaseController +from ..connection import BaseRMConnection import logging @@ -38,7 +36,7 @@ def __init__(self): async def handle_power_constraints_message( self, message: PEBCPowerConstraints, - connection: "Connection", + connection: "BaseRMConnection", send_okay: Awaitable, ): logger.info("Received power constraints.") @@ -50,7 +48,7 @@ async def handle_power_constraints_message( async def handle_energy_constraints_message( self, message: PEBCEnergyConstraint, - connection: "Connection", + connection: "BaseRMConnection", send_okay: Awaitable, ): await send_okay @@ -58,7 +56,7 @@ async def handle_energy_constraints_message( async def handle_instruction_status_update( self, message: InstructionStatusUpdate, - connection: "Connection", + connection: "BaseRMConnection", send_okay: Awaitable, ): await send_okay diff --git a/src/s2-self-certification/message_handlers.py b/s2-testing/src/s2testing/message_handlers.py similarity index 100% rename from src/s2-self-certification/message_handlers.py rename to s2-testing/src/s2testing/message_handlers.py diff --git a/src/s2-self-certification/test_suite/__init__.py b/s2-testing/src/s2testing/test_suite/__init__.py similarity index 100% rename from src/s2-self-certification/test_suite/__init__.py rename to s2-testing/src/s2testing/test_suite/__init__.py diff --git a/src/s2-self-certification/test_suite/base_test_case.py b/s2-testing/src/s2testing/test_suite/base_test_case.py similarity index 86% rename from src/s2-self-certification/test_suite/base_test_case.py rename to s2-testing/src/s2testing/test_suite/base_test_case.py index 6bb3ea0..e0b92e8 100644 --- a/src/s2-self-certification/test_suite/base_test_case.py +++ b/s2-testing/src/s2testing/test_suite/base_test_case.py @@ -1,12 +1,12 @@ -from certificate.certificate import ( +from ..certificate.certificate import ( ComplianceFinding, ComplianceReport, ComplianceStatus, ) -from config import BaseTestConfig -from connection import Connection -from controllers.controller import BaseController -from test_suite.test_suite import S2TestCase +from ..config import BaseTestConfig +from ..connection import BaseRMConnection +from ..controllers.controller import BaseController +from ..test_suite.test_suite import S2TestCase from s2python.common import ( PowerForecast, @@ -28,7 +28,7 @@ class NoSelectionTestCase(S2TestCase): def __init__( self, config: BaseTestConfig, - connection: Connection, + connection: BaseRMConnection, controller: BaseController, report: ComplianceReport, ): diff --git a/src/s2-self-certification/test_suite/frbc_test_cases.py b/s2-testing/src/s2testing/test_suite/frbc_test_cases.py similarity index 87% rename from src/s2-self-certification/test_suite/frbc_test_cases.py rename to s2-testing/src/s2testing/test_suite/frbc_test_cases.py index 9c1897c..70a0785 100644 --- a/src/s2-self-certification/test_suite/frbc_test_cases.py +++ b/s2-testing/src/s2testing/test_suite/frbc_test_cases.py @@ -3,15 +3,15 @@ import logging import uuid -from certificate.certificate import ( +from s2testing.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from config import FRBCTestConfig, PEBCTestConfig -from connection import Connection -from controllers.frbc_controller import FRBCController +from s2testing.config import FRBCTestConfig, PEBCTestConfig +from s2testing.connection import BaseRMConnection +from s2testing.controllers.frbc_controller import FRBCController from s2python.common import PowerMeasurement, ControlType as ProtocolControlType from s2python.frbc import ( FRBCActuatorStatus, @@ -20,8 +20,8 @@ FRBCSystemDescription, FRBCUsageForecast, ) -from test_suite.base_test_case import NoSelectionTestCase -from test_suite.test_suite import S2TestCase +from s2testing.test_suite.base_test_case import NoSelectionTestCase +from s2testing.test_suite.test_suite import S2TestCase logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ class FRBCTestCase(NoSelectionTestCase): def __init__( self, config: FRBCTestConfig, - connection: Connection, + connection: BaseRMConnection, controller: FRBCController, report: ComplianceReport, ): diff --git a/src/s2-self-certification/test_suite/pebc_test_cases.py b/s2-testing/src/s2testing/test_suite/pebc_test_cases.py similarity index 91% rename from src/s2-self-certification/test_suite/pebc_test_cases.py rename to s2-testing/src/s2testing/test_suite/pebc_test_cases.py index 0d2ea76..eb4b2f4 100644 --- a/src/s2-self-certification/test_suite/pebc_test_cases.py +++ b/s2-testing/src/s2testing/test_suite/pebc_test_cases.py @@ -2,16 +2,16 @@ import logging import uuid -from certificate.certificate import ( +from s2testing.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from config import BaseTestConfig, PEBCTestConfig -from connection import Connection -from controllers.controller import Controller -from controllers.pebc_controller import PEBCController +from s2testing.config import BaseTestConfig, PEBCTestConfig +from s2testing.connection import BaseRMConnection +from s2testing.controllers.controller import Controller +from s2testing.controllers.pebc_controller import PEBCController from s2python.common import ( ControlType as ProtocolControlType, PowerMeasurement, @@ -23,8 +23,8 @@ PEBCPowerEnvelope, PEBCPowerEnvelopeElement, ) -from test_suite.base_test_case import NoSelectionTestCase -from test_suite.test_suite import S2TestCase +from s2testing.test_suite.base_test_case import NoSelectionTestCase +from s2testing.test_suite.test_suite import S2TestCase logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ class PEBCTestCase(NoSelectionTestCase): def __init__( self, config: PEBCTestConfig, - connection: Connection, + connection: BaseRMConnection, controller: PEBCController, report: ComplianceReport, ): diff --git a/src/s2-self-certification/test_suite/test_suite.py b/s2-testing/src/s2testing/test_suite/test_suite.py similarity index 93% rename from src/s2-self-certification/test_suite/test_suite.py rename to s2-testing/src/s2testing/test_suite/test_suite.py index 4a6d0a9..6f434e5 100644 --- a/src/s2-self-certification/test_suite/test_suite.py +++ b/s2-testing/src/s2testing/test_suite/test_suite.py @@ -5,18 +5,15 @@ import logging from typing import TYPE_CHECKING, Dict, List, Optional, Type -from click import Option -import test - -from certificate.certificate import ( +from s2testing.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from config import BaseTestConfig, ControlTypeTestConfig -from connection import Connection -from controllers.controller import Controller +from s2testing.config import BaseTestConfig, ControlTypeTestConfig +from s2testing.connection import BaseRMConnection +from s2testing.controllers.controller import Controller from s2python.common import ControlType as ProtocolControlType from s2python.message import S2Message @@ -32,7 +29,7 @@ class S2TestCase(abc.ABC): def __init__( self, config: BaseTestConfig, - connection: Connection, + connection: BaseRMConnection, controller: Controller, report: ComplianceReport, ): @@ -133,7 +130,7 @@ def add_test_case(self, test_case: Type[S2TestCase]): else: self.test_cases[test_case.control_type] = [test_case] - async def execute(self, connection: Connection, controller: Controller): + async def execute(self, connection: BaseRMConnection, controller: Controller): control_type = controller.control_type test_cases = self.test_cases.get(control_type, []) logger.info(self.test_cases) diff --git a/src/s2-self-certification/util.py b/s2-testing/src/s2testing/util.py similarity index 100% rename from src/s2-self-certification/util.py rename to s2-testing/src/s2testing/util.py diff --git a/s2-testing/uv.lock b/s2-testing/uv.lock new file mode 100644 index 0000000..95b5386 --- /dev/null +++ b/s2-testing/uv.lock @@ -0,0 +1,324 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload_time = "2025-04-08T13:27:06.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload_time = "2025-04-08T13:27:03.789Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload_time = "2025-04-02T09:49:41.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021, upload_time = "2025-04-02T09:46:45.065Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742, upload_time = "2025-04-02T09:46:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414, upload_time = "2025-04-02T09:46:48.263Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848, upload_time = "2025-04-02T09:46:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055, upload_time = "2025-04-02T09:46:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806, upload_time = "2025-04-02T09:46:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777, upload_time = "2025-04-02T09:46:53.675Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803, upload_time = "2025-04-02T09:46:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755, upload_time = "2025-04-02T09:46:56.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358, upload_time = "2025-04-02T09:46:58.445Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916, upload_time = "2025-04-02T09:46:59.726Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823, upload_time = "2025-04-02T09:47:01.278Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494, upload_time = "2025-04-02T09:47:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224, upload_time = "2025-04-02T09:47:04.199Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845, upload_time = "2025-04-02T09:47:05.686Z" }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029, upload_time = "2025-04-02T09:47:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784, upload_time = "2025-04-02T09:47:08.63Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075, upload_time = "2025-04-02T09:47:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849, upload_time = "2025-04-02T09:47:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794, upload_time = "2025-04-02T09:47:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237, upload_time = "2025-04-02T09:47:14.355Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351, upload_time = "2025-04-02T09:47:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914, upload_time = "2025-04-02T09:47:17Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385, upload_time = "2025-04-02T09:47:18.631Z" }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765, upload_time = "2025-04-02T09:47:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688, upload_time = "2025-04-02T09:47:22.029Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185, upload_time = "2025-04-02T09:47:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640, upload_time = "2025-04-02T09:47:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649, upload_time = "2025-04-02T09:47:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472, upload_time = "2025-04-02T09:47:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509, upload_time = "2025-04-02T09:47:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702, upload_time = "2025-04-02T09:47:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428, upload_time = "2025-04-02T09:47:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753, upload_time = "2025-04-02T09:47:39.013Z" }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849, upload_time = "2025-04-02T09:47:40.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541, upload_time = "2025-04-02T09:47:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225, upload_time = "2025-04-02T09:47:43.425Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373, upload_time = "2025-04-02T09:47:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034, upload_time = "2025-04-02T09:47:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848, upload_time = "2025-04-02T09:47:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986, upload_time = "2025-04-02T09:47:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551, upload_time = "2025-04-02T09:47:51.648Z" }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785, upload_time = "2025-04-02T09:47:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758, upload_time = "2025-04-02T09:47:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109, upload_time = "2025-04-02T09:47:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159, upload_time = "2025-04-02T09:47:58.088Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222, upload_time = "2025-04-02T09:47:59.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980, upload_time = "2025-04-02T09:48:01.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840, upload_time = "2025-04-02T09:48:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518, upload_time = "2025-04-02T09:48:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025, upload_time = "2025-04-02T09:48:06.226Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991, upload_time = "2025-04-02T09:48:08.114Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262, upload_time = "2025-04-02T09:48:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626, upload_time = "2025-04-02T09:48:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590, upload_time = "2025-04-02T09:48:12.861Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload_time = "2025-04-02T09:48:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload_time = "2025-04-02T09:48:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload_time = "2025-04-02T09:48:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659, upload_time = "2025-04-02T09:48:45.342Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294, upload_time = "2025-04-02T09:48:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771, upload_time = "2025-04-02T09:48:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558, upload_time = "2025-04-02T09:48:51.409Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038, upload_time = "2025-04-02T09:48:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315, upload_time = "2025-04-02T09:48:55.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063, upload_time = "2025-04-02T09:48:57.479Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631, upload_time = "2025-04-02T09:48:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877, upload_time = "2025-04-02T09:49:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858, upload_time = "2025-04-02T09:49:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745, upload_time = "2025-04-02T09:49:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188, upload_time = "2025-04-02T09:49:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479, upload_time = "2025-04-02T09:49:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415, upload_time = "2025-04-02T09:49:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623, upload_time = "2025-04-02T09:49:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175, upload_time = "2025-04-02T09:49:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674, upload_time = "2025-04-02T09:49:17.61Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951, upload_time = "2025-04-02T09:49:19.559Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "s2-python" +version = "0.5.0" +source = { editable = "../../s2-python" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "datamodel-code-generator", marker = "extra == 'development'" }, + { name = "mypy", marker = "extra == 'testing'" }, + { name = "pip-tools", marker = "extra == 'development'" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pylint", marker = "extra == 'testing'" }, + { name = "pyright", marker = "extra == 'testing'" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-coverage", marker = "extra == 'testing'" }, + { name = "pytest-timer", marker = "extra == 'testing'" }, + { name = "pytz" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "sphinx-tabs", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'development'" }, + { name = "types-pytz", marker = "extra == 'testing'" }, + { name = "websockets", specifier = "~=13.1" }, +] +provides-extras = ["testing", "development", "docs"] + +[[package]] +name = "s2-testing" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../../s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload_time = "2025-02-25T17:27:59.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload_time = "2025-02-25T17:27:57.754Z" }, +] + +[[package]] +name = "websockets" +version = "13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload_time = "2024-09-21T17:34:21.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload_time = "2024-09-21T17:32:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload_time = "2024-09-21T17:32:28.428Z" }, + { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload_time = "2024-09-21T17:32:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload_time = "2024-09-21T17:32:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload_time = "2024-09-21T17:32:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload_time = "2024-09-21T17:32:33.398Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload_time = "2024-09-21T17:32:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload_time = "2024-09-21T17:32:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload_time = "2024-09-21T17:32:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload_time = "2024-09-21T17:32:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload_time = "2024-09-21T17:32:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload_time = "2024-09-21T17:32:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload_time = "2024-09-21T17:32:43.858Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload_time = "2024-09-21T17:32:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload_time = "2024-09-21T17:32:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload_time = "2024-09-21T17:32:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload_time = "2024-09-21T17:32:48.046Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload_time = "2024-09-21T17:32:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload_time = "2024-09-21T17:32:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload_time = "2024-09-21T17:32:52.223Z" }, + { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload_time = "2024-09-21T17:32:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload_time = "2024-09-21T17:32:54.721Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload_time = "2024-09-21T17:32:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload_time = "2024-09-21T17:32:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload_time = "2024-09-21T17:32:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload_time = "2024-09-21T17:33:00.495Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload_time = "2024-09-21T17:33:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload_time = "2024-09-21T17:33:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload_time = "2024-09-21T17:33:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload_time = "2024-09-21T17:33:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload_time = "2024-09-21T17:33:06.823Z" }, + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload_time = "2024-09-21T17:33:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload_time = "2024-09-21T17:33:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload_time = "2024-09-21T17:33:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload_time = "2024-09-21T17:33:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload_time = "2024-09-21T17:33:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload_time = "2024-09-21T17:33:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload_time = "2024-09-21T17:33:17.113Z" }, + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload_time = "2024-09-21T17:33:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload_time = "2024-09-21T17:33:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload_time = "2024-09-21T17:33:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload_time = "2024-09-21T17:33:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload_time = "2024-09-21T17:33:24.196Z" }, + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload_time = "2024-09-21T17:33:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload_time = "2024-09-21T17:33:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload_time = "2024-09-21T17:33:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload_time = "2024-09-21T17:33:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload_time = "2024-09-21T17:33:59.061Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload_time = "2024-09-21T17:34:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload_time = "2024-09-21T17:34:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload_time = "2024-09-21T17:34:19.904Z" }, +] diff --git a/uv.lock b/uv.lock index 6fd82f5..21aee1c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,14 @@ version = 1 revision = 2 requires-python = ">=3.10" +[manifest] +members = [ + "s2-self-cert", + "s2-self-certification", + "s2-server", + "s2-testing", +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -12,37 +20,27 @@ wheels = [ ] [[package]] -name = "black" -version = "25.1.0" +name = "anyio" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload_time = "2025-01-29T04:15:40.373Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload_time = "2025-03-17T00:02:54.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload_time = "2025-01-29T05:37:06.642Z" }, - { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload_time = "2025-01-29T05:37:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload_time = "2025-01-29T04:18:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload_time = "2025-01-29T04:19:04.296Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload_time = "2025-01-29T05:37:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload_time = "2025-01-29T05:37:14.309Z" }, - { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload_time = "2025-01-29T04:18:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload_time = "2025-01-29T04:18:51.711Z" }, - { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload_time = "2025-01-29T05:37:16.707Z" }, - { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload_time = "2025-01-29T05:37:18.273Z" }, - { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload_time = "2025-01-29T04:18:33.823Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload_time = "2025-01-29T04:19:12.944Z" }, - { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload_time = "2025-01-29T05:37:20.574Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload_time = "2025-01-29T05:37:22.106Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload_time = "2025-01-29T04:18:58.564Z" }, - { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload_time = "2025-01-29T04:19:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload_time = "2025-01-29T04:15:38.082Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload_time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload_time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload_time = "2025-04-26T02:12:27.662Z" }, ] [[package]] @@ -67,86 +65,250 @@ wheels = [ ] [[package]] -name = "isort" -version = "6.0.1" +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload_time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload_time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload_time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload_time = "2024-06-20T11:30:28.248Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload_time = "2025-02-26T21:13:16.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload_time = "2024-07-12T22:26:00.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload_time = "2025-02-26T21:13:14.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload_time = "2024-07-12T22:25:58.476Z" }, ] [[package]] -name = "mypy" -version = "1.15.0" +name = "fastapi" +version = "0.115.12" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mypy-extensions" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "pydantic" }, + { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload_time = "2025-02-05T03:50:34.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433, upload_time = "2025-02-05T03:49:29.145Z" }, - { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472, upload_time = "2025-02-05T03:49:16.986Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424, upload_time = "2025-02-05T03:49:46.908Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450, upload_time = "2025-02-05T03:50:05.89Z" }, - { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765, upload_time = "2025-02-05T03:49:33.56Z" }, - { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload_time = "2025-02-05T03:49:38.981Z" }, - { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338, upload_time = "2025-02-05T03:50:17.287Z" }, - { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540, upload_time = "2025-02-05T03:49:51.21Z" }, - { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051, upload_time = "2025-02-05T03:50:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751, upload_time = "2025-02-05T03:49:42.408Z" }, - { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783, upload_time = "2025-02-05T03:49:07.707Z" }, - { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload_time = "2025-02-05T03:49:54.581Z" }, - { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload_time = "2025-02-05T03:50:28.25Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload_time = "2025-02-05T03:50:13.411Z" }, - { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload_time = "2025-02-05T03:50:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload_time = "2025-02-05T03:48:48.705Z" }, - { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload_time = "2025-02-05T03:49:03.628Z" }, - { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload_time = "2025-02-05T03:50:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload_time = "2025-02-05T03:48:55.789Z" }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload_time = "2025-02-05T03:48:44.581Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload_time = "2025-02-05T03:49:25.514Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload_time = "2025-02-05T03:49:57.623Z" }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload_time = "2025-02-05T03:48:52.361Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload_time = "2025-02-05T03:49:11.395Z" }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload_time = "2025-02-05T03:50:08.348Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload_time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload_time = "2025-03-23T22:55:42.101Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload_time = "2024-12-15T14:28:10.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload_time = "2024-12-15T14:28:06.18Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, ] [[package]] -name = "packaging" -version = "25.0" +name = "httptools" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload_time = "2024-10-16T19:45:08.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload_time = "2024-10-16T19:44:06.882Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload_time = "2024-10-16T19:44:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload_time = "2024-10-16T19:44:09.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload_time = "2024-10-16T19:44:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload_time = "2024-10-16T19:44:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload_time = "2024-10-16T19:44:15.258Z" }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload_time = "2024-10-16T19:44:16.54Z" }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload_time = "2024-10-16T19:44:18.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload_time = "2024-10-16T19:44:19.515Z" }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload_time = "2024-10-16T19:44:21.067Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload_time = "2024-10-16T19:44:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload_time = "2024-10-16T19:44:24.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload_time = "2024-10-16T19:44:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload_time = "2024-10-16T19:44:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload_time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload_time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload_time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload_time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload_time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload_time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload_time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload_time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload_time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload_time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload_time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload_time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload_time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload_time = "2024-10-16T19:44:46.46Z" }, ] [[package]] -name = "pathspec" -version = "0.12.1" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload_time = "2023-12-10T22:30:45Z" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload_time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, ] [[package]] -name = "platformdirs" -version = "4.3.7" +name = "idna" +version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload_time = "2025-03-19T20:36:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload_time = "2025-03-19T20:36:09.038Z" }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload_time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload_time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload_time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload_time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload_time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload_time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload_time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload_time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload_time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload_time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload_time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload_time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload_time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload_time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload_time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload_time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload_time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload_time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload_time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload_time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload_time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload_time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload_time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload_time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload_time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload_time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload_time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload_time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload_time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload_time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -252,12 +414,30 @@ wheels = [ ] [[package]] -name = "pytz" -version = "2025.2" +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload_time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload_time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload_time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload_time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, ] [[package]] @@ -305,114 +485,120 @@ wheels = [ ] [[package]] -name = "s2-python" -version = "0.5.0" -source = { editable = "../s2-python" } +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload_time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload_time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "pydantic" }, - { name = "pytz" }, - { name = "websockets" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/69/e328fb8986814147562b2617f22b06723f60b0c85c85afc0408b9f324a97/rich_toolkit-0.14.3.tar.gz", hash = "sha256:b72a342e52253b912681b027e94226e2deea616494420eec0b09a7219a72a0a5", size = 104469, upload_time = "2025-04-23T14:54:52.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/09/e0c7b06657ca1d4317d9e37ea5657a88a20dc3507b2ee6939ace0ff9036e/rich_toolkit-0.14.3-py3-none-any.whl", hash = "sha256:2ec72dcdf1bbb09b6a9286a4eddcd4d43369da3b22fe3f28e5a92143618b8ac6", size = 24258, upload_time = "2025-04-23T14:54:51.443Z" }, +] + +[[package]] +name = "s2-self-cert" +version = "0.1.0" +source = { virtual = "s2-self-cert" } +dependencies = [ + { name = "s2-testing" }, ] [package.metadata] -requires-dist = [ - { name = "click" }, - { name = "datamodel-code-generator", marker = "extra == 'development'" }, - { name = "mypy", marker = "extra == 'testing'" }, - { name = "pip-tools", marker = "extra == 'development'" }, - { name = "pre-commit", marker = "extra == 'development'" }, - { name = "pydantic", specifier = ">=2.8.2" }, - { name = "pylint", marker = "extra == 'testing'" }, - { name = "pyright", marker = "extra == 'testing'" }, - { name = "pytest", marker = "extra == 'testing'" }, - { name = "pytest-coverage", marker = "extra == 'testing'" }, - { name = "pytest-timer", marker = "extra == 'testing'" }, - { name = "pytz" }, - { name = "sphinx", marker = "extra == 'docs'" }, - { name = "sphinx-copybutton", marker = "extra == 'docs'" }, - { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, - { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, - { name = "sphinx-tabs", marker = "extra == 'docs'" }, - { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, - { name = "tox", marker = "extra == 'development'" }, - { name = "types-pytz", marker = "extra == 'testing'" }, - { name = "websockets", specifier = "~=13.1" }, -] -provides-extras = ["testing", "development", "docs"] +requires-dist = [{ name = "s2-testing", virtual = "s2-testing" }] [[package]] name = "s2-self-certification" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "pydantic" }, - { name = "s2-python" }, - { name = "watchdog" }, - { name = "websockets" }, + { name = "s2-testing" }, ] -[package.dev-dependencies] -dev = [ - { name = "black" }, - { name = "isort" }, - { name = "mypy" }, - { name = "pyyaml" }, +[package.metadata] +requires-dist = [{ name = "s2-testing", virtual = "s2-testing" }] + +[[package]] +name = "s2-server" +version = "0.1.0" +source = { virtual = "s2-self-cert-server" } +dependencies = [ + { name = "fastapi", extra = ["standard"] }, + { name = "python-multipart" }, + { name = "s2-testing" }, ] [package.metadata] requires-dist = [ - { name = "pydantic", specifier = ">=2.11.3" }, - { name = "s2-python", editable = "../s2-python" }, - { name = "watchdog", specifier = ">=6.0.0" }, - { name = "websockets", specifier = ">=13.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "black", specifier = ">=25.1.0" }, - { name = "isort", specifier = ">=6.0.1" }, - { name = "mypy", specifier = ">=1.15.0" }, - { name = "pyyaml", specifier = ">=6.0.2" }, -] - -[[package]] -name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload_time = "2024-11-27T22:38:36.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload_time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload_time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload_time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload_time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload_time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload_time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload_time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload_time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload_time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload_time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload_time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload_time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload_time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload_time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload_time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload_time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload_time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload_time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload_time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload_time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload_time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload_time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload_time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload_time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload_time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload_time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload_time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload_time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload_time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload_time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "s2-testing", virtual = "s2-testing" }, +] + +[[package]] +name = "s2-testing" +version = "0.1.0" +source = { virtual = "s2-testing" } + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload_time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload_time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "typer" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload_time = "2025-04-28T21:40:59.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload_time = "2025-04-28T21:40:56.269Z" }, ] [[package]] @@ -437,35 +623,125 @@ wheels = [ ] [[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload_time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload_time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload_time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload_time = "2024-11-01T14:06:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload_time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload_time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload_time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload_time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload_time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload_time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload_time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload_time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload_time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload_time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload_time = "2024-11-01T14:06:55.19Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload_time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload_time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload_time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload_time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload_time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload_time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload_time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload_time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload_time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload_time = "2024-11-01T14:07:11.845Z" }, +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload_time = "2025-04-19T06:02:50.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload_time = "2025-04-19T06:02:48.42Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload_time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload_time = "2024-10-14T23:37:20.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload_time = "2024-10-14T23:37:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload_time = "2024-10-14T23:37:25.129Z" }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload_time = "2024-10-14T23:37:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload_time = "2024-10-14T23:37:29.385Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload_time = "2024-10-14T23:37:32.048Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload_time = "2024-10-14T23:37:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload_time = "2024-10-14T23:37:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload_time = "2024-10-14T23:37:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload_time = "2024-10-14T23:37:40.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload_time = "2024-10-14T23:37:42.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload_time = "2024-10-14T23:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload_time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload_time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload_time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload_time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload_time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload_time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload_time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload_time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload_time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload_time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload_time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload_time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload_time = "2025-04-08T10:36:26.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40", size = 405632, upload_time = "2025-04-08T10:34:41.832Z" }, + { url = "https://files.pythonhosted.org/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb", size = 395734, upload_time = "2025-04-08T10:34:44.236Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11", size = 455008, upload_time = "2025-04-08T10:34:45.617Z" }, + { url = "https://files.pythonhosted.org/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487", size = 459029, upload_time = "2025-04-08T10:34:46.814Z" }, + { url = "https://files.pythonhosted.org/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256", size = 488916, upload_time = "2025-04-08T10:34:48.571Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85", size = 523763, upload_time = "2025-04-08T10:34:50.268Z" }, + { url = "https://files.pythonhosted.org/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358", size = 502891, upload_time = "2025-04-08T10:34:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614", size = 454921, upload_time = "2025-04-08T10:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f", size = 631422, upload_time = "2025-04-08T10:34:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d", size = 625675, upload_time = "2025-04-08T10:34:55.173Z" }, + { url = "https://files.pythonhosted.org/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff", size = 277921, upload_time = "2025-04-08T10:34:56.318Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92", size = 291526, upload_time = "2025-04-08T10:34:57.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336, upload_time = "2025-04-08T10:34:59.359Z" }, + { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977, upload_time = "2025-04-08T10:35:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232, upload_time = "2025-04-08T10:35:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151, upload_time = "2025-04-08T10:35:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054, upload_time = "2025-04-08T10:35:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955, upload_time = "2025-04-08T10:35:05.786Z" }, + { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234, upload_time = "2025-04-08T10:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750, upload_time = "2025-04-08T10:35:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591, upload_time = "2025-04-08T10:35:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370, upload_time = "2025-04-08T10:35:12.412Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791, upload_time = "2025-04-08T10:35:13.719Z" }, + { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622, upload_time = "2025-04-08T10:35:15.071Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699, upload_time = "2025-04-08T10:35:16.732Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload_time = "2025-04-08T10:35:17.956Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload_time = "2025-04-08T10:35:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload_time = "2025-04-08T10:35:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload_time = "2025-04-08T10:35:21.87Z" }, + { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload_time = "2025-04-08T10:35:23.143Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload_time = "2025-04-08T10:35:24.702Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload_time = "2025-04-08T10:35:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload_time = "2025-04-08T10:35:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload_time = "2025-04-08T10:35:28.685Z" }, + { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload_time = "2025-04-08T10:35:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload_time = "2025-04-08T10:35:32.023Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload_time = "2025-04-08T10:35:33.225Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload_time = "2025-04-08T10:35:34.568Z" }, + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload_time = "2025-04-08T10:35:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload_time = "2025-04-08T10:35:37.048Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload_time = "2025-04-08T10:35:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload_time = "2025-04-08T10:35:39.708Z" }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload_time = "2025-04-08T10:35:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload_time = "2025-04-08T10:35:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload_time = "2025-04-08T10:35:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload_time = "2025-04-08T10:35:46.336Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload_time = "2025-04-08T10:35:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload_time = "2025-04-08T10:35:49.65Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload_time = "2025-04-08T10:35:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload_time = "2025-04-08T10:35:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d", size = 405947, upload_time = "2025-04-08T10:36:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034", size = 397276, upload_time = "2025-04-08T10:36:15.131Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965", size = 455550, upload_time = "2025-04-08T10:36:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57", size = 455542, upload_time = "2025-04-08T10:36:18.655Z" }, ] [[package]] From 21ceb9015893767700e6c9be175f6575f0abae95 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Wed, 30 Apr 2025 10:34:01 +0200 Subject: [PATCH 10/75] Added the custom version of S2 Python to this repo. (Temporary) --- cert.yaml | 19 +- requirements.txt | 6 +- s2-python/.github/workflows/ci.yml | 198 ++ s2-python/.gitignore | 15 + s2-python/.pre-commit-config.yaml | 34 + s2-python/.pylintrc | 13 + s2-python/LICENSE | 201 ++ s2-python/README.rst | 73 + s2-python/ci/clean.sh | 4 + s2-python/ci/distribute.sh | 2 + s2-python/ci/generate_s2.sh | 5 + s2-python/ci/install_dependencies.sh | 4 + s2-python/ci/lint.sh | 4 + s2-python/ci/setup_dev_environment.sh | 5 + s2-python/ci/test_unit.sh | 4 + s2-python/ci/typecheck.sh | 5 + s2-python/ci/update_dependencies.sh | 4 + s2-python/dev-requirements.txt | 244 +++ .../development_utilities/gen_templates.py | 20 + .../gen_unit_test_template.py | 313 +++ .../generate_s2_message_type_to_class.py | 24 + .../development_utilities/get_all_messages.py | 16 + s2-python/examples/example_frbc_rm.py | 188 ++ s2-python/mypy.ini | 14 + s2-python/pyproject.toml | 3 + s2-python/pyrightconfig.json | 10 + s2-python/setup.cfg | 106 + s2-python/setup.py | 3 + s2-python/specification/openapi.yml | 1762 +++++++++++++++++ s2-python/src/s2python/__init__.py | 10 + s2-python/src/s2python/common/__init__.py | 64 + s2-python/src/s2python/common/duration.py | 24 + s2-python/src/s2python/common/handshake.py | 15 + .../src/s2python/common/handshake_response.py | 15 + .../common/instruction_status_update.py | 18 + s2-python/src/s2python/common/number_range.py | 25 + .../src/s2python/common/power_forecast.py | 18 + .../s2python/common/power_forecast_element.py | 20 + .../s2python/common/power_forecast_value.py | 11 + .../src/s2python/common/power_measurement.py | 18 + s2-python/src/s2python/common/power_range.py | 24 + s2-python/src/s2python/common/power_value.py | 11 + .../src/s2python/common/reception_status.py | 15 + .../common/resource_manager_details.py | 25 + .../src/s2python/common/revoke_object.py | 16 + s2-python/src/s2python/common/role.py | 11 + .../s2python/common/select_control_type.py | 15 + .../src/s2python/common/session_request.py | 15 + s2-python/src/s2python/common/support.py | 27 + s2-python/src/s2python/common/timer.py | 17 + s2-python/src/s2python/common/transition.py | 24 + s2-python/src/s2python/ddbc/__init__.py | 21 + .../ddbc/ddbc_actuator_description.py | 30 + .../src/s2python/ddbc/ddbc_actuator_status.py | 22 + .../ddbc/ddbc_average_demand_rate_forecast.py | 28 + ...bc_average_demand_rate_forecast_element.py | 21 + .../src/s2python/ddbc/ddbc_instruction.py | 19 + .../src/s2python/ddbc/ddbc_operation_mode.py | 26 + .../s2python/ddbc/ddbc_system_description.py | 29 + .../src/s2python/ddbc/ddbc_timer_status.py | 18 + s2-python/src/s2python/frbc/__init__.py | 33 + .../frbc/frbc_actuator_description.py | 149 ++ .../src/s2python/frbc/frbc_actuator_status.py | 23 + .../frbc/frbc_fill_level_target_profile.py | 24 + .../frbc_fill_level_target_profile_element.py | 35 + .../src/s2python/frbc/frbc_instruction.py | 18 + .../s2python/frbc/frbc_leakage_behaviour.py | 20 + .../frbc/frbc_leakage_behaviour_element.py | 33 + .../src/s2python/frbc/frbc_operation_mode.py | 49 + .../frbc/frbc_operation_mode_element.py | 27 + .../s2python/frbc/frbc_storage_description.py | 18 + .../src/s2python/frbc/frbc_storage_status.py | 15 + .../s2python/frbc/frbc_system_description.py | 22 + .../src/s2python/frbc/frbc_timer_status.py | 17 + .../src/s2python/frbc/frbc_usage_forecast.py | 18 + .../frbc/frbc_usage_forecast_element.py | 17 + s2-python/src/s2python/frbc/rm.py | 0 s2-python/src/s2python/generated/__init__.py | 0 s2-python/src/s2python/generated/gen_s2.py | 1575 +++++++++++++++ s2-python/src/s2python/message.py | 145 ++ s2-python/src/s2python/ombc/__init__.py | 5 + .../src/s2python/ombc/ombc_instruction.py | 19 + .../src/s2python/ombc/ombc_operation_mode.py | 25 + s2-python/src/s2python/ombc/ombc_status.py | 17 + .../s2python/ombc/ombc_system_description.py | 25 + .../src/s2python/ombc/ombc_timer_status.py | 17 + s2-python/src/s2python/pebc/__init__.py | 21 + .../s2python/pebc/pebc_allowed_limit_range.py | 43 + .../s2python/pebc/pebc_energy_constraint.py | 25 + .../src/s2python/pebc/pebc_instruction.py | 27 + .../s2python/pebc/pebc_power_constraints.py | 57 + .../src/s2python/pebc/pebc_power_envelope.py | 23 + .../pebc/pebc_power_envelope_element.py | 16 + s2-python/src/s2python/ppbc/__init__.py | 21 + .../ppbc/ppbc_end_interruption_instruction.py | 30 + .../ppbc/ppbc_power_profile_definition.py | 25 + .../ppbc/ppbc_power_profile_status.py | 24 + .../src/s2python/ppbc/ppbc_power_sequence.py | 30 + .../ppbc/ppbc_power_sequence_container.py | 25 + .../ppbc_power_sequence_container_status.py | 30 + .../ppbc/ppbc_power_sequence_element.py | 23 + .../ppbc/ppbc_schedule_instruction.py | 31 + .../ppbc_start_interruption_instruction.py | 30 + s2-python/src/s2python/py.typed | 0 .../src/s2python/reception_status_awaiter.py | 60 + s2-python/src/s2python/s2_connection.py | 581 ++++++ s2-python/src/s2python/s2_control_type.py | 116 ++ s2-python/src/s2python/s2_parser.py | 129 ++ s2-python/src/s2python/s2_validation_error.py | 15 + s2-python/src/s2python/utils.py | 8 + .../src/s2python/validate_values_mixin.py | 85 + s2-python/src/s2python/version.py | 3 + s2-python/tests/unit/__init__.py | 0 s2-python/tests/unit/common/__init__.py | 0 s2-python/tests/unit/common/duration_test.py | 26 + .../unit/common/handshake_response_test.py | 43 + s2-python/tests/unit/common/handshake_test.py | 44 + .../common/instruction_status_update_test.py | 63 + .../tests/unit/common/number_range_test.py | 74 + .../common/power_forecast_element_test.py | 61 + .../tests/unit/common/power_forecast_test.py | 84 + .../unit/common/power_forecast_value_test.py | 79 + .../unit/common/power_measurement_test.py | 64 + .../tests/unit/common/power_range_test.py | 67 + .../tests/unit/common/power_value_test.py | 32 + .../unit/common/reception_status_test.py | 48 + .../common/resource_manager_details_test.py | 147 ++ .../tests/unit/common/revoke_object_test.py | 49 + s2-python/tests/unit/common/role_test.py | 28 + .../unit/common/select_control_type_test.py | 43 + .../tests/unit/common/session_request_test.py | 43 + s2-python/tests/unit/common/timer_test.py | 88 + .../tests/unit/common/transition_test.py | 156 ++ .../frbc/frbc_actuator_description_test.py | 241 +++ .../unit/frbc/frbc_actuator_status_test.py | 95 + ..._fill_level_target_profile_element_test.py | 61 + .../frbc_fill_level_target_profile_test.py | 109 + .../tests/unit/frbc/frbc_instruction_test.py | 96 + .../frbc_leakage_behaviour_element_test.py | 68 + .../unit/frbc/frbc_leakage_behaviour_test.py | 107 + .../frbc/frbc_operation_mode_element_test.py | 117 ++ .../unit/frbc/frbc_operation_mode_test.py | 139 ++ .../frbc/frbc_storage_description_test.py | 77 + .../unit/frbc/frbc_storage_status_test.py | 49 + .../unit/frbc/frbc_system_description_test.py | 359 ++++ .../tests/unit/frbc/frbc_timer_status_test.py | 82 + .../frbc/frbc_usage_forecast_element_test.py | 83 + .../unit/frbc/frbc_usage_forecast_test.py | 119 ++ s2-python/tests/unit/message_test.py | 63 + .../unit/reception_status_awaiter_test.py | 172 ++ s2-python/tests/unit/s2_connection_test.py | 65 + s2-python/tests/unit/s2_parser_test.py | 120 ++ s2-python/tests/unit/utils_test.py | 46 + s2-python/tox.ini | 72 + s2-self-cert/cert.yaml | 2 +- s2-self-cert/pyproject.toml | 2 +- s2-self-cert/src/main.py | 14 +- s2-self-cert/uv.lock | 6 +- s2-testing/pyproject.toml | 2 +- .../src/s2_testing.egg-info/SOURCES.txt | 2 + .../src/s2testing/async_task_manager.py | 66 + s2-testing/src/s2testing/config.py | 2 +- s2-testing/src/s2testing/connection.py | 1 + s2-testing/src/s2testing/orchestrator.py | 283 +++ s2-testing/uv.lock | 4 +- 165 files changed, 11702 insertions(+), 36 deletions(-) create mode 100644 s2-python/.github/workflows/ci.yml create mode 100644 s2-python/.gitignore create mode 100644 s2-python/.pre-commit-config.yaml create mode 100644 s2-python/.pylintrc create mode 100644 s2-python/LICENSE create mode 100644 s2-python/README.rst create mode 100755 s2-python/ci/clean.sh create mode 100755 s2-python/ci/distribute.sh create mode 100755 s2-python/ci/generate_s2.sh create mode 100755 s2-python/ci/install_dependencies.sh create mode 100755 s2-python/ci/lint.sh create mode 100755 s2-python/ci/setup_dev_environment.sh create mode 100755 s2-python/ci/test_unit.sh create mode 100755 s2-python/ci/typecheck.sh create mode 100755 s2-python/ci/update_dependencies.sh create mode 100644 s2-python/dev-requirements.txt create mode 100644 s2-python/development_utilities/gen_templates.py create mode 100644 s2-python/development_utilities/gen_unit_test_template.py create mode 100644 s2-python/development_utilities/generate_s2_message_type_to_class.py create mode 100644 s2-python/development_utilities/get_all_messages.py create mode 100644 s2-python/examples/example_frbc_rm.py create mode 100644 s2-python/mypy.ini create mode 100644 s2-python/pyproject.toml create mode 100644 s2-python/pyrightconfig.json create mode 100644 s2-python/setup.cfg create mode 100644 s2-python/setup.py create mode 100644 s2-python/specification/openapi.yml create mode 100644 s2-python/src/s2python/__init__.py create mode 100644 s2-python/src/s2python/common/__init__.py create mode 100644 s2-python/src/s2python/common/duration.py create mode 100644 s2-python/src/s2python/common/handshake.py create mode 100644 s2-python/src/s2python/common/handshake_response.py create mode 100644 s2-python/src/s2python/common/instruction_status_update.py create mode 100644 s2-python/src/s2python/common/number_range.py create mode 100644 s2-python/src/s2python/common/power_forecast.py create mode 100644 s2-python/src/s2python/common/power_forecast_element.py create mode 100644 s2-python/src/s2python/common/power_forecast_value.py create mode 100644 s2-python/src/s2python/common/power_measurement.py create mode 100644 s2-python/src/s2python/common/power_range.py create mode 100644 s2-python/src/s2python/common/power_value.py create mode 100644 s2-python/src/s2python/common/reception_status.py create mode 100644 s2-python/src/s2python/common/resource_manager_details.py create mode 100644 s2-python/src/s2python/common/revoke_object.py create mode 100644 s2-python/src/s2python/common/role.py create mode 100644 s2-python/src/s2python/common/select_control_type.py create mode 100644 s2-python/src/s2python/common/session_request.py create mode 100644 s2-python/src/s2python/common/support.py create mode 100644 s2-python/src/s2python/common/timer.py create mode 100644 s2-python/src/s2python/common/transition.py create mode 100644 s2-python/src/s2python/ddbc/__init__.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_actuator_description.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_actuator_status.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_instruction.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_operation_mode.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_system_description.py create mode 100644 s2-python/src/s2python/ddbc/ddbc_timer_status.py create mode 100644 s2-python/src/s2python/frbc/__init__.py create mode 100644 s2-python/src/s2python/frbc/frbc_actuator_description.py create mode 100644 s2-python/src/s2python/frbc/frbc_actuator_status.py create mode 100644 s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py create mode 100644 s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py create mode 100644 s2-python/src/s2python/frbc/frbc_instruction.py create mode 100644 s2-python/src/s2python/frbc/frbc_leakage_behaviour.py create mode 100644 s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py create mode 100644 s2-python/src/s2python/frbc/frbc_operation_mode.py create mode 100644 s2-python/src/s2python/frbc/frbc_operation_mode_element.py create mode 100644 s2-python/src/s2python/frbc/frbc_storage_description.py create mode 100644 s2-python/src/s2python/frbc/frbc_storage_status.py create mode 100644 s2-python/src/s2python/frbc/frbc_system_description.py create mode 100644 s2-python/src/s2python/frbc/frbc_timer_status.py create mode 100644 s2-python/src/s2python/frbc/frbc_usage_forecast.py create mode 100644 s2-python/src/s2python/frbc/frbc_usage_forecast_element.py create mode 100644 s2-python/src/s2python/frbc/rm.py create mode 100644 s2-python/src/s2python/generated/__init__.py create mode 100644 s2-python/src/s2python/generated/gen_s2.py create mode 100644 s2-python/src/s2python/message.py create mode 100644 s2-python/src/s2python/ombc/__init__.py create mode 100644 s2-python/src/s2python/ombc/ombc_instruction.py create mode 100644 s2-python/src/s2python/ombc/ombc_operation_mode.py create mode 100644 s2-python/src/s2python/ombc/ombc_status.py create mode 100644 s2-python/src/s2python/ombc/ombc_system_description.py create mode 100644 s2-python/src/s2python/ombc/ombc_timer_status.py create mode 100644 s2-python/src/s2python/pebc/__init__.py create mode 100644 s2-python/src/s2python/pebc/pebc_allowed_limit_range.py create mode 100644 s2-python/src/s2python/pebc/pebc_energy_constraint.py create mode 100644 s2-python/src/s2python/pebc/pebc_instruction.py create mode 100644 s2-python/src/s2python/pebc/pebc_power_constraints.py create mode 100644 s2-python/src/s2python/pebc/pebc_power_envelope.py create mode 100644 s2-python/src/s2python/pebc/pebc_power_envelope_element.py create mode 100644 s2-python/src/s2python/ppbc/__init__.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_power_profile_status.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_power_sequence.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py create mode 100644 s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py create mode 100644 s2-python/src/s2python/py.typed create mode 100644 s2-python/src/s2python/reception_status_awaiter.py create mode 100644 s2-python/src/s2python/s2_connection.py create mode 100644 s2-python/src/s2python/s2_control_type.py create mode 100644 s2-python/src/s2python/s2_parser.py create mode 100644 s2-python/src/s2python/s2_validation_error.py create mode 100644 s2-python/src/s2python/utils.py create mode 100644 s2-python/src/s2python/validate_values_mixin.py create mode 100644 s2-python/src/s2python/version.py create mode 100644 s2-python/tests/unit/__init__.py create mode 100644 s2-python/tests/unit/common/__init__.py create mode 100644 s2-python/tests/unit/common/duration_test.py create mode 100644 s2-python/tests/unit/common/handshake_response_test.py create mode 100644 s2-python/tests/unit/common/handshake_test.py create mode 100644 s2-python/tests/unit/common/instruction_status_update_test.py create mode 100644 s2-python/tests/unit/common/number_range_test.py create mode 100644 s2-python/tests/unit/common/power_forecast_element_test.py create mode 100644 s2-python/tests/unit/common/power_forecast_test.py create mode 100644 s2-python/tests/unit/common/power_forecast_value_test.py create mode 100644 s2-python/tests/unit/common/power_measurement_test.py create mode 100644 s2-python/tests/unit/common/power_range_test.py create mode 100644 s2-python/tests/unit/common/power_value_test.py create mode 100644 s2-python/tests/unit/common/reception_status_test.py create mode 100644 s2-python/tests/unit/common/resource_manager_details_test.py create mode 100644 s2-python/tests/unit/common/revoke_object_test.py create mode 100644 s2-python/tests/unit/common/role_test.py create mode 100644 s2-python/tests/unit/common/select_control_type_test.py create mode 100644 s2-python/tests/unit/common/session_request_test.py create mode 100644 s2-python/tests/unit/common/timer_test.py create mode 100644 s2-python/tests/unit/common/transition_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_actuator_description_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_actuator_status_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_instruction_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_operation_mode_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_storage_description_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_storage_status_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_system_description_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_timer_status_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py create mode 100644 s2-python/tests/unit/frbc/frbc_usage_forecast_test.py create mode 100644 s2-python/tests/unit/message_test.py create mode 100644 s2-python/tests/unit/reception_status_awaiter_test.py create mode 100644 s2-python/tests/unit/s2_connection_test.py create mode 100644 s2-python/tests/unit/s2_parser_test.py create mode 100644 s2-python/tests/unit/utils_test.py create mode 100644 s2-python/tox.ini create mode 100644 s2-testing/src/s2testing/async_task_manager.py create mode 100644 s2-testing/src/s2testing/orchestrator.py diff --git a/cert.yaml b/cert.yaml index 3c9b6ec..8a4311f 100644 --- a/cert.yaml +++ b/cert.yaml @@ -1,17 +1,2 @@ -findings: -- message_type: PowerForecast - parameters: - - name: PowerForecast Provided. - status: PASS - status: PASS -- message_type: PowerMeasurement - parameters: - - name: PowerMeasurement Provided. - status: PASS - status: PASS -- message_type: PEBCPowerConstraints - parameters: - - name: PEBCPowerConstraints Provided. - status: PASS - status: PASS -timestamp: 2025-04-29 15:07:56.715961 +findings: [] +timestamp: 2025-04-29 16:01:38.595197 diff --git a/requirements.txt b/requirements.txt index bd901dd..72e9533 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -websockets +pydantic>=2.11.3 +pyyaml>=6.0.2 s2-python -pydantic -PyYAML \ No newline at end of file +websockets>=13.1 \ No newline at end of file diff --git a/s2-python/.github/workflows/ci.yml b/s2-python/.github/workflows/ci.yml new file mode 100644 index 0000000..7ca3906 --- /dev/null +++ b/s2-python/.github/workflows/ci.yml @@ -0,0 +1,198 @@ +name: tests-and-publish +# this pipeline started as an adaptation of the pipeline of the +# FlexMeasures client (https://github.com/FlexMeasures/flexmeasures-client/) + +on: + push: + # Avoid using all the resources/limits available by checking only + # relevant branches and tags. Other branches can be checked via PRs. + branches: [main] + tags: + - 'v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' # Match tags that resemble a version + - 'v[0-9]+\.[0-9]+\.[0-9]+' # Match tags that resemble a version + pull_request: # Run in every PR + workflow_dispatch: # Allow manually triggering the workflow + schedule: + # Run roughly every 15 days at 00:00 UTC + # (useful to check if updates on dependencies break the package) + - cron: '0 0 1,16 * *' + +concurrency: + group: >- + ${{ github.workflow }}-${{ github.ref_type }}- + ${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} + steps: + - uses: actions/checkout@v4 + with: {fetch-depth: 0} # deep clone for setuptools-scm + - uses: actions/setup-python@v4 + id: setup-python + with: {python-version: "3.11"} + # - name: Run static analysis and format checkers + # run: pipx run pre-commit run --all-files --show-diff-on-failure + - name: Build package distribution files + run: >- + pipx run --python '${{ steps.setup-python.outputs.python-path }}' + tox -e clean,build + - name: Record the path of wheel distribution + id: wheel-distribution + run: echo "path=$(ls dist/*.whl)" >> $GITHUB_OUTPUT + - name: Store the distribution files for use in other stages + # `tests` and `publish` will use the same pre-built distributions, + # so we make sure to release the exact same package that was tested + uses: actions/upload-artifact@v4 + with: + name: python-distribution-files + path: dist/ + retention-days: 1 + + test: + needs: prepare + strategy: + matrix: + python: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" # newest Python that is stable + platform: + - ubuntu-latest + # - macos-latest + # - windows-latest + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + id: setup-python + with: + python-version: ${{ matrix.python }} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v4 + with: {name: python-distribution-files, path: dist/} + - name: Run tests + run: >- + pipx run --python '${{ steps.setup-python.outputs.python-path }}' + tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' + -- -rFEx --durations 10 --color yes # pytest args + # - name: Generate coverage report + # run: pipx run coverage lcov -o coverage.lcov + # - name: Upload partial coverage report + # uses: coverallsapp/github-action@master + # with: + # path-to-lcov: coverage.lcov + # github-token: ${{ secrets.GITHUB_TOKEN }} + # flag-name: ${{ matrix.platform }} - py${{ matrix.python }} + # parallel: true + + lint: + needs: prepare + strategy: + matrix: + python: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" # newest Python that is stable + platform: + - ubuntu-latest + # - macos-latest + # - windows-latest + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + id: setup-python + with: + python-version: ${{ matrix.python }} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v4 + with: {name: python-distribution-files, path: dist/} + - name: Run tests + run: >- + pipx run --python '${{ steps.setup-python.outputs.python-path }}' + tox -e lint --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' + + typecheck: + needs: prepare + strategy: + matrix: + python: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" # newest Python that is stable + platform: + - ubuntu-latest + # - macos-latest + # - windows-latest + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + id: setup-python + with: + python-version: ${{ matrix.python }} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v4 + with: {name: python-distribution-files, path: dist/} + - name: Run tests + run: >- + pipx run --python '${{ steps.setup-python.outputs.python-path }}' + tox -e typecheck --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' + + finalize: + needs: [test, lint, typecheck] + runs-on: ubuntu-latest + steps: + - run: echo "Finished checks" + + publish: + needs: finalize + if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/project/s2-python/ + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: {python-version: "3.11"} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v4 + with: {name: python-distribution-files, path: dist/} + - name: Publish Package + uses: pypa/gh-action-pypi-publish@release/v1 + # run: pipx run tox -e publish + + test-publish: + needs: finalize + if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/test') }} + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/project/s2-python/ + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: {python-version: "3.11"} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v4 + with: {name: python-distribution-files, path: dist/} + - name: Publish Package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + # run: pipx run tox -e publish diff --git a/s2-python/.gitignore b/s2-python/.gitignore new file mode 100644 index 0000000..5baf340 --- /dev/null +++ b/s2-python/.gitignore @@ -0,0 +1,15 @@ +**.pyc +.idea/ +.vscode/ +.venv/ +.pytest_cache/ +.coverage +unit_test_coverage/ +.mypy_cache/ +venv +*.egg-info +*venv* +.tox/ +dist/ +build/ +%LOCALAPPDATA% diff --git a/s2-python/.pre-commit-config.yaml b/s2-python/.pre-commit-config.yaml new file mode 100644 index 0000000..1dc4c9d --- /dev/null +++ b/s2-python/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +exclude: '^docs/conf.py' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: check-added-large-files + - id: check-ast + - id: check-json + - id: check-merge-conflict + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: mixed-line-ending + args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows + +- repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + +- repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3 + +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 diff --git a/s2-python/.pylintrc b/s2-python/.pylintrc new file mode 100644 index 0000000..1a254ee --- /dev/null +++ b/s2-python/.pylintrc @@ -0,0 +1,13 @@ +[Main] +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths=src/s2python/generated/ + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +disable=missing-class-docstring,missing-module-docstring,too-few-public-methods,missing-function-docstring,no-member,unsubscriptable-object,line-too-long diff --git a/s2-python/LICENSE b/s2-python/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/s2-python/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/s2-python/README.rst b/s2-python/README.rst new file mode 100644 index 0000000..ed04b60 --- /dev/null +++ b/s2-python/README.rst @@ -0,0 +1,73 @@ +Python Wrapper for S2 Flexibility Protocol +=========================================== +.. image:: https://img.shields.io/pypi/v/s2-python + :alt: PyPI - Version +.. image:: https://img.shields.io/pypi/pyversions/s2-python + :alt: PyPI - Python Version +.. image:: https://img.shields.io/pypi/l/s2-python + :alt: PyPI - License + +This Python package implements the message validation for the EN50491-12-2 "S2" standard for home and building energy management. This implementation +is based on the asyncapi description of the protocol provided in the `s2-ws-json `_ repository. + +Currently, the package supports the *common* and *FILL RATE BASED CONTROL* types and messages. + +To Install +----------- +You can install this package using pip or any Python dependency manager that collects the packages from Pypi: + +.. code-block:: bash + + pip install s2-python + +The packages on Pypi may be found `here `_ + +Mypy support +------------ +s2-python uses pydantic at its core to define the various S2 messages. As such, the pydantic mypy plugin is required +for type checking to succeed. + +Add to your pyproject.toml: + +.. code-block:: toml + + [tool.mypy] + plugins = ['pydantic.mypy'] + +Example +--------- + +.. code-block:: python + + from s2python.common import PowerRange, CommodityQuantity + + # create s2 messages as Python objects + number_range = PowerRange( + start_of_range=4.0, + end_of_range=5.0, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ) + # serialize s2 messages + number_range.to_json() + # deserialize s2 messages + json_str = '{"start_of_range": 4.0, "end_of_range": 5.0, "commodity_quantity": "ELECTRIC.POWER.L1"}' + PowerRange.from_json(json_str) + +Development +------------- + +For development, you can install the required dependencies using the following command: + + pip install -e .[testing,development] + + +The tests can be run using tox: + + tox + +To build the package, you can use tox as well: + + tox -e build,clean + + + diff --git a/s2-python/ci/clean.sh b/s2-python/ci/clean.sh new file mode 100755 index 0000000..3dec941 --- /dev/null +++ b/s2-python/ci/clean.sh @@ -0,0 +1,4 @@ +. .venv/bin/activate + +tox -e clean +rm -Rf .pytest_cache/ .tox/ dist/ src/*.egg-info/ unit_test_coverage/ .coverage diff --git a/s2-python/ci/distribute.sh b/s2-python/ci/distribute.sh new file mode 100755 index 0000000..4aba026 --- /dev/null +++ b/s2-python/ci/distribute.sh @@ -0,0 +1,2 @@ +. .venv/bin/activate +tox -e build diff --git a/s2-python/ci/generate_s2.sh b/s2-python/ci/generate_s2.sh new file mode 100755 index 0000000..f1ee694 --- /dev/null +++ b/s2-python/ci/generate_s2.sh @@ -0,0 +1,5 @@ +#!/bin/bash + + +. .venv/bin/activate +datamodel-codegen --input specification/openapi.yml --input-file-type openapi --output-model-type pydantic_v2.BaseModel --output src/s2python/generated/gen_s2.py --use-one-literal-as-default diff --git a/s2-python/ci/install_dependencies.sh b/s2-python/ci/install_dependencies.sh new file mode 100755 index 0000000..1fb6290 --- /dev/null +++ b/s2-python/ci/install_dependencies.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +. .venv/bin/activate +pip-sync ./dev-requirements.txt diff --git a/s2-python/ci/lint.sh b/s2-python/ci/lint.sh new file mode 100755 index 0000000..c405891 --- /dev/null +++ b/s2-python/ci/lint.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +. .venv/bin/activate +pylint src/ tests/unit/ examples/ diff --git a/s2-python/ci/setup_dev_environment.sh b/s2-python/ci/setup_dev_environment.sh new file mode 100755 index 0000000..7e10b51 --- /dev/null +++ b/s2-python/ci/setup_dev_environment.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +python3.8 -m venv ./.venv/ +. ./.venv/bin/activate +pip install pip-tools diff --git a/s2-python/ci/test_unit.sh b/s2-python/ci/test_unit.sh new file mode 100755 index 0000000..492b1ab --- /dev/null +++ b/s2-python/ci/test_unit.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +. .venv/bin/activate +PYTHONPATH="$PYTHONPATH:src/" pytest --cov=s2python --cov-report=html:./unit_test_coverage/ -v tests/unit/ diff --git a/s2-python/ci/typecheck.sh b/s2-python/ci/typecheck.sh new file mode 100755 index 0000000..6864b6a --- /dev/null +++ b/s2-python/ci/typecheck.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +. .venv/bin/activate +mypy --config-file mypy.ini src/ ./tests/unit/ examples/ +pyright diff --git a/s2-python/ci/update_dependencies.sh b/s2-python/ci/update_dependencies.sh new file mode 100755 index 0000000..ca11b03 --- /dev/null +++ b/s2-python/ci/update_dependencies.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +. .venv/bin/activate +pip-compile -U --extra=testing --extra=development --extra=docs -o ./dev-requirements.txt setup.cfg diff --git a/s2-python/dev-requirements.txt b/s2-python/dev-requirements.txt new file mode 100644 index 0000000..5b3daa2 --- /dev/null +++ b/s2-python/dev-requirements.txt @@ -0,0 +1,244 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile --extra=development --extra=docs --extra=testing --output-file=./dev-requirements.txt setup.cfg +# +alabaster==0.7.13 + # via sphinx +annotated-types==0.7.0 + # via pydantic +argcomplete==3.5.3 + # via datamodel-code-generator +astroid==3.2.4 + # via pylint +babel==2.17.0 + # via sphinx +black==24.8.0 + # via datamodel-code-generator +build==1.2.2.post1 + # via pip-tools +cachetools==5.5.2 + # via tox +certifi==2025.1.31 + # via requests +cfgv==3.4.0 + # via pre-commit +chardet==5.2.0 + # via tox +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via + # black + # pip-tools + # s2-python (setup.cfg) +colorama==0.4.6 + # via tox +coverage[toml]==7.6.1 + # via pytest-cov +datamodel-code-generator==0.27.3 + # via s2-python (setup.cfg) +dill==0.3.9 + # via pylint +distlib==0.3.9 + # via virtualenv +docutils==0.20.1 + # via + # sphinx + # sphinx-rtd-theme + # sphinx-tabs +exceptiongroup==1.2.2 + # via pytest +filelock==3.16.1 + # via + # tox + # virtualenv +genson==1.3.0 + # via datamodel-code-generator +identify==2.6.1 + # via pre-commit +idna==3.10 + # via requests +imagesize==1.4.1 + # via sphinx +importlib-metadata==8.5.0 + # via + # build + # sphinx +inflect==5.6.2 + # via datamodel-code-generator +iniconfig==2.0.0 + # via pytest +isort==5.13.2 + # via + # datamodel-code-generator + # pylint +jinja2==3.1.5 + # via + # datamodel-code-generator + # sphinx +markupsafe==2.1.5 + # via jinja2 +mccabe==0.7.0 + # via pylint +mypy==1.14.1 + # via s2-python (setup.cfg) +mypy-extensions==1.0.0 + # via + # black + # mypy +nodeenv==1.9.1 + # via + # pre-commit + # pyright +packaging==24.2 + # via + # black + # build + # datamodel-code-generator + # pyproject-api + # pytest + # sphinx + # tox +pathspec==0.12.1 + # via black +pip-tools==7.4.1 + # via s2-python (setup.cfg) +platformdirs==4.3.6 + # via + # black + # pylint + # tox + # virtualenv +pluggy==1.5.0 + # via + # pytest + # tox +pre-commit==3.5.0 + # via s2-python (setup.cfg) +pydantic==2.10.6 + # via + # datamodel-code-generator + # s2-python (setup.cfg) +pydantic-core==2.27.2 + # via pydantic +pygments==2.19.1 + # via + # sphinx + # sphinx-tabs +pylint==3.2.7 + # via s2-python (setup.cfg) +pyproject-api==1.8.0 + # via tox +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pyright==1.1.396 + # via s2-python (setup.cfg) +pytest==8.3.5 + # via + # pytest-cov + # pytest-timer + # s2-python (setup.cfg) +pytest-cov==5.0.0 + # via pytest-cover +pytest-cover==3.0.0 + # via pytest-coverage +pytest-coverage==0.0 + # via s2-python (setup.cfg) +pytest-timer==1.0.0 + # via s2-python (setup.cfg) +pytz==2025.1 + # via + # babel + # s2-python (setup.cfg) +pyyaml==6.0.2 + # via + # datamodel-code-generator + # pre-commit +requests==2.32.3 + # via sphinx +six==1.17.0 + # via sphinxcontrib-httpdomain +snowballstemmer==2.2.0 + # via sphinx +sphinx==7.1.2 + # via + # s2-python (setup.cfg) + # sphinx-copybutton + # sphinx-fontawesome + # sphinx-rtd-theme + # sphinx-tabs + # sphinxcontrib-httpdomain + # sphinxcontrib-jquery +sphinx-copybutton==0.5.2 + # via s2-python (setup.cfg) +sphinx-fontawesome==0.0.6 + # via s2-python (setup.cfg) +sphinx-rtd-theme==3.0.2 + # via s2-python (setup.cfg) +sphinx-tabs==3.4.7 + # via s2-python (setup.cfg) +sphinxcontrib-applehelp==1.0.4 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.1 + # via sphinx +sphinxcontrib-httpdomain==1.8.1 + # via s2-python (setup.cfg) +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +tomli==2.2.1 + # via + # black + # build + # coverage + # datamodel-code-generator + # mypy + # pip-tools + # pylint + # pyproject-api + # pytest + # tox +tomlkit==0.13.2 + # via pylint +tox==4.24.1 + # via s2-python (setup.cfg) +types-pytz==2024.2.0.20241221 + # via s2-python (setup.cfg) +typing-extensions==4.12.2 + # via + # annotated-types + # astroid + # black + # mypy + # pydantic + # pydantic-core + # pylint + # pyright + # tox +urllib3==2.2.3 + # via requests +virtualenv==20.29.2 + # via + # pre-commit + # tox +websockets==13.1 + # via s2-python (setup.cfg) +wheel==0.45.1 + # via pip-tools +zipp==3.20.2 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/s2-python/development_utilities/gen_templates.py b/s2-python/development_utilities/gen_templates.py new file mode 100644 index 0000000..73fccba --- /dev/null +++ b/s2-python/development_utilities/gen_templates.py @@ -0,0 +1,20 @@ +import inspect +import gen_s2 + +all_members = inspect.getmembers(gen_s2) +all_members.sort(key=lambda t: t[0]) + +for name, member in all_members: + if inspect.isclass(member): + print( + f""" +from s2python.generated.gen_s2 import {name} as Gen{name} +from s2python.validate_values_mixin import catch_and_convert_exceptions, ValidateValuesMixin + + +@catch_and_convert_exceptions +class {name}(Gen{name}, ValidateValuesMixin['{name}']): + class Config(Gen{name}.Config): + validate_assignment = True + """ + ) diff --git a/s2-python/development_utilities/gen_unit_test_template.py b/s2-python/development_utilities/gen_unit_test_template.py new file mode 100644 index 0000000..87bddc9 --- /dev/null +++ b/s2-python/development_utilities/gen_unit_test_template.py @@ -0,0 +1,313 @@ +import datetime +import json +import os +from enum import Enum +import inspect +import pprint +import random +from typing import ( + get_type_hints, + Type, + get_origin, + get_args, + Union, + TypeVar, + Callable, + Sequence, +) +import uuid + +import pydantic +from pydantic.types import AwareDatetime + +from s2python import frbc +from s2python.common import Duration, PowerRange, NumberRange +from s2python.generated.gen_s2 import CommodityQuantity + +I = TypeVar("I") + + +def split_words_list(list_: Sequence[I], is_sep: Callable[[I], bool]) -> list[list[I]]: + words = [] + current_word = [] + previous_was_sep = None + for item in list_: + current_is_sep = is_sep(item) + + if previous_was_sep is None: + previous_was_sep = current_is_sep + + if not previous_was_sep and current_is_sep: + # Split detected + words.append(current_word) + current_word = [item] + else: + current_word.append(item) + + previous_was_sep = current_is_sep + words.append(current_word) + return words + + +def is_optional(field_type): + return get_origin(field_type) is Union and type(None) in get_args(field_type) + + +def get_optional_arg(field_type): + return next(type_ for type_ in get_args(field_type) if type_ is not type(None)) + + +def is_list(field_type): + return get_origin(field_type) is list + + +def get_list_arg(field_type): + return get_args(field_type)[0] + + +def is_enum(field_type): + return inspect.isclass(field_type) and issubclass(field_type, Enum) + + +def snake_case(camelcased: str) -> str: + device_type = camelcased[0:4].lower() + class_name = camelcased[4:] + words = split_words_list(class_name, lambda c: c.isupper()) + return "_".join([device_type] + ["".join(word).lower() for word in words]) + + +def message_type_from_class_name(class_name: str) -> str: + return f"{class_name[0:4]}.{class_name[4:]}" + + +def generate_json_test_data_for_field(field_type: Type): + if field_type is Duration: + value = random.randint(0, 39999) + elif field_type is NumberRange: + start = random.random() + offset = random.random() + value = { + "start_of_range": start * 39999, + "end_of_range": (start + offset) * 39999, + } + elif field_type is PowerRange: + start = random.random() + offset = random.random() + value = { + "start_of_range": start * 39999, + "end_of_range": (start + offset) * 39999, + "commodity_quantity": generate_json_test_data_for_field(CommodityQuantity), + } + elif inspect.isclass(field_type) and issubclass(field_type, pydantic.BaseModel): + value = generate_json_test_data_for_class(field_type) + elif is_list(field_type): + value = [generate_json_test_data_for_field(get_list_arg(field_type))] + elif is_optional(field_type): + value = generate_json_test_data_for_field(get_optional_arg(field_type)) + elif is_enum(field_type): + field_type: Enum + value = next(value for value in field_type).value + elif field_type is str: + value = f"some-test-string{random.randint(0, 9999)}" + elif field_type is bool: + value = bool(random.randint(0, 1)) + elif field_type is float: + value = random.random() * 9000.0 + elif field_type in (AwareDatetime, datetime.datetime): + # Generate a timezone-aware datetime + value = datetime.datetime( + year=random.randint(2020, 2023), + month=random.randint(1, 12), + day=random.randint(1, 28), + hour=random.randint(0, 23), + minute=random.randint(0, 59), + second=random.randint(0, 59), + tzinfo=datetime.timezone(datetime.timedelta(hours=random.randint(-12, 14))), + ) + elif field_type is uuid.UUID: + value = uuid.uuid4() + else: + raise RuntimeError(f"Please implement test data for field type {field_type}") + return value + + +def generate_json_test_data_for_class(class_: Type) -> dict: + result = {} + for field_name, field_type in get_type_hints(class_).items(): + if field_name == "message_type": + result[field_name] = message_type_from_class_name(class_.__name__) + else: + result[field_name] = generate_json_test_data_for_field(field_type) + + return result + + +def dump_test_data_as_constructor_field_for(test_data, field_type: Type) -> str: + if field_type is Duration: + value = f"Duration.from_timedelta(timedelta(milliseconds={test_data}))" + elif inspect.isclass(field_type) and issubclass(field_type, pydantic.BaseModel): + value = dump_test_data_as_constructor_for(test_data, field_type) + elif is_list(field_type): + dumped_items = [ + dump_test_data_as_constructor_field_for( + test_data_item, get_list_arg(field_type) + ) + for test_data_item in test_data + ] + value = f'[{",".join(dumped_items)}]' + elif is_optional(field_type): + value = dump_test_data_as_constructor_field_for( + test_data, get_optional_arg(field_type) + ) + elif is_enum(field_type): + field_type: Enum + value = f'{field_type.__name__}.{test_data.replace(".", "_")}' + elif field_type is str: + value = f'"{test_data}"' + elif field_type is bool: + value = str(test_data) + elif field_type is float: + value = str(test_data) + elif field_type is AwareDatetime or field_type is datetime.datetime: + test_data: datetime.datetime + offset: datetime.timedelta = test_data.tzinfo.utcoffset(None) + value = ( + f"datetime(" + f"year={test_data.year}, month={test_data.month}, day={test_data.day}, " + f"hour={test_data.hour}, minute={test_data.minute}, second={test_data.second}, " + f"tzinfo=offset(offset=timedelta(seconds={offset.total_seconds()})))" + ) + elif field_type is uuid.UUID: + value = f'uuid.UUID("{test_data}")' + elif type(field_type).__name__ == "_LiteralGenericAlias": + value = field_type.__args__[0] + else: + raise RuntimeError( + f"Please implement dump test data for field type {field_type}" + ) + return value + + +def dump_test_data_as_constructor_for(test_data: dict, class_: Type) -> str: + result = f"{class_.__name__}" + + first = True + for field_name, field_type in get_type_hints(class_).items(): + value = dump_test_data_as_constructor_field_for( + test_data[field_name], field_type + ) + result += f'{"(" if first else ", "}{field_name}={value}' + first = False + + return result + ")" + + +def dump_test_data_as_json_field_for(test_data, field_type: Type): + if field_type is Duration: + value = test_data + elif inspect.isclass(field_type) and issubclass(field_type, pydantic.BaseModel): + value = dump_test_data_as_json_for(test_data, field_type) + elif is_list(field_type): + value = [ + dump_test_data_as_json_field_for(item, get_list_arg(field_type)) + for item in test_data + ] + elif is_optional(field_type): + value = dump_test_data_as_json_field_for( + test_data, get_optional_arg(field_type) + ) + elif is_enum(field_type): + field_type: Enum + value = test_data + elif field_type is str: + value = test_data + elif field_type is bool: + value = test_data + elif field_type is float: + value = test_data + elif field_type in (AwareDatetime, datetime.datetime): + test_data: datetime.datetime + value = test_data.isoformat() + elif field_type is uuid.UUID: + value = str(test_data) + elif type(field_type).__name__ == "_LiteralGenericAlias": + value = test_data + else: + raise RuntimeError( + f"Please implement dump test data to json for field type {field_type}" + ) + + return value + + +def dump_test_data_as_json_for(test_data: dict, class_: Type) -> dict: + result = {} + + for field_name, field_type in get_type_hints(class_).items(): + result[field_name] = dump_test_data_as_json_field_for( + test_data[field_name], field_type + ) + + return result + + +for class_name, class_ in inspect.getmembers(frbc): + if inspect.isclass(class_) and issubclass(class_, pydantic.BaseModel): + test_data = generate_json_test_data_for_class(class_) + + assert_lines = [] + for field_name, field_type in get_type_hints(class_).items(): + assert_test_data = dump_test_data_as_constructor_field_for( + test_data[field_name], field_type + ) + + assert_lines.append( + f"self.assertEqual({snake_case(class_name)}.{field_name}, {assert_test_data})" + ) + + asserts = "\n ".join(assert_lines) + template = f""" +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class {class_name}Test(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = \"\"\" +{json.dumps(dump_test_data_as_json_for(test_data, class_), indent=4)} + \"\"\" + + # Act + {snake_case(class_name)} = {class_name}.from_json(json_str) + + # Assert + {asserts} + + def test__to_json__happy_path_full(self): + # Arrange + {snake_case(class_name)} = {dump_test_data_as_constructor_for(test_data, class_)} + + # Act + json_str = {snake_case(class_name)}.to_json() + + # Assert + expected_json = {pprint.pformat(dump_test_data_as_json_for(test_data, class_), indent=4)} + self.assertEqual(json.loads(json_str), expected_json) +""" + print(template) + print() + print() + + # Check if the file already exists + if not os.path.exists(f"tests/unit/frbc/{snake_case(class_name)}_test.py"): + with open( + f"tests/unit/frbc/{snake_case(class_name)}_test.py", "w+" + ) as unit_test_file: + unit_test_file.write(template) + print(f"Created tests/unit/frbc/{snake_case(class_name)}_test.py") diff --git a/s2-python/development_utilities/generate_s2_message_type_to_class.py b/s2-python/development_utilities/generate_s2_message_type_to_class.py new file mode 100644 index 0000000..9ddceaa --- /dev/null +++ b/s2-python/development_utilities/generate_s2_message_type_to_class.py @@ -0,0 +1,24 @@ +import inspect +import s2python.generated.gen_s2 + +all_members = inspect.getmembers(s2python.generated.gen_s2) +all_members.sort(key=lambda t: t[0]) + + +print( + """ +from s2python.common import * +from s2python.frbc import * + +TYPE_TO_MESSAGE_CLASS = {""" +) + +for name, member in all_members: + if ( + inspect.isclass(member) + and hasattr(member, "__fields__") + and ("message_type" in member.__fields__) + ): + print(f" '{member.__fields__['message_type'].default}': {name},") + +print("}") diff --git a/s2-python/development_utilities/get_all_messages.py b/s2-python/development_utilities/get_all_messages.py new file mode 100644 index 0000000..5b70631 --- /dev/null +++ b/s2-python/development_utilities/get_all_messages.py @@ -0,0 +1,16 @@ +import inspect +import s2python.frbc as frbc +import s2python.common as common + +from pydantic import BaseModel + +all_members = inspect.getmembers(frbc) + inspect.getmembers(common) +all_members.sort(key=lambda t: t[0]) + +for name, member in all_members: + if ( + inspect.isclass(member) + and issubclass(member, BaseModel) + and "message_type" in member.__fields__ + ): + print(f"{name},") diff --git a/s2-python/examples/example_frbc_rm.py b/s2-python/examples/example_frbc_rm.py new file mode 100644 index 0000000..d69473d --- /dev/null +++ b/s2-python/examples/example_frbc_rm.py @@ -0,0 +1,188 @@ +import argparse +from functools import partial +import logging +import sys +import uuid +import signal +import datetime +from typing import Callable + +from s2python.common import ( + EnergyManagementRole, + Duration, + Role, + RoleType, + Commodity, + Currency, + NumberRange, + PowerRange, + CommodityQuantity, +) +from s2python.frbc import ( + FRBCInstruction, + FRBCSystemDescription, + FRBCActuatorDescription, + FRBCStorageDescription, + FRBCOperationMode, + FRBCOperationModeElement, + FRBCFillLevelTargetProfile, + FRBCFillLevelTargetProfileElement, + FRBCStorageStatus, + FRBCActuatorStatus, +) +from s2python.s2_connection import S2Connection, AssetDetails +from s2python.s2_control_type import FRBCControlType, NoControlControlType +from s2python.message import S2Message + +logger = logging.getLogger("s2python") +logger.addHandler(logging.StreamHandler(sys.stdout)) +logger.setLevel(logging.DEBUG) + + +class MyFRBCControlType(FRBCControlType): + def handle_instruction( + self, conn: S2Connection, msg: S2Message, send_okay: Callable[[], None] + ) -> None: + if not isinstance(msg, FRBCInstruction): + raise RuntimeError( + f"Expected an FRBCInstruction but received a message of type {type(msg)}." + ) + print(f"I have received the message {msg} from {conn}") + + def activate(self, conn: S2Connection) -> None: + print("The control type FRBC is now activated.") + + print("Time to send a FRBC SystemDescription") + actuator_id = uuid.uuid4() + operation_mode_id = uuid.uuid4() + conn.send_msg_and_await_reception_status_sync( + FRBCSystemDescription( + message_id=uuid.uuid4(), + valid_from=datetime.datetime.now(tz=datetime.timezone.utc), + actuators=[ + FRBCActuatorDescription( + id=actuator_id, + operation_modes=[ + FRBCOperationMode( + id=operation_mode_id, + elements=[ + FRBCOperationModeElement( + fill_level_range=NumberRange( + start_of_range=0.0, end_of_range=100.0 + ), + fill_rate=NumberRange( + start_of_range=-5.0, end_of_range=5.0 + ), + power_ranges=[ + PowerRange( + start_of_range=-200.0, + end_of_range=200.0, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ) + ], + ) + ], + diagnostic_label="Load & unload battery", + abnormal_condition_only=False, + ) + ], + transitions=[], + timers=[], + supported_commodities=[Commodity.ELECTRICITY], + ) + ], + storage=FRBCStorageDescription( + fill_level_range=NumberRange(start_of_range=0.0, end_of_range=100.0), + fill_level_label="%", + diagnostic_label="Imaginary battery", + provides_fill_level_target_profile=True, + provides_leakage_behaviour=False, + provides_usage_forecast=False, + ), + ) + ) + print("Also send the target profile") + + conn.send_msg_and_await_reception_status_sync( + FRBCFillLevelTargetProfile( + message_id=uuid.uuid4(), + start_time=datetime.datetime.now(tz=datetime.timezone.utc), + elements=[ + FRBCFillLevelTargetProfileElement( + duration=Duration.from_milliseconds(30_000), + fill_level_range=NumberRange(start_of_range=20.0, end_of_range=30.0), + ), + FRBCFillLevelTargetProfileElement( + duration=Duration.from_milliseconds(300_000), + fill_level_range=NumberRange(start_of_range=40.0, end_of_range=50.0), + ), + ], + ) + ) + + print("Also send the storage status.") + conn.send_msg_and_await_reception_status_sync( + FRBCStorageStatus(message_id=uuid.uuid4(), present_fill_level=10.0) + ) + + print("Also send the actuator status.") + conn.send_msg_and_await_reception_status_sync( + FRBCActuatorStatus( + message_id=uuid.uuid4(), + actuator_id=actuator_id, + active_operation_mode_id=operation_mode_id, + operation_mode_factor=0.5, + ) + ) + + def deactivate(self, conn: S2Connection) -> None: + print("The control type FRBC is now deactivated.") + + +class MyNoControlControlType(NoControlControlType): + def activate(self, conn: S2Connection) -> None: + print("The control type NoControl is now activated.") + + def deactivate(self, conn: S2Connection) -> None: + print("The control type NoControl is now deactivated.") + + +def stop(s2_connection, signal_num, _current_stack_frame): + print(f"Received signal {signal_num}. Will stop S2 connection.") + s2_connection.stop() + + +def start_s2_session(url, client_node_id=str(uuid.uuid4())): + s2_conn = S2Connection( + url=url, + role=EnergyManagementRole.RM, + control_types=[MyFRBCControlType(), MyNoControlControlType()], + asset_details=AssetDetails( + resource_id=client_node_id, + name="Some asset", + instruction_processing_delay=Duration.from_milliseconds(20), + roles=[Role(role=RoleType.ENERGY_CONSUMER, commodity=Commodity.ELECTRICITY)], + currency=Currency.EUR, + provides_forecast=False, + provides_power_measurements=[CommodityQuantity.ELECTRIC_POWER_L1], + ), + reconnect=True, + verify_certificate=False, + ) + signal.signal(signal.SIGINT, partial(stop, s2_conn)) + signal.signal(signal.SIGTERM, partial(stop, s2_conn)) + + s2_conn.start_as_rm() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="A simple S2 reseource manager example.") + parser.add_argument( + "endpoint", + type=str, + help="WebSocket endpoint uri for the server (CEM) e.g. " + "ws://localhost:8080/backend/rm/s2python-frbc/cem/dummy_model/ws", + ) + args = parser.parse_args() + + start_s2_session(args.endpoint) diff --git a/s2-python/mypy.ini b/s2-python/mypy.ini new file mode 100644 index 0000000..1d4ec90 --- /dev/null +++ b/s2-python/mypy.ini @@ -0,0 +1,14 @@ +[mypy] +plugins = pydantic.mypy + +[mypy-s2python.*] +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[mypy-s2python.generated.*] +ignore_errors = true +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[mypy-unit.*] +check_untyped_defs = true diff --git a/s2-python/pyproject.toml b/s2-python/pyproject.toml new file mode 100644 index 0000000..7fd26b9 --- /dev/null +++ b/s2-python/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/s2-python/pyrightconfig.json b/s2-python/pyrightconfig.json new file mode 100644 index 0000000..0df0c10 --- /dev/null +++ b/s2-python/pyrightconfig.json @@ -0,0 +1,10 @@ +{ + "include": [ + "src", + "tests" + ], + + "defineConstant": { + "DEBUG": true + } +} diff --git a/s2-python/setup.cfg b/s2-python/setup.cfg new file mode 100644 index 0000000..f675f05 --- /dev/null +++ b/s2-python/setup.cfg @@ -0,0 +1,106 @@ +[metadata] +name = s2-python +description = S2 Protocol Python Wrapper +author = Flexiblepower +author_email = info@info.nl +license = APACHE +license_files = LICENSE.txt +long_description = file: README.rst +long_description_content_type = text/x-rst; charset=UTF-8 +url = https://github.com/flexiblepower/s2-ws-json-python +version = 0.5.0 + +# Change if running only on Windows, Mac or Linux (comma-separated) +platforms = Linux + +# Add here all kinds of additional classifiers as defined under +# https://pypi.org/classifiers/ +classifiers = + Development Status :: 4 - Beta + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + + +[options] +zip_safe = False +packages = find_namespace: +include_package_data = True +package_dir = + =src + +# Require a min/specific Python version (comma-separated conditions) +python_requires >= 3.8, <= 3.12 + +# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. +# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in +# new major versions. This works if the required packages follow Semantic Versioning. +# For more information, check out https://semver.org/. +install_requires = + pydantic>=2.8.2 + pytz + click + websockets~=13.1 + +[options.packages.find] +where = src +exclude = + tests + +[options.extras_require] +testing = + pytest + pytest-coverage + pytest-timer + mypy + types-pytz + pylint + pyright + + +development = + pip-tools + datamodel-code-generator + pre-commit + tox + +docs = + sphinx + sphinx-rtd-theme >= 1.2 + sphinx-tabs + sphinx_copybutton + sphinx_fontawesome + sphinxcontrib.httpdomain + +[options.entry_points] +console_scripts = + s2python = s2python.tools.cli:s2python_cmd + +[tool:pytest] +addopts = + --cov=s2python + --cov-report=html:./unit_test_coverage/ + -v tests/unit/ + +testpaths = tests + +[devpi:upload] +# Options for the devpi: PyPI server and packaging tool +# VCS export must be deactivated since we are using setuptools-scm +no_vcs = 1 +formats = bdist_wheel + +[flake8] +# Some sane defaults for the code style checker flake8 +max_line_length = 88 +extend_ignore = E203, W503 +# ^ Black-compatible +# E203 and W503 have edge cases handled by black +exclude = + .tox + build + dist + .eggs + docs/conf.py \ No newline at end of file diff --git a/s2-python/setup.py b/s2-python/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/s2-python/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/s2-python/specification/openapi.yml b/s2-python/specification/openapi.yml new file mode 100644 index 0000000..64c932e --- /dev/null +++ b/s2-python/specification/openapi.yml @@ -0,0 +1,1762 @@ +openapi: 3.0.0 +info: + title: s2-ws-json schema's in OpenAPI + version: main + description: "NB: S2-WS-JSON IS NOT A REST API, SO THIS FILE CANNOT BE USED TO GENERATE AN S2-WS-JSON ENDPOINT! S2-ws-json uses WebSockets for communication. An AsyincAPI and JSON-Schema specification is provided as well. This file is provided for developers who prefer OpenAPI code generators for generating data classes in their programming language. In that case you still need to impement the code for using WebSockets yourself." +components: + schemas: + Duration: + type: integer + minimum: 0 + description: "Duration in milliseconds" + ID: + type: string + pattern: "[a-zA-Z0-9\\-_:]{2,64}" + description: "UUID" + Currency: + enum: [AED,ANG,AUD,CHE,CHF,CHW,EUR,GBP,LBP,LKR,LRD,LSL,LYD,MAD,MDL,MGA,MKD,MMK,MNT,MOP,MRO,MUR,MVR,MWK,MXN,MXV,MYR,MZN,NAD,NGN,NIO,NOK,NPR,NZD,OMR,PAB,PEN,PGK,PHP,PKR,PLN,PYG,QAR,RON,RSD,RUB,RWF,SAR,SBD,SCR,SDG,SEK,SGD,SHP,SLL,SOS,SRD,SSP,STD,SYP,SZL,THB,TJS,TMT,TND,TOP,TRY,TTD,TWD,TZS,UAH,UGX,USD,USN,UYI,UYU,UZS,VEF,VND,VUV,WST,XAG,XAU,XBA,XBB,XBC,XBD,XCD,XOF,XPD,XPF,XPT,XSU,XTS,XUA,XXX,YER,ZAR,ZMW,ZWL] + SessionRequestType: + type: string + enum: ["RECONNECT", "TERMINATE"] + description: | + RECONNECT: Please reconnect the WebSocket session. Once reconnected, it starts from scratch with a handshake. + TERMINATE: Disconnect the session (client can try to reconnecting with exponential backoff) + RevokableObjects: + type: string + enum: ["PEBC.PowerConstraints", "PEBC.EnergyConstraint", "PEBC.Instruction", "PPBC.PowerProfileDefinition", "PPBC.ScheduleInstruction", "PPBC.StartInterruptionInstruction", "PPBC.EndInterruptionInstruction", "OMBC.SystemDescription", "OMBC.Instruction", "FRBC.SystemDescription", "FRBC.Instruction", "DDBC.SystemDescription", "DDBC.Instruction"] + description: | + PEBC.PowerConstraints: Object type PEBC.PowerConstraints + PEBC.EnergyConstraint: Object type PEBC.EnergyConstraint + PEBC.Instruction: Object type PEBC.Instruction + PPBC.PowerProfileDefinition: Object type PPBC.PowerProfileDefinition + PPBC.ScheduleInstruction: Object type PPBC.ScheduleInstruction + PPBC.StartInterruptionInstruction: Object type PPBC.StartInterruptionInstruction + PPBC.EndInterruptionInstruction: Object type PPBC.EndInterruptionInstruction + OMBC.SystemDescription: Object type OMBC.SystemDescription + OMBC.Instruction: Object type OMBC.Instruction + FRBC.SystemDescription: Object type FRBC.SystemDescription + FRBC.Instruction: Object type FRBC.Instruction + DDBC.SystemDescription: Object type DDBC.SystemDescription + DDBC.Instruction: Object type DDBC.Instruction + EnergyManagementRole: + type: string + enum: ["CEM", "RM"] + description: | + CEM: Customer Energy Manager + RM: Resource Manager + ReceptionStatusValues: + type: string + enum: ["INVALID_DATA", "INVALID_MESSAGE", "INVALID_CONTENT", "TEMPORARY_ERROR", "PERMANENT_ERROR", "OK"] + description: | + INVALID_DATA: Message not understood (e.g. not valid JSON, no message_id found). Consequence: Message is ignored, proceed if possible + INVALID_MESSAGE: Message was not according to schema. Consequence: Message is ignored, proceed if possible + INVALID_CONTENT: Message contents is invalid (e.g. contains a non-existing ID). Somewhat equivalent to BAD_REQUEST in HTTP.. Consequence: Message is ignored, proceed if possible. + TEMPORARY_ERROR: Receiver encountered an error. Consequence: Try to send to message again + PERMANENT_ERROR: Receiver encountered an error which it cannot recover from. Consequence: Disconnect. + OK: Message processed normally. Consequence: Proceed normally. + PowerValue: + type: object + required: + - commodity_quantity + - value + properties: + commodity_quantity: + $ref: '#/components/schemas/CommodityQuantity' + description: "The power quantity the value refers to" + value: + type: number + description: "Power value expressed in the unit associated with the CommodityQuantity" + additionalProperties: false + PowerForecastValue: + type: object + required: + - value_expected + - commodity_quantity + properties: + value_upper_limit: + type: number + description: "The upper boundary of the range with 100 % certainty the power value is in it" + value_upper_95PPR: + type: number + description: "The upper boundary of the range with 95 % certainty the power value is in it" + value_upper_68PPR: + type: number + description: "The upper boundary of the range with 68 % certainty the power value is in it" + value_expected: + type: number + description: "The expected power value." + value_lower_68PPR: + type: number + description: "The lower boundary of the range with 68 % certainty the power value is in it" + value_lower_95PPR: + type: number + description: "The lower boundary of the range with 95 % certainty the power value is in it" + value_lower_limit: + type: number + description: "The lower boundary of the range with 100 % certainty the power value is in it" + commodity_quantity: + $ref: '#/components/schemas/CommodityQuantity' + description: "The power quantity the value refers to" + additionalProperties: false + PowerRange: + type: object + required: + - start_of_range + - end_of_range + - commodity_quantity + properties: + start_of_range: + type: number + description: "Power value that defines the start of the range." + end_of_range: + type: number + description: "Power value that defines the end of the range." + commodity_quantity: + $ref: '#/components/schemas/CommodityQuantity' + description: "The power quantity the values refer to" + additionalProperties: false + NumberRange: + type: object + required: + - start_of_range + - end_of_range + properties: + start_of_range: + type: number + description: "Number that defines the start of the range" + end_of_range: + type: number + description: "Number that defines the end of the range" + additionalProperties: false + Role: + type: object + required: + - role + - commodity + properties: + role: + $ref: '#/components/schemas/RoleType' + description: "Role type of the Resource Manager for the given commodity" + commodity: + $ref: '#/components/schemas/Commodity' + description: "Commodity the role refers to." + additionalProperties: false + Transition: + type: object + required: + - id + - from + - to + - start_timers + - blocking_timers + - abnormal_condition_only + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the Transition. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used." + from: + $ref: '#/components/schemas/ID' + description: "ID of the OperationMode (exact type differs per ControlType) that should be switched from." + to: + $ref: '#/components/schemas/ID' + description: "ID of the OperationMode (exact type differs per ControlType) that will be switched to." + start_timers: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/ID' + description: "List of IDs of Timers that will be (re)started when this transition is initiated" + blocking_timers: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/ID' + description: "List of IDs of Timers that block this Transition from initiating while at least one of these Timers is not yet finished" + transition_costs: + type: number + description: "Absolute costs for going through this Transition in the currency as described in the ResourceManagerDetails." + transition_duration: + $ref: '#/components/schemas/Duration' + description: "Indicates the time between the initiation of this Transition, and the time at which the device behaves according to the Operation Mode which is defined in the ‘to’ data element. When no value is provided it is assumed the transition duration is negligible." + abnormal_condition_only: + type: boolean + description: "Indicates if this Transition may only be used during an abnormal condition (see Clause )" + additionalProperties: false + Timer: + type: object + required: + - id + - duration + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the Timer. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used." + diagnostic_label: + type: string + description: "Human readable name/description of the Timer. This element is only intended for diagnostic purposes and not for HMI applications." + duration: + $ref: '#/components/schemas/Duration' + description: "The time it takes for the Timer to finish after it has been started" + additionalProperties: false + PowerForecastElement: + type: object + required: + - duration + - power_values + properties: + duration: + $ref: '#/components/schemas/Duration' + description: "Duration of the PowerForecastElement" + power_values: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PowerForecastValue' + description: "The values of power that are expected for the given period of time. There shall be at least one PowerForecastValue, and at most one PowerForecastValue per CommodityQuantity." + additionalProperties: false + PEBCAllowedLimitRange: + type: object + required: + - commodity_quantity + - limit_type + - range_boundary + - abnormal_condition_only + properties: + commodity_quantity: + $ref: '#/components/schemas/CommodityQuantity' + description: "Type of power quantity this PEBC.AllowedLimitRange applies to" + limit_type: + $ref: '#/components/schemas/PEBCPowerEnvelopeLimitType' + description: "Indicates if this ranges applies to the upper limit or the lower limit" + range_boundary: + $ref: '#/components/schemas/NumberRange' + description: "Boundaries of the power range of this PEBC.AllowedLimitRange. The CEM is allowed to choose values within this range for the power envelope for the limit as described in limit_type. The start of the range shall be smaller or equal than the end of the range. " + abnormal_condition_only: + type: boolean + description: "Indicates if this PEBC.AllowedLimitRange may only be used during an abnormal condition" + additionalProperties: false + PEBCPowerEnvelope: + type: object + required: + - id + - commodity_quantity + - power_envelope_elements + properties: + id: + $ref: '#/components/schemas/ID' + description: "Identifier of this PEBC.PowerEnvelope. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + commodity_quantity: + $ref: '#/components/schemas/CommodityQuantity' + description: "Type of power quantity this PEBC.PowerEnvelope applies to" + power_envelope_elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/PEBCPowerEnvelopeElement' + description: "The elements of this PEBC.PowerEnvelope. Shall contain at least one element. Elements must be placed in chronological order." + additionalProperties: false + PEBCPowerEnvelopeElement: + type: object + required: + - duration + - upper_limit + - lower_limit + properties: + duration: + $ref: '#/components/schemas/Duration' + description: "The duration of the element" + upper_limit: + type: number + description: "Upper power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or below the upper_limit. The upper_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type UPPER_LIMIT." + lower_limit: + type: number + description: "Lower power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or above the lower_limit. The lower_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type LOWER_LIMIT." + additionalProperties: false + PPBCPowerSequenceContainer: + type: object + required: + - id + - power_sequences + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequenceContainer. Must be unique in the scope of the PPBC.PowerProfileDefinition in which it is used." + power_sequences: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/PPBCPowerSequence' + description: "List of alternative Sequences where one could be chosen by the CEM" + additionalProperties: false + PPBCPowerSequence: + type: object + required: + - id + - elements + - is_interruptible + - abnormal_condition_only + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequence. Must be unique in the scope of the PPBC.PowerSequnceContainer in which it is used." + elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/PPBCPowerSequenceElement' + description: "List of PPBC.PowerSequenceElements. Shall contain at least one element. Elements must be placed in chronological order." + is_interruptible: + type: boolean + description: "Indicates whether the option of pausing a sequence is available." + max_pause_before: + $ref: '#/components/schemas/Duration' + description: "The maximum duration for which a device can be paused between the end of the previous running sequence and the start of this one" + abnormal_condition_only: + type: boolean + description: "Indicates if this PPBC.PowerSequence may only be used during an abnormal condition" + additionalProperties: false + PPBCPowerSequenceElement: + type: object + required: + - duration + - power_values + properties: + duration: + $ref: '#/components/schemas/Duration' + description: "Duration of the PPBC.PowerSequenceElement." + power_values: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PowerForecastValue' + description: "The value of power and deviations for the given duration. The array should contain at least one PowerForecastValue and at most one PowerForecastValue per CommodityQuantity." + additionalProperties: false + PPBCPowerSequenceContainerStatus: + type: object + required: + - power_profile_id + - sequence_container_id + - status + properties: + power_profile_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerProfileDefinition of which the data element ‘sequence_container_id’ refers to. " + sequence_container_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequenceContainer this PPBC.PowerSequenceContainerStatus provides information about." + selected_sequence_id: + $ref: '#/components/schemas/ID' + description: "ID of selected PPBC.PowerSequence. When no ID is given, no sequence was selected yet." + progress: + $ref: '#/components/schemas/Duration' + description: "Time that has passed since the selected sequence has started. A value must be provided, unless no sequence has been selected or the selected sequence hasn’t started yet." + status: + $ref: '#/components/schemas/PPBCPowerSequenceStatus' + description: "Status of the selected PPBC.PowerSequence" + additionalProperties: false + OMBCOperationMode: + type: object + required: + - id + - power_ranges + - abnormal_condition_only + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the OBMC.OperationMode. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + diagnostic_label: + type: string + description: "Human readable name/description of the OMBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications." + power_ranges: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PowerRange' + description: "The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity." + running_costs: + $ref: '#/components/schemas/NumberRange' + description: "Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails , excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor." + abnormal_condition_only: + type: boolean + description: "Indicates if this OMBC.OperationMode may only be used during an abnormal condition." + additionalProperties: false + FRBCActuatorDescription: + type: object + required: + - id + - supported_commodities + - operation_modes + - transitions + - timers + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the Actuator. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + diagnostic_label: + type: string + description: "Human readable name/description for the actuator. This element is only intended for diagnostic purposes and not for HMI applications." + supported_commodities: + type: array + minItems: 1 + maxItems: 4 + items: + $ref: '#/components/schemas/Commodity' + description: "List of all supported Commodities." + operation_modes: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: '#/components/schemas/FRBCOperationMode' + description: "Provided FRBC.OperationModes associated with this actuator" + transitions: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/Transition' + description: "Possible transitions between FRBC.OperationModes associated with this actuator." + timers: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/Timer' + description: "List of Timers associated with this actuator" + additionalProperties: false + FRBCOperationMode: + type: object + required: + - id + - elements + - abnormal_condition_only + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of the FRBC.OperationMode. Must be unique in the scope of the FRBC.ActuatorDescription in which it is used." + diagnostic_label: + type: string + description: "Human readable name/description of the FRBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications." + elements: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: '#/components/schemas/FRBCOperationModeElement' + description: "List of FRBC.OperationModeElements, which describe the properties of this FRBC.OperationMode depending on the fill_level. The fill_level_ranges of the items in the Array must be contiguous." + abnormal_condition_only: + type: boolean + description: "Indicates if this FRBC.OperationMode may only be used during an abnormal condition" + additionalProperties: false + FRBCOperationModeElement: + type: object + required: + - fill_level_range + - fill_rate + - power_ranges + properties: + fill_level_range: + $ref: '#/components/schemas/NumberRange' + description: "The range of the fill level for which this FRBC.OperationModeElement applies. The start of the NumberRange shall be smaller than the end of the NumberRange." + fill_rate: + $ref: '#/components/schemas/NumberRange' + description: "Indicates the change in fill_level per second. The lower_boundary of the NumberRange is associated with an operation_mode_factor of 0, the upper_boundary is associated with an operation_mode_factor of 1. " + power_ranges: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PowerRange' + description: "The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity." + running_costs: + $ref: '#/components/schemas/NumberRange' + description: "Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor." + additionalProperties: false + FRBCStorageDescription: + type: object + required: + - provides_leakage_behaviour + - provides_fill_level_target_profile + - provides_usage_forecast + - fill_level_range + properties: + diagnostic_label: + type: string + description: "Human readable name/description of the storage (e.g. hot water buffer or battery). This element is only intended for diagnostic purposes and not for HMI applications." + fill_level_label: + type: string + description: "Human readable description of the (physical) units associated with the fill_level (e.g. degrees Celsius or percentage state of charge). This element is only intended for diagnostic purposes and not for HMI applications." + provides_leakage_behaviour: + type: boolean + description: "Indicates whether the Storage could provide details of power leakage behaviour through the FRBC.LeakageBehaviour." + provides_fill_level_target_profile: + type: boolean + description: "Indicates whether the Storage could provide a target profile for the fill level through the FRBC.FillLevelTargetProfile." + provides_usage_forecast: + type: boolean + description: "Indicates whether the Storage could provide a UsageForecast through the FRBC.UsageForecast." + fill_level_range: + $ref: '#/components/schemas/NumberRange' + description: "The range in which the fill_level should remain. It is expected of the CEM to keep the fill_level within this range. When the fill_level is not within this range, the Resource Manager can ignore instructions from the CEM (except during abnormal conditions). " + additionalProperties: false + FRBCLeakageBehaviourElement: + type: object + required: + - fill_level_range + - leakage_rate + properties: + fill_level_range: + $ref: '#/components/schemas/NumberRange' + description: "The fill level range for which this FRBC.LeakageBehaviourElement applies. The start of the range must be less than the end of the range." + leakage_rate: + type: number + description: "Indicates how fast the momentary fill level will decrease per second due to leakage within the given range of the fill level. A positive value indicates that the fill level decreases over time due to leakage." + additionalProperties: false + FRBCUsageForecastElement: + type: object + required: + - duration + - usage_rate_expected + properties: + duration: + $ref: '#/components/schemas/Duration' + description: "Indicator for how long the given usage_rate is valid." + usage_rate_upper_limit: + type: number + description: "The upper limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." + usage_rate_upper_95PPR: + type: number + description: "The upper limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." + usage_rate_upper_68PPR: + type: number + description: "The upper limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." + usage_rate_expected: + type: number + description: "The most likely value for the usage rate; the expected increase or decrease of the fill_level per second. A positive value indicates that the fill level will decrease due to usage." + usage_rate_lower_68PPR: + type: number + description: "The lower limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." + usage_rate_lower_95PPR: + type: number + description: "The lower limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." + usage_rate_lower_limit: + type: number + description: "The lower limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." + additionalProperties: false + FRBCFillLevelTargetProfileElement: + type: object + required: + - duration + - fill_level_range + properties: + duration: + $ref: '#/components/schemas/Duration' + description: "The duration of the element." + fill_level_range: + $ref: '#/components/schemas/NumberRange' + description: "The target range in which the fill_level must be for the time period during which the element is active. The start of the range must be smaller or equal to the end of the range. The CEM must take best-effort actions to proactively achieve this target." + additionalProperties: false + DDBCActuatorDescription: + type: object + required: + - id + - supported_commodites + - operation_modes + - transitions + - timers + properties: + id: + $ref: '#/components/schemas/ID' + description: "ID of this DDBC.ActuatorDescription. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + diagnostic_label: + type: string + description: "Human readable name/description of the actuator. This element is only intended for diagnostic purposes and not for HMI applications." + supported_commodites: + type: array + minItems: 1 + maxItems: 4 + items: + $ref: '#/components/schemas/Commodity' + description: "Commodities supported by the operation modes of this actuator. There shall be at least one commodity" + operation_modes: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: '#/components/schemas/DDBCOperationMode' + description: "List of all Operation Modes that are available for this actuator. There shall be at least one DDBC.OperationMode." + transitions: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/Transition' + description: "List of Transitions between Operation Modes. Shall contain at least one Transition." + timers: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/Timer' + description: "List of Timers associated with Transitions for this Actuator. Can be empty." + additionalProperties: false + DDBCOperationMode: + type: object + required: + - Id + - power_ranges + - supply_range + - abnormal_condition_only + properties: + Id: + $ref: '#/components/schemas/ID' + description: "ID of this operation mode. Must be unique in the scope of the DDBC.ActuatorDescription in which it is used." + diagnostic_label: + type: string + description: "Human readable name/description of the DDBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications." + power_ranges: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PowerRange' + description: "The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity." + supply_range: + $ref: '#/components/schemas/NumberRange' + description: "The supply rate this DDBC.OperationMode can deliver for the CEM to match the demand rate. The start of the NumberRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1." + running_costs: + $ref: '#/components/schemas/NumberRange' + description: "Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor." + abnormal_condition_only: + type: boolean + description: "Indicates if this DDBC.OperationMode may only be used during an abnormal condition." + additionalProperties: false + DDBCAverageDemandRateForecastElement: + type: object + required: + - duration + - demand_rate_expected + properties: + duration: + $ref: '#/components/schemas/Duration' + description: "Duration of the element" + demand_rate_upper_limit: + type: number + description: "The upper limit of the range with a 100 % probability that the demand rate is within that range" + demand_rate_upper_95PPR: + type: number + description: "The upper limit of the range with a 95 % probability that the demand rate is within that range" + demand_rate_upper_68PPR: + type: number + description: "The upper limit of the range with a 68 % probability that the demand rate is within that range" + demand_rate_expected: + type: number + description: "The most likely value for the demand rate; the expected increase or decrease of the fill_level per second" + demand_rate_lower_68PPR: + type: number + description: "The lower limit of the range with a 68 % probability that the demand rate is within that range" + demand_rate_lower_95PPR: + type: number + description: "The lower limit of the range with a 95 % probability that the demand rate is within that range" + demand_rate_lower_limit: + type: number + description: "The lower limit of the range with a 100 % probability that the demand rate is within that range" + additionalProperties: false + RoleType: + type: string + enum: ["ENERGY_PRODUCER", "ENERGY_CONSUMER", "ENERGY_STORAGE"] + description: | + ENERGY_PRODUCER: Identifier for RoleType Producer + ENERGY_CONSUMER: Identifier for RoleType Consumer + ENERGY_STORAGE: Identifier for RoleType Storage + Commodity: + type: string + enum: ["GAS", "HEAT", "ELECTRICITY", "OIL"] + description: | + GAS: Identifier for Commodity GAS + HEAT: Identifier for Commodity HEAT + ELECTRICITY: Identifier for Commodity ELECTRICITY + OIL: Identifier for Commodity OIL + CommodityQuantity: + type: string + enum: ["ELECTRIC.POWER.L1", "ELECTRIC.POWER.L2", "ELECTRIC.POWER.L3", "ELECTRIC.POWER.3_PHASE_SYMMETRIC", "NATURAL_GAS.FLOW_RATE", "HYDROGEN.FLOW_RATE", "HEAT.TEMPERATURE", "HEAT.FLOW_RATE", "HEAT.THERMAL_POWER", "OIL.FLOW_RATE"] + description: | + ELECTRIC.POWER.L1: Electric power described in Watt on phase 1. If a device utilizes only one phase it should always use L1. + ELECTRIC.POWER.L2: Electric power described in Watt on phase 2. Only applicable for 3 phase devices. + ELECTRIC.POWER.L3: Electric power described in Watt on phase 3. Only applicable for 3 phase devices. + ELECTRIC.POWER.3_PHASE_SYMMETRIC: Electric power described in Watt on when power is equally shared among the three phases. Only applicable for 3 phase devices. + NATURAL_GAS.FLOW_RATE: Gas flow rate described in liters per second + HYDROGEN.FLOW_RATE: Gas flow rate described in grams per second + HEAT.TEMPERATURE: Heat described in degrees Celsius + HEAT.FLOW_RATE: Flow rate of heat carrying gas or liquid in liters per second + HEAT.THERMAL_POWER: Thermal power in Watt + OIL.FLOW_RATE: Oil flow rate described in liters per hour + InstructionStatus: + type: string + enum: ["NEW", "ACCEPTED", "REJECTED", "REVOKED", "STARTED", "SUCCEEDED", "ABORTED"] + description: | + NEW: Instruction was newly created + ACCEPTED: Instruction has been accepted + REJECTED: Instruction was rejected + REVOKED: Instruction was revoked + STARTED: Instruction was executed + SUCCEEDED: Instruction finished successfully + ABORTED: Instruction was aborted. + ControlType: + type: string + enum: ["POWER_ENVELOPE_BASED_CONTROL", "POWER_PROFILE_BASED_CONTROL", "OPERATION_MODE_BASED_CONTROL", "FILL_RATE_BASED_CONTROL", "DEMAND_DRIVEN_BASED_CONTROL", "NOT_CONTROLABLE", "NO_SELECTION"] + description: | + POWER_ENVELOPE_BASED_CONTROL: Identifier for the Power Envelope Based Control type + POWER_PROFILE_BASED_CONTROL: Identifier for the Power Profile Based Control type + OPERATION_MODE_BASED_CONTROL: Identifier for the Operation Mode Based Control type + FILL_RATE_BASED_CONTROL: Identifier for the Demand Driven Based Control type + DEMAND_DRIVEN_BASED_CONTROL: Identifier for the Fill Rate Based Control type + NOT_CONTROLABLE: Identifier that is to be used if no control is possible. Resources of this type can still provide measurements and forecast + NO_SELECTION: Identifier that is to be used if no control type is or has been selected. + PEBCPowerEnvelopeLimitType: + type: string + enum: ["UPPER_LIMIT", "LOWER_LIMIT"] + description: | + UPPER_LIMIT: Indicating the upper limit of a PEBC.PowerEnvelope (see Clause 7.6.2) + LOWER_LIMIT: Indicating the lower limit of a PEBC.PowerEnvelope (see Clause 7.6.2) + PEBCPowerEnvelopeConsequenceType: + type: string + enum: ["VANISH", "DEFER"] + description: | + VANISH: Indicating that the limited load or generated will be lost and not reappear in the future (see Clause 7.6.2) + DEFER: Indicating that the limited load or generation will be postponed to a later moment (see Clause 7.6.2) + PPBCPowerSequenceStatus: + type: string + enum: ["NOT_SCHEDULED", "SCHEDULED", "EXECUTING", "INTERRUPTED", "FINISHED", "ABORTED"] + description: | + NOT_SCHEDULED: No PPBC.PowerSequence within the PPBC.PowerSequenceContainer is scheduled + SCHEDULED: The selected PPBC.PowerSequence is scheduled to be executed in the future + EXECUTING: The selected PPBC.PowerSequence is currently being executed + INTERRUPTED: The selected PPBC.PowerSequence is being executed, but is currently interrupted and will continue afterwards + FINISHED: The selected PPBC.PowerSequence was executed and finished successfully + ABORTED: The selected PPBC.PowerSequence was aborted by the device and will not continue + OMBCTimerStatus: + type: object + required: + - message_type + - message_id + - timer_id + - finished_at + properties: + message_type: + const: OMBC.TimerStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + timer_id: + $ref: '#/components/schemas/ID' + description: "The ID of the timer this message refers to" + finished_at: + type: string + format: date-time + description: "Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past." + additionalProperties: false + FRBCTimerStatus: + type: object + required: + - message_type + - message_id + - timer_id + - actuator_id + - finished_at + properties: + message_type: + const: FRBC.TimerStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + timer_id: + $ref: '#/components/schemas/ID' + description: "The ID of the timer this message refers to" + actuator_id: + $ref: '#/components/schemas/ID' + description: "The ID of the actuator the timer belongs to" + finished_at: + type: string + format: date-time + description: "Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past." + additionalProperties: false + DDBCTimerStatus: + type: object + required: + - message_type + - message_id + - timer_id + - actuator_id + - finished_at + properties: + message_type: + const: DDBC.TimerStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + timer_id: + $ref: '#/components/schemas/ID' + description: "The ID of the timer this message refers to" + actuator_id: + $ref: '#/components/schemas/ID' + description: "The ID of the actuator the timer belongs to" + finished_at: + type: string + format: date-time + description: "Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past." + additionalProperties: false + SelectControlType: + type: object + required: + - message_type + - message_id + - control_type + properties: + message_type: + const: SelectControlType + type: string + message_id: + $ref: '#/components/schemas/ID' + control_type: + $ref: '#/components/schemas/ControlType' + description: "The ControlType to activate. Must be one of the available ControlTypes as defined in the ResourceManagerDetails" + additionalProperties: false + SessionRequest: + type: object + required: + - message_type + - message_id + - request + properties: + message_type: + const: SessionRequest + type: string + message_id: + $ref: '#/components/schemas/ID' + request: + $ref: '#/components/schemas/SessionRequestType' + description: "The type of request" + diagnostic_label: + type: string + description: "Optional field for a human readible descirption for debugging purposes" + additionalProperties: false + RevokeObject: + type: object + required: + - message_type + - message_id + - object_type + - object_id + properties: + message_type: + const: RevokeObject + type: string + message_id: + $ref: '#/components/schemas/ID' + object_type: + $ref: '#/components/schemas/RevokableObjects' + description: "The type of object that needs to be revoked" + object_id: + $ref: '#/components/schemas/ID' + description: "The ID of object that needs to be revoked" + additionalProperties: false + Handshake: + type: object + required: + - message_type + - message_id + - role + properties: + message_type: + const: Handshake + type: string + message_id: + $ref: '#/components/schemas/ID' + role: + $ref: '#/components/schemas/EnergyManagementRole' + description: "The role of the sender of this message" + supported_protocol_versions: + type: array + minItems: 1 + items: + type: string + description: "Protocol versions supported by the sender of this message. This field is mandatory for the RM, but optional for the CEM." + additionalProperties: false + HandshakeResponse: + type: object + required: + - message_type + - message_id + - selected_protocol_version + properties: + message_type: + const: HandshakeResponse + type: string + message_id: + $ref: '#/components/schemas/ID' + selected_protocol_version: + type: string + description: "The protocol version the CEM selected for this session" + additionalProperties: false + ResourceManagerDetails: + type: object + required: + - message_type + - message_id + - resource_id + - roles + - instruction_processing_delay + - available_control_types + - provides_forecast + - provides_power_measurement_types + properties: + message_type: + const: ResourceManagerDetails + type: string + message_id: + $ref: '#/components/schemas/ID' + resource_id: + $ref: '#/components/schemas/ID' + description: "Identifier of the Resource Manager. Must be unique within the scope of the CEM." + name: + type: string + description: "Human readable name given by user" + roles: + type: array + minItems: 1 + maxItems: 3 + items: + $ref: '#/components/schemas/Role' + description: "Each Resource Manager provides one or more energy Roles" + manufacturer: + type: string + description: "Name of Manufacturer" + model: + type: string + description: "Name of the model of the device (provided by the manufacturer)" + serial_number: + type: string + description: "Serial number of the device (provided by the manufacturer)" + firmware_version: + type: string + description: "Version identifier of the firmware used in the device (provided by the manufacturer)" + instruction_processing_delay: + $ref: '#/components/schemas/Duration' + description: "The average time the combination of Resource Manager and HBES/BACS/SASS or (Smart) device needs to process and execute an instruction" + available_control_types: + type: array + minItems: 1 + maxItems: 5 + items: + $ref: '#/components/schemas/ControlType' + description: "The control types supported by this Resource Manager." + currency: + $ref: '#/components/schemas/Currency' + description: "Currency to be used for all information regarding costs. Mandatory if cost information is published." + provides_forecast: + type: boolean + description: "Indicates whether the ResourceManager is able to provide PowerForecasts" + provides_power_measurement_types: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/CommodityQuantity' + description: "Array of all CommodityQuantities that this Resource Manager can provide measurements for. " + additionalProperties: false + PowerMeasurement: + type: object + required: + - message_type + - message_id + - measurement_timestamp + - values + properties: + message_type: + const: PowerMeasurement + type: string + message_id: + $ref: '#/components/schemas/ID' + measurement_timestamp: + type: string + format: date-time + description: "Timestamp when PowerValues were measured." + values: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PowerValue' + description: "Array of measured PowerValues. Must contain at least one item and at most one item per ‘commodity_quantity’ (defined inside the PowerValue)." + additionalProperties: false + ReceptionStatus: + type: object + required: + - message_type + - subject_message_id + - status + properties: + message_type: + const: ReceptionStatus + type: string + subject_message_id: + $ref: '#/components/schemas/ID' + description: "The message this ReceptionStatus refers to" + status: + $ref: '#/components/schemas/ReceptionStatusValues' + description: "Enumeration of status values" + diagnostic_label: + type: string + description: "Diagnostic label that can be used to provide additional information for debugging. However, not for HMI purposes." + additionalProperties: false + InstructionStatusUpdate: + type: object + required: + - message_type + - message_id + - instruction_id + - status_type + - timestamp + properties: + message_type: + const: InstructionStatusUpdate + type: string + message_id: + $ref: '#/components/schemas/ID' + instruction_id: + $ref: '#/components/schemas/ID' + description: "ID of this instruction (as provided by the CEM) " + status_type: + $ref: '#/components/schemas/InstructionStatus' + description: "Present status of this instruction." + timestamp: + type: string + format: date-time + description: "Timestamp when status_type has changed the last time." + additionalProperties: false + PowerForecast: + type: object + required: + - message_type + - message_id + - start_time + - elements + properties: + message_type: + const: PowerForecast + type: string + message_id: + $ref: '#/components/schemas/ID' + start_time: + type: string + format: date-time + description: "Start time of time period that is covered by the profile." + elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/PowerForecastElement' + description: "Elements of which this forecast consists. Contains at least one element. Elements must be placed in chronological order." + additionalProperties: false + PEBCPowerConstraints: + type: object + required: + - message_type + - message_id + - id + - valid_from + - consequence_type + - allowed_limit_ranges + properties: + message_type: + const: PEBC.PowerConstraints + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "Identifier of this PEBC.PowerConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + valid_from: + type: string + format: date-time + description: "Moment this PEBC.PowerConstraints start to be valid" + valid_until: + type: string + format: date-time + description: "Moment until this PEBC.PowerConstraints is valid. If valid_until is not present, there is no determined end time of this PEBC.PowerConstraints." + consequence_type: + $ref: '#/components/schemas/PEBCPowerEnvelopeConsequenceType' + description: "Type of consequence of limiting power" + allowed_limit_ranges: + type: array + minItems: 2 + maxItems: 100 + items: + $ref: '#/components/schemas/PEBCAllowedLimitRange' + description: "The actual constraints. There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT. It is allowed to have multiple PEBC.AllowedLimitRange objects with identical CommodityQuantities and LimitTypes." + additionalProperties: false + PEBCEnergyConstraint: + type: object + required: + - message_type + - message_id + - id + - valid_from + - valid_until + - upper_average_power + - lower_average_power + - commodity_quantity + properties: + message_type: + const: PEBC.EnergyConstraint + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "Identifier of this PEBC.EnergyConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + valid_from: + type: string + format: date-time + description: "Moment this PEBC.EnergyConstraints information starts to be valid" + valid_until: + type: string + format: date-time + description: "Moment until this PEBC.EnergyConstraints information is valid." + upper_average_power: + type: number + description: "Upper average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated upper energy content can be derived. This is the highest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy consumption (in case the number is positive). Must be greater than or equal to lower_average_power, and can be negative in case of energy production." + lower_average_power: + type: number + description: "Lower average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated lower energy content can be derived. This is the lowest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy production (in case the number is negative). Must be greater than or equal to lower_average_power, and can be negative in case of energy production." + commodity_quantity: + $ref: '#/components/schemas/CommodityQuantity' + description: "Type of power quantity which applies to upper_average_power and lower_average_power" + additionalProperties: false + PEBCInstruction: + type: object + required: + - message_type + - message_id + - id + - execution_time + - abnormal_condition + - power_constraints_id + - power_envelopes + properties: + message_type: + const: PEBC.Instruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "Identifier of this PEBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + execution_time: + type: string + format: date-time + description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition." + power_constraints_id: + $ref: '#/components/schemas/ID' + description: "Identifier of the PEBC.PowerConstraints this PEBC.Instruction was based on." + power_envelopes: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/PEBCPowerEnvelope' + description: "The PEBC.PowerEnvelope(s) that should be followed by the Resource Manager. There shall be at least one PEBC.PowerEnvelope, but at most one PEBC.PowerEnvelope for each CommodityQuantity." + additionalProperties: false + PPBCPowerProfileDefinition: + type: object + required: + - message_type + - message_id + - id + - start_time + - end_time + - power_sequences_containers + properties: + message_type: + const: PPBC.PowerProfileDefinition + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerProfileDefinition. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + start_time: + type: string + format: date-time + description: "Indicates the first possible time the first PPBC.PowerSequence could start" + end_time: + type: string + format: date-time + description: "Indicates when the last PPBC.PowerSequence shall be finished at the latest" + power_sequences_containers: + type: array + minItems: 1 + maxItems: 1000 + items: + $ref: '#/components/schemas/PPBCPowerSequenceContainer' + description: "The PPBC.PowerSequenceContainers that make up this PPBC.PowerProfileDefinition. There shall be at least one PPBC.PowerSequenceContainer that includes at least one PPBC.PowerSequence. PPBC.PowerSequenceContainers must be placed in chronological order." + additionalProperties: false + PPBCPowerProfileStatus: + type: object + required: + - message_type + - message_id + - sequence_container_status + properties: + message_type: + const: PPBC.PowerProfileStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + sequence_container_status: + type: array + minItems: 1 + maxItems: 1000 + items: + $ref: '#/components/schemas/PPBCPowerSequenceContainerStatus' + description: "Array with status information for all PPBC.PowerSequenceContainers in the PPBC.PowerProfileDefinition." + additionalProperties: false + PPBCScheduleInstruction: + type: object + required: + - message_type + - message_id + - id + - power_profile_id + - sequence_container_id + - power_sequence_id + - execution_time + - abnormal_condition + properties: + message_type: + const: PPBC.ScheduleInstruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + power_profile_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being selected and scheduled by the CEM." + sequence_container_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being selected and scheduled by the CEM." + power_sequence_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequence that is being selected and scheduled by the CEM." + execution_time: + type: string + format: date-time + description: "Indicates the moment the PPBC.PowerSequence shall start. When the specified execution time is in the past, execution must start as soon as possible." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition" + additionalProperties: false + PPBCStartInterruptionInstruction: + type: object + required: + - message_type + - message_id + - id + - power_profile_id + - sequence_container_id + - power_sequence_id + - execution_time + - abnormal_condition + properties: + message_type: + const: PPBC.StartInterruptionInstruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + power_profile_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being interrupted by the CEM." + sequence_container_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being interrupted by the CEM." + power_sequence_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequence that the CEM wants to interrupt." + execution_time: + type: string + format: date-time + description: "Indicates the moment the PPBC.PowerSequence shall be interrupted. When the specified execution time is in the past, execution must start as soon as possible." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition" + additionalProperties: false + PPBCEndInterruptionInstruction: + type: object + required: + - message_type + - message_id + - id + - power_profile_id + - sequence_container_id + - power_sequence_id + - execution_time + - abnormal_condition + properties: + message_type: + const: PPBC.EndInterruptionInstruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + power_profile_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence interruption is being ended by the CEM." + sequence_container_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence interruption is being ended by the CEM." + power_sequence_id: + $ref: '#/components/schemas/ID' + description: "ID of the PPBC.PowerSequence for which the CEM wants to end the interruption." + execution_time: + type: string + format: date-time + description: "Indicates the moment PPBC.PowerSequence interruption shall end. When the specified execution time is in the past, execution must start as soon as possible." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition" + additionalProperties: false + OMBCSystemDescription: + type: object + required: + - message_type + - message_id + - valid_from + - operation_modes + - transitions + - timers + properties: + message_type: + const: OMBC.SystemDescription + type: string + message_id: + $ref: '#/components/schemas/ID' + valid_from: + type: string + format: date-time + description: "Moment this OMBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past." + operation_modes: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: '#/components/schemas/OMBCOperationMode' + description: "OMBC.OperationModes available for the CEM in order to coordinate the device behaviour." + transitions: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/Transition' + description: "Possible transitions to switch from one OMBC.OperationMode to another." + timers: + type: array + minItems: 0 + maxItems: 1000 + items: + $ref: '#/components/schemas/Timer' + description: "Timers that control when certain transitions can be made." + additionalProperties: false + OMBCStatus: + type: object + required: + - message_type + - message_id + - active_operation_mode_id + - operation_mode_factor + properties: + message_type: + const: OMBC.Status + type: string + message_id: + $ref: '#/components/schemas/ID' + active_operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the active OMBC.OperationMode." + operation_mode_factor: + type: number + description: "The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1." + previous_operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the OMBC.OperationMode that was previously active. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of." + transition_timestamp: + type: string + format: date-time + description: "Time at which the transition from the previous OMBC.OperationMode to the active OMBC.OperationMode was initiated. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of." + additionalProperties: false + OMBCInstruction: + type: object + required: + - message_type + - message_id + - id + - execution_time + - operation_mode_id + - operation_mode_factor + - abnormal_condition + properties: + message_type: + const: OMBC.Instruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + execution_time: + type: string + format: date-time + description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." + operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the OMBC.OperationMode that should be activated" + operation_mode_factor: + type: number + description: "The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition" + additionalProperties: false + FRBCSystemDescription: + type: object + required: + - message_type + - message_id + - valid_from + - actuators + - storage + properties: + message_type: + const: FRBC.SystemDescription + type: string + message_id: + $ref: '#/components/schemas/ID' + valid_from: + type: string + format: date-time + description: "Moment this FRBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past." + actuators: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/FRBCActuatorDescription' + description: "Details of all Actuators." + storage: + $ref: '#/components/schemas/FRBCStorageDescription' + description: "Details of the storage." + additionalProperties: false + FRBCActuatorStatus: + type: object + required: + - message_type + - message_id + - actuator_id + - active_operation_mode_id + - operation_mode_factor + properties: + message_type: + const: FRBC.ActuatorStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + actuator_id: + $ref: '#/components/schemas/ID' + description: "ID of the actuator this messages refers to" + active_operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the FRBC.OperationMode that is presently active." + operation_mode_factor: + type: number + description: "The number indicates the factor with which the FRBC.OperationMode is configured. The factor should be greater than or equal than 0 and less or equal to 1." + previous_operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the FRBC.OperationMode that was active before the present one. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of." + transition_timestamp: + type: string + format: date-time + description: "Time at which the transition from the previous FRBC.OperationMode to the active FRBC.OperationMode was initiated. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of." + additionalProperties: false + FRBCStorageStatus: + type: object + required: + - message_type + - message_id + - present_fill_level + properties: + message_type: + const: FRBC.StorageStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + present_fill_level: + type: number + description: "Present fill level of the Storage" + additionalProperties: false + FRBCLeakageBehaviour: + type: object + required: + - message_type + - message_id + - valid_from + - elements + properties: + message_type: + const: FRBC.LeakageBehaviour + type: string + message_id: + $ref: '#/components/schemas/ID' + valid_from: + type: string + format: date-time + description: "Moment this FRBC.LeakageBehaviour starts to be valid. If the FRBC.LeakageBehaviour is immediately valid, the DateTimeStamp should be now or in the past." + elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/FRBCLeakageBehaviourElement' + description: "List of elements that model the leakage behaviour of the buffer. The fill_level_ranges of the elements must be contiguous." + additionalProperties: false + FRBCInstruction: + type: object + required: + - message_type + - message_id + - id + - actuator_id + - operation_mode + - operation_mode_factor + - execution_time + - abnormal_condition + properties: + message_type: + const: FRBC.Instruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + actuator_id: + $ref: '#/components/schemas/ID' + description: "ID of the actuator this instruction belongs to." + operation_mode: + $ref: '#/components/schemas/ID' + description: "ID of the FRBC.OperationMode that should be activated." + operation_mode_factor: + type: number + description: "The number indicates the factor with which the FRBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1." + execution_time: + type: string + format: date-time + description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition." + additionalProperties: false + FRBCUsageForecast: + type: object + required: + - message_type + - message_id + - start_time + - elements + properties: + message_type: + const: FRBC.UsageForecast + type: string + message_id: + $ref: '#/components/schemas/ID' + start_time: + type: string + format: date-time + description: "Time at which the FRBC.UsageForecast starts." + elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/FRBCUsageForecastElement' + description: "Further elements that model the profile. There shall be at least one element. Elements must be placed in chronological order." + additionalProperties: false + FRBCFillLevelTargetProfile: + type: object + required: + - message_type + - message_id + - start_time + - elements + properties: + message_type: + const: FRBC.FillLevelTargetProfile + type: string + message_id: + $ref: '#/components/schemas/ID' + start_time: + type: string + format: date-time + description: "Time at which the FRBC.FillLevelTargetProfile starts." + elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/FRBCFillLevelTargetProfileElement' + description: "List of different fill levels that have to be targeted within a given duration. There shall be at least one element. Elements must be placed in chronological order." + additionalProperties: false + DDBCSystemDescription: + type: object + required: + - message_type + - message_id + - valid_from + - actuators + - present_demand_rate + - provides_average_demand_rate_forecast + properties: + message_type: + const: DDBC.SystemDescription + type: string + message_id: + $ref: '#/components/schemas/ID' + valid_from: + type: string + format: date-time + description: "Moment this DDBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past." + actuators: + type: array + minItems: 1 + maxItems: 10 + items: + $ref: '#/components/schemas/DDBCActuatorDescription' + description: "List of all available actuators in the system. Must contain at least one DDBC.ActuatorAggregated." + present_demand_rate: + $ref: '#/components/schemas/NumberRange' + description: "Present demand rate that needs to be satisfied by the system" + provides_average_demand_rate_forecast: + type: boolean + description: "Indicates whether the Resource Manager could provide a demand rate forecast through the DDBC.AverageDemandRateForecast." + additionalProperties: false + DDBCActuatorStatus: + type: object + required: + - message_type + - message_id + - actuator_id + - active_operation_mode_id + - operation_mode_factor + properties: + message_type: + const: DDBC.ActuatorStatus + type: string + message_id: + $ref: '#/components/schemas/ID' + actuator_id: + $ref: '#/components/schemas/ID' + description: "ID of the actuator this messages refers to" + active_operation_mode_id: + $ref: '#/components/schemas/ID' + description: "The operation mode that is presently active for this actuator." + operation_mode_factor: + type: number + description: "The number indicates the factor with which the DDBC.OperationMode is configured. The factor should be greater than or equal to 0 and less or equal to 1." + previous_operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the DDBC,OperationMode that was active before the present one. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of." + transition_timestamp: + type: string + format: date-time + description: "Time at which the transition from the previous DDBC.OperationMode to the active DDBC.OperationMode was initiated. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of." + additionalProperties: false + DDBCInstruction: + type: object + required: + - message_type + - message_id + - id + - execution_time + - abnormal_condition + - actuator_id + - operation_mode_id + - operation_mode_factor + properties: + message_type: + const: DDBC.Instruction + type: string + message_id: + $ref: '#/components/schemas/ID' + id: + $ref: '#/components/schemas/ID' + description: "Identifier of this DDBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." + execution_time: + type: string + format: date-time + description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." + abnormal_condition: + type: boolean + description: "Indicates if this is an instruction during an abnormal condition" + actuator_id: + $ref: '#/components/schemas/ID' + description: "ID of the actuator this Instruction belongs to." + operation_mode_id: + $ref: '#/components/schemas/ID' + description: "ID of the DDBC.OperationMode" + operation_mode_factor: + type: number + description: "The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1." + additionalProperties: false + DDBCAverageDemandRateForecast: + type: object + required: + - message_type + - message_id + - start_time + - elements + properties: + message_type: + const: DDBC.AverageDemandRateForecast + type: string + message_id: + $ref: '#/components/schemas/ID' + start_time: + type: string + format: date-time + description: "Start time of the profile." + elements: + type: array + minItems: 1 + maxItems: 288 + items: + $ref: '#/components/schemas/DDBCAverageDemandRateForecastElement' + description: "Elements of the profile. Elements must be placed in chronological order." + additionalProperties: false diff --git a/s2-python/src/s2python/__init__.py b/s2-python/src/s2python/__init__.py new file mode 100644 index 0000000..0ab0a42 --- /dev/null +++ b/s2-python/src/s2python/__init__.py @@ -0,0 +1,10 @@ +from importlib.metadata import PackageNotFoundError, version # pragma: no cover + +try: + # Change here if project is renamed and does not equal the package name + dist_name = "s2-python" # pylint: disable=invalid-name + __version__ = version(dist_name) +except PackageNotFoundError: # pragma: no cover + __version__ = "unknown" +finally: + del version, PackageNotFoundError diff --git a/s2-python/src/s2python/common/__init__.py b/s2-python/src/s2python/common/__init__.py new file mode 100644 index 0000000..785b8c8 --- /dev/null +++ b/s2-python/src/s2python/common/__init__.py @@ -0,0 +1,64 @@ +from s2python.generated.gen_s2 import ( + RoleType, + Currency, + CommodityQuantity, + Commodity, + InstructionStatus, + ReceptionStatusValues, + EnergyManagementRole, + SessionRequestType, + ControlType, + RevokableObjects, +) + +from s2python.common.duration import Duration +from s2python.common.role import Role +from s2python.common.handshake import Handshake +from s2python.common.handshake_response import HandshakeResponse +from s2python.common.instruction_status_update import InstructionStatusUpdate +from s2python.common.number_range import NumberRange +from s2python.common.power_forecast_value import PowerForecastValue +from s2python.common.power_forecast_element import PowerForecastElement +from s2python.common.power_forecast import PowerForecast +from s2python.common.power_value import PowerValue +from s2python.common.power_measurement import PowerMeasurement +from s2python.common.power_range import PowerRange +from s2python.common.reception_status import ReceptionStatus +from s2python.common.resource_manager_details import ResourceManagerDetails +from s2python.common.revoke_object import RevokeObject +from s2python.common.select_control_type import SelectControlType +from s2python.common.session_request import SessionRequest +from s2python.common.timer import Timer +from s2python.common.transition import Transition + +__all__ = [ + "RoleType", + "Currency", + "CommodityQuantity", + "Commodity", + "InstructionStatus", + "ReceptionStatusValues", + "EnergyManagementRole", + "SessionRequestType", + "ControlType", + "RevokableObjects", + "Duration", + "Role", + "Handshake", + "HandshakeResponse", + "InstructionStatusUpdate", + "NumberRange", + "PowerForecastValue", + "PowerForecastElement", + "PowerForecast", + "PowerValue", + "PowerMeasurement", + "PowerRange", + "ReceptionStatus", + "ResourceManagerDetails", + "RevokeObject", + "SelectControlType", + "SessionRequest", + "Timer", + "Transition", +] diff --git a/s2-python/src/s2python/common/duration.py b/s2-python/src/s2python/common/duration.py new file mode 100644 index 0000000..3fd2cd5 --- /dev/null +++ b/s2-python/src/s2python/common/duration.py @@ -0,0 +1,24 @@ +from datetime import timedelta +import math + +from s2python.generated.gen_s2 import Duration as GenDuration +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class Duration( # pyright: ignore[reportIncompatibleMethodOverride] + GenDuration, S2MessageComponent +): + def to_timedelta(self) -> timedelta: + return timedelta(milliseconds=self.root) + + @staticmethod + def from_timedelta(duration: timedelta) -> "Duration": + return Duration(root=math.ceil(duration.total_seconds() * 1000)) + + @staticmethod + def from_milliseconds(milliseconds: int) -> "Duration": + return Duration(root=milliseconds) diff --git a/s2-python/src/s2python/common/handshake.py b/s2-python/src/s2python/common/handshake.py new file mode 100644 index 0000000..55e1c7d --- /dev/null +++ b/s2-python/src/s2python/common/handshake.py @@ -0,0 +1,15 @@ +import uuid + +from s2python.generated.gen_s2 import Handshake as GenHandshake +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class Handshake(GenHandshake, S2MessageComponent): + model_config = GenHandshake.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenHandshake.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/handshake_response.py b/s2-python/src/s2python/common/handshake_response.py new file mode 100644 index 0000000..a451e42 --- /dev/null +++ b/s2-python/src/s2python/common/handshake_response.py @@ -0,0 +1,15 @@ +import uuid + +from s2python.generated.gen_s2 import HandshakeResponse as GenHandshakeResponse +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class HandshakeResponse(GenHandshakeResponse, S2MessageComponent): + model_config = GenHandshakeResponse.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenHandshakeResponse.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/instruction_status_update.py b/s2-python/src/s2python/common/instruction_status_update.py new file mode 100644 index 0000000..73732e8 --- /dev/null +++ b/s2-python/src/s2python/common/instruction_status_update.py @@ -0,0 +1,18 @@ +import uuid + +from s2python.generated.gen_s2 import ( + InstructionStatusUpdate as GenInstructionStatusUpdate, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class InstructionStatusUpdate(GenInstructionStatusUpdate, S2MessageComponent): + model_config = GenInstructionStatusUpdate.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenInstructionStatusUpdate.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + instruction_id: uuid.UUID = GenInstructionStatusUpdate.model_fields["instruction_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/number_range.py b/s2-python/src/s2python/common/number_range.py new file mode 100644 index 0000000..5b6af75 --- /dev/null +++ b/s2-python/src/s2python/common/number_range.py @@ -0,0 +1,25 @@ +from typing import Any + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) +from s2python.generated.gen_s2 import NumberRange as GenNumberRange + + +@catch_and_convert_exceptions +class NumberRange(GenNumberRange, S2MessageComponent): + model_config = GenNumberRange.model_config + model_config["validate_assignment"] = True + + def __hash__(self) -> int: + return hash(f"{self.start_of_range}|{self.end_of_range}") + + def __eq__(self, other: Any) -> bool: + if isinstance(other, NumberRange): + return ( + self.start_of_range == other.start_of_range + and self.end_of_range == other.end_of_range + ) + + return False diff --git a/s2-python/src/s2python/common/power_forecast.py b/s2-python/src/s2python/common/power_forecast.py new file mode 100644 index 0000000..5dac5bb --- /dev/null +++ b/s2-python/src/s2python/common/power_forecast.py @@ -0,0 +1,18 @@ +from typing import List +import uuid + +from s2python.common.power_forecast_element import PowerForecastElement +from s2python.generated.gen_s2 import PowerForecast as GenPowerForecast +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PowerForecast(GenPowerForecast, S2MessageComponent): + model_config = GenPowerForecast.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenPowerForecast.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + elements: List[PowerForecastElement] = GenPowerForecast.model_fields["elements"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/power_forecast_element.py b/s2-python/src/s2python/common/power_forecast_element.py new file mode 100644 index 0000000..7d84a73 --- /dev/null +++ b/s2-python/src/s2python/common/power_forecast_element.py @@ -0,0 +1,20 @@ +from typing import List + +from s2python.generated.gen_s2 import PowerForecastElement as GenPowerForecastElement +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) +from s2python.common.duration import Duration +from s2python.common.power_forecast_value import PowerForecastValue + + +@catch_and_convert_exceptions +class PowerForecastElement(GenPowerForecastElement, S2MessageComponent): + model_config = GenPowerForecastElement.model_config + model_config["validate_assignment"] = True + + duration: Duration = GenPowerForecastElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_values: List[PowerForecastValue] = ( # type: ignore[reportIncompatibleVariableOverride] + GenPowerForecastElement.model_fields["power_values"] # type: ignore[assignment] + ) diff --git a/s2-python/src/s2python/common/power_forecast_value.py b/s2-python/src/s2python/common/power_forecast_value.py new file mode 100644 index 0000000..dbf2896 --- /dev/null +++ b/s2-python/src/s2python/common/power_forecast_value.py @@ -0,0 +1,11 @@ +from s2python.generated.gen_s2 import PowerForecastValue as GenPowerForecastValue +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PowerForecastValue(GenPowerForecastValue, S2MessageComponent): + model_config = GenPowerForecastValue.model_config + model_config["validate_assignment"] = True diff --git a/s2-python/src/s2python/common/power_measurement.py b/s2-python/src/s2python/common/power_measurement.py new file mode 100644 index 0000000..afd15cf --- /dev/null +++ b/s2-python/src/s2python/common/power_measurement.py @@ -0,0 +1,18 @@ +from typing import List +import uuid + +from s2python.common.power_value import PowerValue +from s2python.generated.gen_s2 import PowerMeasurement as GenPowerMeasurement +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PowerMeasurement(GenPowerMeasurement, S2MessageComponent): + model_config = GenPowerMeasurement.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenPowerMeasurement.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + values: List[PowerValue] = GenPowerMeasurement.model_fields["values"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/power_range.py b/s2-python/src/s2python/common/power_range.py new file mode 100644 index 0000000..e9c1dd2 --- /dev/null +++ b/s2-python/src/s2python/common/power_range.py @@ -0,0 +1,24 @@ +from typing_extensions import Self + +from pydantic import model_validator + +from s2python.generated.gen_s2 import PowerRange as GenPowerRange +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class PowerRange(GenPowerRange, S2MessageComponent): + model_config = GenPowerRange.model_config + model_config["validate_assignment"] = True + + @model_validator(mode="after") + def validate_start_end_order(self) -> Self: + if self.start_of_range > self.end_of_range: + raise ValueError( + self, "start_of_range should not be higher than end_of_range" + ) + + return self diff --git a/s2-python/src/s2python/common/power_value.py b/s2-python/src/s2python/common/power_value.py new file mode 100644 index 0000000..cb55542 --- /dev/null +++ b/s2-python/src/s2python/common/power_value.py @@ -0,0 +1,11 @@ +from s2python.generated.gen_s2 import PowerValue as GenPowerValue +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PowerValue(GenPowerValue, S2MessageComponent): + model_config = GenPowerValue.model_config + model_config["validate_assignment"] = True diff --git a/s2-python/src/s2python/common/reception_status.py b/s2-python/src/s2python/common/reception_status.py new file mode 100644 index 0000000..12bf559 --- /dev/null +++ b/s2-python/src/s2python/common/reception_status.py @@ -0,0 +1,15 @@ +import uuid + +from s2python.generated.gen_s2 import ReceptionStatus as GenReceptionStatus +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class ReceptionStatus(GenReceptionStatus, S2MessageComponent): + model_config = GenReceptionStatus.model_config + model_config["validate_assignment"] = True + + subject_message_id: uuid.UUID = GenReceptionStatus.model_fields["subject_message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/resource_manager_details.py b/s2-python/src/s2python/common/resource_manager_details.py new file mode 100644 index 0000000..9333fba --- /dev/null +++ b/s2-python/src/s2python/common/resource_manager_details.py @@ -0,0 +1,25 @@ +from typing import List +import uuid + +from s2python.common.duration import Duration +from s2python.common.role import Role +from s2python.generated.gen_s2 import ( + ResourceManagerDetails as GenResourceManagerDetails, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class ResourceManagerDetails(GenResourceManagerDetails, S2MessageComponent): + model_config = GenResourceManagerDetails.model_config + model_config["validate_assignment"] = True + + instruction_processing_delay: Duration = GenResourceManagerDetails.model_fields[ # type: ignore[assignment,reportIncompatibleVariableOverride] + "instruction_processing_delay" + ] + message_id: uuid.UUID = GenResourceManagerDetails.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + resource_id: uuid.UUID = GenResourceManagerDetails.model_fields["resource_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + roles: List[Role] = GenResourceManagerDetails.model_fields["roles"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/revoke_object.py b/s2-python/src/s2python/common/revoke_object.py new file mode 100644 index 0000000..7fc6678 --- /dev/null +++ b/s2-python/src/s2python/common/revoke_object.py @@ -0,0 +1,16 @@ +import uuid + +from s2python.generated.gen_s2 import RevokeObject as GenRevokeObject +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class RevokeObject(GenRevokeObject, S2MessageComponent): + model_config = GenRevokeObject.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenRevokeObject.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + object_id: uuid.UUID = GenRevokeObject.model_fields["object_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/role.py b/s2-python/src/s2python/common/role.py new file mode 100644 index 0000000..7f8f253 --- /dev/null +++ b/s2-python/src/s2python/common/role.py @@ -0,0 +1,11 @@ +from s2python.generated.gen_s2 import Role as GenRole +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class Role(GenRole, S2MessageComponent): + model_config = GenRole.model_config + model_config["validate_assignment"] = True diff --git a/s2-python/src/s2python/common/select_control_type.py b/s2-python/src/s2python/common/select_control_type.py new file mode 100644 index 0000000..e8ac142 --- /dev/null +++ b/s2-python/src/s2python/common/select_control_type.py @@ -0,0 +1,15 @@ +import uuid + +from s2python.generated.gen_s2 import SelectControlType as GenSelectControlType +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class SelectControlType(GenSelectControlType, S2MessageComponent): + model_config = GenSelectControlType.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenSelectControlType.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/session_request.py b/s2-python/src/s2python/common/session_request.py new file mode 100644 index 0000000..b5a62a5 --- /dev/null +++ b/s2-python/src/s2python/common/session_request.py @@ -0,0 +1,15 @@ +import uuid + +from s2python.generated.gen_s2 import SessionRequest as GenSessionRequest +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class SessionRequest(GenSessionRequest, S2MessageComponent): + model_config = GenSessionRequest.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenSessionRequest.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/support.py b/s2-python/src/s2python/common/support.py new file mode 100644 index 0000000..027f65b --- /dev/null +++ b/s2-python/src/s2python/common/support.py @@ -0,0 +1,27 @@ +from s2python.common import CommodityQuantity, Commodity + + +def commodity_has_quantity(commodity: "Commodity", quantity: CommodityQuantity) -> bool: + if commodity == Commodity.HEAT: + result = quantity in [ + CommodityQuantity.HEAT_THERMAL_POWER, + CommodityQuantity.HEAT_TEMPERATURE, + CommodityQuantity.HEAT_FLOW_RATE, + ] + elif commodity == Commodity.ELECTRICITY: + result = quantity in [ + CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, + CommodityQuantity.ELECTRIC_POWER_L1, + CommodityQuantity.ELECTRIC_POWER_L2, + CommodityQuantity.ELECTRIC_POWER_L3, + ] + elif commodity == Commodity.GAS: + result = quantity in [CommodityQuantity.NATURAL_GAS_FLOW_RATE] + elif commodity == Commodity.OIL: + result = quantity in [CommodityQuantity.OIL_FLOW_RATE] + else: + raise RuntimeError( + f"Unsupported commodity {commodity}. Missing implementation." + ) + + return result diff --git a/s2-python/src/s2python/common/timer.py b/s2-python/src/s2python/common/timer.py new file mode 100644 index 0000000..67e226b --- /dev/null +++ b/s2-python/src/s2python/common/timer.py @@ -0,0 +1,17 @@ +import uuid + +from s2python.common.duration import Duration +from s2python.generated.gen_s2 import Timer as GenTimer +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class Timer(GenTimer, S2MessageComponent): + model_config = GenTimer.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenTimer.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + duration: Duration = GenTimer.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/common/transition.py b/s2-python/src/s2python/common/transition.py new file mode 100644 index 0000000..b43334f --- /dev/null +++ b/s2-python/src/s2python/common/transition.py @@ -0,0 +1,24 @@ +import uuid +from typing import Optional, List + +from s2python.common.duration import Duration +from s2python.generated.gen_s2 import Transition as GenTransition +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class Transition(GenTransition, S2MessageComponent): + model_config = GenTransition.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenTransition.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + from_: uuid.UUID = GenTransition.model_fields["from_"] # type: ignore[assignment,reportIncompatibleVariableOverride] + to: uuid.UUID = GenTransition.model_fields["to"] # type: ignore[assignment,reportIncompatibleVariableOverride] + start_timers: List[uuid.UUID] = GenTransition.model_fields["start_timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] + blocking_timers: List[uuid.UUID] = GenTransition.model_fields["blocking_timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] + transition_duration: Optional[Duration] = GenTransition.model_fields[ # type: ignore[assignment,reportIncompatibleVariableOverride] + "transition_duration" + ] diff --git a/s2-python/src/s2python/ddbc/__init__.py b/s2-python/src/s2python/ddbc/__init__.py new file mode 100644 index 0000000..d154814 --- /dev/null +++ b/s2-python/src/s2python/ddbc/__init__.py @@ -0,0 +1,21 @@ +from s2python.ddbc.ddbc_actuator_description import DDBCActuatorDescription +from s2python.ddbc.ddbc_operation_mode import DDBCOperationMode +from s2python.ddbc.ddbc_instruction import DDBCInstruction +from s2python.ddbc.ddbc_actuator_status import DDBCActuatorStatus +from s2python.ddbc.ddbc_average_demand_rate_forecast_element import ( + DDBCAverageDemandRateForecastElement, +) +from s2python.ddbc.ddbc_average_demand_rate_forecast import DDBCAverageDemandRateForecast +from s2python.ddbc.ddbc_system_description import DDBCSystemDescription +from s2python.ddbc.ddbc_timer_status import DDBCTimerStatus + +__all__ = [ + "DDBCActuatorDescription", + "DDBCOperationMode", + "DDBCInstruction", + "DDBCActuatorStatus", + "DDBCAverageDemandRateForecastElement", + "DDBCAverageDemandRateForecast", + "DDBCSystemDescription", + "DDBCTimerStatus", +] diff --git a/s2-python/src/s2python/ddbc/ddbc_actuator_description.py b/s2-python/src/s2python/ddbc/ddbc_actuator_description.py new file mode 100644 index 0000000..a460971 --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_actuator_description.py @@ -0,0 +1,30 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import ( + DDBCActuatorDescription as GenDDBCActuatorDescription, +) +from s2python.generated.gen_s2 import Commodity +from s2python.ddbc.ddbc_operation_mode import DDBCOperationMode + +from s2python.common.timer import Timer + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCActuatorDescription(GenDDBCActuatorDescription, S2MessageComponent): + model_config = GenDDBCActuatorDescription.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenDDBCActuatorDescription.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + supported_commodites: List[Commodity] = GenDDBCActuatorDescription.model_fields[ + "supported_commodites" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] + timers: List[Timer] = GenDDBCActuatorDescription.model_fields["timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] + operation_modes: List[DDBCOperationMode] = GenDDBCActuatorDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "operation_modes" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ddbc/ddbc_actuator_status.py b/s2-python/src/s2python/ddbc/ddbc_actuator_status.py new file mode 100644 index 0000000..57d2ffe --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_actuator_status.py @@ -0,0 +1,22 @@ +import uuid + +from s2python.generated.gen_s2 import DDBCActuatorStatus as GenDDBCActuatorStatus +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCActuatorStatus(GenDDBCActuatorStatus, S2MessageComponent): + model_config = GenDDBCActuatorStatus.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenDDBCActuatorStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + actuator_id: uuid.UUID = GenDDBCActuatorStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + active_operation_mode_id: uuid.UUID = GenDDBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "active_operation_mode_id" + ] # type: ignore[assignment] + operation_mode_factor: float = GenDDBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "operation_mode_factor" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py b/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py new file mode 100644 index 0000000..313aadd --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py @@ -0,0 +1,28 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import ( + DDBCAverageDemandRateForecast as GenDDBCAverageDemandRateForecast, +) +from s2python.ddbc.ddbc_average_demand_rate_forecast_element import ( + DDBCAverageDemandRateForecastElement, +) + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCAverageDemandRateForecast( + GenDDBCAverageDemandRateForecast, + S2MessageComponent, +): + model_config = GenDDBCAverageDemandRateForecast.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenDDBCAverageDemandRateForecast.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + elements: List[DDBCAverageDemandRateForecastElement] = ( # type: ignore[reportIncompatibleVariableOverride] + GenDDBCAverageDemandRateForecast.model_fields["elements"] # type: ignore[assignment] + ) diff --git a/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py b/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py new file mode 100644 index 0000000..8e8bdee --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py @@ -0,0 +1,21 @@ +from s2python.generated.gen_s2 import Duration + +from s2python.generated.gen_s2 import ( + DDBCAverageDemandRateForecastElement as GenDDBCAverageDemandRateForecastElement, +) + +from s2python.validate_values_mixin import catch_and_convert_exceptions, S2MessageComponent + + +@catch_and_convert_exceptions +class DDBCAverageDemandRateForecastElement( + GenDDBCAverageDemandRateForecastElement, + S2MessageComponent, +): + model_config = GenDDBCAverageDemandRateForecastElement.model_config + model_config["validate_assignment"] = True + + duration: Duration = GenDDBCAverageDemandRateForecastElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] + demand_rate_expected: float = GenDDBCAverageDemandRateForecastElement.model_fields[ + "demand_rate_expected" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/ddbc/ddbc_instruction.py b/s2-python/src/s2python/ddbc/ddbc_instruction.py new file mode 100644 index 0000000..2866a33 --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_instruction.py @@ -0,0 +1,19 @@ +import uuid + +from s2python.generated.gen_s2 import DDBCInstruction as GenDDBCInstruction +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCInstruction(GenDDBCInstruction, S2MessageComponent): + model_config = GenDDBCInstruction.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenDDBCInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + actuator_id: uuid.UUID = GenDDBCInstruction.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + operation_mode_id: uuid.UUID = GenDDBCInstruction.model_fields["operation_mode_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + operation_mode_factor: float = GenDDBCInstruction.model_fields["operation_mode_factor"] # type: ignore[assignment,reportIncompatibleVariableOverride] + abnormal_condition: bool = GenDDBCInstruction.model_fields["abnormal_condition"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/ddbc/ddbc_operation_mode.py b/s2-python/src/s2python/ddbc/ddbc_operation_mode.py new file mode 100644 index 0000000..d3ddfbd --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_operation_mode.py @@ -0,0 +1,26 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import DDBCOperationMode as GenDDBCOperationMode + +from s2python.common.power_range import PowerRange +from s2python.common.number_range import NumberRange + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCOperationMode(GenDDBCOperationMode, S2MessageComponent): + model_config = GenDDBCOperationMode.model_config + model_config["validate_assignment"] = True + + # ? Id vs id + id: uuid.UUID = GenDDBCOperationMode.model_fields["Id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_ranges: List[PowerRange] = GenDDBCOperationMode.model_fields["power_ranges"] # type: ignore[assignment,reportIncompatibleVariableOverride] + supply_range: List[NumberRange] = GenDDBCOperationMode.model_fields["supply_range"] # type: ignore[assignment,reportIncompatibleVariableOverride] + abnormal_condition_only: bool = GenDDBCOperationMode.model_fields[ + "abnormal_condition_only" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/ddbc/ddbc_system_description.py b/s2-python/src/s2python/ddbc/ddbc_system_description.py new file mode 100644 index 0000000..007488d --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_system_description.py @@ -0,0 +1,29 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import ( + DDBCSystemDescription as GenDDBCSystemDescription, +) +from s2python.common.number_range import NumberRange +from s2python.ddbc.ddbc_actuator_description import DDBCActuatorDescription +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCSystemDescription(GenDDBCSystemDescription, S2MessageComponent): + model_config = GenDDBCSystemDescription.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenDDBCSystemDescription.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + actuators: List[DDBCActuatorDescription] = GenDDBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "actuators" + ] # type: ignore[assignment] + present_demand_rate: NumberRange = GenDDBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "present_demand_rate" + ] # type: ignore[assignment] + provides_average_demand_rate_forecast: bool = GenDDBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "provides_average_demand_rate_forecast" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ddbc/ddbc_timer_status.py b/s2-python/src/s2python/ddbc/ddbc_timer_status.py new file mode 100644 index 0000000..2c4d95e --- /dev/null +++ b/s2-python/src/s2python/ddbc/ddbc_timer_status.py @@ -0,0 +1,18 @@ +import uuid + +from s2python.generated.gen_s2 import DDBCTimerStatus as GenDDBCTimerStatus + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class DDBCTimerStatus(GenDDBCTimerStatus, S2MessageComponent): + model_config = GenDDBCTimerStatus.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenDDBCTimerStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + timer_id: uuid.UUID = GenDDBCTimerStatus.model_fields["timer_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + actuator_id: uuid.UUID = GenDDBCTimerStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/__init__.py b/s2-python/src/s2python/frbc/__init__.py new file mode 100644 index 0000000..687684e --- /dev/null +++ b/s2-python/src/s2python/frbc/__init__.py @@ -0,0 +1,33 @@ +from s2python.frbc.frbc_fill_level_target_profile_element import FRBCFillLevelTargetProfileElement +from s2python.frbc.frbc_fill_level_target_profile import FRBCFillLevelTargetProfile +from s2python.frbc.frbc_instruction import FRBCInstruction +from s2python.frbc.frbc_leakage_behaviour_element import FRBCLeakageBehaviourElement +from s2python.frbc.frbc_leakage_behaviour import FRBCLeakageBehaviour +from s2python.frbc.frbc_usage_forecast_element import FRBCUsageForecastElement +from s2python.frbc.frbc_usage_forecast import FRBCUsageForecast +from s2python.frbc.frbc_operation_mode_element import FRBCOperationModeElement +from s2python.frbc.frbc_operation_mode import FRBCOperationMode +from s2python.frbc.frbc_actuator_description import FRBCActuatorDescription +from s2python.frbc.frbc_actuator_status import FRBCActuatorStatus +from s2python.frbc.frbc_storage_description import FRBCStorageDescription +from s2python.frbc.frbc_storage_status import FRBCStorageStatus +from s2python.frbc.frbc_system_description import FRBCSystemDescription +from s2python.frbc.frbc_timer_status import FRBCTimerStatus + +__all__ = [ + "FRBCFillLevelTargetProfileElement", + "FRBCFillLevelTargetProfile", + "FRBCInstruction", + "FRBCLeakageBehaviourElement", + "FRBCLeakageBehaviour", + "FRBCUsageForecastElement", + "FRBCUsageForecast", + "FRBCOperationModeElement", + "FRBCOperationMode", + "FRBCActuatorDescription", + "FRBCActuatorStatus", + "FRBCStorageDescription", + "FRBCStorageStatus", + "FRBCSystemDescription", + "FRBCTimerStatus", +] diff --git a/s2-python/src/s2python/frbc/frbc_actuator_description.py b/s2-python/src/s2python/frbc/frbc_actuator_description.py new file mode 100644 index 0000000..f666516 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_actuator_description.py @@ -0,0 +1,149 @@ +import uuid + +from typing import List +from typing_extensions import Self + +from pydantic import model_validator + +from s2python.common import Transition, Timer, Commodity +from s2python.common.support import commodity_has_quantity +from s2python.frbc.frbc_operation_mode import FRBCOperationMode +from s2python.generated.gen_s2 import ( + FRBCActuatorDescription as GenFRBCActuatorDescription, +) +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class FRBCActuatorDescription(GenFRBCActuatorDescription, S2MessageComponent): + model_config = GenFRBCActuatorDescription.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenFRBCActuatorDescription.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + operation_modes: List[FRBCOperationMode] = GenFRBCActuatorDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "operation_modes" + ] # type: ignore[assignment] + transitions: List[Transition] = GenFRBCActuatorDescription.model_fields["transitions"] # type: ignore[assignment,reportIncompatibleVariableOverride] + timers: List[Timer] = GenFRBCActuatorDescription.model_fields["timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] + supported_commodities: List[Commodity] = GenFRBCActuatorDescription.model_fields[ + "supported_commodities" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] + + @model_validator(mode="after") + def validate_timers_in_transitions(self) -> Self: + timers_by_id = {timer.id: timer for timer in self.timers} + transition: Transition + for transition in self.transitions: + for start_timer_id in transition.start_timers: + if start_timer_id not in timers_by_id: + raise ValueError( + self, + f"{start_timer_id} was referenced as start timer in transition " + f"{transition.id} but was not defined in 'timers'.", + ) + + for blocking_timer_id in transition.blocking_timers: + if blocking_timer_id not in timers_by_id: + raise ValueError( + self, + f"{blocking_timer_id} was referenced as blocking timer in transition " + f"{transition.id} but was not defined in 'timers'.", + ) + + return self + + @model_validator(mode="after") + def validate_timers_unique_ids(self) -> Self: + ids = [] + timer: Timer + for timer in self.timers: + if timer.id in ids: + raise ValueError( + self, f"Id {timer.id} was found multiple times in 'timers'." + ) + ids.append(timer.id) + + return self + + @model_validator(mode="after") + def validate_operation_modes_in_transitions(self) -> Self: + operation_mode_by_id = { + operation_mode.id: operation_mode for operation_mode in self.operation_modes + } + transition: Transition + for transition in self.transitions: + if transition.from_ not in operation_mode_by_id: + raise ValueError( + self, + f"Operation mode {transition.from_} was referenced as 'from' in transition " + f"{transition.id} but was not defined in 'operation_modes'.", + ) + + if transition.to not in operation_mode_by_id: + raise ValueError( + self, + f"Operation mode {transition.to} was referenced as 'to' in transition " + f"{transition.id} but was not defined in 'operation_modes'.", + ) + + return self + + @model_validator(mode="after") + def validate_operation_modes_unique_ids(self) -> Self: + ids = [] + operation_mode: FRBCOperationMode + for operation_mode in self.operation_modes: + if operation_mode.id in ids: + raise ValueError( + self, + f"Id {operation_mode.id} was found multiple times in 'operation_modes'.", + ) + ids.append(operation_mode.id) + + return self + + @model_validator(mode="after") + def validate_operation_mode_elements_have_all_supported_commodities(self) -> Self: + supported_commodities = self.supported_commodities + operation_mode: FRBCOperationMode + for operation_mode in self.operation_modes: + for operation_mode_element in operation_mode.elements: + for commodity in supported_commodities: + power_ranges_for_commodity = [ + power_range + for power_range in operation_mode_element.power_ranges + if commodity_has_quantity( + commodity, power_range.commodity_quantity + ) + ] + + if len(power_ranges_for_commodity) > 1: + raise ValueError( + self, + f"Multiple power ranges defined for commodity {commodity} in operation " + f"mode {operation_mode.id} and element with fill_level_range " + f"{operation_mode_element.fill_level_range}", + ) + if not power_ranges_for_commodity: + raise ValueError( + self, + f"No power ranges defined for commodity {commodity} in operation " + f"mode {operation_mode.id} and element with fill_level_range " + f"{operation_mode_element.fill_level_range}", + ) + return self + + @model_validator(mode="after") + def validate_unique_supported_commodities(self) -> Self: + supported_commodities: List[Commodity] = self.supported_commodities + + for supported_commodity in supported_commodities: + if supported_commodities.count(supported_commodity) > 1: + raise ValueError( + self, + f"Found duplicate {supported_commodity} commodity in 'supported_commodities'", + ) + return self diff --git a/s2-python/src/s2python/frbc/frbc_actuator_status.py b/s2-python/src/s2python/frbc/frbc_actuator_status.py new file mode 100644 index 0000000..ecf3b15 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_actuator_status.py @@ -0,0 +1,23 @@ +from typing import Optional +import uuid + +from s2python.generated.gen_s2 import FRBCActuatorStatus as GenFRBCActuatorStatus +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCActuatorStatus(GenFRBCActuatorStatus, S2MessageComponent): + model_config = GenFRBCActuatorStatus.model_config + model_config["validate_assignment"] = True + + active_operation_mode_id: uuid.UUID = GenFRBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "active_operation_mode_id" + ] # type: ignore[assignment] + actuator_id: uuid.UUID = GenFRBCActuatorStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + message_id: uuid.UUID = GenFRBCActuatorStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + previous_operation_mode_id: Optional[uuid.UUID] = GenFRBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "previous_operation_mode_id" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py b/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py new file mode 100644 index 0000000..f98b33a --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py @@ -0,0 +1,24 @@ +from typing import List +import uuid + +from s2python.frbc.frbc_fill_level_target_profile_element import ( + FRBCFillLevelTargetProfileElement, +) +from s2python.generated.gen_s2 import ( + FRBCFillLevelTargetProfile as GenFRBCFillLevelTargetProfile, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCFillLevelTargetProfile(GenFRBCFillLevelTargetProfile, S2MessageComponent): + model_config = GenFRBCFillLevelTargetProfile.model_config + model_config["validate_assignment"] = True + + elements: List[FRBCFillLevelTargetProfileElement] = GenFRBCFillLevelTargetProfile.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "elements" + ] # type: ignore[assignment] + message_id: uuid.UUID = GenFRBCFillLevelTargetProfile.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py b/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py new file mode 100644 index 0000000..3183745 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py @@ -0,0 +1,35 @@ +# pylint: disable=duplicate-code + +from typing_extensions import Self + +from pydantic import model_validator + +from s2python.common import Duration, NumberRange +from s2python.generated.gen_s2 import ( + FRBCFillLevelTargetProfileElement as GenFRBCFillLevelTargetProfileElement, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCFillLevelTargetProfileElement(GenFRBCFillLevelTargetProfileElement, S2MessageComponent): + model_config = GenFRBCFillLevelTargetProfileElement.model_config + model_config["validate_assignment"] = True + + duration: Duration = GenFRBCFillLevelTargetProfileElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] + fill_level_range: NumberRange = GenFRBCFillLevelTargetProfileElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "fill_level_range" + ] # type: ignore[assignment] + + @model_validator(mode="after") + def validate_start_end_order(self) -> Self: + if self.fill_level_range.start_of_range > self.fill_level_range.end_of_range: + raise ValueError( + self, + "start_of_range should not be higher than end_of_range for the fill_level_range", + ) + + return self diff --git a/s2-python/src/s2python/frbc/frbc_instruction.py b/s2-python/src/s2python/frbc/frbc_instruction.py new file mode 100644 index 0000000..a7693da --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_instruction.py @@ -0,0 +1,18 @@ +import uuid + +from s2python.generated.gen_s2 import FRBCInstruction as GenFRBCInstruction +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCInstruction(GenFRBCInstruction, S2MessageComponent): + model_config = GenFRBCInstruction.model_config + model_config["validate_assignment"] = True + + actuator_id: uuid.UUID = GenFRBCInstruction.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + id: uuid.UUID = GenFRBCInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + message_id: uuid.UUID = GenFRBCInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + operation_mode: uuid.UUID = GenFRBCInstruction.model_fields["operation_mode"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py b/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py new file mode 100644 index 0000000..98c44a3 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py @@ -0,0 +1,20 @@ +from typing import List +import uuid + +from s2python.frbc.frbc_leakage_behaviour_element import FRBCLeakageBehaviourElement +from s2python.generated.gen_s2 import FRBCLeakageBehaviour as GenFRBCLeakageBehaviour +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCLeakageBehaviour(GenFRBCLeakageBehaviour, S2MessageComponent): + model_config = GenFRBCLeakageBehaviour.model_config + model_config["validate_assignment"] = True + + elements: List[FRBCLeakageBehaviourElement] = GenFRBCLeakageBehaviour.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "elements" + ] # type: ignore[assignment] + message_id: uuid.UUID = GenFRBCLeakageBehaviour.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py b/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py new file mode 100644 index 0000000..594c594 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py @@ -0,0 +1,33 @@ +# pylint: disable=duplicate-code + +from pydantic import model_validator +from typing_extensions import Self + +from s2python.common import NumberRange +from s2python.generated.gen_s2 import ( + FRBCLeakageBehaviourElement as GenFRBCLeakageBehaviourElement, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCLeakageBehaviourElement(GenFRBCLeakageBehaviourElement, S2MessageComponent): + model_config = GenFRBCLeakageBehaviourElement.model_config + model_config["validate_assignment"] = True + + fill_level_range: NumberRange = GenFRBCLeakageBehaviourElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "fill_level_range" + ] # type: ignore[assignment] + + @model_validator(mode="after") + def validate_start_end_order(self) -> Self: + if self.fill_level_range.start_of_range > self.fill_level_range.end_of_range: + raise ValueError( + self, + "start_of_range should not be higher than end_of_range for the fill_level_range", + ) + + return self diff --git a/s2-python/src/s2python/frbc/frbc_operation_mode.py b/s2-python/src/s2python/frbc/frbc_operation_mode.py new file mode 100644 index 0000000..cbee3d4 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_operation_mode.py @@ -0,0 +1,49 @@ +# from itertools import pairwise +import uuid +from typing import List, Dict +from typing_extensions import Self + +from pydantic import model_validator + +from s2python.common import NumberRange +from s2python.frbc.frbc_operation_mode_element import FRBCOperationModeElement +from s2python.generated.gen_s2 import FRBCOperationMode as GenFRBCOperationMode +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) +from s2python.utils import pairwise + + +@catch_and_convert_exceptions +class FRBCOperationMode(GenFRBCOperationMode, S2MessageComponent): + model_config = GenFRBCOperationMode.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenFRBCOperationMode.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + elements: List[FRBCOperationModeElement] = GenFRBCOperationMode.model_fields["elements"] # type: ignore[assignment,reportIncompatibleVariableOverride] + + @model_validator(mode="after") + def validate_contiguous_fill_levels_operation_mode_elements(self) -> Self: + elements_by_fill_level_range: Dict[NumberRange, FRBCOperationModeElement] + elements_by_fill_level_range = { + element.fill_level_range: element for element in self.elements + } + + sorted_fill_level_ranges: List[NumberRange] + sorted_fill_level_ranges = list(elements_by_fill_level_range.keys()) + sorted_fill_level_ranges.sort(key=lambda r: r.start_of_range) + + for current_fill_level_range, next_fill_level_range in pairwise( + sorted_fill_level_ranges + ): + if ( + current_fill_level_range.end_of_range + != next_fill_level_range.start_of_range + ): + raise ValueError( + self, + f"Elements with fill level ranges {current_fill_level_range} and " + f"{next_fill_level_range} are closest match to each other but not contiguous.", + ) + return self diff --git a/s2-python/src/s2python/frbc/frbc_operation_mode_element.py b/s2-python/src/s2python/frbc/frbc_operation_mode_element.py new file mode 100644 index 0000000..e22ddb7 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_operation_mode_element.py @@ -0,0 +1,27 @@ +from typing import Optional, List + +from s2python.common import NumberRange, PowerRange +from s2python.generated.gen_s2 import ( + FRBCOperationModeElement as GenFRBCOperationModeElement, +) +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class FRBCOperationModeElement(GenFRBCOperationModeElement, S2MessageComponent): + model_config = GenFRBCOperationModeElement.model_config + model_config["validate_assignment"] = True + + fill_level_range: NumberRange = GenFRBCOperationModeElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "fill_level_range" + ] # type: ignore[assignment] + fill_rate: NumberRange = GenFRBCOperationModeElement.model_fields["fill_rate"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_ranges: List[PowerRange] = GenFRBCOperationModeElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_ranges" + ] # type: ignore[assignment] + running_costs: Optional[NumberRange] = GenFRBCOperationModeElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "running_costs" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/frbc/frbc_storage_description.py b/s2-python/src/s2python/frbc/frbc_storage_description.py new file mode 100644 index 0000000..7672d85 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_storage_description.py @@ -0,0 +1,18 @@ +from s2python.common import NumberRange +from s2python.generated.gen_s2 import ( + FRBCStorageDescription as GenFRBCStorageDescription, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCStorageDescription(GenFRBCStorageDescription, S2MessageComponent): + model_config = GenFRBCStorageDescription.model_config + model_config["validate_assignment"] = True + + fill_level_range: NumberRange = GenFRBCStorageDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "fill_level_range" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/frbc/frbc_storage_status.py b/s2-python/src/s2python/frbc/frbc_storage_status.py new file mode 100644 index 0000000..04ade4c --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_storage_status.py @@ -0,0 +1,15 @@ +import uuid + +from s2python.generated.gen_s2 import FRBCStorageStatus as GenFRBCStorageStatus +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCStorageStatus(GenFRBCStorageStatus, S2MessageComponent): + model_config = GenFRBCStorageStatus.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenFRBCStorageStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_system_description.py b/s2-python/src/s2python/frbc/frbc_system_description.py new file mode 100644 index 0000000..f1efdf8 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_system_description.py @@ -0,0 +1,22 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import FRBCSystemDescription as GenFRBCSystemDescription +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) +from s2python.frbc.frbc_actuator_description import FRBCActuatorDescription +from s2python.frbc.frbc_storage_description import FRBCStorageDescription + + +@catch_and_convert_exceptions +class FRBCSystemDescription(GenFRBCSystemDescription, S2MessageComponent): + model_config = GenFRBCSystemDescription.model_config + model_config["validate_assignment"] = True + + actuators: List[FRBCActuatorDescription] = GenFRBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "actuators" + ] # type: ignore[assignment] + message_id: uuid.UUID = GenFRBCSystemDescription.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + storage: FRBCStorageDescription = GenFRBCSystemDescription.model_fields["storage"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_timer_status.py b/s2-python/src/s2python/frbc/frbc_timer_status.py new file mode 100644 index 0000000..b374750 --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_timer_status.py @@ -0,0 +1,17 @@ +import uuid + +from s2python.generated.gen_s2 import FRBCTimerStatus as GenFRBCTimerStatus +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCTimerStatus(GenFRBCTimerStatus, S2MessageComponent): + model_config = GenFRBCTimerStatus.model_config + model_config["validate_assignment"] = True + + actuator_id: uuid.UUID = GenFRBCTimerStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + message_id: uuid.UUID = GenFRBCTimerStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + timer_id: uuid.UUID = GenFRBCTimerStatus.model_fields["timer_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_usage_forecast.py b/s2-python/src/s2python/frbc/frbc_usage_forecast.py new file mode 100644 index 0000000..777d7cb --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_usage_forecast.py @@ -0,0 +1,18 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import FRBCUsageForecast as GenFRBCUsageForecast +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) +from s2python.frbc.frbc_usage_forecast_element import FRBCUsageForecastElement + + +@catch_and_convert_exceptions +class FRBCUsageForecast(GenFRBCUsageForecast, S2MessageComponent): + model_config = GenFRBCUsageForecast.model_config + model_config["validate_assignment"] = True + + elements: List[FRBCUsageForecastElement] = GenFRBCUsageForecast.model_fields["elements"] # type: ignore[assignment,reportIncompatibleVariableOverride] + message_id: uuid.UUID = GenFRBCUsageForecast.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py b/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py new file mode 100644 index 0000000..c81467a --- /dev/null +++ b/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py @@ -0,0 +1,17 @@ +from s2python.common import Duration + +from s2python.generated.gen_s2 import ( + FRBCUsageForecastElement as GenFRBCUsageForecastElement, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class FRBCUsageForecastElement(GenFRBCUsageForecastElement, S2MessageComponent): + model_config = GenFRBCUsageForecastElement.model_config + model_config["validate_assignment"] = True + + duration: Duration = GenFRBCUsageForecastElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/frbc/rm.py b/s2-python/src/s2python/frbc/rm.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-python/src/s2python/generated/__init__.py b/s2-python/src/s2python/generated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-python/src/s2python/generated/gen_s2.py b/s2-python/src/s2python/generated/gen_s2.py new file mode 100644 index 0000000..f09a9f6 --- /dev/null +++ b/s2-python/src/s2python/generated/gen_s2.py @@ -0,0 +1,1575 @@ +# generated by datamodel-codegen: +# filename: openapi.yml +# timestamp: 2024-07-29T10:18:52+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import List, Optional + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + RootModel, + conint, + constr, +) +from typing_extensions import Literal + + +class Duration(RootModel[conint(ge=0)]): + root: conint(ge=0) = Field( # pyright: ignore[reportInvalidTypeForm] + ..., description="Duration in milliseconds" + ) + + +class ID(RootModel[constr(pattern=r"[a-zA-Z0-9\-_:]{2,64}")]): + root: constr(pattern=r"[a-zA-Z0-9\-_:]{2,64}") = ( # pyright: ignore[reportInvalidTypeForm] + Field(..., description="UUID") + ) + + +class Currency(Enum): + AED = "AED" + ANG = "ANG" + AUD = "AUD" + CHE = "CHE" + CHF = "CHF" + CHW = "CHW" + EUR = "EUR" + GBP = "GBP" + LBP = "LBP" + LKR = "LKR" + LRD = "LRD" + LSL = "LSL" + LYD = "LYD" + MAD = "MAD" + MDL = "MDL" + MGA = "MGA" + MKD = "MKD" + MMK = "MMK" + MNT = "MNT" + MOP = "MOP" + MRO = "MRO" + MUR = "MUR" + MVR = "MVR" + MWK = "MWK" + MXN = "MXN" + MXV = "MXV" + MYR = "MYR" + MZN = "MZN" + NAD = "NAD" + NGN = "NGN" + NIO = "NIO" + NOK = "NOK" + NPR = "NPR" + NZD = "NZD" + OMR = "OMR" + PAB = "PAB" + PEN = "PEN" + PGK = "PGK" + PHP = "PHP" + PKR = "PKR" + PLN = "PLN" + PYG = "PYG" + QAR = "QAR" + RON = "RON" + RSD = "RSD" + RUB = "RUB" + RWF = "RWF" + SAR = "SAR" + SBD = "SBD" + SCR = "SCR" + SDG = "SDG" + SEK = "SEK" + SGD = "SGD" + SHP = "SHP" + SLL = "SLL" + SOS = "SOS" + SRD = "SRD" + SSP = "SSP" + STD = "STD" + SYP = "SYP" + SZL = "SZL" + THB = "THB" + TJS = "TJS" + TMT = "TMT" + TND = "TND" + TOP = "TOP" + TRY = "TRY" + TTD = "TTD" + TWD = "TWD" + TZS = "TZS" + UAH = "UAH" + UGX = "UGX" + USD = "USD" + USN = "USN" + UYI = "UYI" + UYU = "UYU" + UZS = "UZS" + VEF = "VEF" + VND = "VND" + VUV = "VUV" + WST = "WST" + XAG = "XAG" + XAU = "XAU" + XBA = "XBA" + XBB = "XBB" + XBC = "XBC" + XBD = "XBD" + XCD = "XCD" + XOF = "XOF" + XPD = "XPD" + XPF = "XPF" + XPT = "XPT" + XSU = "XSU" + XTS = "XTS" + XUA = "XUA" + XXX = "XXX" + YER = "YER" + ZAR = "ZAR" + ZMW = "ZMW" + ZWL = "ZWL" + + +class SessionRequestType(Enum): + RECONNECT = "RECONNECT" + TERMINATE = "TERMINATE" + + +class RevokableObjects(Enum): + PEBC_PowerConstraints = "PEBC.PowerConstraints" + PEBC_EnergyConstraint = "PEBC.EnergyConstraint" + PEBC_Instruction = "PEBC.Instruction" + PPBC_PowerProfileDefinition = "PPBC.PowerProfileDefinition" + PPBC_ScheduleInstruction = "PPBC.ScheduleInstruction" + PPBC_StartInterruptionInstruction = "PPBC.StartInterruptionInstruction" + PPBC_EndInterruptionInstruction = "PPBC.EndInterruptionInstruction" + OMBC_SystemDescription = "OMBC.SystemDescription" + OMBC_Instruction = "OMBC.Instruction" + FRBC_SystemDescription = "FRBC.SystemDescription" + FRBC_Instruction = "FRBC.Instruction" + DDBC_SystemDescription = "DDBC.SystemDescription" + DDBC_Instruction = "DDBC.Instruction" + + +class EnergyManagementRole(Enum): + CEM = "CEM" + RM = "RM" + + +class ReceptionStatusValues(Enum): + INVALID_DATA = "INVALID_DATA" + INVALID_MESSAGE = "INVALID_MESSAGE" + INVALID_CONTENT = "INVALID_CONTENT" + TEMPORARY_ERROR = "TEMPORARY_ERROR" + PERMANENT_ERROR = "PERMANENT_ERROR" + OK = "OK" + + +class NumberRange(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + start_of_range: float = Field(..., description="Number that defines the start of the range") + end_of_range: float = Field(..., description="Number that defines the end of the range") + + +class Transition(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the Transition. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used.", + ) + from_: ID = Field( + ..., + alias="from", + description="ID of the OperationMode (exact type differs per ControlType) that should be switched from.", + ) + to: ID = Field( + ..., + description="ID of the OperationMode (exact type differs per ControlType) that will be switched to.", + ) + start_timers: List[ID] = Field( + ..., + description="List of IDs of Timers that will be (re)started when this transition is initiated", + max_length=1000, + min_length=0, + ) + blocking_timers: List[ID] = Field( + ..., + description="List of IDs of Timers that block this Transition from initiating while at least one of these Timers is not yet finished", + max_length=1000, + min_length=0, + ) + transition_costs: Optional[float] = Field( + None, + description="Absolute costs for going through this Transition in the currency as described in the ResourceManagerDetails.", + ) + transition_duration: Optional[Duration] = Field( + None, + description="Indicates the time between the initiation of this Transition, and the time at which the device behaves according to the Operation Mode which is defined in the ‘to’ data element. When no value is provided it is assumed the transition duration is negligible.", + ) + abnormal_condition_only: bool = Field( + ..., + description="Indicates if this Transition may only be used during an abnormal condition (see Clause )", + ) + + +class Timer(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the Timer. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used.", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description of the Timer. This element is only intended for diagnostic purposes and not for HMI applications.", + ) + duration: Duration = Field( + ..., + description="The time it takes for the Timer to finish after it has been started", + ) + + +class PEBCPowerEnvelopeElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + duration: Duration = Field(..., description="The duration of the element") + upper_limit: float = Field( + ..., + description="Upper power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or below the upper_limit. The upper_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type UPPER_LIMIT.", + ) + lower_limit: float = Field( + ..., + description="Lower power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or above the lower_limit. The lower_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type LOWER_LIMIT.", + ) + + +class FRBCStorageDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description of the storage (e.g. hot water buffer or battery). This element is only intended for diagnostic purposes and not for HMI applications.", + ) + fill_level_label: Optional[str] = Field( + None, + description="Human readable description of the (physical) units associated with the fill_level (e.g. degrees Celsius or percentage state of charge). This element is only intended for diagnostic purposes and not for HMI applications.", + ) + provides_leakage_behaviour: bool = Field( + ..., + description="Indicates whether the Storage could provide details of power leakage behaviour through the FRBC.LeakageBehaviour.", + ) + provides_fill_level_target_profile: bool = Field( + ..., + description="Indicates whether the Storage could provide a target profile for the fill level through the FRBC.FillLevelTargetProfile.", + ) + provides_usage_forecast: bool = Field( + ..., + description="Indicates whether the Storage could provide a UsageForecast through the FRBC.UsageForecast.", + ) + fill_level_range: NumberRange = Field( + ..., + description="The range in which the fill_level should remain. It is expected of the CEM to keep the fill_level within this range. When the fill_level is not within this range, the Resource Manager can ignore instructions from the CEM (except during abnormal conditions). ", + ) + + +class FRBCLeakageBehaviourElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + fill_level_range: NumberRange = Field( + ..., + description="The fill level range for which this FRBC.LeakageBehaviourElement applies. The start of the range must be less than the end of the range.", + ) + leakage_rate: float = Field( + ..., + description="Indicates how fast the momentary fill level will decrease per second due to leakage within the given range of the fill level. A positive value indicates that the fill level decreases over time due to leakage.", + ) + + +class FRBCUsageForecastElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + duration: Duration = Field( + ..., description="Indicator for how long the given usage_rate is valid." + ) + usage_rate_upper_limit: Optional[float] = Field( + None, + description="The upper limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", + ) + usage_rate_upper_95PPR: Optional[float] = Field( + None, + description="The upper limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", + ) + usage_rate_upper_68PPR: Optional[float] = Field( + None, + description="The upper limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", + ) + usage_rate_expected: float = Field( + ..., + description="The most likely value for the usage rate; the expected increase or decrease of the fill_level per second. A positive value indicates that the fill level will decrease due to usage.", + ) + usage_rate_lower_68PPR: Optional[float] = Field( + None, + description="The lower limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", + ) + usage_rate_lower_95PPR: Optional[float] = Field( + None, + description="The lower limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", + ) + usage_rate_lower_limit: Optional[float] = Field( + None, + description="The lower limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", + ) + + +class FRBCFillLevelTargetProfileElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + duration: Duration = Field(..., description="The duration of the element.") + fill_level_range: NumberRange = Field( + ..., + description="The target range in which the fill_level must be for the time period during which the element is active. The start of the range must be smaller or equal to the end of the range. The CEM must take best-effort actions to proactively achieve this target.", + ) + + +class DDBCAverageDemandRateForecastElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + duration: Duration = Field(..., description="Duration of the element") + demand_rate_upper_limit: Optional[float] = Field( + None, + description="The upper limit of the range with a 100 % probability that the demand rate is within that range", + ) + demand_rate_upper_95PPR: Optional[float] = Field( + None, + description="The upper limit of the range with a 95 % probability that the demand rate is within that range", + ) + demand_rate_upper_68PPR: Optional[float] = Field( + None, + description="The upper limit of the range with a 68 % probability that the demand rate is within that range", + ) + demand_rate_expected: float = Field( + ..., + description="The most likely value for the demand rate; the expected increase or decrease of the fill_level per second", + ) + demand_rate_lower_68PPR: Optional[float] = Field( + None, + description="The lower limit of the range with a 68 % probability that the demand rate is within that range", + ) + demand_rate_lower_95PPR: Optional[float] = Field( + None, + description="The lower limit of the range with a 95 % probability that the demand rate is within that range", + ) + demand_rate_lower_limit: Optional[float] = Field( + None, + description="The lower limit of the range with a 100 % probability that the demand rate is within that range", + ) + + +class RoleType(Enum): + ENERGY_PRODUCER = "ENERGY_PRODUCER" + ENERGY_CONSUMER = "ENERGY_CONSUMER" + ENERGY_STORAGE = "ENERGY_STORAGE" + + +class Commodity(Enum): + GAS = "GAS" + HEAT = "HEAT" + ELECTRICITY = "ELECTRICITY" + OIL = "OIL" + + +class CommodityQuantity(Enum): + ELECTRIC_POWER_L1 = "ELECTRIC.POWER.L1" + ELECTRIC_POWER_L2 = "ELECTRIC.POWER.L2" + ELECTRIC_POWER_L3 = "ELECTRIC.POWER.L3" + ELECTRIC_POWER_3_PHASE_SYMMETRIC = "ELECTRIC.POWER.3_PHASE_SYMMETRIC" + NATURAL_GAS_FLOW_RATE = "NATURAL_GAS.FLOW_RATE" + HYDROGEN_FLOW_RATE = "HYDROGEN.FLOW_RATE" + HEAT_TEMPERATURE = "HEAT.TEMPERATURE" + HEAT_FLOW_RATE = "HEAT.FLOW_RATE" + HEAT_THERMAL_POWER = "HEAT.THERMAL_POWER" + OIL_FLOW_RATE = "OIL.FLOW_RATE" + + +class InstructionStatus(Enum): + NEW = "NEW" + ACCEPTED = "ACCEPTED" + REJECTED = "REJECTED" + REVOKED = "REVOKED" + STARTED = "STARTED" + SUCCEEDED = "SUCCEEDED" + ABORTED = "ABORTED" + + +class ControlType(Enum): + POWER_ENVELOPE_BASED_CONTROL = "POWER_ENVELOPE_BASED_CONTROL" + POWER_PROFILE_BASED_CONTROL = "POWER_PROFILE_BASED_CONTROL" + OPERATION_MODE_BASED_CONTROL = "OPERATION_MODE_BASED_CONTROL" + FILL_RATE_BASED_CONTROL = "FILL_RATE_BASED_CONTROL" + DEMAND_DRIVEN_BASED_CONTROL = "DEMAND_DRIVEN_BASED_CONTROL" + NOT_CONTROLABLE = "NOT_CONTROLABLE" + NO_SELECTION = "NO_SELECTION" + + +class PEBCPowerEnvelopeLimitType(Enum): + UPPER_LIMIT = "UPPER_LIMIT" + LOWER_LIMIT = "LOWER_LIMIT" + + +class PEBCPowerEnvelopeConsequenceType(Enum): + VANISH = "VANISH" + DEFER = "DEFER" + + +class PPBCPowerSequenceStatus(Enum): + NOT_SCHEDULED = "NOT_SCHEDULED" + SCHEDULED = "SCHEDULED" + EXECUTING = "EXECUTING" + INTERRUPTED = "INTERRUPTED" + FINISHED = "FINISHED" + ABORTED = "ABORTED" + + +class OMBCTimerStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["OMBC.TimerStatus"] = "OMBC.TimerStatus" + message_id: ID + timer_id: ID = Field(..., description="The ID of the timer this message refers to") + finished_at: AwareDatetime = Field( + ..., + description="Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past.", + ) + + +class FRBCTimerStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.TimerStatus"] = "FRBC.TimerStatus" + message_id: ID + timer_id: ID = Field(..., description="The ID of the timer this message refers to") + actuator_id: ID = Field(..., description="The ID of the actuator the timer belongs to") + finished_at: AwareDatetime = Field( + ..., + description="Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past.", + ) + + +class DDBCTimerStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["DDBC.TimerStatus"] = "DDBC.TimerStatus" + message_id: ID + timer_id: ID = Field(..., description="The ID of the timer this message refers to") + actuator_id: ID = Field(..., description="The ID of the actuator the timer belongs to") + finished_at: AwareDatetime = Field( + ..., + description="Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past.", + ) + + +class SelectControlType(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["SelectControlType"] = "SelectControlType" + message_id: ID + control_type: ControlType = Field( + ..., + description="The ControlType to activate. Must be one of the available ControlTypes as defined in the ResourceManagerDetails", + ) + + +class SessionRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["SessionRequest"] = "SessionRequest" + message_id: ID + request: SessionRequestType = Field(..., description="The type of request") + diagnostic_label: Optional[str] = Field( + None, + description="Optional field for a human readible descirption for debugging purposes", + ) + + +class RevokeObject(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["RevokeObject"] = "RevokeObject" + message_id: ID + object_type: RevokableObjects = Field( + ..., description="The type of object that needs to be revoked" + ) + object_id: ID = Field(..., description="The ID of object that needs to be revoked") + + +class Handshake(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["Handshake"] = "Handshake" + message_id: ID + role: EnergyManagementRole = Field(..., description="The role of the sender of this message") + supported_protocol_versions: Optional[List[str]] = Field( + None, + description="Protocol versions supported by the sender of this message. This field is mandatory for the RM, but optional for the CEM.", + min_length=1, + ) + + +class HandshakeResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["HandshakeResponse"] = "HandshakeResponse" + message_id: ID + selected_protocol_version: str = Field( + ..., description="The protocol version the CEM selected for this session" + ) + + +class ReceptionStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["ReceptionStatus"] = "ReceptionStatus" + subject_message_id: ID = Field(..., description="The message this ReceptionStatus refers to") + status: ReceptionStatusValues = Field(..., description="Enumeration of status values") + diagnostic_label: Optional[str] = Field( + None, + description="Diagnostic label that can be used to provide additional information for debugging. However, not for HMI purposes.", + ) + + +class InstructionStatusUpdate(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["InstructionStatusUpdate"] = "InstructionStatusUpdate" + message_id: ID + instruction_id: ID = Field(..., description="ID of this instruction (as provided by the CEM) ") + status_type: InstructionStatus = Field(..., description="Present status of this instruction.") + timestamp: AwareDatetime = Field( + ..., description="Timestamp when status_type has changed the last time." + ) + + +class PEBCEnergyConstraint(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PEBC.EnergyConstraint"] = "PEBC.EnergyConstraint" + message_id: ID + id: ID = Field( + ..., + description="Identifier of this PEBC.EnergyConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + valid_from: AwareDatetime = Field( + ..., + description="Moment this PEBC.EnergyConstraints information starts to be valid", + ) + valid_until: AwareDatetime = Field( + ..., + description="Moment until this PEBC.EnergyConstraints information is valid.", + ) + upper_average_power: float = Field( + ..., + description="Upper average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated upper energy content can be derived. This is the highest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy consumption (in case the number is positive). Must be greater than or equal to lower_average_power, and can be negative in case of energy production.", + ) + lower_average_power: float = Field( + ..., + description="Lower average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated lower energy content can be derived. This is the lowest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy production (in case the number is negative). Must be greater than or equal to lower_average_power, and can be negative in case of energy production.", + ) + commodity_quantity: CommodityQuantity = Field( + ..., + description="Type of power quantity which applies to upper_average_power and lower_average_power", + ) + + +class PPBCScheduleInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PPBC.ScheduleInstruction"] = "PPBC.ScheduleInstruction" + message_id: ID + id: ID = Field( + ..., + description="ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + power_profile_id: ID = Field( + ..., + description="ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being selected and scheduled by the CEM.", + ) + sequence_container_id: ID = Field( + ..., + description="ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being selected and scheduled by the CEM.", + ) + power_sequence_id: ID = Field( + ..., + description="ID of the PPBC.PowerSequence that is being selected and scheduled by the CEM.", + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment the PPBC.PowerSequence shall start. When the specified execution time is in the past, execution must start as soon as possible.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition", + ) + + +class PPBCStartInterruptionInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PPBC.StartInterruptionInstruction"] = "PPBC.StartInterruptionInstruction" + message_id: ID + id: ID = Field( + ..., + description="ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + power_profile_id: ID = Field( + ..., + description="ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being interrupted by the CEM.", + ) + sequence_container_id: ID = Field( + ..., + description="ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being interrupted by the CEM.", + ) + power_sequence_id: ID = Field( + ..., description="ID of the PPBC.PowerSequence that the CEM wants to interrupt." + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment the PPBC.PowerSequence shall be interrupted. When the specified execution time is in the past, execution must start as soon as possible.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition", + ) + + +class PPBCEndInterruptionInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PPBC.EndInterruptionInstruction"] = "PPBC.EndInterruptionInstruction" + message_id: ID + id: ID = Field( + ..., + description="ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + power_profile_id: ID = Field( + ..., + description="ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence interruption is being ended by the CEM.", + ) + sequence_container_id: ID = Field( + ..., + description="ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence interruption is being ended by the CEM.", + ) + power_sequence_id: ID = Field( + ..., + description="ID of the PPBC.PowerSequence for which the CEM wants to end the interruption.", + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment PPBC.PowerSequence interruption shall end. When the specified execution time is in the past, execution must start as soon as possible.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition", + ) + + +class OMBCStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["OMBC.Status"] = "OMBC.Status" + message_id: ID + active_operation_mode_id: ID = Field(..., description="ID of the active OMBC.OperationMode.") + operation_mode_factor: float = Field( + ..., + description="The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1.", + ) + previous_operation_mode_id: Optional[ID] = Field( + None, + description="ID of the OMBC.OperationMode that was previously active. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of.", + ) + transition_timestamp: Optional[AwareDatetime] = Field( + None, + description="Time at which the transition from the previous OMBC.OperationMode to the active OMBC.OperationMode was initiated. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of.", + ) + + +class OMBCInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["OMBC.Instruction"] = "OMBC.Instruction" + message_id: ID + id: ID = Field( + ..., + description="ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", + ) + operation_mode_id: ID = Field( + ..., description="ID of the OMBC.OperationMode that should be activated" + ) + operation_mode_factor: float = Field( + ..., + description="The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition", + ) + + +class FRBCActuatorStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.ActuatorStatus"] = "FRBC.ActuatorStatus" + message_id: ID + actuator_id: ID = Field(..., description="ID of the actuator this messages refers to") + active_operation_mode_id: ID = Field( + ..., description="ID of the FRBC.OperationMode that is presently active." + ) + operation_mode_factor: float = Field( + ..., + description="The number indicates the factor with which the FRBC.OperationMode is configured. The factor should be greater than or equal than 0 and less or equal to 1.", + ) + previous_operation_mode_id: Optional[ID] = Field( + None, + description="ID of the FRBC.OperationMode that was active before the present one. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of.", + ) + transition_timestamp: Optional[AwareDatetime] = Field( + None, + description="Time at which the transition from the previous FRBC.OperationMode to the active FRBC.OperationMode was initiated. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of.", + ) + + +class FRBCStorageStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.StorageStatus"] = "FRBC.StorageStatus" + message_id: ID + present_fill_level: float = Field(..., description="Present fill level of the Storage") + + +class FRBCLeakageBehaviour(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.LeakageBehaviour"] = "FRBC.LeakageBehaviour" + message_id: ID + valid_from: AwareDatetime = Field( + ..., + description="Moment this FRBC.LeakageBehaviour starts to be valid. If the FRBC.LeakageBehaviour is immediately valid, the DateTimeStamp should be now or in the past.", + ) + elements: List[FRBCLeakageBehaviourElement] = Field( + ..., + description="List of elements that model the leakage behaviour of the buffer. The fill_level_ranges of the elements must be contiguous.", + max_length=288, + min_length=1, + ) + + +class FRBCInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.Instruction"] = "FRBC.Instruction" + message_id: ID + id: ID = Field( + ..., + description="ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + actuator_id: ID = Field(..., description="ID of the actuator this instruction belongs to.") + operation_mode: ID = Field( + ..., description="ID of the FRBC.OperationMode that should be activated." + ) + operation_mode_factor: float = Field( + ..., + description="The number indicates the factor with which the FRBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1.", + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition.", + ) + + +class FRBCUsageForecast(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.UsageForecast"] = "FRBC.UsageForecast" + message_id: ID + start_time: AwareDatetime = Field( + ..., description="Time at which the FRBC.UsageForecast starts." + ) + elements: List[FRBCUsageForecastElement] = Field( + ..., + description="Further elements that model the profile. There shall be at least one element. Elements must be placed in chronological order.", + max_length=288, + min_length=1, + ) + + +class FRBCFillLevelTargetProfile(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.FillLevelTargetProfile"] = "FRBC.FillLevelTargetProfile" + message_id: ID + start_time: AwareDatetime = Field( + ..., description="Time at which the FRBC.FillLevelTargetProfile starts." + ) + elements: List[FRBCFillLevelTargetProfileElement] = Field( + ..., + description="List of different fill levels that have to be targeted within a given duration. There shall be at least one element. Elements must be placed in chronological order.", + max_length=288, + min_length=1, + ) + + +class DDBCActuatorStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["DDBC.ActuatorStatus"] = "DDBC.ActuatorStatus" + message_id: ID + actuator_id: ID = Field(..., description="ID of the actuator this messages refers to") + active_operation_mode_id: ID = Field( + ..., + description="The operation mode that is presently active for this actuator.", + ) + operation_mode_factor: float = Field( + ..., + description="The number indicates the factor with which the DDBC.OperationMode is configured. The factor should be greater than or equal to 0 and less or equal to 1.", + ) + previous_operation_mode_id: Optional[ID] = Field( + None, + description="ID of the DDBC,OperationMode that was active before the present one. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of.", + ) + transition_timestamp: Optional[AwareDatetime] = Field( + None, + description="Time at which the transition from the previous DDBC.OperationMode to the active DDBC.OperationMode was initiated. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of.", + ) + + +class DDBCInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["DDBC.Instruction"] = "DDBC.Instruction" + message_id: ID + id: ID = Field( + ..., + description="Identifier of this DDBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition", + ) + actuator_id: ID = Field(..., description="ID of the actuator this Instruction belongs to.") + operation_mode_id: ID = Field(..., description="ID of the DDBC.OperationMode") + operation_mode_factor: float = Field( + ..., + description="The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1.", + ) + + +class DDBCAverageDemandRateForecast(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["DDBC.AverageDemandRateForecast"] = "DDBC.AverageDemandRateForecast" + message_id: ID + start_time: AwareDatetime = Field(..., description="Start time of the profile.") + elements: List[DDBCAverageDemandRateForecastElement] = Field( + ..., + description="Elements of the profile. Elements must be placed in chronological order.", + max_length=288, + min_length=1, + ) + + +class PowerValue(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + commodity_quantity: CommodityQuantity = Field( + ..., description="The power quantity the value refers to" + ) + value: float = Field( + ..., + description="Power value expressed in the unit associated with the CommodityQuantity", + ) + + +class PowerForecastValue(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + value_upper_limit: Optional[float] = Field( + None, + description="The upper boundary of the range with 100 % certainty the power value is in it", + ) + value_upper_95PPR: Optional[float] = Field( + None, + description="The upper boundary of the range with 95 % certainty the power value is in it", + ) + value_upper_68PPR: Optional[float] = Field( + None, + description="The upper boundary of the range with 68 % certainty the power value is in it", + ) + value_expected: float = Field(..., description="The expected power value.") + value_lower_68PPR: Optional[float] = Field( + None, + description="The lower boundary of the range with 68 % certainty the power value is in it", + ) + value_lower_95PPR: Optional[float] = Field( + None, + description="The lower boundary of the range with 95 % certainty the power value is in it", + ) + value_lower_limit: Optional[float] = Field( + None, + description="The lower boundary of the range with 100 % certainty the power value is in it", + ) + commodity_quantity: CommodityQuantity = Field( + ..., description="The power quantity the value refers to" + ) + + +class PowerRange(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + start_of_range: float = Field( + ..., description="Power value that defines the start of the range." + ) + end_of_range: float = Field(..., description="Power value that defines the end of the range.") + commodity_quantity: CommodityQuantity = Field( + ..., description="The power quantity the values refer to" + ) + + +class Role(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + role: RoleType = Field( + ..., description="Role type of the Resource Manager for the given commodity" + ) + commodity: Commodity = Field(..., description="Commodity the role refers to.") + + +class PowerForecastElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + duration: Duration = Field(..., description="Duration of the PowerForecastElement") + power_values: List[PowerForecastValue] = Field( + ..., + description="The values of power that are expected for the given period of time. There shall be at least one PowerForecastValue, and at most one PowerForecastValue per CommodityQuantity.", + max_length=10, + min_length=1, + ) + + +class PEBCAllowedLimitRange(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + commodity_quantity: CommodityQuantity = Field( + ..., description="Type of power quantity this PEBC.AllowedLimitRange applies to" + ) + limit_type: PEBCPowerEnvelopeLimitType = Field( + ..., + description="Indicates if this ranges applies to the upper limit or the lower limit", + ) + range_boundary: NumberRange = Field( + ..., + description="Boundaries of the power range of this PEBC.AllowedLimitRange. The CEM is allowed to choose values within this range for the power envelope for the limit as described in limit_type. The start of the range shall be smaller or equal than the end of the range. ", + ) + abnormal_condition_only: bool = Field( + ..., + description="Indicates if this PEBC.AllowedLimitRange may only be used during an abnormal condition", + ) + + +class PEBCPowerEnvelope(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="Identifier of this PEBC.PowerEnvelope. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + commodity_quantity: CommodityQuantity = Field( + ..., description="Type of power quantity this PEBC.PowerEnvelope applies to" + ) + power_envelope_elements: List[PEBCPowerEnvelopeElement] = Field( + ..., + description="The elements of this PEBC.PowerEnvelope. Shall contain at least one element. Elements must be placed in chronological order.", + max_length=288, + min_length=1, + ) + + +class PPBCPowerSequenceElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + duration: Duration = Field(..., description="Duration of the PPBC.PowerSequenceElement.") + power_values: List[PowerForecastValue] = Field( + ..., + description="The value of power and deviations for the given duration. The array should contain at least one PowerForecastValue and at most one PowerForecastValue per CommodityQuantity.", + max_length=10, + min_length=1, + ) + + +class PPBCPowerSequenceContainerStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + power_profile_id: ID = Field( + ..., + description="ID of the PPBC.PowerProfileDefinition of which the data element ‘sequence_container_id’ refers to. ", + ) + sequence_container_id: ID = Field( + ..., + description="ID of the PPBC.PowerSequenceContainer this PPBC.PowerSequenceContainerStatus provides information about.", + ) + selected_sequence_id: Optional[ID] = Field( + None, + description="ID of selected PPBC.PowerSequence. When no ID is given, no sequence was selected yet.", + ) + progress: Optional[Duration] = Field( + None, + description="Time that has passed since the selected sequence has started. A value must be provided, unless no sequence has been selected or the selected sequence hasn’t started yet.", + ) + status: PPBCPowerSequenceStatus = Field( + ..., description="Status of the selected PPBC.PowerSequence" + ) + + +class OMBCOperationMode(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the OBMC.OperationMode. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description of the OMBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications.", + ) + power_ranges: List[PowerRange] = Field( + ..., + description="The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity.", + max_length=10, + min_length=1, + ) + running_costs: Optional[NumberRange] = Field( + None, + description="Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails , excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor.", + ) + abnormal_condition_only: bool = Field( + ..., + description="Indicates if this OMBC.OperationMode may only be used during an abnormal condition.", + ) + + +class FRBCOperationModeElement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + fill_level_range: NumberRange = Field( + ..., + description="The range of the fill level for which this FRBC.OperationModeElement applies. The start of the NumberRange shall be smaller than the end of the NumberRange.", + ) + fill_rate: NumberRange = Field( + ..., + description="Indicates the change in fill_level per second. The lower_boundary of the NumberRange is associated with an operation_mode_factor of 0, the upper_boundary is associated with an operation_mode_factor of 1. ", + ) + power_ranges: List[PowerRange] = Field( + ..., + description="The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity.", + max_length=10, + min_length=1, + ) + running_costs: Optional[NumberRange] = Field( + None, + description="Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor.", + ) + + +class DDBCOperationMode(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + Id: ID = Field( + ..., + description="ID of this operation mode. Must be unique in the scope of the DDBC.ActuatorDescription in which it is used.", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description of the DDBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications.", + ) + power_ranges: List[PowerRange] = Field( + ..., + description="The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity.", + max_length=10, + min_length=1, + ) + supply_range: NumberRange = Field( + ..., + description="The supply rate this DDBC.OperationMode can deliver for the CEM to match the demand rate. The start of the NumberRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1.", + ) + running_costs: Optional[NumberRange] = Field( + None, + description="Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor.", + ) + abnormal_condition_only: bool = Field( + ..., + description="Indicates if this DDBC.OperationMode may only be used during an abnormal condition.", + ) + + +class ResourceManagerDetails(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["ResourceManagerDetails"] = "ResourceManagerDetails" + message_id: ID + resource_id: ID = Field( + ..., + description="Identifier of the Resource Manager. Must be unique within the scope of the CEM.", + ) + name: Optional[str] = Field(None, description="Human readable name given by user") + roles: List[Role] = Field( + ..., + description="Each Resource Manager provides one or more energy Roles", + max_length=3, + min_length=1, + ) + manufacturer: Optional[str] = Field(None, description="Name of Manufacturer") + model: Optional[str] = Field( + None, + description="Name of the model of the device (provided by the manufacturer)", + ) + serial_number: Optional[str] = Field( + None, description="Serial number of the device (provided by the manufacturer)" + ) + firmware_version: Optional[str] = Field( + None, + description="Version identifier of the firmware used in the device (provided by the manufacturer)", + ) + instruction_processing_delay: Duration = Field( + ..., + description="The average time the combination of Resource Manager and HBES/BACS/SASS or (Smart) device needs to process and execute an instruction", + ) + available_control_types: List[ControlType] = Field( + ..., + description="The control types supported by this Resource Manager.", + max_length=5, + min_length=1, + ) + currency: Optional[Currency] = Field( + None, + description="Currency to be used for all information regarding costs. Mandatory if cost information is published.", + ) + provides_forecast: bool = Field( + ..., + description="Indicates whether the ResourceManager is able to provide PowerForecasts", + ) + provides_power_measurement_types: List[CommodityQuantity] = Field( + ..., + description="Array of all CommodityQuantities that this Resource Manager can provide measurements for. ", + max_length=10, + min_length=1, + ) + + +class PowerMeasurement(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PowerMeasurement"] = "PowerMeasurement" + message_id: ID + measurement_timestamp: AwareDatetime = Field( + ..., description="Timestamp when PowerValues were measured." + ) + values: List[PowerValue] = Field( + ..., + description="Array of measured PowerValues. Must contain at least one item and at most one item per ‘commodity_quantity’ (defined inside the PowerValue).", + max_length=10, + min_length=1, + ) + + +class PowerForecast(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PowerForecast"] = "PowerForecast" + message_id: ID + start_time: AwareDatetime = Field( + ..., description="Start time of time period that is covered by the profile." + ) + elements: List[PowerForecastElement] = Field( + ..., + description="Elements of which this forecast consists. Contains at least one element. Elements must be placed in chronological order.", + max_length=288, + min_length=1, + ) + + +class PEBCPowerConstraints(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PEBC.PowerConstraints"] = "PEBC.PowerConstraints" + message_id: ID + id: ID = Field( + ..., + description="Identifier of this PEBC.PowerConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + valid_from: AwareDatetime = Field( + ..., description="Moment this PEBC.PowerConstraints start to be valid" + ) + valid_until: Optional[AwareDatetime] = Field( + None, + description="Moment until this PEBC.PowerConstraints is valid. If valid_until is not present, there is no determined end time of this PEBC.PowerConstraints.", + ) + consequence_type: PEBCPowerEnvelopeConsequenceType = Field( + ..., description="Type of consequence of limiting power" + ) + allowed_limit_ranges: List[PEBCAllowedLimitRange] = Field( + ..., + description="The actual constraints. There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT. It is allowed to have multiple PEBC.AllowedLimitRange objects with identical CommodityQuantities and LimitTypes.", + max_length=100, + min_length=2, + ) + + +class PEBCInstruction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PEBC.Instruction"] = "PEBC.Instruction" + message_id: ID + id: ID = Field( + ..., + description="Identifier of this PEBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + execution_time: AwareDatetime = Field( + ..., + description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", + ) + abnormal_condition: bool = Field( + ..., + description="Indicates if this is an instruction during an abnormal condition.", + ) + power_constraints_id: ID = Field( + ..., + description="Identifier of the PEBC.PowerConstraints this PEBC.Instruction was based on.", + ) + power_envelopes: List[PEBCPowerEnvelope] = Field( + ..., + description="The PEBC.PowerEnvelope(s) that should be followed by the Resource Manager. There shall be at least one PEBC.PowerEnvelope, but at most one PEBC.PowerEnvelope for each CommodityQuantity.", + max_length=10, + min_length=1, + ) + + +class PPBCPowerProfileStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PPBC.PowerProfileStatus"] = "PPBC.PowerProfileStatus" + message_id: ID + sequence_container_status: List[PPBCPowerSequenceContainerStatus] = Field( + ..., + description="Array with status information for all PPBC.PowerSequenceContainers in the PPBC.PowerProfileDefinition.", + max_length=1000, + min_length=1, + ) + + +class OMBCSystemDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["OMBC.SystemDescription"] = "OMBC.SystemDescription" + message_id: ID + valid_from: AwareDatetime = Field( + ..., + description="Moment this OMBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past.", + ) + operation_modes: List[OMBCOperationMode] = Field( + ..., + description="OMBC.OperationModes available for the CEM in order to coordinate the device behaviour.", + max_length=100, + min_length=1, + ) + transitions: List[Transition] = Field( + ..., + description="Possible transitions to switch from one OMBC.OperationMode to another.", + max_length=1000, + min_length=0, + ) + timers: List[Timer] = Field( + ..., + description="Timers that control when certain transitions can be made.", + max_length=1000, + min_length=0, + ) + + +class PPBCPowerSequence(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the PPBC.PowerSequence. Must be unique in the scope of the PPBC.PowerSequnceContainer in which it is used.", + ) + elements: List[PPBCPowerSequenceElement] = Field( + ..., + description="List of PPBC.PowerSequenceElements. Shall contain at least one element. Elements must be placed in chronological order.", + max_length=288, + min_length=1, + ) + is_interruptible: bool = Field( + ..., + description="Indicates whether the option of pausing a sequence is available.", + ) + max_pause_before: Optional[Duration] = Field( + None, + description="The maximum duration for which a device can be paused between the end of the previous running sequence and the start of this one", + ) + abnormal_condition_only: bool = Field( + ..., + description="Indicates if this PPBC.PowerSequence may only be used during an abnormal condition", + ) + + +class FRBCOperationMode(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the FRBC.OperationMode. Must be unique in the scope of the FRBC.ActuatorDescription in which it is used.", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description of the FRBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications.", + ) + elements: List[FRBCOperationModeElement] = Field( + ..., + description="List of FRBC.OperationModeElements, which describe the properties of this FRBC.OperationMode depending on the fill_level. The fill_level_ranges of the items in the Array must be contiguous.", + max_length=100, + min_length=1, + ) + abnormal_condition_only: bool = Field( + ..., + description="Indicates if this FRBC.OperationMode may only be used during an abnormal condition", + ) + + +class DDBCActuatorDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of this DDBC.ActuatorDescription. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description of the actuator. This element is only intended for diagnostic purposes and not for HMI applications.", + ) + supported_commodites: List[Commodity] = Field( + ..., + description="Commodities supported by the operation modes of this actuator. There shall be at least one commodity", + max_length=4, + min_length=1, + ) + operation_modes: List[DDBCOperationMode] = Field( + ..., + description="List of all Operation Modes that are available for this actuator. There shall be at least one DDBC.OperationMode.", + max_length=100, + min_length=1, + ) + transitions: List[Transition] = Field( + ..., + description="List of Transitions between Operation Modes. Shall contain at least one Transition.", + max_length=1000, + min_length=0, + ) + timers: List[Timer] = Field( + ..., + description="List of Timers associated with Transitions for this Actuator. Can be empty.", + max_length=1000, + min_length=0, + ) + + +class DDBCSystemDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["DDBC.SystemDescription"] = "DDBC.SystemDescription" + message_id: ID + valid_from: AwareDatetime = Field( + ..., + description="Moment this DDBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past.", + ) + actuators: List[DDBCActuatorDescription] = Field( + ..., + description="List of all available actuators in the system. Must contain at least one DDBC.ActuatorAggregated.", + max_length=10, + min_length=1, + ) + present_demand_rate: NumberRange = Field( + ..., description="Present demand rate that needs to be satisfied by the system" + ) + provides_average_demand_rate_forecast: bool = Field( + ..., + description="Indicates whether the Resource Manager could provide a demand rate forecast through the DDBC.AverageDemandRateForecast.", + ) + + +class PPBCPowerSequenceContainer(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the PPBC.PowerSequenceContainer. Must be unique in the scope of the PPBC.PowerProfileDefinition in which it is used.", + ) + power_sequences: List[PPBCPowerSequence] = Field( + ..., + description="List of alternative Sequences where one could be chosen by the CEM", + max_length=288, + min_length=1, + ) + + +class FRBCActuatorDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + id: ID = Field( + ..., + description="ID of the Actuator. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + diagnostic_label: Optional[str] = Field( + None, + description="Human readable name/description for the actuator. This element is only intended for diagnostic purposes and not for HMI applications.", + ) + supported_commodities: List[Commodity] = Field( + ..., + description="List of all supported Commodities.", + max_length=4, + min_length=1, + ) + operation_modes: List[FRBCOperationMode] = Field( + ..., + description="Provided FRBC.OperationModes associated with this actuator", + max_length=100, + min_length=1, + ) + transitions: List[Transition] = Field( + ..., + description="Possible transitions between FRBC.OperationModes associated with this actuator.", + max_length=1000, + min_length=0, + ) + timers: List[Timer] = Field( + ..., + description="List of Timers associated with this actuator", + max_length=1000, + min_length=0, + ) + + +class PPBCPowerProfileDefinition(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["PPBC.PowerProfileDefinition"] = "PPBC.PowerProfileDefinition" + message_id: ID + id: ID = Field( + ..., + description="ID of the PPBC.PowerProfileDefinition. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", + ) + start_time: AwareDatetime = Field( + ..., + description="Indicates the first possible time the first PPBC.PowerSequence could start", + ) + end_time: AwareDatetime = Field( + ..., + description="Indicates when the last PPBC.PowerSequence shall be finished at the latest", + ) + power_sequences_containers: List[PPBCPowerSequenceContainer] = Field( + ..., + description="The PPBC.PowerSequenceContainers that make up this PPBC.PowerProfileDefinition. There shall be at least one PPBC.PowerSequenceContainer that includes at least one PPBC.PowerSequence. PPBC.PowerSequenceContainers must be placed in chronological order.", + max_length=1000, + min_length=1, + ) + + +class FRBCSystemDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + message_type: Literal["FRBC.SystemDescription"] = "FRBC.SystemDescription" + message_id: ID + valid_from: AwareDatetime = Field( + ..., + description="Moment this FRBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past.", + ) + actuators: List[FRBCActuatorDescription] = Field( + ..., description="Details of all Actuators.", max_length=10, min_length=1 + ) + storage: FRBCStorageDescription = Field(..., description="Details of the storage.") diff --git a/s2-python/src/s2python/message.py b/s2-python/src/s2python/message.py new file mode 100644 index 0000000..3467a57 --- /dev/null +++ b/s2-python/src/s2python/message.py @@ -0,0 +1,145 @@ +from typing import Union + +from s2python.frbc import ( + FRBCActuatorDescription, + FRBCActuatorStatus, + FRBCFillLevelTargetProfile, + FRBCFillLevelTargetProfileElement, + FRBCInstruction, + FRBCLeakageBehaviour, + FRBCLeakageBehaviourElement, + FRBCOperationMode, + FRBCOperationModeElement, + FRBCStorageDescription, + FRBCStorageStatus, + FRBCSystemDescription, + FRBCTimerStatus, + FRBCUsageForecast, + FRBCUsageForecastElement, +) +from s2python.ppbc import ( + PPBCEndInterruptionInstruction, + PPBCPowerProfileDefinition, + PPBCPowerSequenceContainer, + PPBCPowerSequence, + PPBCPowerProfileStatus, + PPBCPowerSequenceContainerStatus, + PPBCPowerSequenceElement, + PPBCScheduleInstruction, + PPBCStartInterruptionInstruction, +) +from s2python.ddbc import ( + DDBCActuatorDescription, + DDBCActuatorStatus, + DDBCAverageDemandRateForecast, + DDBCAverageDemandRateForecastElement, + DDBCInstruction, + DDBCOperationMode, + DDBCSystemDescription, + DDBCTimerStatus, +) +from s2python.ombc import ( + OMBCInstruction, + OMBCOperationMode, + OMBCTimerStatus, + OMBCStatus, + OMBCSystemDescription, +) + +from s2python.pebc import ( + PEBCAllowedLimitRange, + PEBCEnergyConstraint, + PEBCInstruction, + PEBCPowerConstraints, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, +) +from s2python.common import ( + Duration, + Handshake, + HandshakeResponse, + InstructionStatusUpdate, + NumberRange, + PowerForecast, + PowerForecastElement, + PowerForecastValue, + PowerMeasurement, + PowerRange, + PowerValue, + ReceptionStatus, + ResourceManagerDetails, + RevokeObject, + Role, + SelectControlType, + SessionRequest, + Timer, + Transition, +) + +S2Message = Union[ + DDBCAverageDemandRateForecast, + DDBCInstruction, + DDBCSystemDescription, + DDBCTimerStatus, + FRBCActuatorStatus, + FRBCFillLevelTargetProfile, + FRBCInstruction, + FRBCLeakageBehaviour, + FRBCStorageStatus, + FRBCSystemDescription, + FRBCTimerStatus, + FRBCUsageForecast, + OMBCSystemDescription, + OMBCStatus, + OMBCTimerStatus, + OMBCInstruction, + PEBCPowerConstraints, + PPBCEndInterruptionInstruction, + PPBCPowerProfileDefinition, + PPBCPowerProfileStatus, + PPBCScheduleInstruction, + PPBCStartInterruptionInstruction, + ResourceManagerDetails, + RevokeObject, + SelectControlType, + SessionRequest, + DDBCActuatorStatus, + PEBCEnergyConstraint, + PEBCInstruction, + Handshake, + HandshakeResponse, + InstructionStatusUpdate, + PowerForecast, + PowerMeasurement, + ReceptionStatus, +] + +S2MessageElement = Union[ + DDBCActuatorDescription, + DDBCAverageDemandRateForecastElement, + DDBCOperationMode, + FRBCActuatorDescription, + FRBCFillLevelTargetProfileElement, + FRBCLeakageBehaviourElement, + FRBCOperationMode, + FRBCOperationModeElement, + FRBCStorageDescription, + FRBCUsageForecastElement, + OMBCOperationMode, + PEBCAllowedLimitRange, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, + PPBCPowerSequenceContainer, + PPBCPowerSequence, + PPBCPowerSequenceContainerStatus, + PPBCPowerSequenceElement, + Duration, + NumberRange, + PowerForecastElement, + PowerForecastValue, + PowerRange, + PowerValue, + Role, + Timer, + Transition, +] diff --git a/s2-python/src/s2python/ombc/__init__.py b/s2-python/src/s2python/ombc/__init__.py new file mode 100644 index 0000000..623f04d --- /dev/null +++ b/s2-python/src/s2python/ombc/__init__.py @@ -0,0 +1,5 @@ +from s2python.ombc.ombc_instruction import OMBCInstruction +from s2python.ombc.ombc_operation_mode import OMBCOperationMode +from s2python.ombc.ombc_status import OMBCStatus +from s2python.ombc.ombc_system_description import OMBCSystemDescription +from s2python.ombc.ombc_timer_status import OMBCTimerStatus diff --git a/s2-python/src/s2python/ombc/ombc_instruction.py b/s2-python/src/s2python/ombc/ombc_instruction.py new file mode 100644 index 0000000..6131916 --- /dev/null +++ b/s2-python/src/s2python/ombc/ombc_instruction.py @@ -0,0 +1,19 @@ +import uuid + +from s2python.generated.gen_s2 import OMBCInstruction as GenOMBCInstruction +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class OMBCInstruction(GenOMBCInstruction, S2MessageComponent): + model_config = GenOMBCInstruction.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenOMBCInstruction.model_fields["id"] # type: ignore[assignment] + message_id: uuid.UUID = GenOMBCInstruction.model_fields["message_id"] # type: ignore[assignment] + abnormal_condition: bool = GenOMBCInstruction.model_fields["abnormal_condition"] # type: ignore[assignment] + operation_mode_factor: float = GenOMBCInstruction.model_fields["operation_mode_factor"] # type: ignore[assignment] + operation_mode_id: uuid.UUID = GenOMBCInstruction.model_fields["operation_mode_id"] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ombc/ombc_operation_mode.py b/s2-python/src/s2python/ombc/ombc_operation_mode.py new file mode 100644 index 0000000..4c2b778 --- /dev/null +++ b/s2-python/src/s2python/ombc/ombc_operation_mode.py @@ -0,0 +1,25 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import OMBCOperationMode as GenOMBCOperationMode +from s2python.common.power_range import PowerRange + + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class OMBCOperationMode(GenOMBCOperationMode, S2MessageComponent): + model_config = GenOMBCOperationMode.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenOMBCOperationMode.model_fields["id"] # type: ignore[assignment] + power_ranges: List[PowerRange] = GenOMBCOperationMode.model_fields[ + "power_ranges" + ] # type: ignore[assignment] + abnormal_condition_only: bool = GenOMBCOperationMode.model_fields[ + "abnormal_condition_only" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ombc/ombc_status.py b/s2-python/src/s2python/ombc/ombc_status.py new file mode 100644 index 0000000..c782c25 --- /dev/null +++ b/s2-python/src/s2python/ombc/ombc_status.py @@ -0,0 +1,17 @@ +import uuid + +from s2python.generated.gen_s2 import OMBCStatus as GenOMBCStatus + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class OMBCStatus(GenOMBCStatus, S2MessageComponent): + model_config = GenOMBCStatus.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenOMBCStatus.model_fields["message_id"] # type: ignore[assignment] + operation_mode_factor: float = GenOMBCStatus.model_fields["operation_mode_factor"] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ombc/ombc_system_description.py b/s2-python/src/s2python/ombc/ombc_system_description.py new file mode 100644 index 0000000..efb4826 --- /dev/null +++ b/s2-python/src/s2python/ombc/ombc_system_description.py @@ -0,0 +1,25 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import OMBCSystemDescription as GenOMBCSystemDescription +from s2python.ombc.ombc_operation_mode import OMBCOperationMode +from s2python.common.transition import Transition +from s2python.common.timer import Timer + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class OMBCSystemDescription(GenOMBCSystemDescription, S2MessageComponent): + model_config = GenOMBCSystemDescription.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenOMBCSystemDescription.model_fields["message_id"] # type: ignore[assignment] + operation_modes: List[OMBCOperationMode] = GenOMBCSystemDescription.model_fields[ + "operation_modes" + ] # type: ignore[assignment] + transitions: List[Transition] = GenOMBCSystemDescription.model_fields["transitions"] # type: ignore[assignment] + timers: List[Timer] = GenOMBCSystemDescription.model_fields["timers"] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ombc/ombc_timer_status.py b/s2-python/src/s2python/ombc/ombc_timer_status.py new file mode 100644 index 0000000..906ea7d --- /dev/null +++ b/s2-python/src/s2python/ombc/ombc_timer_status.py @@ -0,0 +1,17 @@ +from uuid import UUID + +from s2python.generated.gen_s2 import OMBCTimerStatus as GenOMBCTimerStatus + +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class OMBCTimerStatus(GenOMBCTimerStatus, S2MessageComponent): + model_config = GenOMBCTimerStatus.model_config + model_config["validate_assignment"] = True + + message_id: UUID = GenOMBCTimerStatus.model_fields["message_id"] # type: ignore[assignment] + timer_id: UUID = GenOMBCTimerStatus.model_fields["timer_id"] # type: ignore[assignment] diff --git a/s2-python/src/s2python/pebc/__init__.py b/s2-python/src/s2python/pebc/__init__.py new file mode 100644 index 0000000..27f8801 --- /dev/null +++ b/s2-python/src/s2python/pebc/__init__.py @@ -0,0 +1,21 @@ +from s2python.pebc.pebc_allowed_limit_range import PEBCAllowedLimitRange +from s2python.pebc.pebc_power_constraints import PEBCPowerConstraints +from s2python.pebc.pebc_power_envelope import PEBCPowerEnvelope +from s2python.pebc.pebc_power_envelope_element import PEBCPowerEnvelopeElement +from s2python.pebc.pebc_energy_constraint import PEBCEnergyConstraint +from s2python.generated.gen_s2 import ( + PEBCPowerEnvelopeConsequenceType, + PEBCPowerEnvelopeLimitType, +) +from s2python.pebc.pebc_instruction import PEBCInstruction + +__all__ = [ + "PEBCAllowedLimitRange", + "PEBCPowerConstraints", + "PEBCPowerEnvelope", + "PEBCPowerEnvelopeElement", + "PEBCEnergyConstraint", + "PEBCPowerEnvelopeConsequenceType", + "PEBCPowerEnvelopeLimitType", + "PEBCInstruction", +] diff --git a/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py b/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py new file mode 100644 index 0000000..81e82b6 --- /dev/null +++ b/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py @@ -0,0 +1,43 @@ +from typing_extensions import Self +from pydantic import model_validator +from s2python.generated.gen_s2 import ( + PEBCAllowedLimitRange as GenPEBCAllowedLimitRange, + PEBCPowerEnvelopeLimitType as GenPEBCPowerEnvelopeLimitType, +) +from s2python.common import CommodityQuantity, NumberRange +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PEBCAllowedLimitRange(GenPEBCAllowedLimitRange, S2MessageComponent): + model_config = GenPEBCAllowedLimitRange.model_config + model_config["validate_assignment"] = True + + commodity_quantity: CommodityQuantity = GenPEBCAllowedLimitRange.model_fields[ + "commodity_quantity" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] + limit_type: GenPEBCPowerEnvelopeLimitType = GenPEBCAllowedLimitRange.model_fields[ + "limit_type" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] + range_boundary: NumberRange = GenPEBCAllowedLimitRange.model_fields["range_boundary"] # type: ignore[assignment,reportIncompatibleVariableOverride] + abnormal_condition_only: bool = [ + GenPEBCAllowedLimitRange.model_fields["abnormal_condition_only"] # type: ignore[assignment,reportIncompatibleVariableOverride] + ] + + @model_validator(mode="after") + def validate_range_boundary(self) -> Self: + # According to the specification "There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT + # and at least one AllowedLimitRange for the LOWER_LIMIT." However for something that produces energy + # end_of_range=-2000 and start_of_range=0 is valid. Therefore absolute value used here. + # TODO: Check that this is the correct interpretation of the wording + if abs(self.range_boundary.start_of_range) > abs( + self.range_boundary.end_of_range + ): + raise ValueError( + self, + f"The start of the range must shall be smaller or equal than the end of the range.", + ) + return self diff --git a/s2-python/src/s2python/pebc/pebc_energy_constraint.py b/s2-python/src/s2python/pebc/pebc_energy_constraint.py new file mode 100644 index 0000000..eaf76fe --- /dev/null +++ b/s2-python/src/s2python/pebc/pebc_energy_constraint.py @@ -0,0 +1,25 @@ +import uuid + +from s2python.generated.gen_s2 import ( + PEBCEnergyConstraint as GenPEBCEnergyConstraint, +) +from s2python.common import CommodityQuantity +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PEBCEnergyConstraint(GenPEBCEnergyConstraint, S2MessageComponent): + model_config = GenPEBCEnergyConstraint.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenPEBCEnergyConstraint.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + id: uuid.UUID = GenPEBCEnergyConstraint.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + + upper_average_power: float = GenPEBCEnergyConstraint.model_fields["upper_average_power"] # type: ignore[assignment,reportIncompatibleVariableOverride] + lower_average_power: float = GenPEBCEnergyConstraint.model_fields["lower_average_power"] # type: ignore[assignment,reportIncompatibleVariableOverride] + commodity_quantity: CommodityQuantity = [ + GenPEBCEnergyConstraint.model_fields["commodity_quantity"] # type: ignore[assignment,reportIncompatibleVariableOverride] + ] diff --git a/s2-python/src/s2python/pebc/pebc_instruction.py b/s2-python/src/s2python/pebc/pebc_instruction.py new file mode 100644 index 0000000..320f931 --- /dev/null +++ b/s2-python/src/s2python/pebc/pebc_instruction.py @@ -0,0 +1,27 @@ +import uuid +from typing import List + +from s2python.generated.gen_s2 import ( + PEBCInstruction as GenPEBCInstruction, +) +from s2python.pebc.pebc_power_envelope import PEBCPowerEnvelope +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PEBCInstruction(GenPEBCInstruction, S2MessageComponent): + model_config = GenPEBCInstruction.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenPEBCInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + id: uuid.UUID = GenPEBCInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_constraints_id: uuid.UUID = [ # type: ignore[reportIncompatibleVariableOverride] + GenPEBCInstruction.model_fields["power_constraints_id"] # type: ignore[assignment] + ] + power_envelopes: List[PEBCPowerEnvelope] = [ # type: ignore[reportIncompatibleVariableOverride] + GenPEBCInstruction.model_fields["power_envelopes"] # type: ignore[assignment] + ] + abnormal_condition: bool = GenPEBCInstruction.model_fields["abnormal_condition"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/pebc/pebc_power_constraints.py b/s2-python/src/s2python/pebc/pebc_power_constraints.py new file mode 100644 index 0000000..fa5cff1 --- /dev/null +++ b/s2-python/src/s2python/pebc/pebc_power_constraints.py @@ -0,0 +1,57 @@ +import uuid +from typing import List +from typing_extensions import Self + +from pydantic import model_validator + +from s2python.generated.gen_s2 import ( + PEBCPowerConstraints as GenPEBCPowerConstraints, + PEBCPowerEnvelopeConsequenceType as GenPEBCPowerEnvelopeConsequenceType, + PEBCPowerEnvelopeLimitType, +) +from s2python.pebc.pebc_allowed_limit_range import PEBCAllowedLimitRange +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PEBCPowerConstraints(GenPEBCPowerConstraints, S2MessageComponent): + model_config = GenPEBCPowerConstraints.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenPEBCPowerConstraints.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + id: uuid.UUID = GenPEBCPowerConstraints.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + consequence_type: GenPEBCPowerEnvelopeConsequenceType = GenPEBCPowerConstraints.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "consequence_type" + ] # type: ignore[assignment] + allowed_limit_ranges: List[PEBCAllowedLimitRange] = GenPEBCPowerConstraints.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "allowed_limit_ranges" + ] # type: ignore[assignment] + + @model_validator(mode="after") + def validate_has_one_upper_one_lower_limit_range(self) -> Self: + has_upper = any( + l.limit_type == PEBCPowerEnvelopeLimitType.UPPER_LIMIT + for l in self.allowed_limit_ranges + ) + has_lower = any( + l.limit_type == PEBCPowerEnvelopeLimitType.UPPER_LIMIT + for l in self.allowed_limit_ranges + ) + if not (has_upper and has_lower): + raise ValueError( + self, + f"There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT.", + ) + + return self + + @model_validator(mode="after") + def validate_valid_until_after_valid_from(self) -> Self: + if self.valid_until is not None and self.valid_until < self.valid_from: + raise ValueError( + self, f"valid_until cannot be set to a value that is before valid_from." + ) + return self diff --git a/s2-python/src/s2python/pebc/pebc_power_envelope.py b/s2-python/src/s2python/pebc/pebc_power_envelope.py new file mode 100644 index 0000000..8ac20e6 --- /dev/null +++ b/s2-python/src/s2python/pebc/pebc_power_envelope.py @@ -0,0 +1,23 @@ +from typing import List +from s2python.generated.gen_s2 import ( + PEBCPowerEnvelope as GenPEBCPowerEnvelope, +) +from s2python.pebc.pebc_power_envelope_element import PEBCPowerEnvelopeElement +from s2python.common import CommodityQuantity +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PEBCPowerEnvelope(GenPEBCPowerEnvelope, S2MessageComponent): + model_config = GenPEBCPowerEnvelope.model_config + model_config["validate_assignment"] = True + + commodity_quantity: CommodityQuantity = GenPEBCPowerEnvelope.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "commodity_quantity" + ] # type: ignore[assignment] + power_envelope_elements: List[PEBCPowerEnvelopeElement] = GenPEBCPowerEnvelope.model_fields[ # type: ignore[assignment,reportIncompatibleVariableOverride] + "power_envelope_elements" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/pebc/pebc_power_envelope_element.py b/s2-python/src/s2python/pebc/pebc_power_envelope_element.py new file mode 100644 index 0000000..0e9f53b --- /dev/null +++ b/s2-python/src/s2python/pebc/pebc_power_envelope_element.py @@ -0,0 +1,16 @@ +from s2python.generated.gen_s2 import ( + PEBCPowerEnvelopeElement as GenPEBCPowerEnvelopeElement, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PEBCPowerEnvelopeElement(GenPEBCPowerEnvelopeElement, S2MessageComponent): + model_config = GenPEBCPowerEnvelopeElement.model_config + model_config["validate_assignment"] = True + + lower_limit: float = GenPEBCPowerEnvelopeElement.model_fields["lower_limit"] # type: ignore[assignment,reportIncompatibleVariableOverride] + upper_limit: float = GenPEBCPowerEnvelopeElement.model_fields["upper_limit"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/ppbc/__init__.py b/s2-python/src/s2python/ppbc/__init__.py new file mode 100644 index 0000000..e28a750 --- /dev/null +++ b/s2-python/src/s2python/ppbc/__init__.py @@ -0,0 +1,21 @@ +from s2python.ppbc.ppbc_schedule_instruction import PPBCScheduleInstruction +from s2python.ppbc.ppbc_end_interruption_instruction import PPBCEndInterruptionInstruction +from s2python.ppbc.ppbc_power_profile_definition import PPBCPowerProfileDefinition +from s2python.ppbc.ppbc_power_sequence_container import PPBCPowerSequenceContainer +from s2python.ppbc.ppbc_power_sequence import PPBCPowerSequence +from s2python.ppbc.ppbc_power_profile_status import PPBCPowerProfileStatus +from s2python.ppbc.ppbc_power_sequence_container_status import PPBCPowerSequenceContainerStatus +from s2python.ppbc.ppbc_power_sequence_element import PPBCPowerSequenceElement +from s2python.ppbc.ppbc_start_interruption_instruction import PPBCStartInterruptionInstruction + +__all__ = [ + "PPBCScheduleInstruction", + "PPBCEndInterruptionInstruction", + "PPBCPowerProfileDefinition", + "PPBCPowerSequenceContainer", + "PPBCPowerSequence", + "PPBCPowerProfileStatus", + "PPBCPowerSequenceContainerStatus", + "PPBCPowerSequenceElement", + "PPBCStartInterruptionInstruction", +] diff --git a/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py b/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py new file mode 100644 index 0000000..d38a454 --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py @@ -0,0 +1,30 @@ +import uuid + +from s2python.generated.gen_s2 import ( + PPBCEndInterruptionInstruction as GenPPBCEndInterruptionInstruction, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class PPBCEndInterruptionInstruction(GenPPBCEndInterruptionInstruction, S2MessageComponent): + model_config = GenPPBCEndInterruptionInstruction.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_profile_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_profile_id" + ] # type: ignore[assignment] + sequence_container_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "sequence_container_id" + ] # type: ignore[assignment] + power_sequence_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_sequence_id" + ] # type: ignore[assignment] + abnormal_condition: bool = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "abnormal_condition" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py b/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py new file mode 100644 index 0000000..53c22c8 --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py @@ -0,0 +1,25 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import ( + PPBCPowerProfileDefinition as GenPPBCPowerProfileDefinition, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + +from s2python.ppbc.ppbc_power_sequence_container import PPBCPowerSequenceContainer + + +@catch_and_convert_exceptions +class PPBCPowerProfileDefinition(GenPPBCPowerProfileDefinition, S2MessageComponent): + model_config = GenPPBCPowerProfileDefinition.model_config + model_config["validate_assignment"] = True + + message_id: uuid.UUID = GenPPBCPowerProfileDefinition.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + id: uuid.UUID = GenPPBCPowerProfileDefinition.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_sequences_containers: List[PPBCPowerSequenceContainer] = ( # type: ignore[reportIncompatibleVariableOverride] + GenPPBCPowerProfileDefinition.model_fields["power_sequences_containers"] # type: ignore[assignment] + ) diff --git a/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py b/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py new file mode 100644 index 0000000..a43661f --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py @@ -0,0 +1,24 @@ +from typing import List + +from s2python.generated.gen_s2 import ( + PPBCPowerProfileStatus as GenPPBCPowerProfileStatus, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + +from s2python.ppbc.ppbc_power_sequence_container_status import ( + PPBCPowerSequenceContainerStatus, +) + + +@catch_and_convert_exceptions +class PPBCPowerProfileStatus(GenPPBCPowerProfileStatus, S2MessageComponent): + model_config = GenPPBCPowerProfileStatus.model_config + model_config["validate_assignment"] = True + + sequence_container_status: List[PPBCPowerSequenceContainerStatus] = ( # type: ignore[reportIncompatibleVariableOverride] + GenPPBCPowerProfileStatus.model_fields["sequence_container_status"] # type: ignore[assignment] + ) diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence.py b/s2-python/src/s2python/ppbc/ppbc_power_sequence.py new file mode 100644 index 0000000..9fc545a --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_power_sequence.py @@ -0,0 +1,30 @@ +from typing import List +import uuid + +from s2python.generated.gen_s2 import ( + PPBCPowerSequence as GenPPBCPowerSequence, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + +from s2python.ppbc.ppbc_power_sequence_element import PPBCPowerSequenceElement +from s2python.common import Duration + + +@catch_and_convert_exceptions +class PPBCPowerSequence(GenPPBCPowerSequence, S2MessageComponent): + model_config = GenPPBCPowerSequence.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenPPBCPowerSequence.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + elements: List[PPBCPowerSequenceElement] = GenPPBCPowerSequence.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "elements" + ] # type: ignore[assignment] + is_interruptible: bool = GenPPBCPowerSequence.model_fields["is_interruptible"] # type: ignore[assignment,reportIncompatibleVariableOverride] + max_pause_before: Duration = GenPPBCPowerSequence.model_fields["max_pause_before"] # type: ignore[assignment,reportIncompatibleVariableOverride] + abnormal_condition_only: bool = GenPPBCPowerSequence.model_fields[ + "abnormal_condition_only" + ] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py b/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py new file mode 100644 index 0000000..c5edcf1 --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py @@ -0,0 +1,25 @@ +from typing import List +import uuid + + +from s2python.generated.gen_s2 import ( + PPBCPowerSequenceContainer as GenPPBCPowerSequenceContainer, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + +from s2python.ppbc.ppbc_power_sequence import PPBCPowerSequence + + +@catch_and_convert_exceptions +class PPBCPowerSequenceContainer(GenPPBCPowerSequenceContainer, S2MessageComponent): + model_config = GenPPBCPowerSequenceContainer.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenPPBCPowerSequenceContainer.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_sequences: List[PPBCPowerSequence] = GenPPBCPowerSequenceContainer.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_sequences" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py b/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py new file mode 100644 index 0000000..81fb48a --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py @@ -0,0 +1,30 @@ +import uuid +from typing import Union + +from s2python.generated.gen_s2 import ( + PPBCPowerSequenceContainerStatus as GenPPBCPowerSequenceContainerStatus, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class PPBCPowerSequenceContainerStatus(GenPPBCPowerSequenceContainerStatus, S2MessageComponent): + model_config = GenPPBCPowerSequenceContainerStatus.model_config + model_config["validate_assignment"] = True + + power_profile_id: uuid.UUID = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_profile_id" # type: ignore[assignment] + ] + sequence_container_id: uuid.UUID = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "sequence_container_id" # type: ignore[assignment] + ] + selected_sequence_id: Union[uuid.UUID, None] = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "selected_sequence_id" + ] # type: ignore[assignment] + progress: Union[uuid.UUID, None] = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "progress" # type: ignore[assignment] + ] diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py b/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py new file mode 100644 index 0000000..444bd08 --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py @@ -0,0 +1,23 @@ +from typing import List + +from s2python.generated.gen_s2 import ( + PPBCPowerSequenceElement as GenPPBCPowerSequenceElement, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + +from s2python.common import Duration, PowerForecastValue + + +@catch_and_convert_exceptions +class PPBCPowerSequenceElement(GenPPBCPowerSequenceElement, S2MessageComponent): + model_config = GenPPBCPowerSequenceElement.model_config + model_config["validate_assignment"] = True + + duration: Duration = GenPPBCPowerSequenceElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_values: List[PowerForecastValue] = GenPPBCPowerSequenceElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_values" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py b/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py new file mode 100644 index 0000000..2434e93 --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py @@ -0,0 +1,31 @@ +import uuid + +from s2python.generated.gen_s2 import ( + PPBCScheduleInstruction as GenPPBCScheduleInstruction, +) +from s2python.validate_values_mixin import ( + catch_and_convert_exceptions, + S2MessageComponent, +) + + +@catch_and_convert_exceptions +class PPBCScheduleInstruction(GenPPBCScheduleInstruction, S2MessageComponent): + model_config = GenPPBCScheduleInstruction.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenPPBCScheduleInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + + power_profile_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_profile_id" + ] # type: ignore[assignment] + + message_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + + sequence_container_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "sequence_container_id" + ] # type: ignore[assignment] + + power_sequence_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_sequence_id" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py b/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py new file mode 100644 index 0000000..a9fdea6 --- /dev/null +++ b/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py @@ -0,0 +1,30 @@ +import uuid + +from s2python.generated.gen_s2 import ( + PPBCStartInterruptionInstruction as GenPPBCStartInterruptionInstruction, +) + +from s2python.validate_values_mixin import ( + S2MessageComponent, + catch_and_convert_exceptions, +) + + +@catch_and_convert_exceptions +class PPBCStartInterruptionInstruction(GenPPBCStartInterruptionInstruction, S2MessageComponent): + model_config = GenPPBCStartInterruptionInstruction.model_config + model_config["validate_assignment"] = True + + id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + power_profile_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_profile_id" + ] # type: ignore[assignment] + sequence_container_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "sequence_container_id" + ] # type: ignore[assignment] + power_sequence_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "power_sequence_id" + ] # type: ignore[assignment] + abnormal_condition: bool = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] + "abnormal_condition" + ] # type: ignore[assignment] diff --git a/s2-python/src/s2python/py.typed b/s2-python/src/s2python/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/s2-python/src/s2python/reception_status_awaiter.py b/s2-python/src/s2python/reception_status_awaiter.py new file mode 100644 index 0000000..5c4bd42 --- /dev/null +++ b/s2-python/src/s2python/reception_status_awaiter.py @@ -0,0 +1,60 @@ +"""ReceptationStatusAwaiter class which notifies any coroutine waiting for a certain reception status message. + +Copied from +https://github.com/flexiblepower/s2-analyzer/blob/main/backend/s2_analyzer_backend/reception_status_awaiter.py under +Apache2 license on 31-08-2024. +""" + +import asyncio +import uuid +from typing import Dict + +from s2python.common import ReceptionStatus + + +class ReceptionStatusAwaiter: + received: Dict[uuid.UUID, ReceptionStatus] + awaiting: Dict[uuid.UUID, asyncio.Event] + + def __init__(self) -> None: + self.received = {} + self.awaiting = {} + + async def wait_for_reception_status( + self, message_id: uuid.UUID, timeout_reception_status: float + ) -> ReceptionStatus: + if message_id in self.received: + reception_status = self.received[message_id] + else: + if message_id in self.awaiting: + received_event = self.awaiting[message_id] + else: + received_event = asyncio.Event() + self.awaiting[message_id] = received_event + + await asyncio.wait_for(received_event.wait(), timeout_reception_status) + reception_status = self.received[message_id] + + if message_id in self.awaiting: + del self.awaiting[message_id] + + return reception_status + + async def receive_reception_status(self, reception_status: ReceptionStatus) -> None: + if not isinstance(reception_status, ReceptionStatus): + raise RuntimeError( + f"Expected a ReceptionStatus but received message {reception_status}" + ) + + if reception_status.subject_message_id in self.received: + raise RuntimeError( + f"ReceptationStatus for message_subject_id {reception_status.subject_message_id} has already " + f"been received!" + ) + + self.received[reception_status.subject_message_id] = reception_status + awaiting = self.awaiting.get(reception_status.subject_message_id) + + if awaiting: + awaiting.set() + del self.awaiting[reception_status.subject_message_id] diff --git a/s2-python/src/s2python/s2_connection.py b/s2-python/src/s2python/s2_connection.py new file mode 100644 index 0000000..ba497d4 --- /dev/null +++ b/s2-python/src/s2python/s2_connection.py @@ -0,0 +1,581 @@ +import asyncio +import json +import logging +import time +import threading +import uuid +import ssl +from dataclasses import dataclass +from typing import Any, Optional, List, Type, Dict, Callable, Awaitable, Union + +import websockets +from websockets.asyncio.client import ( + ClientConnection as WSConnection, + connect as ws_connect, +) + +from s2python.common import ( + ReceptionStatusValues, + ReceptionStatus, + Handshake, + EnergyManagementRole, + Role, + HandshakeResponse, + ResourceManagerDetails, + Duration, + Currency, + SelectControlType, +) +from s2python.generated.gen_s2 import CommodityQuantity +from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_control_type import S2ControlType +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from s2python.message import S2Message +from s2python.version import S2_VERSION + +logger = logging.getLogger("s2python") + + +@dataclass +class AssetDetails: # pylint: disable=too-many-instance-attributes + resource_id: uuid.UUID + + provides_forecast: bool + provides_power_measurements: List[CommodityQuantity] + + instruction_processing_delay: Duration + roles: List[Role] + currency: Optional[Currency] = None + + name: Optional[str] = None + manufacturer: Optional[str] = None + model: Optional[str] = None + firmware_version: Optional[str] = None + serial_number: Optional[str] = None + + def to_resource_manager_details( + self, control_types: List[S2ControlType] + ) -> ResourceManagerDetails: + return ResourceManagerDetails( + available_control_types=[ + control_type.get_protocol_control_type() + for control_type in control_types + ], + currency=self.currency, + firmware_version=self.firmware_version, + instruction_processing_delay=self.instruction_processing_delay, + manufacturer=self.manufacturer, + message_id=uuid.uuid4(), + model=self.model, + name=self.name, + provides_forecast=self.provides_forecast, + provides_power_measurement_types=self.provides_power_measurements, + resource_id=self.resource_id, + roles=self.roles, + serial_number=self.serial_number, + ) + + +S2MessageHandler = Union[ + Callable[["S2Connection", S2Message, Callable[[], None]], None], + Callable[["S2Connection", S2Message, Awaitable[None]], Awaitable[None]], +] + + +class SendOkay: + status_is_send: threading.Event + connection: "S2Connection" + subject_message_id: uuid.UUID + + def __init__(self, connection: "S2Connection", subject_message_id: uuid.UUID): + self.status_is_send = threading.Event() + self.connection = connection + self.subject_message_id = subject_message_id + + async def run_async(self) -> None: + self.status_is_send.set() + + await self.connection.respond_with_reception_status( + subject_message_id=self.subject_message_id, + status=ReceptionStatusValues.OK, + diagnostic_label="Processed okay.", + ) + + def run_sync(self) -> None: + self.status_is_send.set() + + self.connection.respond_with_reception_status_sync( + subject_message_id=self.subject_message_id, + status=ReceptionStatusValues.OK, + diagnostic_label="Processed okay.", + ) + + async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: + if not self.status_is_send.is_set(): + logger.warning( + "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " + "Sending it now.", + type_msg, + self.subject_message_id, + ) + await self.run_async() + + def ensure_send_sync(self, type_msg: Type[S2Message]) -> None: + if not self.status_is_send.is_set(): + logger.warning( + "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " + "Sending it now.", + type_msg, + self.subject_message_id, + ) + self.run_sync() + + +class MessageHandlers: + handlers: Dict[Type[S2Message], S2MessageHandler] + + def __init__(self) -> None: + self.handlers = {} + + async def handle_message(self, connection: "S2Connection", msg: S2Message) -> None: + """Handle the S2 message using the registered handler. + + :param connection: The S2 conncetion the `msg` is received from. + :param msg: The S2 message + """ + handler = self.handlers.get(type(msg)) + if handler is not None: + send_okay = SendOkay(connection, msg.message_id) # type: ignore[attr-defined, union-attr] + + try: + if asyncio.iscoroutinefunction(handler): + await handler(connection, msg, send_okay.run_async()) # type: ignore[arg-type] + await send_okay.ensure_send_async(type(msg)) + else: + + def do_message() -> None: + handler(connection, msg, send_okay.run_sync) # type: ignore[arg-type] + send_okay.ensure_send_sync(type(msg)) + + eventloop = asyncio.get_event_loop() + await eventloop.run_in_executor(executor=None, func=do_message) + except Exception: + if not send_okay.status_is_send.is_set(): + await connection.respond_with_reception_status( + subject_message_id=msg.message_id, # type: ignore[attr-defined, union-attr] + status=ReceptionStatusValues.PERMANENT_ERROR, + diagnostic_label=f"While processing message {msg.message_id} " # type: ignore[attr-defined, union-attr] # pylint: disable=line-too-long + f"an unrecoverable error occurred.", + ) + raise + else: + logger.warning( + "Received a message of type %s but no handler is registered. Ignoring the message.", + type(msg), + ) + + def register_handler( + self, msg_type: Type[S2Message], handler: S2MessageHandler + ) -> None: + """Register a coroutine function or a normal function as the handler for a specific S2 message type. + + :param msg_type: The S2 message type to attach the handler to. + :param handler: The function (asynchronuous or normal) which should handle the S2 message. + """ + self.handlers[msg_type] = handler + + +class S2Connection: # pylint: disable=too-many-instance-attributes + url: str + reconnect: bool + reception_status_awaiter: ReceptionStatusAwaiter + ws: Optional[WSConnection] + s2_parser: S2Parser + control_types: List[S2ControlType] + role: EnergyManagementRole + asset_details: AssetDetails + + _thread: threading.Thread + + _handlers: MessageHandlers + _current_control_type: Optional[S2ControlType] + _received_messages: asyncio.Queue + + _eventloop: asyncio.AbstractEventLoop + _stop_event: asyncio.Event + _restart_connection_event: asyncio.Event + _verify_certificate: bool + _bearer_token: Optional[str] + + def __init__( # pylint: disable=too-many-arguments + self, + url: str, + role: EnergyManagementRole, + control_types: List[S2ControlType], + asset_details: AssetDetails, + reconnect: bool = False, + verify_certificate: bool = True, + bearer_token: Optional[str] = None, + ) -> None: + self.url = url + self.reconnect = reconnect + self.reception_status_awaiter = ReceptionStatusAwaiter() + self.ws = None + self.s2_parser = S2Parser() + + self._handlers = MessageHandlers() + self._current_control_type = None + + self._eventloop = asyncio.new_event_loop() + + self.control_types = control_types + self.role = role + self.asset_details = asset_details + self._verify_certificate = verify_certificate + + self._handlers.register_handler( + SelectControlType, self.handle_select_control_type_as_rm + ) + self._handlers.register_handler(Handshake, self.handle_handshake) + self._handlers.register_handler(HandshakeResponse, self.handle_handshake_response_as_rm) + self._bearer_token = bearer_token + + def start_as_rm(self) -> None: + self._run_eventloop(self._run_as_rm()) + + def _run_eventloop(self, main_task: Awaitable[None]) -> None: + self._thread = threading.current_thread() + logger.debug("Starting eventloop") + try: + self._eventloop.run_until_complete(main_task) + except asyncio.CancelledError: + pass + logger.debug("S2 connection thread has stopped.") + + def stop(self) -> None: + """Stops the S2 connection. + + Note: Ensure this method is called from a different thread than the thread running the S2 connection. + Otherwise it will block waiting on the coroutine _do_stop to terminate successfully but it can't run + the coroutine. A `RuntimeError` will be raised to prevent the indefinite block. + """ + if threading.current_thread() == self._thread: + raise RuntimeError( + "Do not call stop from the thread running the S2 connection. This results in an infinite block!" + ) + if self._eventloop.is_running(): + asyncio.run_coroutine_threadsafe(self._do_stop(), self._eventloop).result() + self._thread.join() + logger.info("Stopped the S2 connection.") + + async def _do_stop(self) -> None: + logger.info("Will stop the S2 connection.") + self._stop_event.set() + + async def _run_as_rm(self) -> None: + logger.debug("Connecting as S2 resource manager.") + + self._stop_event = asyncio.Event() + + first_run = True + + while (first_run or self.reconnect) and not self._stop_event.is_set(): + first_run = False + self._restart_connection_event = asyncio.Event() + await self._connect_and_run() + time.sleep(1) + + logger.debug("Finished S2 connection eventloop.") + + async def _connect_and_run(self) -> None: + self._received_messages = asyncio.Queue() + await self._connect_ws() + if self.ws: + + async def wait_till_stop() -> None: + await self._stop_event.wait() + + async def wait_till_connection_restart() -> None: + await self._restart_connection_event.wait() + + background_tasks = [ + self._eventloop.create_task(self._receive_messages()), + self._eventloop.create_task(wait_till_stop()), + self._eventloop.create_task(self._connect_as_rm()), + self._eventloop.create_task(wait_till_connection_restart()), + ] + + (done, pending) = await asyncio.wait( + background_tasks, return_when=asyncio.FIRST_COMPLETED + ) + if self._current_control_type: + self._current_control_type.deactivate(self) + self._current_control_type = None + + for task in done: + try: + await task + except asyncio.CancelledError: + pass + except ( + websockets.ConnectionClosedError, + websockets.ConnectionClosedOK, + ): + logger.info("The other party closed the websocket connection.") + + for task in pending: + try: + task.cancel() + await task + except asyncio.CancelledError: + pass + + await self.ws.close() + await self.ws.wait_closed() + + async def _connect_ws(self) -> None: + try: + # set up connection arguments for SSL and bearer token, if required + connection_kwargs: Dict[str, Any] = {} + if self.url.startswith("wss://") and not self._verify_certificate: + connection_kwargs["ssl"] = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + connection_kwargs["ssl"].check_hostname = False + connection_kwargs["ssl"].verify_mode = ssl.CERT_NONE + + if self._bearer_token: + connection_kwargs["additional_headers"] = { + "Authorization": f"Bearer {self._bearer_token}" + } + + self.ws = await ws_connect(uri=self.url, **connection_kwargs) + except (EOFError, OSError) as e: + logger.info("Could not connect due to: %s", str(e)) + + async def _connect_as_rm(self) -> None: + await self.send_msg_and_await_reception_status_async( + Handshake( + message_id=uuid.uuid4(), + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + logger.debug( + "Send handshake to CEM. Expecting Handshake and HandshakeResponse from CEM." + ) + + await self._handle_received_messages() + + async def handle_handshake( + self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] + ) -> None: + if not isinstance(message, Handshake): + logger.error( + "Handler for Handshake received a message of the wrong type: %s", + type(message), + ) + return + + logger.debug( + "%s supports S2 protocol versions: %s", + message.role, + message.supported_protocol_versions, + ) + await send_okay + + async def handle_handshake_response_as_rm( + self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] + ) -> None: + if not isinstance(message, HandshakeResponse): + logger.error( + "Handler for HandshakeResponse received a message of the wrong type: %s", + type(message), + ) + return + + logger.debug("Received HandshakeResponse %s", message.to_json()) + + logger.debug( + "CEM selected to use version %s", message.selected_protocol_version + ) + await send_okay + logger.debug("Handshake complete. Sending first ResourceManagerDetails.") + + await self.send_msg_and_await_reception_status_async( + self.asset_details.to_resource_manager_details(self.control_types) + ) + + async def handle_select_control_type_as_rm( + self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] + ) -> None: + if not isinstance(message, SelectControlType): + logger.error( + "Handler for SelectControlType received a message of the wrong type: %s", + type(message), + ) + return + + await send_okay + + logger.debug( + "CEM selected control type %s. Activating control type.", + message.control_type, + ) + + control_types_by_protocol_name = { + c.get_protocol_control_type(): c for c in self.control_types + } + selected_control_type: Optional[S2ControlType] = ( + control_types_by_protocol_name.get(message.control_type) + ) + + if self._current_control_type is not None: + await self._eventloop.run_in_executor( + None, self._current_control_type.deactivate, self + ) + + self._current_control_type = selected_control_type + + if self._current_control_type is not None: + await self._eventloop.run_in_executor( + None, self._current_control_type.activate, self + ) + self._current_control_type.register_handlers(self._handlers) + + async def _receive_messages(self) -> None: + """Receives all incoming messages in the form of a generator. + + Will also receive the ReceptionStatus messages but instead of yielding these messages, they are routed + to any calls of `send_msg_and_await_reception_status`. + """ + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + + logger.info("S2 connection has started to receive messages.") + + async for message in self.ws: + try: + s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) + except json.JSONDecodeError: + await self._send_and_forget( + ReceptionStatus( + subject_message_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Not valid json.", + ) + ) + except S2ValidationError as e: + json_msg = json.loads(message) + message_id = json_msg.get("message_id") + if message_id: + await self.respond_with_reception_status( + subject_message_id=message_id, + status=ReceptionStatusValues.INVALID_MESSAGE, + diagnostic_label=str(e), + ) + else: + await self.respond_with_reception_status( + subject_message_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Message appears valid json but could not find a message_id field.", + ) + else: + logger.debug("Received message %s", s2_msg.to_json()) + + if isinstance(s2_msg, ReceptionStatus): + logger.debug( + "Message is a reception status for %s so registering in cache.", + s2_msg.subject_message_id, + ) + await self.reception_status_awaiter.receive_reception_status(s2_msg) + else: + await self._received_messages.put(s2_msg) + + async def _send_and_forget(self, s2_msg: S2Message) -> None: + if self.ws is None: + raise RuntimeError( + "Cannot send messages if websocket connection is not yet established." + ) + + json_msg = s2_msg.to_json() + logger.debug("Sending message %s", json_msg) + try: + await self.ws.send(json_msg) + except websockets.ConnectionClosedError as e: + logger.error("Unable to send message %s due to %s", s2_msg, str(e)) + self._restart_connection_event.set() + + async def respond_with_reception_status( + self, subject_message_id: uuid.UUID, status: ReceptionStatusValues, diagnostic_label: str + ) -> None: + logger.debug( + "Responding to message %s with status %s", subject_message_id, status + ) + await self._send_and_forget( + ReceptionStatus( + subject_message_id=subject_message_id, + status=status, + diagnostic_label=diagnostic_label, + ) + ) + + def respond_with_reception_status_sync( + self, subject_message_id: uuid.UUID, status: ReceptionStatusValues, diagnostic_label: str + ) -> None: + asyncio.run_coroutine_threadsafe( + self.respond_with_reception_status( + subject_message_id, status, diagnostic_label + ), + self._eventloop, + ).result() + + async def send_msg_and_await_reception_status_async( + self, + s2_msg: S2Message, + timeout_reception_status: float = 5.0, + raise_on_error: bool = True, + ) -> ReceptionStatus: + await self._send_and_forget(s2_msg) + logger.debug( + "Waiting for ReceptionStatus for %s %s seconds", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + timeout_reception_status, + ) + try: + reception_status = await self.reception_status_awaiter.wait_for_reception_status( + s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] + ) + except TimeoutError: + logger.error( + "Did not receive a reception status on time for %s", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + ) + self._stop_event.set() + raise + + if reception_status.status != ReceptionStatusValues.OK and raise_on_error: + raise RuntimeError( + f"ReceptionStatus was not OK but rather {reception_status.status}" + ) + + return reception_status + + def send_msg_and_await_reception_status_sync( + self, + s2_msg: S2Message, + timeout_reception_status: float = 5.0, + raise_on_error: bool = True, + ) -> ReceptionStatus: + return asyncio.run_coroutine_threadsafe( + self.send_msg_and_await_reception_status_async( + s2_msg, timeout_reception_status, raise_on_error + ), + self._eventloop, + ).result() + + async def _handle_received_messages(self) -> None: + while True: + msg = await self._received_messages.get() + await self._handlers.handle_message(self, msg) diff --git a/s2-python/src/s2python/s2_control_type.py b/s2-python/src/s2python/s2_control_type.py new file mode 100644 index 0000000..135f775 --- /dev/null +++ b/s2-python/src/s2python/s2_control_type.py @@ -0,0 +1,116 @@ +import abc +import typing + +from s2python.common import ControlType as ProtocolControlType +from s2python.frbc import FRBCInstruction +from s2python.ppbc import PPBCScheduleInstruction +from s2python.ombc import OMBCInstruction +from s2python.message import S2Message + +if typing.TYPE_CHECKING: + from s2python.s2_connection import S2Connection, MessageHandlers + + +class S2ControlType(abc.ABC): + @abc.abstractmethod + def get_protocol_control_type(self) -> ProtocolControlType: ... + + @abc.abstractmethod + def register_handlers(self, handlers: "MessageHandlers") -> None: ... + + @abc.abstractmethod + def activate(self, conn: "S2Connection") -> None: ... + + @abc.abstractmethod + def deactivate(self, conn: "S2Connection") -> None: ... + + +class FRBCControlType(S2ControlType): + def get_protocol_control_type(self) -> ProtocolControlType: + return ProtocolControlType.FILL_RATE_BASED_CONTROL + + def register_handlers(self, handlers: "MessageHandlers") -> None: + handlers.register_handler(FRBCInstruction, self.handle_instruction) + + @abc.abstractmethod + def handle_instruction( + self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None] + ) -> None: ... + + @abc.abstractmethod + def activate(self, conn: "S2Connection") -> None: + """Overwrite with the actual dctivation logic of your Resource Manager for this particular control type.""" + + @abc.abstractmethod + def deactivate(self, conn: "S2Connection") -> None: + """Overwrite with the actual deactivation logic of your Resource Manager for this particular control type.""" + + +class PPBCControlType(S2ControlType): + def get_protocol_control_type(self) -> ProtocolControlType: + return ProtocolControlType.POWER_PROFILE_BASED_CONTROL + + def register_handlers(self, handlers: "MessageHandlers") -> None: + handlers.register_handler(PPBCScheduleInstruction, self.handle_instruction) + + @abc.abstractmethod + def handle_instruction( + self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None] + ) -> None: ... + + @abc.abstractmethod + def activate(self, conn: "S2Connection") -> None: + """Overwrite with the actual dctivation logic of your Resource Manager for this particular control type.""" + + @abc.abstractmethod + def deactivate(self, conn: "S2Connection") -> None: + """Overwrite with the actual deactivation logic of your Resource Manager for this particular control type.""" + + +class OMBCControlType(S2ControlType): + def get_protocol_control_type(self) -> ProtocolControlType: + return ProtocolControlType.OPERATION_MODE_BASED_CONTROL + + def register_handlers(self, handlers: "MessageHandlers") -> None: + handlers.register_handler(OMBCInstruction, self.handle_instruction) + + @abc.abstractmethod + def handle_instruction( + self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None] + ) -> None: ... + + @abc.abstractmethod + def activate(self, conn: "S2Connection") -> None: + """Overwrite with the actual dctivation logic of your Resource Manager for this particular control type.""" + + @abc.abstractmethod + def deactivate(self, conn: "S2Connection") -> None: + """Overwrite with the actual deactivation logic of your Resource Manager for this particular control type.""" + + +class PEBCControlType(S2ControlType): + def get_protocol_control_type(self) -> ProtocolControlType: + return ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL + + def register_handlers(self, handlers: "MessageHandlers") -> None: + pass + + @abc.abstractmethod + def activate(self, conn: "S2Connection") -> None: ... + + @abc.abstractmethod + def deactivate(self, conn: "S2Connection") -> None: ... + + +class NoControlControlType(S2ControlType): + def get_protocol_control_type(self) -> ProtocolControlType: + return ProtocolControlType.NOT_CONTROLABLE + + def register_handlers(self, handlers: "MessageHandlers") -> None: + pass + + @abc.abstractmethod + def activate(self, conn: "S2Connection") -> None: ... + + @abc.abstractmethod + def deactivate(self, conn: "S2Connection") -> None: ... diff --git a/s2-python/src/s2python/s2_parser.py b/s2-python/src/s2python/s2_parser.py new file mode 100644 index 0000000..8bf1ddf --- /dev/null +++ b/s2-python/src/s2python/s2_parser.py @@ -0,0 +1,129 @@ +import json +import logging +from typing import Optional, TypeVar, Union, Type, Dict + +from s2python.common import ( + Handshake, + HandshakeResponse, + InstructionStatusUpdate, + PowerForecast, + PowerMeasurement, + ReceptionStatus, + ResourceManagerDetails, + RevokeObject, + SelectControlType, + SessionRequest, +) +from s2python.frbc import ( + FRBCActuatorStatus, + FRBCFillLevelTargetProfile, + FRBCInstruction, + FRBCLeakageBehaviour, + FRBCStorageStatus, + FRBCSystemDescription, + FRBCTimerStatus, + FRBCUsageForecast, +) +from s2python.pebc import ( + PEBCPowerConstraints, + PEBCEnergyConstraint, + PEBCInstruction, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, +) +from s2python.ppbc import PPBCScheduleInstruction + +from s2python.message import S2Message +from s2python.validate_values_mixin import S2MessageComponent +from s2python.s2_validation_error import S2ValidationError + + +LOGGER = logging.getLogger(__name__) +S2MessageType = str + +M = TypeVar("M", bound=S2MessageComponent) + + +# May be generated with development_utilities/generate_s2_message_type_to_class.py +TYPE_TO_MESSAGE_CLASS: Dict[str, Type[S2Message]] = { + "FRBC.ActuatorStatus": FRBCActuatorStatus, + "FRBC.FillLevelTargetProfile": FRBCFillLevelTargetProfile, + "FRBC.Instruction": FRBCInstruction, + "FRBC.LeakageBehaviour": FRBCLeakageBehaviour, + "FRBC.StorageStatus": FRBCStorageStatus, + "FRBC.SystemDescription": FRBCSystemDescription, + "FRBC.TimerStatus": FRBCTimerStatus, + "FRBC.UsageForecast": FRBCUsageForecast, + "PPBC.ScheduleInstruction": PPBCScheduleInstruction, + "PEBC.PowerConstraints": PEBCPowerConstraints, + "Handshake": Handshake, + "HandshakeResponse": HandshakeResponse, + "InstructionStatusUpdate": InstructionStatusUpdate, + "PowerForecast": PowerForecast, + "PowerMeasurement": PowerMeasurement, + "ReceptionStatus": ReceptionStatus, + "ResourceManagerDetails": ResourceManagerDetails, + "RevokeObject": RevokeObject, + "SelectControlType": SelectControlType, + "SessionRequest": SessionRequest, +} + + +class S2Parser: + @staticmethod + def _parse_json_if_required(unparsed_message: Union[dict, str, bytes]) -> dict: + if isinstance(unparsed_message, (str, bytes)): + return json.loads(unparsed_message) + return unparsed_message + + @staticmethod + def parse_as_any_message(unparsed_message: Union[dict, str, bytes]) -> S2Message: + """Parse the message as any S2 python message regardless of message type. + + :param unparsed_message: The message as a JSON-formatted string or as a json-parsed dictionary. + :raises: S2ValidationError, json.JSONDecodeError + :return: The parsed S2 message if no errors were found. + """ + message_json = S2Parser._parse_json_if_required(unparsed_message) + message_type = S2Parser.parse_message_type(message_json) + + if message_type not in TYPE_TO_MESSAGE_CLASS: + raise S2ValidationError( + None, + message_json, + f"Unable to parse {message_type} as an S2 message. Type unknown.", + None, + ) + + return TYPE_TO_MESSAGE_CLASS[message_type].model_validate(message_json) + + @staticmethod + def parse_as_message( + unparsed_message: Union[dict, str, bytes], as_message: Type[M] + ) -> M: + """Parse the message to a specific S2 python message. + + :param unparsed_message: The message as a JSON-formatted string or as a JSON-parsed dictionary. + :param as_message: The type of message that is expected within the `message` + :raises: S2ValidationError, json.JSONDecodeError + :return: The parsed S2 message if no errors were found. + """ + message_json = S2Parser._parse_json_if_required(unparsed_message) + return as_message.from_dict(message_json) + + @staticmethod + def parse_message_type( + unparsed_message: Union[dict, str, bytes], + ) -> Optional[S2MessageType]: + """Parse only the message type from the unparsed message. + + This is useful to call before `parse_as_message` to retrieve the message type and allows for strictly-typed + parsing. + + :param unparsed_message: The message as a JSON-formatted string or as a JSON-parsed dictionary. + :raises: json.JSONDecodeError + :return: The parsed S2 message type if no errors were found. + """ + message_json = S2Parser._parse_json_if_required(unparsed_message) + + return message_json.get("message_type") diff --git a/s2-python/src/s2python/s2_validation_error.py b/s2-python/src/s2python/s2_validation_error.py new file mode 100644 index 0000000..dc43419 --- /dev/null +++ b/s2-python/src/s2python/s2_validation_error.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import Union, Type, Optional + +from pydantic import ValidationError +from pydantic.v1.error_wrappers import ValidationError as ValidationErrorV1 + + +@dataclass +class S2ValidationError(Exception): + class_: Optional[Type] + obj: object + msg: str + pydantic_validation_error: Union[ + ValidationErrorV1, ValidationError, TypeError, None + ] diff --git a/s2-python/src/s2python/utils.py b/s2-python/src/s2python/utils.py new file mode 100644 index 0000000..b4f78ed --- /dev/null +++ b/s2-python/src/s2python/utils.py @@ -0,0 +1,8 @@ +from typing import Generator, Tuple, List, TypeVar + +P = TypeVar("P") + + +def pairwise(arr: List[P]) -> Generator[Tuple[P, P], None, None]: + for i in range(max(len(arr) - 1, 0)): + yield arr[i], arr[i + 1] diff --git a/s2-python/src/s2python/validate_values_mixin.py b/s2-python/src/s2python/validate_values_mixin.py new file mode 100644 index 0000000..6026b0d --- /dev/null +++ b/s2-python/src/s2python/validate_values_mixin.py @@ -0,0 +1,85 @@ +from typing import ( + TypeVar, + Type, + Callable, + Any, + Union, + AbstractSet, + Mapping, + List, + Dict, +) + +from typing_extensions import Self + +from pydantic.v1.error_wrappers import display_errors # pylint: disable=no-name-in-module + +from pydantic import ( # pylint: disable=no-name-in-module + BaseModel, + ValidationError, +) + +from s2python.s2_validation_error import S2ValidationError + + +IntStr = Union[int, str] +AbstractSetIntStr = AbstractSet[IntStr] +MappingIntStrAny = Mapping[IntStr, Any] + + +class S2MessageComponent(BaseModel): + def to_json(self) -> str: + try: + return self.model_dump_json(by_alias=True, exclude_none=True) + except (ValidationError, TypeError) as e: + raise S2ValidationError( + type(self), self, "Pydantic raised a format validation error.", e + ) from e + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump() + + @classmethod + def from_json(cls, json_str: str) -> Self: + gen_model = cls.model_validate_json(json_str) + return gen_model + + @classmethod + def from_dict(cls, json_dict: Dict[str, Any]) -> Self: + gen_model = cls.model_validate(json_dict) + return gen_model + + +def convert_to_s2exception(f: Callable) -> Callable: + def inner(*args: List[Any], **kwargs: Dict[str, Any]) -> Any: + try: + return f(*args, **kwargs) + except ValidationError as e: + if isinstance(args[0], BaseModel): + class_type = type(args[0]) + args = args[1:] + else: + class_type = None + + raise S2ValidationError(class_type, args, display_errors(e.errors()), e) from e # type: ignore[arg-type] + except TypeError as e: + raise S2ValidationError(None, args, str(e), e) from e + + inner.__doc__ = f.__doc__ + inner.__annotations__ = f.__annotations__ + + return inner + + +S = TypeVar("S", bound=S2MessageComponent) + + +def catch_and_convert_exceptions(input_class: Type[S]) -> Type[S]: + input_class.__init__ = convert_to_s2exception(input_class.__init__) # type: ignore[method-assign] + input_class.__setattr__ = convert_to_s2exception(input_class.__setattr__) # type: ignore[method-assign] + input_class.model_validate_json = convert_to_s2exception( # type: ignore[method-assign] + input_class.model_validate_json + ) + input_class.model_validate = convert_to_s2exception(input_class.model_validate) # type: ignore[method-assign] + + return input_class diff --git a/s2-python/src/s2python/version.py b/s2-python/src/s2python/version.py new file mode 100644 index 0000000..3789fe8 --- /dev/null +++ b/s2-python/src/s2python/version.py @@ -0,0 +1,3 @@ +VERSION = "0.2.0" + +S2_VERSION = "0.0.2-beta" diff --git a/s2-python/tests/unit/__init__.py b/s2-python/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-python/tests/unit/common/__init__.py b/s2-python/tests/unit/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-python/tests/unit/common/duration_test.py b/s2-python/tests/unit/common/duration_test.py new file mode 100644 index 0000000..a256bf7 --- /dev/null +++ b/s2-python/tests/unit/common/duration_test.py @@ -0,0 +1,26 @@ +from datetime import timedelta +from unittest import TestCase + +from s2python.common import Duration + + +class DurationTest(TestCase): + def test__from_timedelta__happy_path(self): + # Arrange + duration_timedelta = timedelta(seconds=10) + + # Act + duration = Duration.from_timedelta(duration_timedelta) + + # Assert + self.assertEqual(duration.root, 10_000) + + def test__to_timedelta__happy_path(self): + # Arrange + duration = Duration(root=20_000) + + # Act + duration_timedelta = duration.to_timedelta() + + # Assert + self.assertEqual(duration_timedelta, timedelta(milliseconds=20_000)) diff --git a/s2-python/tests/unit/common/handshake_response_test.py b/s2-python/tests/unit/common/handshake_response_test.py new file mode 100644 index 0000000..5e628da --- /dev/null +++ b/s2-python/tests/unit/common/handshake_response_test.py @@ -0,0 +1,43 @@ +import json +import uuid +from unittest import TestCase + +from s2python.common import HandshakeResponse + + +class HandshakeResponseTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = ( + '{"message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", "message_type": "HandshakeResponse", ' + '"selected_protocol_version": "v1"}' + ) + + # Act + handshake_response: HandshakeResponse = HandshakeResponse.from_json(json_str) + + # Assert + self.assertEqual( + handshake_response.message_id, + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + ) + self.assertEqual(handshake_response.message_type, "HandshakeResponse") + self.assertEqual(handshake_response.selected_protocol_version, "v1") + + def test__to_json__happy_path(self): + # Arrange + handshake_response = HandshakeResponse( + message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + selected_protocol_version="v1", + ) + + # Act + json_str = handshake_response.to_json() + + # Assert + expected_json = { + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "message_type": "HandshakeResponse", + "selected_protocol_version": "v1", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/handshake_test.py b/s2-python/tests/unit/common/handshake_test.py new file mode 100644 index 0000000..715e360 --- /dev/null +++ b/s2-python/tests/unit/common/handshake_test.py @@ -0,0 +1,44 @@ +import json +import uuid +from unittest import TestCase + +from s2python.common import Handshake, EnergyManagementRole + + +class HandshakeTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = ( + '{"message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", "message_type": "Handshake", "role": "RM", ' + '"supported_protocol_versions": ["v1", "v2"]}' + ) + + # Act + handshake = Handshake.from_json(json_str) + + # Assert + self.assertEqual( + handshake.message_id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3") + ) + self.assertEqual(handshake.role, EnergyManagementRole.RM) + self.assertEqual(handshake.supported_protocol_versions, ["v1", "v2"]) + + def test__to_json__happy_path(self): + # Arrange + handshake = Handshake( + message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + role=EnergyManagementRole.CEM, + supported_protocol_versions=["v3"], + ) + + # Act + json_str = handshake.to_json() + + # Assert + expected_json = { + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "message_type": "Handshake", + "role": "CEM", + "supported_protocol_versions": ["v3"], + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/instruction_status_update_test.py b/s2-python/tests/unit/common/instruction_status_update_test.py new file mode 100644 index 0000000..91b81f2 --- /dev/null +++ b/s2-python/tests/unit/common/instruction_status_update_test.py @@ -0,0 +1,63 @@ +from datetime import datetime, timezone as offset, timedelta +import json +import uuid +from unittest import TestCase + +from pytz import timezone + +from s2python.common import InstructionStatusUpdate, InstructionStatus + + +class InstructionStatusUpdateTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{"message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "message_type": "InstructionStatusUpdate", + "instruction_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced4", + "status_type": "SUCCEEDED", + "timestamp": "2023-08-02T12:48:42+01:00"} + """ + + # Act + instruction_status_update = InstructionStatusUpdate.from_json(json_str) + + # Assert + self.assertEqual( + instruction_status_update.message_id, + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + ) + self.assertEqual( + instruction_status_update.instruction_id, + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4"), + ) + self.assertEqual( + instruction_status_update.status_type, InstructionStatus.SUCCEEDED + ) + self.assertEqual( + instruction_status_update.timestamp, + datetime(2023, 8, 2, 12, 48, 42, tzinfo=offset(timedelta(hours=1))), + ) + + def test__to_json__happy_path(self): + # Arrange + instruction_status_update = InstructionStatusUpdate( + message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + instruction_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4"), + status_type=InstructionStatus.SUCCEEDED, + timestamp=timezone("Europe/Amsterdam").localize( + datetime(2023, 8, 2, 12, 48, 42) + ), + ) + + # Act + json_str = instruction_status_update.to_json() + + # Assert + expected_json = { + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "message_type": "InstructionStatusUpdate", + "instruction_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced4", + "status_type": "SUCCEEDED", + "timestamp": "2023-08-02T12:48:42+02:00", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/number_range_test.py b/s2-python/tests/unit/common/number_range_test.py new file mode 100644 index 0000000..e597113 --- /dev/null +++ b/s2-python/tests/unit/common/number_range_test.py @@ -0,0 +1,74 @@ +import json +from unittest import TestCase + +from s2python.common import NumberRange +from s2python.s2_validation_error import S2ValidationError + + +class NumberRangeTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = '{"start_of_range": 4.0, "end_of_range": 5.0}' + + # Act + number_range = NumberRange.from_json(json_str) + + # Assert + expected_start_of_range = 4.0 + expected_end_of_range = 5.0 + self.assertEqual(number_range.start_of_range, expected_start_of_range) + self.assertEqual(number_range.end_of_range, expected_end_of_range) + + def test__from_json__happy_path_equals(self): + # Arrange + json_str = '{"start_of_range": 4.0, "end_of_range": 5.0}' + + # Act + number_range = NumberRange.from_json(json_str) + + # Assert + self.assertEqual( + number_range, NumberRange(start_of_range=4.0, end_of_range=5.0) + ) + + def test__from_json__format_validation_error(self): + # Arrange + json_str = '{"start_of_range": 4.0}' + + # Act / Assert + with self.assertRaises(S2ValidationError): + NumberRange.from_json(json_str) + + def test__from_json__end_of_range_smaller_than_start(self): + # Arrange + json_str = '{"start_of_range": 6.0, "end_of_range": 5.0}' + + # Act + number_range = NumberRange.from_json(json_str) + + # Assert + self.assertEqual( + number_range, NumberRange(start_of_range=6.0, end_of_range=5.0) + ) + + def test__to_json__happy_path(self): + # Arrange + number_range = NumberRange(start_of_range=4.0, end_of_range=5.0) + + # Act + json_str = number_range.to_json() + + # Assert + expected_json = {"start_of_range": 4.0, "end_of_range": 5.0} + self.assertEqual(json.loads(json_str), expected_json) + + def test__to_json__end_of_range_smaller_than_start(self): + # Arrange + number_range = NumberRange(start_of_range=6.0, end_of_range=5.0) + + # Act + json_str = number_range.to_json() + + # Assert + expected_json = {"start_of_range": 6.0, "end_of_range": 5.0} + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/power_forecast_element_test.py b/s2-python/tests/unit/common/power_forecast_element_test.py new file mode 100644 index 0000000..4f68f45 --- /dev/null +++ b/s2-python/tests/unit/common/power_forecast_element_test.py @@ -0,0 +1,61 @@ +import json +from datetime import timedelta +from unittest import TestCase + +from s2python.common import ( + PowerForecastElement, + Duration, + PowerForecastValue, + CommodityQuantity, +) + + +class PowerForecastElementTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = ( + '{"duration": 4000, "power_values": [{"commodity_quantity": "NATURAL_GAS.FLOW_RATE", ' + '"value_expected": 500.2}]}' + ) + + # Act + power_forecast_element = PowerForecastElement.from_json(json_str) + + # Assert + self.assertEqual( + power_forecast_element.duration, + Duration.from_timedelta(timedelta(seconds=4)), + ) + self.assertEqual( + power_forecast_element.power_values, + [ + PowerForecastValue( # pyright: ignore[reportCallIssue] + commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, + value_expected=500.2, + ) + ], + ) + + def test__to_json__happy_path(self): + # Arrange + power_forecast_element = PowerForecastElement( + power_values=[ + PowerForecastValue( # pyright: ignore[reportCallIssue] + commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, + value_expected=500.2, + ) + ], + duration=Duration.from_timedelta(timedelta(seconds=4)), + ) + + # Act + json_str = power_forecast_element.to_json() + + # Assert + expected_json = { + "duration": 4000, + "power_values": [ + {"commodity_quantity": "NATURAL_GAS.FLOW_RATE", "value_expected": 500.2} + ], + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/power_forecast_test.py b/s2-python/tests/unit/common/power_forecast_test.py new file mode 100644 index 0000000..044365e --- /dev/null +++ b/s2-python/tests/unit/common/power_forecast_test.py @@ -0,0 +1,84 @@ +import uuid +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase + +from s2python.common import ( + PowerForecast, + Duration, + PowerForecastValue, + PowerForecastElement, + CommodityQuantity, +) + + +class PowerForecastTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """ + {"elements": [{"duration": 4000, "power_values": [{"commodity_quantity": "NATURAL_GAS.FLOW_RATE", "value_expected": 500.2}]}], + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced9", + "message_type": "PowerForecast", + "start_time": "2023-08-02T12:48:42+01:00"} + """ + + # Act + power_forecast = PowerForecast.from_json(json_str) + + # Assert + power_forecast_element = PowerForecastElement( + power_values=[ + PowerForecastValue( # pyright: ignore[reportCallIssue] + commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, + value_expected=500.2, + ) + ], + duration=Duration.from_timedelta(timedelta(seconds=4)), + ) + self.assertEqual(power_forecast.elements, [power_forecast_element]) + self.assertEqual( + power_forecast.message_id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced9") + ) + self.assertEqual( + power_forecast.start_time, + datetime(2023, 8, 2, 12, 48, 42, tzinfo=offset(timedelta(hours=1))), + ) + + def test__to_json__happy_path(self): + # Arrange + power_forecast_element = PowerForecastElement( + power_values=[ + PowerForecastValue( # pyright: ignore[reportCallIssue] + commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, + value_expected=500.2, + ) + ], + duration=Duration.from_timedelta(timedelta(seconds=4)), + ) + power_forecast = PowerForecast( + elements=[power_forecast_element], + message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced9"), + start_time=datetime(2023, 8, 2, 12, 48, 42, tzinfo=offset(timedelta(hours=2))), + ) + + # Act + json_str = power_forecast.to_json() + + # Assert + expected_json = { + "elements": [ + { + "duration": 4000, + "power_values": [ + { + "commodity_quantity": "NATURAL_GAS.FLOW_RATE", + "value_expected": 500.2, + } + ], + } + ], + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced9", + "message_type": "PowerForecast", + "start_time": "2023-08-02T12:48:42+02:00", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/power_forecast_value_test.py b/s2-python/tests/unit/common/power_forecast_value_test.py new file mode 100644 index 0000000..4a94e0e --- /dev/null +++ b/s2-python/tests/unit/common/power_forecast_value_test.py @@ -0,0 +1,79 @@ +import json +from unittest import TestCase + +from s2python.common import PowerForecastValue, CommodityQuantity + + +class PowerForecastValueTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{"commodity_quantity": "HEAT.FLOW_RATE", + "value_lower_limit": 450.3, + "value_lower_95PPR": 470.4, + "value_lower_68PPR": 480.3, + "value_expected": 500.2, + "value_upper_68PPR": 510.3, + "value_upper_95PPR": 515.9, + "value_upper_limit": 600}""" + + # Act + power_forecast_value: PowerForecastValue = PowerForecastValue.from_json( + json_str + ) + + # Assert + self.assertEqual( + power_forecast_value.commodity_quantity, CommodityQuantity.HEAT_FLOW_RATE + ) + self.assertEqual(power_forecast_value.value_lower_limit, 450.3) + self.assertEqual(power_forecast_value.value_lower_95PPR, 470.4) + self.assertEqual(power_forecast_value.value_lower_68PPR, 480.3) + self.assertEqual(power_forecast_value.value_expected, 500.2) + self.assertEqual(power_forecast_value.value_upper_68PPR, 510.3) + self.assertEqual(power_forecast_value.value_upper_95PPR, 515.9) + self.assertEqual(power_forecast_value.value_upper_limit, 600) + + def test__to_json__happy_path(self): + # Arrange + power_forecast_value = PowerForecastValue( # pyright: ignore[reportCallIssue] + commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, + value_lower_limit=450.3, + value_lower_95PPR=470.4, + value_lower_68PPR=480.3, + value_expected=500.2, + value_upper_68PPR=510.3, + value_upper_95PPR=515.9, + value_upper_limit=600, + ) + + # Act + json_str = power_forecast_value.to_json() + + # Assert + expected_json = { + "commodity_quantity": "HEAT.TEMPERATURE", + "value_lower_limit": 450.3, + "value_lower_95PPR": 470.4, + "value_lower_68PPR": 480.3, + "value_expected": 500.2, + "value_upper_68PPR": 510.3, + "value_upper_95PPR": 515.9, + "value_upper_limit": 600, + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__to_json__only_value_expected(self): + # Arrange + power_forecast_value = PowerForecastValue( # pyright: ignore[reportCallIssue] + commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, value_expected=500.2 + ) + + # Act + json_str = power_forecast_value.to_json() + + # Assert + expected_json = { + "commodity_quantity": "HEAT.TEMPERATURE", + "value_expected": 500.2, + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/power_measurement_test.py b/s2-python/tests/unit/common/power_measurement_test.py new file mode 100644 index 0000000..a8c555c --- /dev/null +++ b/s2-python/tests/unit/common/power_measurement_test.py @@ -0,0 +1,64 @@ +from datetime import datetime, timezone as offset, timedelta +import json +import uuid +from unittest import TestCase + +from s2python.common import PowerMeasurement, PowerValue, CommodityQuantity + + +class PowerMeasurementTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """ + {"values": [{"commodity_quantity": "OIL.FLOW_RATE", "value": 42.42}], + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced8", + "message_type": "PowerMeasurement", + "measurement_timestamp": "2023-08-03T12:48:42+01:00"} + """ + + # Act + power_measurement: PowerMeasurement = PowerMeasurement.from_json(json_str) + + # Assert + self.assertEqual( + power_measurement.message_id, + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced8"), + ) + self.assertEqual( + power_measurement.measurement_timestamp, + datetime(2023, 8, 3, 12, 48, 42, tzinfo=offset(timedelta(hours=1))), + ) + self.assertEqual( + power_measurement.values, + [ + PowerValue( + commodity_quantity=CommodityQuantity.OIL_FLOW_RATE, value=42.42 + ) + ], + ) + + def test__to_json__happy_path(self): + # Arrange + power_measurement = PowerMeasurement( + values=[ + PowerValue( + commodity_quantity=CommodityQuantity.OIL_FLOW_RATE, value=42.42 + ) + ], + message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced8"), + measurement_timestamp=datetime( + 2023, 8, 3, 12, 48, 42, tzinfo=offset(timedelta(hours=1)) + ), + ) + + # Act + json_str = power_measurement.to_json() + + # Assert + expected_json = { + "values": [{"commodity_quantity": "OIL.FLOW_RATE", "value": 42.42}], + "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced8", + "message_type": "PowerMeasurement", + "measurement_timestamp": "2023-08-03T12:48:42+01:00", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/power_range_test.py b/s2-python/tests/unit/common/power_range_test.py new file mode 100644 index 0000000..7cbaed4 --- /dev/null +++ b/s2-python/tests/unit/common/power_range_test.py @@ -0,0 +1,67 @@ +import json +from unittest import TestCase + +from s2python.common import PowerRange, CommodityQuantity +from s2python.s2_validation_error import S2ValidationError + + +class PowerRangeTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = '{"start_of_range": 4.0, "end_of_range": 5.0, "commodity_quantity": "ELECTRIC.POWER.L1"}' + + # Act + power_range: PowerRange = PowerRange.from_json(json_str) + + # Assert + expected_start_of_range = 4.0 + expected_end_of_range = 5.0 + self.assertEqual(power_range.start_of_range, expected_start_of_range) + self.assertEqual(power_range.end_of_range, expected_end_of_range) + self.assertEqual( + power_range.commodity_quantity, CommodityQuantity.ELECTRIC_POWER_L1 + ) + + def test__from_json__format_validation_error(self): + # Arrange + json_str = '{"start_of_range": 4.0}' + + # Act / Assert + with self.assertRaises(S2ValidationError): + PowerRange.from_json(json_str) + + def test__from_json__value_validation_error(self): + # Arrange + json_str = '{"start_of_range": 6.0, "end_of_range": 5.0, "commodity_quantity": "ELECTRIC.POWER.L1"}' + + # Act / Assert + with self.assertRaises(S2ValidationError): + PowerRange.from_json(json_str) + + def test__to_json__happy_path(self): + # Arrange + number_range = PowerRange( + start_of_range=4.0, + end_of_range=5.0, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ) + + # Act + json_str = number_range.to_json() + + # Assert + expected_json = { + "start_of_range": 4.0, + "end_of_range": 5.0, + "commodity_quantity": "ELECTRIC.POWER.L1", + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__to_json__value_validation_error(self): + # Arrange/ Act / Assert + with self.assertRaises(S2ValidationError): + PowerRange( + start_of_range=6.0, + end_of_range=5.0, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ) diff --git a/s2-python/tests/unit/common/power_value_test.py b/s2-python/tests/unit/common/power_value_test.py new file mode 100644 index 0000000..637e729 --- /dev/null +++ b/s2-python/tests/unit/common/power_value_test.py @@ -0,0 +1,32 @@ +import json +from unittest import TestCase + +from s2python.common import PowerValue, CommodityQuantity + + +class PowerValueTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = '{"commodity_quantity": "OIL.FLOW_RATE", "value": 43.43}' + + # Act + power_value: PowerValue = PowerValue.from_json(json_str) + + # Assert + self.assertEqual( + power_value.commodity_quantity, CommodityQuantity.OIL_FLOW_RATE + ) + self.assertEqual(power_value.value, 43.43) + + def test__to_json__happy_path(self): + # Arrange + power_value = PowerValue( + commodity_quantity=CommodityQuantity.OIL_FLOW_RATE, value=43.43 + ) + + # Act + json_str = power_value.to_json() + + # Assert + expected_json = {"commodity_quantity": "OIL.FLOW_RATE", "value": 43.43} + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/reception_status_test.py b/s2-python/tests/unit/common/reception_status_test.py new file mode 100644 index 0000000..6229b4e --- /dev/null +++ b/s2-python/tests/unit/common/reception_status_test.py @@ -0,0 +1,48 @@ +import json +import uuid +from unittest import TestCase + +from s2python.common import ReceptionStatus, ReceptionStatusValues + + +class ReceptionStatusTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """ + { "diagnostic_label": "blablabla", + "message_type": "ReceptionStatus", + "status": "TEMPORARY_ERROR", + "subject_message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced5" + }""" + + # Act + reception_status: ReceptionStatus = ReceptionStatus.from_json(json_str) + + # Assert + self.assertEqual(reception_status.diagnostic_label, "blablabla") + self.assertEqual(reception_status.status, ReceptionStatusValues.TEMPORARY_ERROR) + self.assertEqual( + reception_status.subject_message_id, + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + ) + + def test__to_json__happy_path(self): + # Arrange + reception_status = ReceptionStatus( + diagnostic_label="Dagobert Duck is king!", + message_type="ReceptionStatus", + status=ReceptionStatusValues.OK, + subject_message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + ) + + # Act + json_str = reception_status.to_json() + + # Assert + expected_json = { + "diagnostic_label": "Dagobert Duck is king!", + "message_type": "ReceptionStatus", + "status": "OK", + "subject_message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced5", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/resource_manager_details_test.py b/s2-python/tests/unit/common/resource_manager_details_test.py new file mode 100644 index 0000000..b5b5843 --- /dev/null +++ b/s2-python/tests/unit/common/resource_manager_details_test.py @@ -0,0 +1,147 @@ +import json +import uuid +from datetime import timedelta +from unittest import TestCase + +from s2python.common import ( + ResourceManagerDetails, + CommodityQuantity, + ControlType, + Currency, + Duration, + Commodity, + Role, + RoleType, +) + + +class ResourceManagerDetailsTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{ + "available_control_types": ["POWER_ENVELOPE_BASED_CONTROL", "NOT_CONTROLABLE", "FILL_RATE_BASED_CONTROL"], + "currency": "CHE", + "firmware_version": "5.4.2v", + "instruction_processing_delay": 342, + "manufacturer": "Dagobert inc.", + "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "ResourceManagerDetails", + "model": "Safe", + "name": "Dagobert's safe", + "provides_forecast": true, + "provides_power_measurement_types": ["HEAT.THERMAL_POWER", "ELECTRIC.POWER.3_PHASE_SYMMETRIC"], + "resource_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", + "roles": [{"commodity": "HEAT", "role": "ENERGY_PRODUCER"}, {"commodity": "ELECTRICITY", "role": "ENERGY_CONSUMER"}], + "serial_number": "safe_batch6_model432" + } + """ + + # Act + resource_manager_details: ResourceManagerDetails = ( + ResourceManagerDetails.from_json(json_str) + ) + + # Assert + self.assertEqual( + resource_manager_details.available_control_types, + [ + ControlType.POWER_ENVELOPE_BASED_CONTROL, + ControlType.NOT_CONTROLABLE, + ControlType.FILL_RATE_BASED_CONTROL, + ], + ) + self.assertEqual(resource_manager_details.currency, Currency.CHE) + self.assertEqual(resource_manager_details.firmware_version, "5.4.2v") + self.assertEqual( + resource_manager_details.instruction_processing_delay, + Duration.from_timedelta(timedelta(milliseconds=342)), + ) + self.assertEqual(resource_manager_details.manufacturer, "Dagobert inc.") + self.assertEqual( + resource_manager_details.message_id, + uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + ) + self.assertEqual(resource_manager_details.model, "Safe") + self.assertEqual(resource_manager_details.name, "Dagobert's safe") + self.assertEqual(resource_manager_details.provides_forecast, True) + self.assertEqual( + resource_manager_details.provides_power_measurement_types, + [ + CommodityQuantity.HEAT_THERMAL_POWER, + CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, + ], + ) + self.assertEqual( + resource_manager_details.resource_id, + uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6"), + ) + self.assertEqual( + resource_manager_details.roles, + [ + Role(commodity=Commodity.HEAT, role=RoleType.ENERGY_PRODUCER), + Role(commodity=Commodity.ELECTRICITY, role=RoleType.ENERGY_CONSUMER), + ], + ) + self.assertEqual(resource_manager_details.serial_number, "safe_batch6_model432") + + def test__to_json__happy_path(self): + # Arrange + resource_manager_details = ResourceManagerDetails( + available_control_types=[ + ControlType.POWER_ENVELOPE_BASED_CONTROL, + ControlType.NOT_CONTROLABLE, + ControlType.FILL_RATE_BASED_CONTROL, + ], + currency=Currency.CHE, + firmware_version="5.4.2v", + instruction_processing_delay=Duration.from_timedelta( + timedelta(milliseconds=342) + ), + manufacturer="Dagobert inc.", + message_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + model="Safe", + name="Dagobert's safe", + provides_forecast=True, + provides_power_measurement_types=[ + CommodityQuantity.HEAT_THERMAL_POWER, + CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, + ], + resource_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6"), + roles=[ + Role(commodity=Commodity.HEAT, role=RoleType.ENERGY_PRODUCER), + Role(commodity=Commodity.ELECTRICITY, role=RoleType.ENERGY_CONSUMER), + ], + serial_number="safe_batch6_model432", + ) + + # Act + json_str = resource_manager_details.to_json() + + # Assert + expected_json = { + "available_control_types": [ + "POWER_ENVELOPE_BASED_CONTROL", + "NOT_CONTROLABLE", + "FILL_RATE_BASED_CONTROL", + ], + "currency": "CHE", + "firmware_version": "5.4.2v", + "instruction_processing_delay": 342, + "manufacturer": "Dagobert inc.", + "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "ResourceManagerDetails", + "model": "Safe", + "name": "Dagobert's safe", + "provides_forecast": True, + "provides_power_measurement_types": [ + "HEAT.THERMAL_POWER", + "ELECTRIC.POWER.3_PHASE_SYMMETRIC", + ], + "resource_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", + "roles": [ + {"commodity": "HEAT", "role": "ENERGY_PRODUCER"}, + {"commodity": "ELECTRICITY", "role": "ENERGY_CONSUMER"}, + ], + "serial_number": "safe_batch6_model432", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/revoke_object_test.py b/s2-python/tests/unit/common/revoke_object_test.py new file mode 100644 index 0000000..f146d74 --- /dev/null +++ b/s2-python/tests/unit/common/revoke_object_test.py @@ -0,0 +1,49 @@ +import json +import uuid +from unittest import TestCase + +from s2python.common import RevokeObject, RevokableObjects + + +class RevokeObjectTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{ + "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "RevokeObject", + "object_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", + "object_type": "FRBC.Instruction" + } + """ + + # Act + revoke_object: RevokeObject = RevokeObject.from_json(json_str) + + # Assert + self.assertEqual( + revoke_object.message_id, uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5") + ) + self.assertEqual( + revoke_object.object_id, uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6") + ) + self.assertEqual(revoke_object.object_type, RevokableObjects.FRBC_Instruction) + + def test__to_json__happy_path(self): + # Arrange + revoke_object = RevokeObject( + message_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + object_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6"), + object_type=RevokableObjects.FRBC_Instruction, + ) + + # Act + json_str = revoke_object.to_json() + + # Assert + expected_json = { + "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "RevokeObject", + "object_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", + "object_type": "FRBC.Instruction", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/role_test.py b/s2-python/tests/unit/common/role_test.py new file mode 100644 index 0000000..ce9e896 --- /dev/null +++ b/s2-python/tests/unit/common/role_test.py @@ -0,0 +1,28 @@ +import json +from unittest import TestCase + +from s2python.common import Role, Commodity, RoleType + + +class RoleTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = '{"commodity": "HEAT", "role": "ENERGY_STORAGE"}' + + # Act + role: Role = Role.from_json(json_str) + + # Assert + self.assertEqual(role.commodity, Commodity.HEAT) + self.assertEqual(role.role, RoleType.ENERGY_STORAGE) + + def test__to_json__happy_path(self): + # Arrange + role = Role(commodity=Commodity.HEAT, role=RoleType.ENERGY_STORAGE) + + # Act + json_str = role.to_json() + + # Assert + expected_json = {"commodity": "HEAT", "role": "ENERGY_STORAGE"} + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/select_control_type_test.py b/s2-python/tests/unit/common/select_control_type_test.py new file mode 100644 index 0000000..8f8fae2 --- /dev/null +++ b/s2-python/tests/unit/common/select_control_type_test.py @@ -0,0 +1,43 @@ +import json +import uuid +from unittest import TestCase + +from s2python.common import SelectControlType, ControlType + + +class SelectControlTypeTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{ + "control_type": "OPERATION_MODE_BASED_CONTROL", + "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "SelectControlType" + }""" + + # Act + select_control_type: SelectControlType = SelectControlType.from_json(json_str) + + # Assert + self.assertEqual( + select_control_type.message_id, + uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + ) + self.assertEqual(select_control_type.control_type, ControlType.OPERATION_MODE_BASED_CONTROL) + + def test__to_json__happy_path(self): + # Arrange + select_control_type = SelectControlType( + message_id=uuid.UUID("3bdec96b-be3b-4ba9-afa1-c4a0632cced5"), + control_type=ControlType.DEMAND_DRIVEN_BASED_CONTROL, + ) + + # Act + json_str = select_control_type.to_json() + + # Assert + expected_json = { + "control_type": "DEMAND_DRIVEN_BASED_CONTROL", + "message_id": "3bdec96b-be3b-4ba9-afa1-c4a0632cced5", + "message_type": "SelectControlType", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/session_request_test.py b/s2-python/tests/unit/common/session_request_test.py new file mode 100644 index 0000000..4813bcf --- /dev/null +++ b/s2-python/tests/unit/common/session_request_test.py @@ -0,0 +1,43 @@ +import json +import uuid +from unittest import TestCase + +from s2python.common import SessionRequest, SessionRequestType + + +class SessionRequestTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{ + "request": "TERMINATE", + "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "SessionRequest" + }""" + + # Act + session_request: SessionRequest = SessionRequest.from_json(json_str) + + # Assert + self.assertEqual( + session_request.message_id, + uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + ) + self.assertEqual(session_request.request, SessionRequestType.TERMINATE) + + def test__to_json__happy_path(self): + # Arrange + session_request = SessionRequest( # pyright: ignore[reportCallIssue] + message_id=uuid.UUID("3bdec96e-be3b-4ba9-afa0-c4a0632cced5"), + request=SessionRequestType.RECONNECT, + ) + + # Act + json_str = session_request.to_json() + + # Assert + expected_json = { + "request": "RECONNECT", + "message_id": "3bdec96e-be3b-4ba9-afa0-c4a0632cced5", + "message_type": "SessionRequest", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/common/timer_test.py b/s2-python/tests/unit/common/timer_test.py new file mode 100644 index 0000000..e319b8c --- /dev/null +++ b/s2-python/tests/unit/common/timer_test.py @@ -0,0 +1,88 @@ +import json +import uuid +from datetime import timedelta +from unittest import TestCase + +from s2python.common import Timer +from s2python.common.duration import Duration +from s2python.s2_validation_error import S2ValidationError + + +class TimerTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = '{"id": "2bdec96b-be3b-4ba9-afa0-c4a0632ccedf", "duration": 5000, "diagnostic_label": "some_label"}' + + # Act + timer = Timer.from_json(json_str) + + # Assert + expected_id = uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf") + expected_duration = timedelta(seconds=5) + expected_diagnostic_label = "some_label" + self.assertEqual(timer.id, expected_id) + self.assertEqual(timer.duration.to_timedelta(), expected_duration) + self.assertEqual(timer.diagnostic_label, expected_diagnostic_label) + + def test_optional_parameters(self): + # Arrange / Act + timer = Timer( # pyright: ignore[reportCallIssue] + id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf"), + duration=Duration.from_timedelta(timedelta(seconds=5)), + ) + + # Assert + expected_id = uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf") + expected_duration = timedelta(seconds=5) + + self.assertIsNone(timer.diagnostic_label) + self.assertEqual(timer.id, expected_id) + self.assertEqual(timer.duration.to_timedelta(), expected_duration) + + def test__from_json__format_validation_error(self): + # Arrange + json_str = ( + '{"id": "2bdec96b-be3b-4ba9-afa0-c4a0632ccedf", "diagnostic_label": "some_label"}' + ) + + # Act / Assert + with self.assertRaises(S2ValidationError): + Timer.from_json(json_str) + + def test__to_json__happy_path(self): + # Arrange + timer = Timer( + id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf"), + duration=Duration.from_timedelta(timedelta(seconds=5)), + diagnostic_label="some_label", + ) + + # Act + json_str = timer.to_json() + + # Assert + expected_json = { + "id": "2bdec96b-be3b-4ba9-afa0-c4a0632ccedf", + "diagnostic_label": "some_label", + "duration": 5000, + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__assignment__overriden_duration_field(self): + # Arrange + timer = Timer( + id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf"), + duration=Duration.from_timedelta(timedelta(seconds=5)), + diagnostic_label="some_label", + ) + + # Act + timer.duration = Duration.from_timedelta(timedelta(seconds=4)) + + # Assert + expected_id = uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf") + expected_duration = timedelta(seconds=4) + expected_diagnostic_label = "some_label" + self.assertEqual(timer.id, expected_id) + self.assertEqual(timer.duration.to_timedelta(), expected_duration) + self.assertEqual(timer.diagnostic_label, expected_diagnostic_label) diff --git a/s2-python/tests/unit/common/transition_test.py b/s2-python/tests/unit/common/transition_test.py new file mode 100644 index 0000000..c81dbd2 --- /dev/null +++ b/s2-python/tests/unit/common/transition_test.py @@ -0,0 +1,156 @@ +import uuid +from datetime import timedelta +import json +from unittest import TestCase + +from s2python.common import Transition, Duration +from s2python.s2_validation_error import S2ValidationError + + +class TransitionTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ + { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", + "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", + "start_timers": ["2bdec96b-be3b-4ba9-afa0-c4a0632cced4", "2bdec96b-be3b-4ba9-afa0-c4a0632cced5"], + "blocking_timers": ["2bdec96b-be3b-4ba9-afa0-c4a0632cced4"], + "transition_costs": 4.3, + "transition_duration": 1500, + "abnormal_condition_only": false} + """ + + # Act + transition: Transition = Transition.from_json(json_str) + + # Assert + self.assertEqual( + transition.id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3") + ) + self.assertEqual( + transition.from_, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2") + ) + self.assertEqual( + transition.to, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1") + ) + self.assertEqual( + transition.start_timers, + [ + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4"), + uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced5"), + ], + ) + self.assertEqual( + transition.blocking_timers, + [uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4")], + ) + self.assertEqual(transition.transition_costs, 4.3) + assert transition.transition_duration is not None + self.assertEqual( + transition.transition_duration.to_timedelta(), timedelta(seconds=1.5) + ) + self.assertEqual(transition.abnormal_condition_only, False) + + def test__from_json__happy_path_min(self): + # Arrange + json_str = """ + { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", + "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", + "start_timers": [], + "blocking_timers": [], + "abnormal_condition_only": true} + """ + + # Act + transition: Transition = Transition.from_json(json_str) + + # Assert + self.assertEqual( + transition.id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3") + ) + self.assertEqual( + transition.from_, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2") + ) + self.assertEqual( + transition.to, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1") + ) + self.assertEqual(transition.start_timers, []) + self.assertEqual(transition.blocking_timers, []) + self.assertEqual(transition.transition_costs, None) + self.assertEqual(transition.transition_duration, None) + self.assertEqual(transition.abnormal_condition_only, True) + + def test__from_json__format_validation_error(self): + # Arrange + json_str = """ + { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3" } + """ + + # Act / Assert + with self.assertRaises(S2ValidationError): + Transition.from_json(json_str) + + def test__from_json__value_validation_error_neg_duration(self): + # Arrange + json_str = """ + { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", + "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", + "start_timers": [], + "blocking_timers": [], + "transition_duration": -1500, + "abnormal_condition_only": true} + """ + + # Act / Assert + with self.assertRaises(S2ValidationError): + Transition.from_json(json_str) + + def test__to_json__happy_path(self): + # Arrange + # BUG We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. + # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to + # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 + transition = Transition( + **{ + "id": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + "from": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2"), + "to": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1"), + "start_timers": [], + "blocking_timers": [], + "transition_duration": Duration.from_timedelta( + timedelta(minutes=1, seconds=1) + ), + "abnormal_condition_only": False, + } + ) + + # Act + json_str = transition.to_json() + + # Assert + expected_json = { + "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", + "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", + "start_timers": [], + "blocking_timers": [], + "transition_duration": 61000, + "abnormal_condition_only": False, + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__to_json__value_validation_error_neg_duration(self): + # Arrange/ Act / Assert + with self.assertRaises(S2ValidationError): + Transition( # pyright: ignore[reportCallIssue] + id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + from_=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2"), + to=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1"), + start_timers=[], + blocking_timers=[], + transition_duration=Duration(root=-5000), + abnormal_condition_only=False, + ) diff --git a/s2-python/tests/unit/frbc/frbc_actuator_description_test.py b/s2-python/tests/unit/frbc/frbc_actuator_description_test.py new file mode 100644 index 0000000..1b4f31d --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_actuator_description_test.py @@ -0,0 +1,241 @@ +import json +import uuid +from datetime import timedelta +from unittest import TestCase + +from s2python.common import ( + Transition, + Duration, + Timer, + NumberRange, + PowerRange, + CommodityQuantity, + Commodity, +) +from s2python.frbc import ( + FRBCActuatorDescription, + FRBCOperationMode, + FRBCOperationModeElement, +) + + +class FRBCActuatorDescriptionTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """{ + "diagnostic_label": "some name of actuator", + "id": "3bdec96b-be3b-4ba9-afa0-c4a0632dded5", + "operation_modes": [{ + "abnormal_condition_only": false, + "diagnostic_label": "om1", + "id": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", + "elements": [{ "fill_level_range": {"start_of_range": 4.0, "end_of_range": 5.0}, + "fill_rate": {"start_of_range": 0.13, "end_of_range": 10342.569}, + "power_ranges": [{"start_of_range": 400, "end_of_range": 6000, "commodity_quantity": "HEAT.TEMPERATURE"}, + {"start_of_range": 500, "end_of_range": 7000, "commodity_quantity": "ELECTRIC.POWER.L1"}], + "running_costs": {"start_of_range": 4.3, "end_of_range": 4.6}}] + }], + "supported_commodities": ["HEAT", "ELECTRICITY"], + "timers": [{ + "diagnostic_label": "timer1", + "duration": 2300, + "id": "3bdec10b-be3b-4ba9-afa0-c4a0632ffed6" + }], + "transitions": [{ "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "from": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", + "to": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", + "start_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], + "blocking_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], + "transition_costs": 4.3, + "transition_duration": 1500, + "abnormal_condition_only": false}] + }""" + + # Act + frbc_actuator_description: FRBCActuatorDescription = ( + FRBCActuatorDescription.from_json(json_str) + ) + + # Assert + expected_timer = Timer( + id=uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"), + diagnostic_label="timer1", + duration=Duration.from_timedelta(timedelta(seconds=2.3)), + ) + + # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. + # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to + # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 + expected_transition = Transition( + **{ + "id": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + "from": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), + "to": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), + "start_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], + "blocking_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], + "transition_costs": 4.3, + "transition_duration": Duration.from_milliseconds(1500), + "abnormal_condition_only": False, + } + ) + expected_operation_mode_element = FRBCOperationModeElement( + fill_level_range=NumberRange(start_of_range=4.0, end_of_range=5.0), + fill_rate=NumberRange(start_of_range=0.13, end_of_range=10342.569), + power_ranges=[ + PowerRange( + start_of_range=400, + end_of_range=6000, + commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, + ), + PowerRange( + start_of_range=500, + end_of_range=7000, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ), + ], + running_costs=NumberRange(start_of_range=4.3, end_of_range=4.6), + ) + expected_operation_mode = FRBCOperationMode( + abnormal_condition_only=False, + diagnostic_label="om1", + id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), + elements=[expected_operation_mode_element], + ) + + self.assertEqual( + frbc_actuator_description.diagnostic_label, "some name of actuator" + ) + self.assertEqual( + frbc_actuator_description.id, + uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632dded5"), + ) + self.assertEqual( + frbc_actuator_description.supported_commodities, + [Commodity.HEAT, Commodity.ELECTRICITY], + ) + self.assertEqual( + frbc_actuator_description.operation_modes, [expected_operation_mode] + ) + self.assertEqual(frbc_actuator_description.timers, [expected_timer]) + self.assertEqual(frbc_actuator_description.transitions, [expected_transition]) + + def test__to_json__happy_path(self): + # Arrange + timer = Timer( + id=uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"), + diagnostic_label="timer1", + duration=Duration.from_timedelta(timedelta(seconds=2.3)), + ) + + # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. + # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to + # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 + transition = Transition( + **{ + "id": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), + "from": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), + "to": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), + "start_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], + "blocking_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], + "transition_costs": 4.3, + "transition_duration": Duration.from_milliseconds(1500), + "abnormal_condition_only": False, + } + ) + operation_mode_element = FRBCOperationModeElement( + fill_level_range=NumberRange(start_of_range=4.0, end_of_range=5.0), + fill_rate=NumberRange(start_of_range=0.13, end_of_range=10342.569), + power_ranges=[ + PowerRange( + start_of_range=400, + end_of_range=6000, + commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, + ), + PowerRange( + start_of_range=500, + end_of_range=7000, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ), + ], + running_costs=NumberRange(start_of_range=4.3, end_of_range=4.6), + ) + operation_mode = FRBCOperationMode( + abnormal_condition_only=False, + diagnostic_label="om1", + id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), + elements=[operation_mode_element], + ) + + frbc_actuator_description = FRBCActuatorDescription( + diagnostic_label="some name of actuator", + id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632dded5"), + supported_commodities=[Commodity.HEAT, Commodity.ELECTRICITY], + operation_modes=[operation_mode], + timers=[timer], + transitions=[transition], + ) + + # Act + json_str = frbc_actuator_description.to_json() + + # Assert + expected_json = { + "diagnostic_label": "some name of actuator", + "id": "3bdec96b-be3b-4ba9-afa0-c4a0632dded5", + "operation_modes": [ + { + "abnormal_condition_only": False, + "diagnostic_label": "om1", + "id": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", + "elements": [ + { + "fill_level_range": { + "start_of_range": 4.0, + "end_of_range": 5.0, + }, + "fill_rate": { + "start_of_range": 0.13, + "end_of_range": 10342.569, + }, + "power_ranges": [ + { + "start_of_range": 400, + "end_of_range": 6000, + "commodity_quantity": "HEAT.TEMPERATURE", + }, + { + "start_of_range": 500, + "end_of_range": 7000, + "commodity_quantity": "ELECTRIC.POWER.L1", + }, + ], + "running_costs": { + "start_of_range": 4.3, + "end_of_range": 4.6, + }, + } + ], + } + ], + "supported_commodities": ["HEAT", "ELECTRICITY"], + "timers": [ + { + "diagnostic_label": "timer1", + "duration": 2300, + "id": "3bdec10b-be3b-4ba9-afa0-c4a0632ffed6", + } + ], + "transitions": [ + { + "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", + "from": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", + "to": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", + "start_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], + "blocking_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], + "transition_costs": 4.3, + "transition_duration": 1500, + "abnormal_condition_only": False, + } + ], + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_actuator_status_test.py b/s2-python/tests/unit/frbc/frbc_actuator_status_test.py new file mode 100644 index 0000000..2538381 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_actuator_status_test.py @@ -0,0 +1,95 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCActuatorStatusTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "active_operation_mode_id": "395dcbc5-5c7f-415e-8727-e48fc53761bc", + "actuator_id": "1cee425e-861b-417a-8208-bb6d53aafb00", + "message_id": "07f3d559-63c5-4369-a9e0-deed4195f651", + "message_type": "FRBC.ActuatorStatus", + "operation_mode_factor": 6919.960475850124, + "previous_operation_mode_id": "2ed8f7de-cbaa-4cab-9d25-6792317aa284", + "transition_timestamp": "2020-01-02T07:56:46Z" +} + """ + + # Act + frbc_actuator_status = FRBCActuatorStatus.from_json(json_str) + + # Assert + self.assertEqual( + frbc_actuator_status.active_operation_mode_id, + uuid.UUID("395dcbc5-5c7f-415e-8727-e48fc53761bc"), + ) + self.assertEqual( + frbc_actuator_status.actuator_id, + uuid.UUID("1cee425e-861b-417a-8208-bb6d53aafb00"), + ) + self.assertEqual( + frbc_actuator_status.message_id, + uuid.UUID("07f3d559-63c5-4369-a9e0-deed4195f651"), + ) + self.assertEqual(frbc_actuator_status.message_type, "FRBC.ActuatorStatus") + self.assertEqual(frbc_actuator_status.operation_mode_factor, 6919.960475850124) + self.assertEqual( + frbc_actuator_status.previous_operation_mode_id, + uuid.UUID("2ed8f7de-cbaa-4cab-9d25-6792317aa284"), + ) + self.assertEqual( + frbc_actuator_status.transition_timestamp, + datetime( + year=2020, + month=1, + day=2, + hour=7, + minute=56, + second=46, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_actuator_status = FRBCActuatorStatus( + active_operation_mode_id=uuid.UUID("395dcbc5-5c7f-415e-8727-e48fc53761bc"), + actuator_id=uuid.UUID("1cee425e-861b-417a-8208-bb6d53aafb00"), + message_id=uuid.UUID("07f3d559-63c5-4369-a9e0-deed4195f651"), + message_type="FRBC.ActuatorStatus", + operation_mode_factor=6919.960475850124, + previous_operation_mode_id=uuid.UUID( + "2ed8f7de-cbaa-4cab-9d25-6792317aa284" + ), + transition_timestamp=datetime( + year=2020, + month=1, + day=2, + hour=7, + minute=56, + second=46, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + # Act + json_str = frbc_actuator_status.to_json() + + # Assert + expected_json = { + "active_operation_mode_id": "395dcbc5-5c7f-415e-8727-e48fc53761bc", + "actuator_id": "1cee425e-861b-417a-8208-bb6d53aafb00", + "message_id": "07f3d559-63c5-4369-a9e0-deed4195f651", + "message_type": "FRBC.ActuatorStatus", + "operation_mode_factor": 6919.960475850124, + "previous_operation_mode_id": "2ed8f7de-cbaa-4cab-9d25-6792317aa284", + "transition_timestamp": "2020-01-02T07:56:46Z", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py b/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py new file mode 100644 index 0000000..f3ea375 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py @@ -0,0 +1,61 @@ +from datetime import timedelta +import json +from unittest import TestCase + +from s2python.common import * +from s2python.frbc import * +from s2python.s2_validation_error import S2ValidationError + + +class FRBCFillLevelTargetProfileElementTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "duration": 12950, + "fill_level_range": { + "end_of_range": 8176, + "start_of_range": 6207 + } +} + """ + + # Act + frbc_fill_level_target_profile_element = ( + FRBCFillLevelTargetProfileElement.from_json(json_str) + ) + + # Assert + self.assertEqual( + frbc_fill_level_target_profile_element.duration, + Duration.from_timedelta(timedelta(milliseconds=12950)), + ) + self.assertEqual( + frbc_fill_level_target_profile_element.fill_level_range, + NumberRange(end_of_range=8176.0, start_of_range=6207.0), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_fill_level_target_profile_element = FRBCFillLevelTargetProfileElement( + duration=Duration.from_timedelta(timedelta(milliseconds=12950)), + fill_level_range=NumberRange(end_of_range=8176, start_of_range=6207), + ) + + # Act + json_str = frbc_fill_level_target_profile_element.to_json() + + # Assert + expected_json = { + "duration": 12950, + "fill_level_range": {"end_of_range": 8176, "start_of_range": 6207}, + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__init__fill_level_range_end_is_smaller_than_start(self): + # Arrange / Act / Assert + with self.assertRaises(S2ValidationError): + FRBCFillLevelTargetProfileElement( + duration=Duration.from_timedelta(timedelta(milliseconds=12950)), + fill_level_range=NumberRange(end_of_range=6000, start_of_range=8176), + ) diff --git a/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py b/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py new file mode 100644 index 0000000..5f2f2b3 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py @@ -0,0 +1,109 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCFillLevelTargetProfileTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "elements": [ + { + "duration": 4704, + "fill_level_range": { + "end_of_range": 10800.98606857073545, + "start_of_range": 6891.19014440217 + } + } + ], + "message_id": "04a6c8af-ca8d-420c-9c11-e96a70fe82b1", + "message_type": "FRBC.FillLevelTargetProfile", + "start_time": "2021-04-17T00:19:20Z" +} + """ + + # Act + frbc_fill_level_target_profile = FRBCFillLevelTargetProfile.from_json(json_str) + + # Assert + self.assertEqual( + frbc_fill_level_target_profile.elements, + [ + FRBCFillLevelTargetProfileElement( + duration=Duration.from_timedelta(timedelta(milliseconds=4704)), + fill_level_range=NumberRange( + end_of_range=10800.98606857073545, + start_of_range=6891.19014440217, + ), + ) + ], + ) + self.assertEqual( + frbc_fill_level_target_profile.message_id, + uuid.UUID("04a6c8af-ca8d-420c-9c11-e96a70fe82b1"), + ) + self.assertEqual( + frbc_fill_level_target_profile.message_type, "FRBC.FillLevelTargetProfile" + ) + self.assertEqual( + frbc_fill_level_target_profile.start_time, + datetime( + year=2021, + month=4, + day=17, + hour=0, + minute=19, + second=20, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_fill_level_target_profile = FRBCFillLevelTargetProfile( + elements=[ + FRBCFillLevelTargetProfileElement( + duration=Duration.from_timedelta(timedelta(milliseconds=4704)), + fill_level_range=NumberRange( + end_of_range=10800.98606857073545, + start_of_range=6891.19014440217, + ), + ) + ], + message_id=uuid.UUID("04a6c8af-ca8d-420c-9c11-e96a70fe82b1"), + message_type="FRBC.FillLevelTargetProfile", + start_time=datetime( + year=2021, + month=4, + day=17, + hour=0, + minute=19, + second=20, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + # Act + json_str = frbc_fill_level_target_profile.to_json() + + # Assert + expected_json = { + "elements": [ + { + "duration": 4704, + "fill_level_range": { + "end_of_range": 10800.98606857073545, + "start_of_range": 6891.19014440217, + }, + } + ], + "message_id": "04a6c8af-ca8d-420c-9c11-e96a70fe82b1", + "message_type": "FRBC.FillLevelTargetProfile", + "start_time": "2021-04-17T00:19:20Z", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_instruction_test.py b/s2-python/tests/unit/frbc/frbc_instruction_test.py new file mode 100644 index 0000000..901a711 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_instruction_test.py @@ -0,0 +1,96 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCInstructionTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "abnormal_condition": true, + "actuator_id": "db7855dd-05c4-4ba8-81e2-d10001c5bc3f", + "execution_time": "2023-04-11T16:46:33+01:00", + "id": "9ffd68cd-b0e2-44a6-aded-4dce6c18247e", + "message_id": "bcb3e1da-e797-4951-86be-5e5d9136c63f", + "message_type": "FRBC.Instruction", + "operation_mode": "e7bf29a7-4ebc-49c1-a1fb-20725f450c91", + "operation_mode_factor": 2303.58902271682 +} + """ + + # Act + frbc_instruction = FRBCInstruction.from_json(json_str) + + # Assert + self.assertEqual(frbc_instruction.abnormal_condition, True) + self.assertEqual( + frbc_instruction.actuator_id, + uuid.UUID("db7855dd-05c4-4ba8-81e2-d10001c5bc3f"), + ) + self.assertEqual( + frbc_instruction.execution_time, + datetime( + year=2023, + month=4, + day=11, + hour=16, + minute=46, + second=33, + tzinfo=offset(offset=timedelta(seconds=3600.0)), + ), + ) + self.assertEqual( + frbc_instruction.id, uuid.UUID("9ffd68cd-b0e2-44a6-aded-4dce6c18247e") + ) + self.assertEqual( + frbc_instruction.message_id, + uuid.UUID("bcb3e1da-e797-4951-86be-5e5d9136c63f"), + ) + self.assertEqual(frbc_instruction.message_type, "FRBC.Instruction") + self.assertEqual( + frbc_instruction.operation_mode, + uuid.UUID("e7bf29a7-4ebc-49c1-a1fb-20725f450c91"), + ) + self.assertEqual(frbc_instruction.operation_mode_factor, 2303.58902271682) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_instruction = FRBCInstruction( + abnormal_condition=True, + actuator_id=uuid.UUID("db7855dd-05c4-4ba8-81e2-d10001c5bc3f"), + execution_time=datetime( + year=2023, + month=4, + day=11, + hour=16, + minute=46, + second=33, + tzinfo=offset(offset=timedelta(seconds=3600.0)), + ), + id=uuid.UUID("9ffd68cd-b0e2-44a6-aded-4dce6c18247e"), + message_id=uuid.UUID("bcb3e1da-e797-4951-86be-5e5d9136c63f"), + message_type="FRBC.Instruction", + operation_mode=uuid.UUID("e7bf29a7-4ebc-49c1-a1fb-20725f450c91"), + operation_mode_factor=2303.58902271682, + ) + + # Act + json_str = frbc_instruction.to_json() + + # Assert + expected_json = { + "abnormal_condition": True, + "actuator_id": "db7855dd-05c4-4ba8-81e2-d10001c5bc3f", + "execution_time": "2023-04-11T16:46:33+01:00", + "id": "9ffd68cd-b0e2-44a6-aded-4dce6c18247e", + "message_id": "bcb3e1da-e797-4951-86be-5e5d9136c63f", + "message_type": "FRBC.Instruction", + "operation_mode": "e7bf29a7-4ebc-49c1-a1fb-20725f450c91", + "operation_mode_factor": 2303.58902271682, + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py b/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py new file mode 100644 index 0000000..08a5364 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py @@ -0,0 +1,68 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * +from s2python.s2_validation_error import S2ValidationError + + +class FRBCLeakageBehaviourElementTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "fill_level_range": { + "end_of_range": 40192.498918818455, + "start_of_range": 29234.82582981918 + }, + "leakage_rate": 1170.4041485129987 +} + """ + + # Act + frbc_leakage_behaviour_element = FRBCLeakageBehaviourElement.from_json(json_str) + + # Assert + self.assertEqual( + frbc_leakage_behaviour_element.fill_level_range, + NumberRange( + end_of_range=40192.498918818455, start_of_range=29234.82582981918 + ), + ) + self.assertEqual( + frbc_leakage_behaviour_element.leakage_rate, 1170.4041485129987 + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_leakage_behaviour_element = FRBCLeakageBehaviourElement( + fill_level_range=NumberRange( + end_of_range=40192.498918818455, start_of_range=29234.82582981918 + ), + leakage_rate=1170.4041485129987, + ) + + # Act + json_str = frbc_leakage_behaviour_element.to_json() + + # Assert + expected_json = { + "fill_level_range": { + "end_of_range": 40192.498918818455, + "start_of_range": 29234.82582981918, + }, + "leakage_rate": 1170.4041485129987, + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__init__fill_level_range_end_is_smaller_than_start(self): + # Arrange / Act / Assert + with self.assertRaises(S2ValidationError): + FRBCLeakageBehaviourElement( + fill_level_range=NumberRange( + end_of_range=29234.82582981918, start_of_range=40192.498918818455 + ), + leakage_rate=1170.4041485129987, + ) diff --git a/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py b/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py new file mode 100644 index 0000000..ad290d1 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py @@ -0,0 +1,107 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCLeakageBehaviourTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "elements": [ + { + "fill_level_range": { + "end_of_range": 31155.931914859895, + "start_of_range": 5727.722922773178 + }, + "leakage_rate": 1225.9695121338086 + } + ], + "message_id": "b3e9604a-1127-4ecc-9f9e-336047fde285", + "message_type": "FRBC.LeakageBehaviour", + "valid_from": "2022-05-26T15:02:32Z" +} + """ + + # Act + frbc_leakage_behaviour = FRBCLeakageBehaviour.from_json(json_str) + + # Assert + self.assertEqual( + frbc_leakage_behaviour.elements, + [ + FRBCLeakageBehaviourElement( + fill_level_range=NumberRange( + end_of_range=31155.931914859895, + start_of_range=5727.722922773178, + ), + leakage_rate=1225.9695121338086, + ) + ], + ) + self.assertEqual( + frbc_leakage_behaviour.message_id, + uuid.UUID("b3e9604a-1127-4ecc-9f9e-336047fde285"), + ) + self.assertEqual(frbc_leakage_behaviour.message_type, "FRBC.LeakageBehaviour") + self.assertEqual( + frbc_leakage_behaviour.valid_from, + datetime( + year=2022, + month=5, + day=26, + hour=15, + minute=2, + second=32, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_leakage_behaviour = FRBCLeakageBehaviour( + elements=[ + FRBCLeakageBehaviourElement( + fill_level_range=NumberRange( + end_of_range=31155.931914859895, + start_of_range=5727.722922773178, + ), + leakage_rate=1225.9695121338086, + ) + ], + message_id=uuid.UUID("b3e9604a-1127-4ecc-9f9e-336047fde285"), + message_type="FRBC.LeakageBehaviour", + valid_from=datetime( + year=2022, + month=5, + day=26, + hour=15, + minute=2, + second=32, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + # Act + json_str = frbc_leakage_behaviour.to_json() + + # Assert + expected_json = { + "elements": [ + { + "fill_level_range": { + "end_of_range": 31155.931914859895, + "start_of_range": 5727.722922773178, + }, + "leakage_rate": 1225.9695121338086, + } + ], + "message_id": "b3e9604a-1127-4ecc-9f9e-336047fde285", + "message_type": "FRBC.LeakageBehaviour", + "valid_from": "2022-05-26T15:02:32Z", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py b/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py new file mode 100644 index 0000000..d2f3455 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py @@ -0,0 +1,117 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import NumberRange, PowerRange +from s2python.frbc.frbc_operation_mode_element import FRBCOperationModeElement +from s2python.generated.gen_s2 import CommodityQuantity + + +class FRBCOperationModeElementTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "fill_level_range": { + "end_of_range": 51798.05122344172, + "start_of_range": 12901.48976850875 + }, + "fill_rate": { + "end_of_range": 35734.54630113551, + "start_of_range": 10740.443924585083 + }, + "power_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "end_of_range": 69093.48993128976, + "start_of_range": 34859.59303603876 + } + ], + "running_costs": { + "end_of_range": 47869.03540464825, + "start_of_range": 19009.60894672492 + } +} + """ + + # Act + frbc_operation_mode_element = FRBCOperationModeElement.from_json(json_str) + + # Assert + self.assertEqual( + frbc_operation_mode_element.fill_level_range, + NumberRange( + end_of_range=51798.05122344172, start_of_range=12901.48976850875 + ), + ) + self.assertEqual( + frbc_operation_mode_element.fill_rate, + NumberRange( + end_of_range=35734.54630113551, start_of_range=10740.443924585083 + ), + ) + self.assertEqual( + frbc_operation_mode_element.power_ranges, + [ + PowerRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + end_of_range=69093.48993128976, + start_of_range=34859.59303603876, + ) + ], + ) + self.assertEqual( + frbc_operation_mode_element.running_costs, + NumberRange( + end_of_range=47869.03540464825, start_of_range=19009.60894672492 + ), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_operation_mode_element = FRBCOperationModeElement( + fill_level_range=NumberRange( + end_of_range=51798.05122344172, start_of_range=12901.48976850875 + ), + fill_rate=NumberRange( + end_of_range=35734.54630113551, start_of_range=10740.443924585083 + ), + power_ranges=[ + PowerRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + end_of_range=69093.48993128976, + start_of_range=34859.59303603876, + ) + ], + running_costs=NumberRange( + end_of_range=47869.03540464825, start_of_range=19009.60894672492 + ), + ) + + # Act + json_str = frbc_operation_mode_element.to_json() + + # Assert + expected_json = { + "fill_level_range": { + "end_of_range": 51798.05122344172, + "start_of_range": 12901.48976850875, + }, + "fill_rate": { + "end_of_range": 35734.54630113551, + "start_of_range": 10740.443924585083, + }, + "power_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "end_of_range": 69093.48993128976, + "start_of_range": 34859.59303603876, + } + ], + "running_costs": { + "end_of_range": 47869.03540464825, + "start_of_range": 19009.60894672492, + }, + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_operation_mode_test.py b/s2-python/tests/unit/frbc/frbc_operation_mode_test.py new file mode 100644 index 0000000..0d97f27 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_operation_mode_test.py @@ -0,0 +1,139 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCOperationModeTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "abnormal_condition_only": true, + "diagnostic_label": "some-test-string7557", + "elements": [ + { + "fill_level_range": { + "end_of_range": 34304.92092046668, + "start_of_range": 17579.18236077446 + }, + "fill_rate": { + "end_of_range": 41719.931165871916, + "start_of_range": 10542.600445486576 + }, + "power_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "end_of_range": 44983.5145552435, + "start_of_range": 29337.138579372047 + } + ], + "running_costs": { + "end_of_range": 62835.00070350196, + "start_of_range": 33318.34845926906 + } + } + ], + "id": "b1255236-475c-4dc7-a728-afb620a99ec8" +} + """ + + # Act + frbc_operation_mode = FRBCOperationMode.from_json(json_str) + + # Assert + self.assertEqual(frbc_operation_mode.abnormal_condition_only, True) + self.assertEqual(frbc_operation_mode.diagnostic_label, "some-test-string7557") + self.assertEqual( + frbc_operation_mode.elements, + [ + FRBCOperationModeElement( + fill_level_range=NumberRange( + end_of_range=34304.92092046668, start_of_range=17579.18236077446 + ), + fill_rate=NumberRange( + end_of_range=41719.931165871916, + start_of_range=10542.600445486576, + ), + power_ranges=[ + PowerRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + end_of_range=44983.5145552435, + start_of_range=29337.138579372047, + ) + ], + running_costs=NumberRange( + end_of_range=62835.00070350196, start_of_range=33318.34845926906 + ), + ) + ], + ) + self.assertEqual( + frbc_operation_mode.id, uuid.UUID("b1255236-475c-4dc7-a728-afb620a99ec8") + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_operation_mode = FRBCOperationMode( + abnormal_condition_only=True, + diagnostic_label="some-test-string7557", + elements=[ + FRBCOperationModeElement( + fill_level_range=NumberRange( + end_of_range=34304.92092046668, start_of_range=17579.18236077446 + ), + fill_rate=NumberRange( + end_of_range=41719.931165871916, + start_of_range=10542.600445486576, + ), + power_ranges=[ + PowerRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + end_of_range=44983.5145552435, + start_of_range=29337.138579372047, + ) + ], + running_costs=NumberRange( + end_of_range=62835.00070350196, start_of_range=33318.34845926906 + ), + ) + ], + id=uuid.UUID("b1255236-475c-4dc7-a728-afb620a99ec8"), + ) + + # Act + json_str = frbc_operation_mode.to_json() + + # Assert + expected_json = { + "abnormal_condition_only": True, + "diagnostic_label": "some-test-string7557", + "elements": [ + { + "fill_level_range": { + "end_of_range": 34304.92092046668, + "start_of_range": 17579.18236077446, + }, + "fill_rate": { + "end_of_range": 41719.931165871916, + "start_of_range": 10542.600445486576, + }, + "power_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "end_of_range": 44983.5145552435, + "start_of_range": 29337.138579372047, + } + ], + "running_costs": { + "end_of_range": 62835.00070350196, + "start_of_range": 33318.34845926906, + }, + } + ], + "id": "b1255236-475c-4dc7-a728-afb620a99ec8", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_storage_description_test.py b/s2-python/tests/unit/frbc/frbc_storage_description_test.py new file mode 100644 index 0000000..a1e8e2e --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_storage_description_test.py @@ -0,0 +1,77 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCStorageDescriptionTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "diagnostic_label": "some-test-string3063", + "fill_level_label": "some-test-string2323", + "fill_level_range": { + "end_of_range": 14555.806367871957, + "start_of_range": 10409.397377840089 + }, + "provides_fill_level_target_profile": true, + "provides_leakage_behaviour": false, + "provides_usage_forecast": false +} + """ + + # Act + frbc_storage_description = FRBCStorageDescription.from_json(json_str) + + # Assert + self.assertEqual( + frbc_storage_description.diagnostic_label, "some-test-string3063" + ) + self.assertEqual( + frbc_storage_description.fill_level_label, "some-test-string2323" + ) + self.assertEqual( + frbc_storage_description.fill_level_range, + NumberRange( + end_of_range=14555.806367871957, start_of_range=10409.397377840089 + ), + ) + self.assertEqual( + frbc_storage_description.provides_fill_level_target_profile, True + ) + self.assertEqual(frbc_storage_description.provides_leakage_behaviour, False) + self.assertEqual(frbc_storage_description.provides_usage_forecast, False) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_storage_description = FRBCStorageDescription( + diagnostic_label="some-test-string3063", + fill_level_label="some-test-string2323", + fill_level_range=NumberRange( + end_of_range=14555.806367871957, start_of_range=10409.397377840089 + ), + provides_fill_level_target_profile=True, + provides_leakage_behaviour=False, + provides_usage_forecast=False, + ) + + # Act + json_str = frbc_storage_description.to_json() + + # Assert + expected_json = { + "diagnostic_label": "some-test-string3063", + "fill_level_label": "some-test-string2323", + "fill_level_range": { + "end_of_range": 14555.806367871957, + "start_of_range": 10409.397377840089, + }, + "provides_fill_level_target_profile": True, + "provides_leakage_behaviour": False, + "provides_usage_forecast": False, + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_storage_status_test.py b/s2-python/tests/unit/frbc/frbc_storage_status_test.py new file mode 100644 index 0000000..c2b99ab --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_storage_status_test.py @@ -0,0 +1,49 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCStorageStatusTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "message_id": "6bad8186-9ebf-4647-ac45-1c6856511a2f", + "message_type": "FRBC.StorageStatus", + "present_fill_level": 2443.939298819414 +} + """ + + # Act + frbc_storage_status = FRBCStorageStatus.from_json(json_str) + + # Assert + self.assertEqual( + frbc_storage_status.message_id, + uuid.UUID("6bad8186-9ebf-4647-ac45-1c6856511a2f"), + ) + self.assertEqual(frbc_storage_status.message_type, "FRBC.StorageStatus") + self.assertEqual(frbc_storage_status.present_fill_level, 2443.939298819414) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_storage_status = FRBCStorageStatus( + message_id=uuid.UUID("6bad8186-9ebf-4647-ac45-1c6856511a2f"), + message_type="FRBC.StorageStatus", + present_fill_level=2443.939298819414, + ) + + # Act + json_str = frbc_storage_status.to_json() + + # Assert + expected_json = { + "message_id": "6bad8186-9ebf-4647-ac45-1c6856511a2f", + "message_type": "FRBC.StorageStatus", + "present_fill_level": 2443.939298819414, + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_system_description_test.py b/s2-python/tests/unit/frbc/frbc_system_description_test.py new file mode 100644 index 0000000..0ad8bfd --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_system_description_test.py @@ -0,0 +1,359 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCSystemDescriptionTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "actuators": [ + { + "diagnostic_label": "some-test-string2728", + "id": "a1061148-f19e-4b1b-8fe3-b506583ce61e", + "operation_modes": [ + { + "abnormal_condition_only": false, + "diagnostic_label": "some-test-string2930", + "elements": [ + { + "fill_level_range": { + "end_of_range": 36932.65171036228, + "start_of_range": 12649.272766336762 + }, + "fill_rate": { + "end_of_range": 34553.16163528188, + "start_of_range": 14377.963894945604 + }, + "power_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "end_of_range": 46924.65023353163, + "start_of_range": 11888.235871902496 + } + ], + "running_costs": { + "end_of_range": 42897.60731684277, + "start_of_range": 33997.56376994998 + } + } + ], + "id": "2795136c-eb30-4f8a-bdaa-61feba1e71b6" + } + ], + "supported_commodities": [ + "ELECTRICITY" + ], + "timers": [ + { + "diagnostic_label": "some-test-string4315", + "duration": 14099, + "id": "e1ff9e58-935b-4765-92e3-5e7679f73eb6" + } + ], + "transitions": [ + { + "abnormal_condition_only": true, + "blocking_timers": [ + "e1ff9e58-935b-4765-92e3-5e7679f73eb6" + ], + "from": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", + "id": "c32cc1d3-4722-41e3-a8de-55307c723611", + "start_timers": [ + "e1ff9e58-935b-4765-92e3-5e7679f73eb6" + ], + "to": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", + "transition_costs": 1018.4228054114793, + "transition_duration": 11988 + } + ] + } + ], + "message_id": "97256813-de70-4640-a992-9ae0b2d8e4d1", + "message_type": "FRBC.SystemDescription", + "storage": { + "diagnostic_label": "some-test-string8418", + "fill_level_label": "some-test-string9512", + "fill_level_range": { + "end_of_range": 20876.752745956997, + "start_of_range": 18324.0229135081 + }, + "provides_fill_level_target_profile": false, + "provides_leakage_behaviour": true, + "provides_usage_forecast": false + }, + "valid_from": "2020-10-07T06:30:55Z" +} + """ + + # Act + frbc_system_description = FRBCSystemDescription.from_json(json_str) + + # Assert + # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. + # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to + # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 + transition = Transition( + **{ + "id": uuid.UUID("c32cc1d3-4722-41e3-a8de-55307c723611"), + "from": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), + "to": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), + "start_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], + "blocking_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], + "transition_costs": 1018.4228054114793, + "transition_duration": Duration.from_milliseconds(11988), + "abnormal_condition_only": True, + } + ) + + self.assertEqual( + frbc_system_description.actuators, + [ + FRBCActuatorDescription( + diagnostic_label="some-test-string2728", + id=uuid.UUID("a1061148-f19e-4b1b-8fe3-b506583ce61e"), + operation_modes=[ + FRBCOperationMode( + abnormal_condition_only=False, + diagnostic_label="some-test-string2930", + elements=[ + FRBCOperationModeElement( + fill_level_range=NumberRange( + end_of_range=36932.65171036228, + start_of_range=12649.272766336762, + ), + fill_rate=NumberRange( + end_of_range=34553.16163528188, + start_of_range=14377.963894945604, + ), + power_ranges=[ + PowerRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + end_of_range=46924.65023353163, + start_of_range=11888.235871902496, + ) + ], + running_costs=NumberRange( + end_of_range=42897.60731684277, + start_of_range=33997.56376994998, + ), + ) + ], + id=uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), + ) + ], + supported_commodities=[Commodity.ELECTRICITY], + timers=[ + Timer( + diagnostic_label="some-test-string4315", + duration=Duration.from_timedelta( + timedelta(milliseconds=14099) + ), + id=uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6"), + ) + ], + transitions=[transition], + ) + ], + ) + self.assertEqual( + frbc_system_description.message_id, + uuid.UUID("97256813-de70-4640-a992-9ae0b2d8e4d1"), + ) + self.assertEqual(frbc_system_description.message_type, "FRBC.SystemDescription") + self.assertEqual( + frbc_system_description.storage, + FRBCStorageDescription( + diagnostic_label="some-test-string8418", + fill_level_label="some-test-string9512", + fill_level_range=NumberRange( + end_of_range=20876.752745956997, start_of_range=18324.0229135081 + ), + provides_fill_level_target_profile=False, + provides_leakage_behaviour=True, + provides_usage_forecast=False, + ), + ) + self.assertEqual( + frbc_system_description.valid_from, + datetime( + year=2020, + month=10, + day=7, + hour=6, + minute=30, + second=55, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + def test__to_json__happy_path_full(self): + # Arrange + # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. + # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to + # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 + transition = Transition( + **{ + "id": uuid.UUID("c32cc1d3-4722-41e3-a8de-55307c723611"), + "from": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), + "to": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), + "start_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], + "blocking_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], + "transition_costs": 1018.4228054114793, + "transition_duration": Duration.from_milliseconds(11988), + "abnormal_condition_only": True, + } + ) + frbc_system_description = FRBCSystemDescription( + actuators=[ + FRBCActuatorDescription( + diagnostic_label="some-test-string2728", + id=uuid.UUID("a1061148-f19e-4b1b-8fe3-b506583ce61e"), + operation_modes=[ + FRBCOperationMode( + abnormal_condition_only=False, + diagnostic_label="some-test-string2930", + elements=[ + FRBCOperationModeElement( + fill_level_range=NumberRange( + end_of_range=36932.65171036228, + start_of_range=12649.272766336762, + ), + fill_rate=NumberRange( + end_of_range=34553.16163528188, + start_of_range=14377.963894945604, + ), + power_ranges=[ + PowerRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + end_of_range=46924.65023353163, + start_of_range=11888.235871902496, + ) + ], + running_costs=NumberRange( + end_of_range=42897.60731684277, + start_of_range=33997.56376994998, + ), + ) + ], + id=uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), + ) + ], + supported_commodities=[Commodity.ELECTRICITY], + timers=[ + Timer( + diagnostic_label="some-test-string4315", + duration=Duration.from_timedelta( + timedelta(milliseconds=14099) + ), + id=uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6"), + ) + ], + transitions=[transition], + ) + ], + message_id=uuid.UUID("97256813-de70-4640-a992-9ae0b2d8e4d1"), + message_type="FRBC.SystemDescription", + storage=FRBCStorageDescription( + diagnostic_label="some-test-string8418", + fill_level_label="some-test-string9512", + fill_level_range=NumberRange( + end_of_range=20876.752745956997, start_of_range=18324.0229135081 + ), + provides_fill_level_target_profile=False, + provides_leakage_behaviour=True, + provides_usage_forecast=False, + ), + valid_from=datetime( + year=2020, + month=10, + day=7, + hour=6, + minute=30, + second=55, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + # Act + json_str = frbc_system_description.to_json() + + # Assert + expected_json = { + "actuators": [ + { + "diagnostic_label": "some-test-string2728", + "id": "a1061148-f19e-4b1b-8fe3-b506583ce61e", + "operation_modes": [ + { + "abnormal_condition_only": False, + "diagnostic_label": "some-test-string2930", + "elements": [ + { + "fill_level_range": { + "end_of_range": 36932.65171036228, + "start_of_range": 12649.272766336762, + }, + "fill_rate": { + "end_of_range": 34553.16163528188, + "start_of_range": 14377.963894945604, + }, + "power_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "end_of_range": 46924.65023353163, + "start_of_range": 11888.235871902496, + } + ], + "running_costs": { + "end_of_range": 42897.60731684277, + "start_of_range": 33997.56376994998, + }, + } + ], + "id": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", + } + ], + "supported_commodities": ["ELECTRICITY"], + "timers": [ + { + "diagnostic_label": "some-test-string4315", + "duration": 14099, + "id": "e1ff9e58-935b-4765-92e3-5e7679f73eb6", + } + ], + "transitions": [ + { + "abnormal_condition_only": True, + "blocking_timers": ["e1ff9e58-935b-4765-92e3-5e7679f73eb6"], + "from": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", + "id": "c32cc1d3-4722-41e3-a8de-55307c723611", + "start_timers": ["e1ff9e58-935b-4765-92e3-5e7679f73eb6"], + "to": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", + "transition_costs": 1018.4228054114793, + "transition_duration": 11988, + } + ], + } + ], + "message_id": "97256813-de70-4640-a992-9ae0b2d8e4d1", + "message_type": "FRBC.SystemDescription", + "storage": { + "diagnostic_label": "some-test-string8418", + "fill_level_label": "some-test-string9512", + "fill_level_range": { + "end_of_range": 20876.752745956997, + "start_of_range": 18324.0229135081, + }, + "provides_fill_level_target_profile": False, + "provides_leakage_behaviour": True, + "provides_usage_forecast": False, + }, + "valid_from": "2020-10-07T06:30:55Z", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_timer_status_test.py b/s2-python/tests/unit/frbc/frbc_timer_status_test.py new file mode 100644 index 0000000..74e2924 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_timer_status_test.py @@ -0,0 +1,82 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCTimerStatusTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "actuator_id": "f2e1f540-0235-429f-a45c-4d5cbe65d33f", + "finished_at": "2020-11-03T12:57:27+02:00", + "message_id": "57240f00-0b91-49bb-a4b0-2107d062faec", + "message_type": "FRBC.TimerStatus", + "timer_id": "bcb8e64f-ea4c-4b92-b4cb-20026a13d663" +} + """ + + # Act + frbc_timer_status = FRBCTimerStatus.from_json(json_str) + + # Assert + self.assertEqual( + frbc_timer_status.actuator_id, + uuid.UUID("f2e1f540-0235-429f-a45c-4d5cbe65d33f"), + ) + self.assertEqual( + frbc_timer_status.finished_at, + datetime( + year=2020, + month=11, + day=3, + hour=12, + minute=57, + second=27, + tzinfo=offset(offset=timedelta(seconds=7200.0)), + ), + ) + self.assertEqual( + frbc_timer_status.message_id, + uuid.UUID("57240f00-0b91-49bb-a4b0-2107d062faec"), + ) + self.assertEqual(frbc_timer_status.message_type, "FRBC.TimerStatus") + self.assertEqual( + frbc_timer_status.timer_id, + uuid.UUID("bcb8e64f-ea4c-4b92-b4cb-20026a13d663"), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_timer_status = FRBCTimerStatus( + actuator_id=uuid.UUID("f2e1f540-0235-429f-a45c-4d5cbe65d33f"), + finished_at=datetime( + year=2020, + month=11, + day=3, + hour=12, + minute=57, + second=27, + tzinfo=offset(offset=timedelta(seconds=7200.0)), + ), + message_id=uuid.UUID("57240f00-0b91-49bb-a4b0-2107d062faec"), + message_type="FRBC.TimerStatus", + timer_id=uuid.UUID("bcb8e64f-ea4c-4b92-b4cb-20026a13d663"), + ) + + # Act + json_str = frbc_timer_status.to_json() + + # Assert + expected_json = { + "actuator_id": "f2e1f540-0235-429f-a45c-4d5cbe65d33f", + "finished_at": "2020-11-03T12:57:27+02:00", + "message_id": "57240f00-0b91-49bb-a4b0-2107d062faec", + "message_type": "FRBC.TimerStatus", + "timer_id": "bcb8e64f-ea4c-4b92-b4cb-20026a13d663", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py b/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py new file mode 100644 index 0000000..3df7f63 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py @@ -0,0 +1,83 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCUsageForecastElementTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "duration": 9317, + "usage_rate_expected": 866.9362374046218, + "usage_rate_lower_68PPR": 3496.6233093198375, + "usage_rate_lower_95PPR": 4206.0536932975065, + "usage_rate_lower_limit": 7353.272756502293, + "usage_rate_upper_68PPR": 5124.8129813156465, + "usage_rate_upper_95PPR": 264.3386978845277, + "usage_rate_upper_limit": 4474.174577002476 +} + """ + + # Act + frbc_usage_forecast_element = FRBCUsageForecastElement.from_json(json_str) + + # Assert + self.assertEqual( + frbc_usage_forecast_element.duration, + Duration.from_timedelta(timedelta(milliseconds=9317)), + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_expected, 866.9362374046218 + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_lower_68PPR, 3496.6233093198375 + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_lower_95PPR, 4206.0536932975065 + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_lower_limit, 7353.272756502293 + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_upper_68PPR, 5124.8129813156465 + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_upper_95PPR, 264.3386978845277 + ) + self.assertEqual( + frbc_usage_forecast_element.usage_rate_upper_limit, 4474.174577002476 + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_usage_forecast_element = FRBCUsageForecastElement( + duration=Duration.from_timedelta(timedelta(milliseconds=9317)), + usage_rate_expected=866.9362374046218, + usage_rate_lower_68PPR=3496.6233093198375, + usage_rate_lower_95PPR=4206.0536932975065, + usage_rate_lower_limit=7353.272756502293, + usage_rate_upper_68PPR=5124.8129813156465, + usage_rate_upper_95PPR=264.3386978845277, + usage_rate_upper_limit=4474.174577002476, + ) + + # Act + json_str = frbc_usage_forecast_element.to_json() + + # Assert + expected_json = { + "duration": 9317, + "usage_rate_expected": 866.9362374046218, + "usage_rate_lower_68PPR": 3496.6233093198375, + "usage_rate_lower_95PPR": 4206.0536932975065, + "usage_rate_lower_limit": 7353.272756502293, + "usage_rate_upper_68PPR": 5124.8129813156465, + "usage_rate_upper_95PPR": 264.3386978845277, + "usage_rate_upper_limit": 4474.174577002476, + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py b/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py new file mode 100644 index 0000000..a7f0da1 --- /dev/null +++ b/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py @@ -0,0 +1,119 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.frbc import * + + +class FRBCUsageForecastTest(TestCase): + def test__from_json__happy_path_full(self): + # Arrange + json_str = """ +{ + "elements": [ + { + "duration": 14010, + "usage_rate_expected": 8032.572599815139, + "usage_rate_lower_68PPR": 3910.197692207213, + "usage_rate_lower_95PPR": 6541.633895752248, + "usage_rate_lower_limit": 3419.1709124422173, + "usage_rate_upper_68PPR": 7146.0702352976305, + "usage_rate_upper_95PPR": 627.7040858037238, + "usage_rate_upper_limit": 8477.800850190179 + } + ], + "message_id": "4a91b4ab-21fb-42ae-b97d-6170f8b922cc", + "message_type": "FRBC.UsageForecast", + "start_time": "2023-03-25T13:48:35+02:00" +} + """ + + # Act + frbc_usage_forecast = FRBCUsageForecast.from_json(json_str) + + # Assert + self.assertEqual( + frbc_usage_forecast.elements, + [ + FRBCUsageForecastElement( + duration=Duration.from_timedelta(timedelta(milliseconds=14010)), + usage_rate_expected=8032.572599815139, + usage_rate_lower_68PPR=3910.197692207213, + usage_rate_lower_95PPR=6541.633895752248, + usage_rate_lower_limit=3419.1709124422173, + usage_rate_upper_68PPR=7146.0702352976305, + usage_rate_upper_95PPR=627.7040858037238, + usage_rate_upper_limit=8477.800850190179, + ) + ], + ) + self.assertEqual( + frbc_usage_forecast.message_id, + uuid.UUID("4a91b4ab-21fb-42ae-b97d-6170f8b922cc"), + ) + self.assertEqual(frbc_usage_forecast.message_type, "FRBC.UsageForecast") + self.assertEqual( + frbc_usage_forecast.start_time, + datetime( + year=2023, + month=3, + day=25, + hour=13, + minute=48, + second=35, + tzinfo=offset(offset=timedelta(seconds=7200.0)), + ), + ) + + def test__to_json__happy_path_full(self): + # Arrange + frbc_usage_forecast = FRBCUsageForecast( + elements=[ + FRBCUsageForecastElement( + duration=Duration.from_timedelta(timedelta(milliseconds=14010)), + usage_rate_expected=8032.572599815139, + usage_rate_lower_68PPR=3910.197692207213, + usage_rate_lower_95PPR=6541.633895752248, + usage_rate_lower_limit=3419.1709124422173, + usage_rate_upper_68PPR=7146.0702352976305, + usage_rate_upper_95PPR=627.7040858037238, + usage_rate_upper_limit=8477.800850190179, + ) + ], + message_id=uuid.UUID("4a91b4ab-21fb-42ae-b97d-6170f8b922cc"), + message_type="FRBC.UsageForecast", + start_time=datetime( + year=2023, + month=3, + day=25, + hour=13, + minute=48, + second=35, + tzinfo=offset(offset=timedelta(seconds=7200.0)), + ), + ) + + # Act + json_str = frbc_usage_forecast.to_json() + + # Assert + expected_json = { + "elements": [ + { + "duration": 14010, + "usage_rate_expected": 8032.572599815139, + "usage_rate_lower_68PPR": 3910.197692207213, + "usage_rate_lower_95PPR": 6541.633895752248, + "usage_rate_lower_limit": 3419.1709124422173, + "usage_rate_upper_68PPR": 7146.0702352976305, + "usage_rate_upper_95PPR": 627.7040858037238, + "usage_rate_upper_limit": 8477.800850190179, + } + ], + "message_id": "4a91b4ab-21fb-42ae-b97d-6170f8b922cc", + "message_type": "FRBC.UsageForecast", + "start_time": "2023-03-25T13:48:35+02:00", + } + self.assertEqual(json.loads(json_str), expected_json) diff --git a/s2-python/tests/unit/message_test.py b/s2-python/tests/unit/message_test.py new file mode 100644 index 0000000..f667306 --- /dev/null +++ b/s2-python/tests/unit/message_test.py @@ -0,0 +1,63 @@ +import unittest + +import importlib +import inspect +import pkgutil +from typing import get_args + +from s2python import message +from s2python.validate_values_mixin import S2MessageComponent + + +class S2MessageTest(unittest.TestCase): + """Check importing S2Message classes from s2_python.message.""" + + def _test_import_s2_messages(self, module_name): + """Check each S2MessageComponent subclass in the given module is importable.""" + module = importlib.import_module(module_name) + + # Find all submodules + all_subclasses = [] + for _, name, _ in pkgutil.iter_modules(module.__path__, module.__name__ + "."): + submodule = importlib.import_module(name) + + # Find all classes in the submodule that subclass BaseClass + subclasses = [ + obj + for _, obj in inspect.getmembers(submodule, inspect.isclass) + if issubclass(obj, S2MessageComponent) and obj is not S2MessageComponent + ] + all_subclasses.extend(subclasses) + + # Ensure we found at least one subclass + self.assertGreater( + len(all_subclasses), 0, f"No subclasses found in {module_name}" + ) + + for _class in all_subclasses: + assert hasattr( + message, _class.__name__ + ), f"{_class} should be importable from s2_python.message" + if "message_id" in _class.model_fields or "subject_message_id" in _class.model_fields: + assert _class in get_args(message.S2Message), ( + f"{_class} should be typed as a s2_python.message.S2Message", + ) + else: + assert _class in get_args(message.S2MessageElement), ( + f"{_class} should be typed as a s2_python.message.S2MessageElement", + ) + + def test_import_s2_messages__common(self): + self._test_import_s2_messages("s2python.common") + + def test_import_s2_messages__ddbc(self): + self._test_import_s2_messages("s2python.ddbc") + + def test_import_s2_messages__frbc(self): + self._test_import_s2_messages("s2python.frbc") + + def test_import_s2_messages__pebc(self): + self._test_import_s2_messages("s2python.pebc") + + def test_import_s2_messages__ppbc(self): + self._test_import_s2_messages("s2python.ppbc") diff --git a/s2-python/tests/unit/reception_status_awaiter_test.py b/s2-python/tests/unit/reception_status_awaiter_test.py new file mode 100644 index 0000000..fb06630 --- /dev/null +++ b/s2-python/tests/unit/reception_status_awaiter_test.py @@ -0,0 +1,172 @@ +"""Tests for ReceptionStatusAwaiter. + +Copied from +https://github.com/flexiblepower/s2-analyzer/blob/main/backend/test/s2_analyzer_backend/reception_status_awaiter_test.py +under Apache2 license on 31-08-2024. +""" + +import asyncio +import datetime +import uuid +from unittest import IsolatedAsyncioTestCase + +from s2python.common import ( + ReceptionStatus, + ReceptionStatusValues, + InstructionStatus, + InstructionStatusUpdate, +) +from s2python.reception_status_awaiter import ReceptionStatusAwaiter + + +class ReceptionStatusAwaiterTest(IsolatedAsyncioTestCase): + async def test__wait_for_reception_status__receive_while_waiting(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + message_id = uuid.uuid4() + s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + # Act + wait_task = asyncio.create_task( + awaiter.wait_for_reception_status(message_id, 1.0) + ) + should_be_waiting_still = not wait_task.done() + await awaiter.receive_reception_status(s2_reception_status) + await wait_task + received_s2_reception_status = wait_task.result() + + # Assert + expected_s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + self.assertTrue(should_be_waiting_still) + self.assertEqual(expected_s2_reception_status, received_s2_reception_status) + + async def test__wait_for_reception_status__already_received(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + message_id = uuid.uuid4() + s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + # Act + await awaiter.receive_reception_status(s2_reception_status) + received_s2_reception_status = await awaiter.wait_for_reception_status( + message_id, 1.0 + ) + + # Assert + expected_s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + self.assertEqual(expected_s2_reception_status, received_s2_reception_status) + + async def test__wait_for_reception_status__multiple_receive_while_waiting(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + message_id = uuid.uuid4() + s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + # Act + wait_task_1 = asyncio.create_task( + awaiter.wait_for_reception_status(message_id, 1.0) + ) + wait_task_2 = asyncio.create_task( + awaiter.wait_for_reception_status(message_id, 1.0) + ) + should_be_waiting_still_1 = not wait_task_1.done() + should_be_waiting_still_2 = not wait_task_2.done() + await awaiter.receive_reception_status(s2_reception_status) + await wait_task_1 + await wait_task_2 + received_s2_reception_status_1 = wait_task_1.result() + received_s2_reception_status_2 = wait_task_2.result() + + # Assert + expected_s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + self.assertTrue(should_be_waiting_still_1) + self.assertTrue(should_be_waiting_still_2) + self.assertEqual(expected_s2_reception_status, received_s2_reception_status_1) + self.assertEqual(expected_s2_reception_status, received_s2_reception_status_2) + + async def test__receive_reception_status__wrong_message(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + s2_msg = InstructionStatusUpdate( + message_id=uuid.uuid4(), + instruction_id=uuid.uuid4(), + status_type=InstructionStatus.NEW, + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + + # Act / Assert + with self.assertRaises(RuntimeError): + await awaiter.receive_reception_status(s2_msg) # type: ignore[arg-type] + + async def test__receive_reception_status__received_duplicate(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=uuid.uuid4(), status=ReceptionStatusValues.OK + ) + + # Act / Assert + await awaiter.receive_reception_status(s2_reception_status) + with self.assertRaises(RuntimeError): + await awaiter.receive_reception_status(s2_reception_status) + + async def test__receive_reception_status__receive_no_awaiting(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + message_id = uuid.uuid4() + s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + # Act + await awaiter.receive_reception_status(s2_reception_status) + + # Assert + expected_received = { + message_id: ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + } + self.assertEqual(awaiter.received, expected_received) + self.assertEqual(awaiter.awaiting, {}) + + async def test__receive_reception_status__receive_with_awaiting(self): + # Arrange + awaiter = ReceptionStatusAwaiter() + awaiting_event = asyncio.Event() + message_id = uuid.uuid4() + awaiter.awaiting = {message_id: awaiting_event} + s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + + # Act + should_not_be_set = not awaiting_event.is_set() + await awaiter.receive_reception_status(s2_reception_status) + should_be_set = awaiting_event.is_set() + + # Assert + expected_received = { + message_id: ReceptionStatus( # pyright: ignore[reportCallIssue] + subject_message_id=message_id, status=ReceptionStatusValues.OK + ) + } + + self.assertTrue(should_not_be_set) + self.assertTrue(should_be_set) + self.assertEqual(awaiter.received, expected_received) + self.assertEqual(awaiter.awaiting, {}) diff --git a/s2-python/tests/unit/s2_connection_test.py b/s2-python/tests/unit/s2_connection_test.py new file mode 100644 index 0000000..fcb8b37 --- /dev/null +++ b/s2-python/tests/unit/s2_connection_test.py @@ -0,0 +1,65 @@ +# import unittest +# +# +# class S2ConnectionTest(unittest.TestCase): +# async def test__send_and_await_reception_status__receive_while_waiting(self): +# # Arrange +# conn = Mock() +# awaiter = ReceptionStatusAwaiter() +# message_id = "1" +# s2_message = { +# "message_type": "Handshake", +# "message_id": message_id, +# "role": "RM", +# "supported_protocol_versions": ["1.0"], +# } +# s2_reception_status = { +# "message_type": "ReceptionStatus", +# "subject_message_id": message_id, +# "status": "OK", +# } +# +# # Act +# wait_task = asyncio.create_task( +# awaiter.send_and_await_reception_status(conn, s2_message, True) +# ) +# should_be_waiting_still = not wait_task.done() +# await awaiter.receive_reception_status(s2_reception_status) +# await wait_task +# received_s2_reception_status = wait_task.result() +# +# # Assert +# expected_s2_reception_status = { +# "message_type": "ReceptionStatus", +# "subject_message_id": "1", +# "status": "OK", +# } +# +# self.assertTrue(should_be_waiting_still) +# self.assertEqual(expected_s2_reception_status, received_s2_reception_status) +# +# async def test__send_and_await_reception_status__receive_while_waiting_not_okay(self): +# # Arrange +# conn = Mock() +# awaiter = ReceptionStatusAwaiter() +# message_id = "1" +# s2_message = { +# "message_type": "Handshake", +# "message_id": message_id, +# "role": "RM", +# "supported_protocol_versions": ["1.0"], +# } +# s2_reception_status = { +# "message_type": "ReceptionStatus", +# "subject_message_id": message_id, +# "status": "INVALID_MESSAGE", +# } +# +# # Act / Assert +# wait_task = asyncio.create_task( +# awaiter.send_and_await_reception_status(conn, s2_message, True) +# ) +# await awaiter.receive_reception_status(s2_reception_status) +# +# with self.assertRaises(RuntimeError): +# await wait_task diff --git a/s2-python/tests/unit/s2_parser_test.py b/s2-python/tests/unit/s2_parser_test.py new file mode 100644 index 0000000..eac9f4a --- /dev/null +++ b/s2-python/tests/unit/s2_parser_test.py @@ -0,0 +1,120 @@ +from unittest import TestCase +from uuid import UUID + +from s2python.common import HandshakeResponse +from s2python.generated.gen_s2 import EnergyManagementRole +from s2python.s2_parser import S2Parser +from s2python.common.handshake import Handshake +from s2python.s2_validation_error import S2ValidationError + + +class S2ParserTest(TestCase): + def test_parse_as_any_message__str(self): + # Arrange + message_json = ( + '{"message_id": "ca093515-0bb3-4709-bd56-092c1808b791", "message_type": "Handshake", "role": ' + '"CEM", "supported_protocol_versions": ["3.0alpha"]}' + ) + + # Act + parsed_message = S2Parser.parse_as_any_message(message_json) + + # Assert + self.assertEqual( + parsed_message, + Handshake( + message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), + role=EnergyManagementRole.CEM, + supported_protocol_versions=["3.0alpha"], + ), + ) + + def test_parse_as_any_message__dict(self): + # Arrange + message_json = { + "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", + "message_type": "Handshake", + "role": "CEM", + "supported_protocol_versions": ["3.0alpha"], + } + + # Act + parsed_message = S2Parser.parse_as_any_message(message_json) + + # Assert + self.assertEqual( + parsed_message, + Handshake( + message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), + role=EnergyManagementRole.CEM, + supported_protocol_versions=["3.0alpha"], + ), + ) + + def test_parse_as_any_message__dict_validation_error(self): + # Arrange + message_json = { + # "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", + "message_type": "Handshake", + "role": "CEM", + "supported_protocol_versions": ["3.0alpha"], + } + + # Act / Assert + with self.assertRaises(S2ValidationError): + S2Parser.parse_as_any_message(message_json) + + def test_parse_as_message__str(self): + # Arrange + message_json = ( + '{"message_id": "ca093515-0bb3-4709-bd56-092c1808b791", "message_type": "Handshake", "role": ' + '"CEM", "supported_protocol_versions": ["3.0alpha"]}' + ) + + # Act + parsed_message = S2Parser.parse_as_message(message_json, Handshake) + + # Assert + self.assertEqual( + parsed_message, + Handshake( + message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), + role=EnergyManagementRole.CEM, + supported_protocol_versions=["3.0alpha"], + ), + ) + + def test_parse_as_message__dict(self): + # Arrange + message_json = { + "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", + "message_type": "Handshake", + "role": "CEM", + "supported_protocol_versions": ["3.0alpha"], + } + + # Act + parsed_message = S2Parser.parse_as_message(message_json, Handshake) + + # Assert + self.assertEqual( + parsed_message, + Handshake( + message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), + role=EnergyManagementRole.CEM, + supported_protocol_versions=["3.0alpha"], + ), + ) + + def test_parse_as_message__dict_wrong_class(self): + # Arrange + message_json = { + "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", + "message_type": "Handshake", + "role": "CEM", + "supported_protocol_versions": ["3.0alpha"], + } + + # Act / Assert + with self.assertRaises(S2ValidationError): + S2Parser.parse_as_message(message_json, HandshakeResponse) diff --git a/s2-python/tests/unit/utils_test.py b/s2-python/tests/unit/utils_test.py new file mode 100644 index 0000000..1b96baa --- /dev/null +++ b/s2-python/tests/unit/utils_test.py @@ -0,0 +1,46 @@ +from typing import List +from unittest import TestCase + +from s2python.utils import pairwise + + +class PairwiseTest(TestCase): + def test_empty(self): + # Arrange + input_array: List[int] = [] + + # Act + pairs = list(pairwise(input_array)) + + # Assert + self.assertEqual(len(pairs), 0) + + def test_len_2(self): + # Arrange + input_array = [1, 2] + + # Act + pairs = list(pairwise(input_array)) + + # Assert + self.assertEqual(pairs, [(1, 2)]) + + def test_odd(self): + # Arrange + input_array = [1, 2, 3] + + # Act + pairs = list(pairwise(input_array)) + + # Assert + self.assertEqual(pairs, [(1, 2), (2, 3)]) + + def test_even(self): + # Arrange + input_array = [1, 2, 3, 4] + + # Act + pairs = list(pairwise(input_array)) + + # Assert + self.assertEqual(pairs, [(1, 2), (2, 3), (3, 4)]) diff --git a/s2-python/tox.ini b/s2-python/tox.ini new file mode 100644 index 0000000..deeca3b --- /dev/null +++ b/s2-python/tox.ini @@ -0,0 +1,72 @@ +[tox] +minversion = 3.24 +envlist = default +isolated_build = True + + +[testenv] +description = Invoke pytest to run automated tests + +setenv = + TOXINIDIR = {toxinidir} +passenv = + HOME + SETUPTOOLS_* +extras = + testing +commands = + pytest {posargs} + + +[testenv:{build,clean}] +description = + build: Build the package in isolation according to PEP517, see https://github.com/pypa/build + clean: Remove old distribution files and temporary build artifacts (./build and ./dist) +skip_install = True +changedir = {toxinidir} +deps = + build: build[virtualenv] +passenv = + SETUPTOOLS_* +commands = + clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' + clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' + build: python -m build {posargs} + + +[testenv:lint] +description = Lint the source code using pylint. +skip_install = True +changedir = {toxinidir} +deps = + -r dev-requirements.txt +commands = + pylint src/ tests/unit/ + +[testenv:typecheck] +description = Typecheck the source code using mypy. +skip_install = True +changedir = {toxinidir} +deps = + -r dev-requirements.txt +commands = + mypy --config-file mypy.ini src/ ./tests/unit/ + + +[testenv:publish] +description = + Publish the package you have been developing to a package index server. + By default, it uses testpypi. If you really want to publish your package + to be publicly accessible in PyPI, use the `-- --repository pypi` option. +skip_install = True +changedir = {toxinidir} +passenv = + # See: https://twine.readthedocs.io/en/latest/ + TWINE_USERNAME + TWINE_PASSWORD + TWINE_REPOSITORY + TWINE_REPOSITORY_URL +deps = twine +commands = + python -m twine check dist/* + python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* \ No newline at end of file diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml index 605ad7e..caac485 100644 --- a/s2-self-cert/cert.yaml +++ b/s2-self-cert/cert.yaml @@ -14,4 +14,4 @@ findings: - name: PEBCPowerConstraints Provided. status: PASS status: PASS -timestamp: 2025-04-29 14:54:29.709989 +timestamp: 2025-04-29 17:00:55.179552 diff --git a/s2-self-cert/pyproject.toml b/s2-self-cert/pyproject.toml index db818cc..4f2327f 100644 --- a/s2-self-cert/pyproject.toml +++ b/s2-self-cert/pyproject.toml @@ -11,5 +11,5 @@ dependencies = [ ] [tool.uv.sources] -s2-python = { path = "../../s2-python", editable = true } s2-testing = { path = "../s2-testing", editable = true } +s2-python = { path = "../s2-python", editable = true } diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index 4649d27..5080e20 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -9,20 +9,20 @@ import logging.config from typing import Dict +from s2python.common import ControlType as ProtocolControlType from s2testing.certificate.certificate import ComplianceReport -from s2testing.config import Config, ControlTypeTestConfig, load_config +from s2testing.config import Config, load_config from s2testing.controllers import ( Controller, BaseController, PEBCController, FRBCController, ) -from log import LOGGING_CONFIG -from orchestrator import IntegrationTestOrchestrator -from s2python.common import ControlType as ProtocolControlType -from server import S2Server +from s2testing.orchestrator import IntegrationTestOrchestrator from s2testing.test_suite import PEBCTestCase, TestSuiteBuilder from s2testing.test_suite.frbc_test_cases import FRBCTestCase +from log import LOGGING_CONFIG +from server import S2Server logging.config.dictConfig(LOGGING_CONFIG) @@ -32,7 +32,7 @@ parser.add_argument("config") -def create_controllers_dict( +def create_controllers_dict_with_config( config: Config, report: ComplianceReport ) -> Dict[ProtocolControlType, Controller]: controllers: Dict[ProtocolControlType, Controller] = {} @@ -59,7 +59,7 @@ async def main(): report = ComplianceReport(timestamp=datetime.now()) - controllers = create_controllers_dict(config, report) + controllers = create_controllers_dict_with_config(config, report) test_suite = ( TestSuiteBuilder(config.control_types, report) diff --git a/s2-self-cert/uv.lock b/s2-self-cert/uv.lock index 18458ae..aa41915 100644 --- a/s2-self-cert/uv.lock +++ b/s2-self-cert/uv.lock @@ -190,7 +190,7 @@ wheels = [ [[package]] name = "s2-python" version = "0.5.0" -source = { editable = "../../s2-python" } +source = { editable = "../s2-python" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -235,7 +235,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "s2-python", editable = "../../s2-python" }, + { name = "s2-python", editable = "../s2-python" }, { name = "s2-testing", editable = "../s2-testing" }, ] @@ -254,7 +254,7 @@ dependencies = [ requires-dist = [ { name = "pydantic", specifier = ">=2.11.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "s2-python", editable = "../../s2-python" }, + { name = "s2-python", editable = "../s2-python" }, { name = "websockets", specifier = ">=13.1" }, ] diff --git a/s2-testing/pyproject.toml b/s2-testing/pyproject.toml index 793cdc1..d3ac840 100644 --- a/s2-testing/pyproject.toml +++ b/s2-testing/pyproject.toml @@ -12,4 +12,4 @@ dependencies = [ ] [tool.uv.sources] -s2-python = { path = "../../s2-python", editable = true } +s2-python = { path = "../s2-python", editable = true } diff --git a/s2-testing/src/s2_testing.egg-info/SOURCES.txt b/s2-testing/src/s2_testing.egg-info/SOURCES.txt index 465cd63..c6b58b8 100644 --- a/s2-testing/src/s2_testing.egg-info/SOURCES.txt +++ b/s2-testing/src/s2_testing.egg-info/SOURCES.txt @@ -7,9 +7,11 @@ src/s2_testing.egg-info/dependency_links.txt src/s2_testing.egg-info/requires.txt src/s2_testing.egg-info/top_level.txt src/s2testing/__init__.py +src/s2testing/async_task_manager.py src/s2testing/config.py src/s2testing/connection.py src/s2testing/message_handlers.py +src/s2testing/orchestrator.py src/s2testing/util.py src/s2testing/certificate/certificate.py src/s2testing/controllers/__init__.py diff --git a/s2-testing/src/s2testing/async_task_manager.py b/s2-testing/src/s2testing/async_task_manager.py new file mode 100644 index 0000000..c6c8b08 --- /dev/null +++ b/s2-testing/src/s2testing/async_task_manager.py @@ -0,0 +1,66 @@ +import asyncio + +import logging +from typing import Coroutine + +logger = logging.getLogger(__name__) + + +class AsyncTaskManager: + """Manages a collection of asyncio tasks, providing methods for task creation, cleanup, and stopping. + + This class helps to manage the lifecycle of asynchronous tasks, ensuring + exceptions are caught and logged, and providing a mechanism to stop all + running tasks. + + Attributes: + _tasks (set): A set containing the asyncio task objects being managed. + _stop_event (asyncio.Event): An event used to signal that tasks should stop. + running (bool): Indicates whether the task manager is currently considered + to be running. + + """ + _tasks = set() + + _stop_event: asyncio.Event + + running = False + + def __init__( + self, + ): + self._stop_event = asyncio.Event() + + async def task_wrapper(self, task: Coroutine, stop_on_complete): + """Uncaught exceptions don't get logged reliably in tasks. This catches all exceptions to log them and kill the controller. + + TODO: Maybe handle the exceptions in a better way... + """ + try: + await task + if stop_on_complete: + logger.debug("Task execution complete. Stopping.") + self.stop() + except: + logger.exception("Exception in task!") + self.stop() + + def create_task(self, task: Coroutine, stop_on_complete=False): + self._tasks.add(asyncio.create_task(self.task_wrapper(task, stop_on_complete))) + + async def setup(self): + self._stop_event = asyncio.Event() + + async def cleanup(self): + for task in self._tasks: + task.cancel() + + await asyncio.gather(*self._tasks) + + self._tasks.clear() + + def stop(self): + self._stop_event.set() + + def is_running(self): + return self.running diff --git a/s2-testing/src/s2testing/config.py b/s2-testing/src/s2testing/config.py index b4015e5..9de4da3 100644 --- a/s2-testing/src/s2testing/config.py +++ b/s2-testing/src/s2testing/config.py @@ -48,7 +48,7 @@ class DeviceDetails(BaseModel): class Config(BaseModel): mode: Literal["testing", "certification"] - device_details: DeviceDetails + device_details: Optional[DeviceDetails] control_types: ControlTypeTestConfig diff --git a/s2-testing/src/s2testing/connection.py b/s2-testing/src/s2testing/connection.py index 95a6e01..9212547 100644 --- a/s2-testing/src/s2testing/connection.py +++ b/s2-testing/src/s2testing/connection.py @@ -81,6 +81,7 @@ async def receive(self): async def _send_and_forget(self, s2_msg: S2Message) -> None: json_msg = s2_msg.to_json() + logger.info(json_msg) try: await self.send(json_msg) except websockets.ConnectionClosedError as e: diff --git a/s2-testing/src/s2testing/orchestrator.py b/s2-testing/src/s2testing/orchestrator.py new file mode 100644 index 0000000..39b39de --- /dev/null +++ b/s2-testing/src/s2testing/orchestrator.py @@ -0,0 +1,283 @@ +import asyncio +import logging +import uuid +from types import CoroutineType +from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type + +from s2testing.certificate.certificate import ComplianceReport +from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + EnergyManagementRole, + Handshake, + HandshakeResponse, + ResourceManagerDetails, + SelectControlType, +) +from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError +from s2python.version import S2_VERSION + + +from .connection import BaseRMConnection +from s2testing.connection import SendOkay +from s2testing.controllers import Controller +from s2testing.test_suite.test_suite import TestSuite +from .async_task_manager import AsyncTaskManager +from s2testing.util import wait_for_event_or_stop + +logger = logging.getLogger(__name__) + +class IntegrationTestOrchestrator(AsyncTaskManager): + role: EnergyManagementRole = EnergyManagementRole.CEM + + connection: Optional["BaseRMConnection"] = None + + resource_manager_details: Optional[ResourceManagerDetails] = None + + controller: Controller + controllers: Dict[ProtocolControlType, Controller] + + # When set the main loop of Connection will trigger the stopping of all `_tasks` + _stop_event: asyncio.Event + + # The functions which handle the Handshake messages. All other messages should be handled by the controllers. + handshake_message_handlers: Dict[ + Type[S2Message], Callable[[S2Message, Awaitable[None]], CoroutineType] + ] + + test_suite: TestSuite + + def __init__( + self, + available_control_types: Dict[ProtocolControlType, Controller], + test_suite: TestSuite, + report: ComplianceReport, + ) -> None: + super().__init__() + + self.controllers = available_control_types + + controller = available_control_types.get(ProtocolControlType.NO_SELECTION) + if controller is None: + raise ValueError("A NO_SELECTION controller must be provided.") + self.controller = controller + + self.test_suite = test_suite + + self.handshake_message_handlers = { # type: ignore + Handshake: self.handle_handshake, + ResourceManagerDetails: self.handle_rm_details, + } + + self.report = report + + def set_control_type(self, control_type: ProtocolControlType): + controller = self.controllers[control_type] + # Put the RM Details into the new controller. + controller.resource_manager_details = controller.resource_manager_details + self.controller = controller + + async def process_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.connection is None: + raise ValueError("Connection not set.") + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + msg: S2Message = await asyncio.wait_for( + self.connection.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + # If Handshake message then use the handshake message handlers + if type(msg) in self.handshake_message_handlers: + send_okay = SendOkay(self.connection, msg.message_id) # type: ignore + await self.handshake_message_handlers[type(msg)]( + msg, send_okay.run_async() + ) + await send_okay.ensure_send_async(type(msg)) + elif self.controller is not None: + await self.controller.handle_message(msg, self.connection) # type: ignore + else: + logger.warning("No handler available for %s", msg.message_type) + + except asyncio.CancelledError: + logger.info("Message receiver cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + self.stop() + + async def execute_test_suite(self): + # Wait until the handshake is complete before starting the testing. + # TODO: Figure out how to include the handshake process in the testing. + + if self.connection is None: + raise ValueError("Connection not set.") + + if self.controller: + await self.test_suite.execute(self.connection, self.controller) + + async def main_loop(self): + await self.initiate_handshake() + + if not await wait_for_event_or_stop(self._handshake_complete, self._stop_event): + return + + logger.info("Handshake Complete!") + + await self.send_select_control_type() + + logger.info("Starting tests!") + + await self.execute_test_suite() + + logger.info(self.report.generate_certificate_dict()) + + async def connection_receive_messages(self): + """Wrapping the receive messages method to allow catching of validation errors.""" + + if self.connection is None: + raise ValueError("Connection not set.") + + try: + await self.connection.receive_messages() + except S2ValidationError as e: + if self.controller is not None: + self.controller.handle_s2_validation_exception(e) + else: + logger.error("S2 Validation Error encountered: %s", e) + except: + logger.exception("An error occurred whilst receiving messages.") + + async def initiate_handshake(self): + if self.connection is None: + raise ValueError("Connection not set.") + + await self.connection.send_msg_and_await_reception_status( + Handshake( + message_id=uuid.uuid4(), # type: ignore + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + + self.controller.handshake_acknowledged() + + async def handle_handshake( + self, + message: Handshake, + send_okay: Awaitable[None], + ) -> None: + + if self.connection is None: + raise ValueError("Connection not set.") + + logger.debug( + "%s supports S2 protocol versions: %s", + message.role, + message.supported_protocol_versions, + ) + if message.supported_protocol_versions is None: + raise ValueError( + "Missing supported protocol versions in handshake message." + ) + + await send_okay + + await self.connection.send_msg_and_await_reception_status( + HandshakeResponse( + message_id=uuid.uuid4(), + selected_protocol_version=message.supported_protocol_versions[0], + ) + ) + + self.controller.handshake_received() + + async def send_select_control_type(self): + # TODO: Select the control type in a better way. + logger.info("Selecting Control Type.") + if ( + self.resource_manager_details is None + or self.resource_manager_details.available_control_types is None + ): + raise Exception("Missing Resource Details.") + + if self.connection is None: + raise ValueError("Connection not set.") + + controller: Optional[Controller] = None + + while ( + controller is None + and len(self.resource_manager_details.available_control_types) > 0 + ): + control_type = self.resource_manager_details.available_control_types.pop() + if control_type in self.controllers: + # logger.info( + # "Getting controller %s from %s", control_type, self.controllers + # ) + controller = self.controllers[control_type] + + if controller is None: + logger.warning("No suitable control types available. Exiting...") + self.stop() + return + + logger.info("Selecting control type %s", controller) + + self.set_control_type(controller.control_type) + + await self.connection.send_msg_and_await_reception_status( + SelectControlType( + message_id=uuid.uuid4(), control_type=controller.control_type + ) + ) + + async def handle_rm_details( + self, + message: ResourceManagerDetails, + send_okay: Awaitable[None], + ): + + self.resource_manager_details = message + + if self.connection is None: + raise ValueError("Connection not set.") + + # Pass it to the no selection controller to use it as part of the test cases. + await self.controller.handle_message(message, self.connection, send_okay) + + self._handshake_complete.set() + + async def setup(self, connection: BaseRMConnection, *args, **kwargs): + await super().setup() + self.connection = connection + + self._handshake_complete = asyncio.Event() + + # Receives messages and puts them onto the queue. + self.create_task(self.connection_receive_messages(), True) + self.create_task(self.process_received_messages(), True) + self.create_task(self.main_loop(), True) + + async def cleanup(self, *args, **kwargs): + logger.info("Cleanup of Orchestrator") + await super().cleanup() + + await self.connection.stop() + + async def run(self, *args, **kwargs): + self.running = True + + await self.setup(*args, **kwargs) + + await self._stop_event.wait() + + await self.cleanup(*args, **kwargs) + + self.running = False diff --git a/s2-testing/uv.lock b/s2-testing/uv.lock index 95b5386..7d20b95 100644 --- a/s2-testing/uv.lock +++ b/s2-testing/uv.lock @@ -190,7 +190,7 @@ wheels = [ [[package]] name = "s2-python" version = "0.5.0" -source = { editable = "../../s2-python" } +source = { editable = "../s2-python" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -239,7 +239,7 @@ dependencies = [ requires-dist = [ { name = "pydantic", specifier = ">=2.11.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "s2-python", editable = "../../s2-python" }, + { name = "s2-python", editable = "../s2-python" }, { name = "websockets", specifier = ">=13.1" }, ] From 8dc5d3da4b9361b8ba6b29d89d0e450871e1ce7c Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Wed, 30 Apr 2025 11:03:29 +0200 Subject: [PATCH 11/75] Started on server side implementation --- .gitignore | 1 + s2-self-cert-server/README.md | 0 s2-self-cert-server/pyproject.toml | 16 + s2-self-cert-server/src/__init__.py | 0 s2-self-cert-server/src/client_connection.py | 115 +++ s2-self-cert-server/src/log.py | 29 + s2-self-cert-server/src/main.py | 145 ++++ s2-self-cert-server/src/model.py | 49 ++ s2-self-cert-server/src/rm_connection.py | 55 ++ s2-self-cert-server/uv.lock | 836 +++++++++++++++++++ 10 files changed, 1246 insertions(+) create mode 100644 s2-self-cert-server/README.md create mode 100644 s2-self-cert-server/pyproject.toml create mode 100644 s2-self-cert-server/src/__init__.py create mode 100644 s2-self-cert-server/src/client_connection.py create mode 100644 s2-self-cert-server/src/log.py create mode 100644 s2-self-cert-server/src/main.py create mode 100644 s2-self-cert-server/src/model.py create mode 100644 s2-self-cert-server/src/rm_connection.py create mode 100644 s2-self-cert-server/uv.lock diff --git a/.gitignore b/.gitignore index 7f10cfa..1953182 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv/ .vscode __pycache__ +**/*.egg-info \ No newline at end of file diff --git a/s2-self-cert-server/README.md b/s2-self-cert-server/README.md new file mode 100644 index 0000000..e69de29 diff --git a/s2-self-cert-server/pyproject.toml b/s2-self-cert-server/pyproject.toml new file mode 100644 index 0000000..41b877c --- /dev/null +++ b/s2-self-cert-server/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "s2-server" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "fastapi[standard]>=0.115.12", + "python-multipart>=0.0.20", + "s2-python", + "s2-testing", +] + +[tool.uv.sources] +s2-testing = { path = "../s2-testing", editable = true } +s2-python = { path = "../s2-python", editable = true } diff --git a/s2-self-cert-server/src/__init__.py b/s2-self-cert-server/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-self-cert-server/src/client_connection.py b/s2-self-cert-server/src/client_connection.py new file mode 100644 index 0000000..66c477d --- /dev/null +++ b/s2-self-cert-server/src/client_connection.py @@ -0,0 +1,115 @@ +import asyncio +import json +import logging +import uuid +from typing import Type + +from fastapi import WebSocket +from starlette.websockets import WebSocketClose +from test.ann_module import pars +from s2python.common import ReceptionStatus, ReceptionStatusValues +from s2python.message import S2Message +from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from .model import ( + ControlMessageEnvelope, + MessageEnvelopeTypeEnum, + S2MessageEnvelope, + ServerMessage, + ServerMessageValidationException, +) + +logger = logging.getLogger(__name__) + + +class ClientConnection: + ws: WebSocket + + message_queue: asyncio.Queue + + _stop_event: asyncio.Event + + def __init__(self, ws) -> None: + self.ws = ws + + self._stop_event = asyncio.Event() + + self.message_queue = asyncio.Queue() + + async def send_message(self, message: ServerMessage) -> None: + if self.ws is None: + raise RuntimeError( + "Cannot send messages if websocket connection is not yet established." + ) + + await self.ws.send_json(message.model_dump()) + + async def send_s2_message(self, message: S2Message): + envelope = S2MessageEnvelope(message=message) + await self.send_message(envelope) + + async def handle_received_message(self, message: dict): + try: + message_type = MessageEnvelopeTypeEnum(message["message_type"]) + + parsed_message = None + if message_type == MessageEnvelopeTypeEnum.LOG: + logger.warning("Received log message from client. Ignoring.") + pass + # raise ValueError("Server cannot receive log messages.") + elif message_type == MessageEnvelopeTypeEnum.S2: + parsed_message = S2MessageEnvelope.model_validate(message) + logger.info("Received S2 Message: %s", parsed_message) + elif message_type == MessageEnvelopeTypeEnum.CONTROL: + parsed_message = ControlMessageEnvelope.model_validate(message) + logger.info("Received Control Message: %s", parsed_message) + + if parsed_message is not None: + await self.message_queue.put(parsed_message) + + except KeyError: + raise ServerMessageValidationException("Message must have a message type.") + + async def receive_messages(self): + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + logger.debug("Connection has started to receive messages.") + + # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. + try: + while not self._stop_event.is_set(): + try: + message = await self.ws.receive_json() + # message = await asyncio.wait_for(self.ws.receive_json(), timeout=1) + except asyncio.TimeoutError: + continue + + if isinstance(message, WebSocketClose): + logger.info("Received WebSocket close message.") + self.stop() + break + logger.info("Received Message: %s", message) + await self.handle_received_message(message) + + except asyncio.CancelledError: + logger.exception("Cancelled error thrown.") + except RuntimeError as e: + logger.info("WebSocket connection closed (RuntimeError): %s", e) + except Exception as e: + logger.exception("Exception while receiving message:") + finally: + self.stop() + + async def run(self): + await self.receive_messages() + + await self.ws.close() + + async def get_next_message(self) -> ServerMessage: + return await self.message_queue.get() + + def stop(self): + self._stop_event.set() diff --git a/s2-self-cert-server/src/log.py b/s2-self-cert-server/src/log.py new file mode 100644 index 0000000..c0096fb --- /dev/null +++ b/s2-self-cert-server/src/log.py @@ -0,0 +1,29 @@ +from typing import Dict + +LOGGING_CONFIG: Dict = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "logging.Formatter", + "fmt": "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", + }, + "short": { + "()": "logging.Formatter", + "fmt": "%(name)s:%(lineno)d - %(levelname)s - %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "short", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, + "watchfiles.main": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "websockets": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "asyncio": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + }, +} diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py new file mode 100644 index 0000000..016f95b --- /dev/null +++ b/s2-self-cert-server/src/main.py @@ -0,0 +1,145 @@ +import asyncio +from enum import Enum +import json +import logging +import logging.config +from typing import Callable, Dict, Optional +from fastapi import UploadFile, WebSocket + +from fastapi import FastAPI + +from .log import LOGGING_CONFIG +from s2python.message import S2Message +from .model import ( + ConfigControlMessage, + ControlMessageType, + ServerMessage, + ControlMessage, + ControlMessageEnvelope, + MessageEnvelopeTypeEnum, + S2MessageEnvelope, + ServerMessageValidationException, +) +from .client_connection import ClientConnection +from .rm_connection import RMConnection +from s2testing.async_task_manager import AsyncTaskManager +from s2testing.config import Config + + +logging.config.dictConfig(LOGGING_CONFIG) +logger = logging.getLogger(__name__) + +app = FastAPI() + + +@app.post("/certificate/verify") +def verify_certificate(file: UploadFile): + # TODO: Implement verification + if file: + return {"valid": True} + else: + return {"valid": False} + + +class ServerController: + handlers: Dict[ControlMessageType, Callable] = {} + + config: Optional[Config] = None + + def __init__(self): + self.handlers = {ControlMessageType.CONFIG: self.handle_config_message} + + async def handle_config_message(self, message: ConfigControlMessage): + logger.info("Config Message: %s", message.config) + self.config = message.config + + async def handle_message(self, message: ControlMessage): + try: + handler: Callable = self.handlers[message.message_type] + + await handler(message) + except KeyError: + logger.warning("No handler for `%s` message type.", message.message_type) + + +class CertificationServerManager(AsyncTaskManager): + client_connection: ClientConnection + rm_connection: RMConnection + + controller: Optional[ServerController] = None + + def __init__(self, websocket: WebSocket) -> None: + self.client_connection = ClientConnection(websocket) + self.rm_connection = RMConnection(self.client_connection) + + self.controller = ServerController() + + async def handle_s2_message(self, message: S2Message): + await self.rm_connection.add_message_to_queue(message) + + async def process_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.client_connection is None: + raise ValueError("Connection not set.") + + logger.info("Server processing messages.") + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + envelope: ServerMessage = await asyncio.wait_for( + self.client_connection.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + if envelope.message_type == MessageEnvelopeTypeEnum.CONTROL: + await self.controller.handle_message(envelope.message) + elif envelope.message_type == MessageEnvelopeTypeEnum.S2: + await self.handle_s2_message(envelope.message) + else: + raise ValueError("Invalid message type.") + + except asyncio.CancelledError: + logger.info("Message receiver cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + self.stop() + + # async def connection_receive_messages(self): + # pass + + async def main_loop(self): + pass + + async def setup(self, *args, **kwargs): + await super().setup() + + self.create_task(self.client_connection.receive_messages(), True) + self.create_task(self.process_received_messages(), True) + # self.create_task(self.main_loop(), True) + + async def run(self): + self.running = True + + await self.setup() + + logger.info("Setup complete %s", self._stop_event) + await self._stop_event.wait() + logger.info("Cleaning up. %s", self._stop_event) + + await self.cleanup() + + self.running = False + + +@app.websocket("/ws") +async def connect_tester(websocket: WebSocket): + await websocket.accept() + server = CertificationServerManager(websocket) + await server.run() + + await websocket.close() diff --git a/s2-self-cert-server/src/model.py b/s2-self-cert-server/src/model.py new file mode 100644 index 0000000..47749cd --- /dev/null +++ b/s2-self-cert-server/src/model.py @@ -0,0 +1,49 @@ +from enum import Enum +from typing import Union +from pydantic import BaseModel + +from s2python.message import S2Message +from s2testing.config import Config + + +class ServerMessageValidationException(Exception): + pass + + +class MessageEnvelopeTypeEnum(str, Enum): + CONTROL = "CONTROL" + S2 = "S2" + LOG = "LOG" + +class ControlMessageType(str, Enum): + CONFIG = "CONFIG" + CERTIFICATE = "CERTIFICATE" + +class LogMessage(BaseModel): + level: str + content: str + + +class ConfigControlMessage(BaseModel): + message_type : ControlMessageType = ControlMessageType.CONFIG + config : Config + +ControlMessage = Union[ConfigControlMessage] + +class ControlMessageEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CONTROL + message: ControlMessage + + +class LogMessageEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.LOG + message: LogMessage + + +class S2MessageEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.S2 + # keep it as a dict so that the orchestrator can do the parsing and catch the errors as part of the testing. + message: dict + + +ServerMessage = Union[ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope] diff --git a/s2-self-cert-server/src/rm_connection.py b/s2-self-cert-server/src/rm_connection.py new file mode 100644 index 0000000..e072d6a --- /dev/null +++ b/s2-self-cert-server/src/rm_connection.py @@ -0,0 +1,55 @@ +import asyncio +import json +import logging +import uuid +from typing import Type + +from fastapi import WebSocket +from starlette.websockets import WebSocketClose +from s2testing.connection import BaseRMConnection +from s2python.message import S2Message + +from .client_connection import ClientConnection +from .model import ( + ControlMessageEnvelope, + MessageEnvelopeTypeEnum, + S2MessageEnvelope, + ServerMessage, + ServerMessageValidationException, +) + +logger = logging.getLogger(__name__) + + +class RMConnection(BaseRMConnection): + """ + RMConnection is an implementation of BaseRMConnection used for server-side testing. + + This class facilitates communication with the RM (Resource Manager) via the local s2-self-cert instance. + S2Messages are encapsulated in an envelope for transmission between the client and server sides. + The client side is responsible for unwrapping the envelope and forwarding the S2Message to the physical RM. + + Attributes: + receive_queue (asyncio.Queue): Queue which received messages are put onto. + client_connection (ClientConnection): The connection to the local instance which is used to send the message via the WS. + """ + + receive_queue: asyncio.Queue + client_connection: ClientConnection + + def __init__(self, client_connection: ClientConnection): + super().__init__() + + self.receive_queue = asyncio.Queue() + self.client_connection = client_connection + + async def send(self, message: S2Message): + await self.client_connection.send_s2_message(message) + + async def receive(self): + msg = await self.receive_queue.get() + + logger.info(f"Received S2 message: {msg} ") + + async def add_message_to_queue(self, message: dict): + await self.receive_queue.put(message) diff --git a/s2-self-cert-server/uv.lock b/s2-self-cert-server/uv.lock new file mode 100644 index 0000000..e295b28 --- /dev/null +++ b/s2-self-cert-server/uv.lock @@ -0,0 +1,836 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload_time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload_time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload_time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload_time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload_time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload_time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload_time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload_time = "2024-06-20T11:30:28.248Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload_time = "2024-07-12T22:26:00.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload_time = "2024-07-12T22:25:58.476Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload_time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload_time = "2025-03-23T22:55:42.101Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload_time = "2024-12-15T14:28:10.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload_time = "2024-12-15T14:28:06.18Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload_time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload_time = "2024-10-16T19:44:06.882Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload_time = "2024-10-16T19:44:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload_time = "2024-10-16T19:44:09.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload_time = "2024-10-16T19:44:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload_time = "2024-10-16T19:44:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload_time = "2024-10-16T19:44:15.258Z" }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload_time = "2024-10-16T19:44:16.54Z" }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload_time = "2024-10-16T19:44:18.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload_time = "2024-10-16T19:44:19.515Z" }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload_time = "2024-10-16T19:44:21.067Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload_time = "2024-10-16T19:44:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload_time = "2024-10-16T19:44:24.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload_time = "2024-10-16T19:44:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload_time = "2024-10-16T19:44:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload_time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload_time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload_time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload_time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload_time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload_time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload_time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload_time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload_time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload_time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload_time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload_time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload_time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload_time = "2024-10-16T19:44:46.46Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload_time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload_time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload_time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload_time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload_time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload_time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload_time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload_time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload_time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload_time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload_time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload_time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload_time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload_time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload_time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload_time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload_time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload_time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload_time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload_time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload_time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload_time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload_time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload_time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload_time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload_time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload_time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload_time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload_time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload_time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload_time = "2025-04-08T13:27:06.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload_time = "2025-04-08T13:27:03.789Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload_time = "2025-04-02T09:49:41.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021, upload_time = "2025-04-02T09:46:45.065Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742, upload_time = "2025-04-02T09:46:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414, upload_time = "2025-04-02T09:46:48.263Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848, upload_time = "2025-04-02T09:46:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055, upload_time = "2025-04-02T09:46:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806, upload_time = "2025-04-02T09:46:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777, upload_time = "2025-04-02T09:46:53.675Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803, upload_time = "2025-04-02T09:46:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755, upload_time = "2025-04-02T09:46:56.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358, upload_time = "2025-04-02T09:46:58.445Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916, upload_time = "2025-04-02T09:46:59.726Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823, upload_time = "2025-04-02T09:47:01.278Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494, upload_time = "2025-04-02T09:47:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224, upload_time = "2025-04-02T09:47:04.199Z" }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845, upload_time = "2025-04-02T09:47:05.686Z" }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029, upload_time = "2025-04-02T09:47:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784, upload_time = "2025-04-02T09:47:08.63Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075, upload_time = "2025-04-02T09:47:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849, upload_time = "2025-04-02T09:47:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794, upload_time = "2025-04-02T09:47:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237, upload_time = "2025-04-02T09:47:14.355Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351, upload_time = "2025-04-02T09:47:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914, upload_time = "2025-04-02T09:47:17Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385, upload_time = "2025-04-02T09:47:18.631Z" }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765, upload_time = "2025-04-02T09:47:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688, upload_time = "2025-04-02T09:47:22.029Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185, upload_time = "2025-04-02T09:47:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640, upload_time = "2025-04-02T09:47:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649, upload_time = "2025-04-02T09:47:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472, upload_time = "2025-04-02T09:47:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509, upload_time = "2025-04-02T09:47:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702, upload_time = "2025-04-02T09:47:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428, upload_time = "2025-04-02T09:47:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753, upload_time = "2025-04-02T09:47:39.013Z" }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849, upload_time = "2025-04-02T09:47:40.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541, upload_time = "2025-04-02T09:47:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225, upload_time = "2025-04-02T09:47:43.425Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373, upload_time = "2025-04-02T09:47:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034, upload_time = "2025-04-02T09:47:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848, upload_time = "2025-04-02T09:47:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986, upload_time = "2025-04-02T09:47:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551, upload_time = "2025-04-02T09:47:51.648Z" }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785, upload_time = "2025-04-02T09:47:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758, upload_time = "2025-04-02T09:47:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109, upload_time = "2025-04-02T09:47:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159, upload_time = "2025-04-02T09:47:58.088Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222, upload_time = "2025-04-02T09:47:59.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980, upload_time = "2025-04-02T09:48:01.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840, upload_time = "2025-04-02T09:48:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518, upload_time = "2025-04-02T09:48:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025, upload_time = "2025-04-02T09:48:06.226Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991, upload_time = "2025-04-02T09:48:08.114Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262, upload_time = "2025-04-02T09:48:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626, upload_time = "2025-04-02T09:48:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590, upload_time = "2025-04-02T09:48:12.861Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload_time = "2025-04-02T09:48:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload_time = "2025-04-02T09:48:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload_time = "2025-04-02T09:48:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659, upload_time = "2025-04-02T09:48:45.342Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294, upload_time = "2025-04-02T09:48:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771, upload_time = "2025-04-02T09:48:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558, upload_time = "2025-04-02T09:48:51.409Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038, upload_time = "2025-04-02T09:48:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315, upload_time = "2025-04-02T09:48:55.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063, upload_time = "2025-04-02T09:48:57.479Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631, upload_time = "2025-04-02T09:48:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877, upload_time = "2025-04-02T09:49:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858, upload_time = "2025-04-02T09:49:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745, upload_time = "2025-04-02T09:49:05.391Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188, upload_time = "2025-04-02T09:49:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479, upload_time = "2025-04-02T09:49:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415, upload_time = "2025-04-02T09:49:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623, upload_time = "2025-04-02T09:49:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175, upload_time = "2025-04-02T09:49:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674, upload_time = "2025-04-02T09:49:17.61Z" }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951, upload_time = "2025-04-02T09:49:19.559Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload_time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload_time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload_time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload_time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload_time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload_time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/69/e328fb8986814147562b2617f22b06723f60b0c85c85afc0408b9f324a97/rich_toolkit-0.14.3.tar.gz", hash = "sha256:b72a342e52253b912681b027e94226e2deea616494420eec0b09a7219a72a0a5", size = 104469, upload_time = "2025-04-23T14:54:52.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/09/e0c7b06657ca1d4317d9e37ea5657a88a20dc3507b2ee6939ace0ff9036e/rich_toolkit-0.14.3-py3-none-any.whl", hash = "sha256:2ec72dcdf1bbb09b6a9286a4eddcd4d43369da3b22fe3f28e5a92143618b8ac6", size = 24258, upload_time = "2025-04-23T14:54:51.443Z" }, +] + +[[package]] +name = "s2-python" +version = "0.5.0" +source = { editable = "../s2-python" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "datamodel-code-generator", marker = "extra == 'development'" }, + { name = "mypy", marker = "extra == 'testing'" }, + { name = "pip-tools", marker = "extra == 'development'" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pylint", marker = "extra == 'testing'" }, + { name = "pyright", marker = "extra == 'testing'" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-coverage", marker = "extra == 'testing'" }, + { name = "pytest-timer", marker = "extra == 'testing'" }, + { name = "pytz" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "sphinx-tabs", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'development'" }, + { name = "types-pytz", marker = "extra == 'testing'" }, + { name = "websockets", specifier = "~=13.1" }, +] +provides-extras = ["testing", "development", "docs"] + +[[package]] +name = "s2-server" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi", extra = ["standard"] }, + { name = "python-multipart" }, + { name = "s2-python" }, + { name = "s2-testing" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "s2-python", editable = "../s2-python" }, + { name = "s2-testing", editable = "../s2-testing" }, +] + +[[package]] +name = "s2-testing" +version = "0.1.0" +source = { editable = "../s2-testing" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload_time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload_time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "typer" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload_time = "2025-04-28T21:40:59.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload_time = "2025-04-28T21:40:56.269Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload_time = "2025-02-25T17:27:59.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload_time = "2025-02-25T17:27:57.754Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload_time = "2025-04-19T06:02:50.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload_time = "2025-04-19T06:02:48.42Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload_time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload_time = "2024-10-14T23:37:20.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload_time = "2024-10-14T23:37:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload_time = "2024-10-14T23:37:25.129Z" }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload_time = "2024-10-14T23:37:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload_time = "2024-10-14T23:37:29.385Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload_time = "2024-10-14T23:37:32.048Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload_time = "2024-10-14T23:37:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload_time = "2024-10-14T23:37:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload_time = "2024-10-14T23:37:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload_time = "2024-10-14T23:37:40.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload_time = "2024-10-14T23:37:42.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload_time = "2024-10-14T23:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload_time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload_time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload_time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload_time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload_time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload_time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload_time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload_time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload_time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload_time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload_time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload_time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload_time = "2025-04-08T10:36:26.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40", size = 405632, upload_time = "2025-04-08T10:34:41.832Z" }, + { url = "https://files.pythonhosted.org/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb", size = 395734, upload_time = "2025-04-08T10:34:44.236Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11", size = 455008, upload_time = "2025-04-08T10:34:45.617Z" }, + { url = "https://files.pythonhosted.org/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487", size = 459029, upload_time = "2025-04-08T10:34:46.814Z" }, + { url = "https://files.pythonhosted.org/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256", size = 488916, upload_time = "2025-04-08T10:34:48.571Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85", size = 523763, upload_time = "2025-04-08T10:34:50.268Z" }, + { url = "https://files.pythonhosted.org/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358", size = 502891, upload_time = "2025-04-08T10:34:51.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614", size = 454921, upload_time = "2025-04-08T10:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f", size = 631422, upload_time = "2025-04-08T10:34:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d", size = 625675, upload_time = "2025-04-08T10:34:55.173Z" }, + { url = "https://files.pythonhosted.org/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff", size = 277921, upload_time = "2025-04-08T10:34:56.318Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92", size = 291526, upload_time = "2025-04-08T10:34:57.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336, upload_time = "2025-04-08T10:34:59.359Z" }, + { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977, upload_time = "2025-04-08T10:35:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232, upload_time = "2025-04-08T10:35:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151, upload_time = "2025-04-08T10:35:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054, upload_time = "2025-04-08T10:35:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955, upload_time = "2025-04-08T10:35:05.786Z" }, + { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234, upload_time = "2025-04-08T10:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750, upload_time = "2025-04-08T10:35:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591, upload_time = "2025-04-08T10:35:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370, upload_time = "2025-04-08T10:35:12.412Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791, upload_time = "2025-04-08T10:35:13.719Z" }, + { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622, upload_time = "2025-04-08T10:35:15.071Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699, upload_time = "2025-04-08T10:35:16.732Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload_time = "2025-04-08T10:35:17.956Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload_time = "2025-04-08T10:35:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload_time = "2025-04-08T10:35:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload_time = "2025-04-08T10:35:21.87Z" }, + { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload_time = "2025-04-08T10:35:23.143Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload_time = "2025-04-08T10:35:24.702Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload_time = "2025-04-08T10:35:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload_time = "2025-04-08T10:35:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload_time = "2025-04-08T10:35:28.685Z" }, + { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload_time = "2025-04-08T10:35:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload_time = "2025-04-08T10:35:32.023Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload_time = "2025-04-08T10:35:33.225Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload_time = "2025-04-08T10:35:34.568Z" }, + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload_time = "2025-04-08T10:35:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload_time = "2025-04-08T10:35:37.048Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload_time = "2025-04-08T10:35:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload_time = "2025-04-08T10:35:39.708Z" }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload_time = "2025-04-08T10:35:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload_time = "2025-04-08T10:35:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload_time = "2025-04-08T10:35:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload_time = "2025-04-08T10:35:46.336Z" }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload_time = "2025-04-08T10:35:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload_time = "2025-04-08T10:35:49.65Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload_time = "2025-04-08T10:35:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload_time = "2025-04-08T10:35:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d", size = 405947, upload_time = "2025-04-08T10:36:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034", size = 397276, upload_time = "2025-04-08T10:36:15.131Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965", size = 455550, upload_time = "2025-04-08T10:36:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57", size = 455542, upload_time = "2025-04-08T10:36:18.655Z" }, +] + +[[package]] +name = "websockets" +version = "13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload_time = "2024-09-21T17:34:21.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload_time = "2024-09-21T17:32:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload_time = "2024-09-21T17:32:28.428Z" }, + { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload_time = "2024-09-21T17:32:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload_time = "2024-09-21T17:32:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload_time = "2024-09-21T17:32:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload_time = "2024-09-21T17:32:33.398Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload_time = "2024-09-21T17:32:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload_time = "2024-09-21T17:32:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload_time = "2024-09-21T17:32:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload_time = "2024-09-21T17:32:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload_time = "2024-09-21T17:32:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload_time = "2024-09-21T17:32:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload_time = "2024-09-21T17:32:43.858Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload_time = "2024-09-21T17:32:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload_time = "2024-09-21T17:32:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload_time = "2024-09-21T17:32:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload_time = "2024-09-21T17:32:48.046Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload_time = "2024-09-21T17:32:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload_time = "2024-09-21T17:32:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload_time = "2024-09-21T17:32:52.223Z" }, + { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload_time = "2024-09-21T17:32:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload_time = "2024-09-21T17:32:54.721Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload_time = "2024-09-21T17:32:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload_time = "2024-09-21T17:32:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload_time = "2024-09-21T17:32:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload_time = "2024-09-21T17:33:00.495Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload_time = "2024-09-21T17:33:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload_time = "2024-09-21T17:33:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload_time = "2024-09-21T17:33:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload_time = "2024-09-21T17:33:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload_time = "2024-09-21T17:33:06.823Z" }, + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload_time = "2024-09-21T17:33:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload_time = "2024-09-21T17:33:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload_time = "2024-09-21T17:33:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload_time = "2024-09-21T17:33:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload_time = "2024-09-21T17:33:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload_time = "2024-09-21T17:33:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload_time = "2024-09-21T17:33:17.113Z" }, + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload_time = "2024-09-21T17:33:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload_time = "2024-09-21T17:33:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload_time = "2024-09-21T17:33:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload_time = "2024-09-21T17:33:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload_time = "2024-09-21T17:33:24.196Z" }, + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload_time = "2024-09-21T17:33:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload_time = "2024-09-21T17:33:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload_time = "2024-09-21T17:33:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload_time = "2024-09-21T17:33:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload_time = "2024-09-21T17:33:59.061Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload_time = "2024-09-21T17:34:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload_time = "2024-09-21T17:34:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload_time = "2024-09-21T17:34:19.904Z" }, +] From 51beca953ae9d258eeff012dd5e05abfa8c633f6 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Wed, 30 Apr 2025 11:43:36 +0200 Subject: [PATCH 12/75] Refactored Orchestrator to facilitate creation of server orchestrator --- s2-self-cert-server/src/main.py | 1 + s2-self-cert/src/orchestrator.py | 307 ------------------ s2-self-cert/src/server.py | 2 +- .../server_side_certification_orchestrator.py | 50 +++ s2-testing/src/s2testing/connection.py | 68 ++-- .../src/s2testing/orchestrator/__init__.py | 2 + s2-testing/src/s2testing/orchestrator/base.py | 97 ++++++ .../test_orchestrator.py} | 85 ++--- 8 files changed, 213 insertions(+), 399 deletions(-) delete mode 100644 s2-self-cert/src/orchestrator.py create mode 100644 s2-self-cert/src/server_side_certification_orchestrator.py create mode 100644 s2-testing/src/s2testing/orchestrator/__init__.py create mode 100644 s2-testing/src/s2testing/orchestrator/base.py rename s2-testing/src/s2testing/{orchestrator.py => orchestrator/test_orchestrator.py} (72%) diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index 016f95b..bd7e25f 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -69,6 +69,7 @@ class CertificationServerManager(AsyncTaskManager): controller: Optional[ServerController] = None def __init__(self, websocket: WebSocket) -> None: + super().__init__() self.client_connection = ClientConnection(websocket) self.rm_connection = RMConnection(self.client_connection) diff --git a/s2-self-cert/src/orchestrator.py b/s2-self-cert/src/orchestrator.py deleted file mode 100644 index a157cb8..0000000 --- a/s2-self-cert/src/orchestrator.py +++ /dev/null @@ -1,307 +0,0 @@ -import asyncio -import logging -import uuid -from types import CoroutineType -from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type - -from s2testing.certificate.certificate import ComplianceReport -from s2python.common import ControlType as ProtocolControlType -from s2python.common import ( - EnergyManagementRole, - Handshake, - HandshakeResponse, - ResourceManagerDetails, - SelectControlType, -) -from s2python.message import S2Message -from s2python.s2_validation_error import S2ValidationError -from s2python.version import S2_VERSION - - -from connection import Connection -from s2testing.connection import SendOkay -from s2testing.controllers import Controller -from s2testing.test_suite.test_suite import TestSuite -from s2testing.util import wait_for_event_or_stop - -logger = logging.getLogger(__name__) - - -class IntegrationTestOrchestrator: - role: EnergyManagementRole = EnergyManagementRole.CEM - - connection: Optional["Connection"] = None - - resource_manager_details: Optional[ResourceManagerDetails] = None - - controller: Controller - controllers: Dict[ProtocolControlType, Controller] - - _tasks = set() - - # When set the main loop of Connection will trigger the stopping of all `_tasks` - _stop_event: asyncio.Event - - # The functions which handle the Handshake messages. All other messages should be handled by the controllers. - handshake_message_handlers: Dict[ - Type[S2Message], Callable[[S2Message, Awaitable[None]], CoroutineType] - ] - - test_suite: TestSuite - - running = False - - def __init__( - self, - available_control_types: Dict[ProtocolControlType, Controller], - test_suite: TestSuite, - report: ComplianceReport, - ) -> None: # pylint: disable=too-many-arguments - - self.controllers = available_control_types - - controller = available_control_types.get(ProtocolControlType.NO_SELECTION) - if controller is None: - raise ValueError("A NO_SELECTION controller must be provided.") - self.controller = controller - - self.test_suite = test_suite - - self.handshake_message_handlers = { # type: ignore - Handshake: self.handle_handshake, - ResourceManagerDetails: self.handle_rm_details, - } - - self._stop_event = asyncio.Event() - - self.report = report - - def set_control_type(self, control_type: ProtocolControlType): - controller = self.controllers[control_type] - # Put the RM Details into the new controller. - controller.resource_manager_details = controller.resource_manager_details - self.controller = controller - - async def process_received_messages(self): - """AsyncIO task which pops messages off the queue and processes them using the control type.""" - - if self.connection is None: - raise ValueError("Connection not set.") - - try: - while not self._stop_event.is_set(): - try: - # Use a timeout to periodically check for cancellation - msg: S2Message = await asyncio.wait_for( - self.connection.get_next_message(), timeout=1.0 - ) - except asyncio.TimeoutError: - continue # Check stop event and loop again - - # If Handshake message then use the handshake message handlers - if type(msg) in self.handshake_message_handlers: - send_okay = SendOkay(self.connection, msg.message_id) # type: ignore - await self.handshake_message_handlers[type(msg)]( - msg, send_okay.run_async() - ) - await send_okay.ensure_send_async(type(msg)) - elif self.controller is not None: - await self.controller.handle_message(msg, self.connection) # type: ignore - else: - logger.warning("No handler available for %s", msg.message_type) - - except asyncio.CancelledError: - logger.info("Message receiver cancelled.") - except Exception as e: - logger.exception("Message processor encountered an error: %s", e) - finally: - self._stop_event.set() - - async def execute_test_suite(self): - # Wait until the handshake is complete before starting the testing. - # TODO: Figure out how to include the handshake process in the testing. - - if self.connection is None: - raise ValueError("Connection not set.") - - if self.controller: - await self.test_suite.execute(self.connection, self.controller) - - async def main_loop(self): - await self.initiate_handshake() - - if not await wait_for_event_or_stop(self._handshake_complete, self._stop_event): - return - - logger.info("Handshake Complete!") - - await self.send_select_control_type() - - logger.info("Starting tests!") - - await self.execute_test_suite() - - logger.info(self.report.generate_certificate_dict()) - - async def connection_receive_messages(self): - """Wrapping the receive messages method to allow catching of validation errors.""" - - if self.connection is None: - raise ValueError("Connection not set.") - - try: - await self.connection.receive_messages() - except S2ValidationError as e: - if self.controller is not None: - self.controller.handle_s2_validation_exception(e) - else: - logger.error("S2 Validation Error encountered: %s", e) - except: - logger.exception("An error occurred whilst receiving messages.") - - async def task_wrapper(self, task: Coroutine, stop_on_complete): - """Uncaught exceptions don't get logged reliably in tasks. This catches all exceptions to log them and kill the controller. - - TODO: Maybe handle the exceptions in a better way... - """ - try: - await task - if stop_on_complete: - self.stop() - except: - logger.exception("Exception in task!") - self.stop() - - def create_task(self, task: Coroutine, stop_on_complete=False): - self._tasks.add(asyncio.create_task(self.task_wrapper(task, stop_on_complete))) - - async def run(self, connection: Connection): - self.running = True - self.connection = connection - - self._handshake_complete = asyncio.Event() - self._stop_event = asyncio.Event() - - # Receives messages and puts them onto the queue. - self.create_task(self.connection_receive_messages(), True) - self.create_task(self.process_received_messages(), True) - self.create_task(self.main_loop(), True) - - await self._stop_event.wait() - - for task in self._tasks: - task.cancel() - - await self.connection.stop() - - await asyncio.gather(*self._tasks) - - self._tasks.clear() - - self.running = False - - def stop(self): - self._stop_event.set() - - def is_running(self): - return self.running - - async def initiate_handshake(self): - if self.connection is None: - raise ValueError("Connection not set.") - - await self.connection.send_msg_and_await_reception_status( - Handshake( - message_id=uuid.uuid4(), # type: ignore - role=self.role, - supported_protocol_versions=[S2_VERSION], - ) - ) - - self.controller.handshake_acknowledged() - - async def handle_handshake( - self, - message: Handshake, - send_okay: Awaitable[None], - ) -> None: - - if self.connection is None: - raise ValueError("Connection not set.") - - logger.debug( - "%s supports S2 protocol versions: %s", - message.role, - message.supported_protocol_versions, - ) - if message.supported_protocol_versions is None: - raise ValueError( - "Missing supported protocol versions in handshake message." - ) - - await send_okay - - await self.connection.send_msg_and_await_reception_status( - HandshakeResponse( - message_id=uuid.uuid4(), - selected_protocol_version=message.supported_protocol_versions[0], - ) - ) - - self.controller.handshake_received() - - async def send_select_control_type(self): - # TODO: Select the control type in a better way. - logger.info("Selecting Control Type.") - if ( - self.resource_manager_details is None - or self.resource_manager_details.available_control_types is None - ): - raise Exception("Missing Resource Details.") - - if self.connection is None: - raise ValueError("Connection not set.") - - controller: Optional[Controller] = None - - while ( - controller is None - and len(self.resource_manager_details.available_control_types) > 0 - ): - control_type = self.resource_manager_details.available_control_types.pop() - if control_type in self.controllers: - # logger.info( - # "Getting controller %s from %s", control_type, self.controllers - # ) - controller = self.controllers[control_type] - - if controller is None: - logger.warning("No suitable control types available. Exiting...") - self.stop() - return - - logger.info("Selecting control type %s", controller) - - self.set_control_type(controller.control_type) - - await self.connection.send_msg_and_await_reception_status( - SelectControlType( - message_id=uuid.uuid4(), control_type=controller.control_type - ) - ) - - async def handle_rm_details( - self, - message: ResourceManagerDetails, - send_okay: Awaitable[None], - ): - - self.resource_manager_details = message - - if self.connection is None: - raise ValueError("Connection not set.") - - # Pass it to the no selection controller to use it as part of the test cases. - await self.controller.handle_message(message, self.connection, send_okay) - - self._handshake_complete.set() diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index c078c4c..329b893 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -3,7 +3,7 @@ import signal from connection import Connection -from orchestrator import IntegrationTestOrchestrator +from s2testing.orchestrator import IntegrationTestOrchestrator from websockets.asyncio.connection import Connection as WSConnection from websockets.asyncio.server import serve as ws_serve diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py new file mode 100644 index 0000000..56659cb --- /dev/null +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -0,0 +1,50 @@ +import asyncio +import logging +import uuid +from types import CoroutineType +from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type + +from s2testing.certificate.certificate import ComplianceReport +from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + EnergyManagementRole, + Handshake, + HandshakeResponse, + ResourceManagerDetails, + SelectControlType, +) +from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError +from s2python.version import S2_VERSION + + +from s2testing.connection import BaseConnection +from s2testing.async_task_manager import AsyncTaskManager +from s2testing.orchestrator import Orchestrator +from s2testing.connection import SendOkay +from s2testing.controllers import Controller +from s2testing.test_suite.test_suite import TestSuite +from s2testing.util import wait_for_event_or_stop + +logger = logging.getLogger(__name__) + + +class ServerSideCertificationOrchestrator(Orchestrator): + connection: Optional["BaseConnection"] = None + + def __init__( + self, + ) -> None: + super().__init__() + + async def process_message(self, message: str): + # TODO: Send the message to the server + pass + + async def main_loop(self): + pass + + async def setup(self, connection: BaseConnection, *args, **kwargs): + await super().setup(connection) + + # self.create_task(self.main_loop(), True) diff --git a/s2-testing/src/s2testing/connection.py b/s2-testing/src/s2testing/connection.py index 9212547..fff1c8e 100644 --- a/s2-testing/src/s2testing/connection.py +++ b/s2-testing/src/s2testing/connection.py @@ -48,7 +48,47 @@ async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: await self.run_async() -class BaseRMConnection(abc.ABC): # pylint: disable=too-many-instance-attributes +class BaseConnection(abc.ABC): + + message_queue: asyncio.Queue + + _stop_event: asyncio.Event + + def __init__(self) -> None: # pylint: disable=too-many-arguments + self._stop_event = asyncio.Event() + + self.message_queue = asyncio.Queue() + + @abc.abstractmethod + async def send(self, message: str): + pass + + @abc.abstractmethod + async def receive(self): + pass + + async def process_received_message(self, message: str): + await self.message_queue.put(message) + + async def receive_messages(self): + while not self._stop_event.is_set(): + # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. + try: + message = await asyncio.wait_for(self.receive(), timeout=1) + logger.debug("Received Message: %s", message) + except asyncio.TimeoutError: + continue + + await self.process_received_message(str(message)) + + async def get_next_message(self): + return await self.message_queue.get() + + def stop(self): + self._stop_event.set() + + +class BaseRMConnection(BaseConnection): # pylint: disable=too-many-instance-attributes """ Manged the websocket connection to the RM. Puts all received messages onto the message queue so they can be retrieved by other tasks. @@ -59,18 +99,13 @@ class BaseRMConnection(abc.ABC): # pylint: disable=too-many-instance-attributes reception_status_awaiter: ReceptionStatusAwaiter - message_queue: asyncio.Queue - _stop_event: asyncio.Event def __init__(self) -> None: # pylint: disable=too-many-arguments + super().__init__() self.reception_status_awaiter = ReceptionStatusAwaiter() self.s2_parser = S2Parser() - self._stop_event = asyncio.Event() - - self.message_queue = asyncio.Queue() - @abc.abstractmethod async def send(self, message: str): pass @@ -134,7 +169,7 @@ async def send_msg_and_await_reception_status( return reception_status - async def parse_received_message(self, message: str): + async def process_received_message(self, message: str): try: s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) except json.JSONDecodeError: @@ -185,20 +220,3 @@ async def parse_received_message(self, message: str): await self.reception_status_awaiter.receive_reception_status(s2_msg) else: await self.message_queue.put(s2_msg) - - async def receive_messages(self): - while not self._stop_event.is_set(): - # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. - try: - message = await asyncio.wait_for(self.receive(), timeout=1) - logger.debug("Received Message: %s", message) - except asyncio.TimeoutError: - continue - - await self.parse_received_message(str(message)) - - async def get_next_message(self): - return await self.message_queue.get() - - def stop(self): - self._stop_event.set() diff --git a/s2-testing/src/s2testing/orchestrator/__init__.py b/s2-testing/src/s2testing/orchestrator/__init__.py new file mode 100644 index 0000000..bb2aeed --- /dev/null +++ b/s2-testing/src/s2testing/orchestrator/__init__.py @@ -0,0 +1,2 @@ +from .base import Orchestrator +from .test_orchestrator import IntegrationTestOrchestrator diff --git a/s2-testing/src/s2testing/orchestrator/base.py b/s2-testing/src/s2testing/orchestrator/base.py new file mode 100644 index 0000000..89ede58 --- /dev/null +++ b/s2-testing/src/s2testing/orchestrator/base.py @@ -0,0 +1,97 @@ +import abc +import asyncio +import logging +import uuid +from types import CoroutineType +from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type + +from s2testing.certificate.certificate import ComplianceReport +from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + EnergyManagementRole, + Handshake, + HandshakeResponse, + ResourceManagerDetails, + SelectControlType, +) +from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError +from s2python.version import S2_VERSION + + +from ..connection import BaseConnection +from ..async_task_manager import AsyncTaskManager +from s2testing.connection import SendOkay +from s2testing.controllers import Controller +from s2testing.test_suite.test_suite import TestSuite +from s2testing.util import wait_for_event_or_stop + +logger = logging.getLogger(__name__) + + +class Orchestrator(AsyncTaskManager): + connection: Optional["BaseConnection"] = None + + @abc.abstractmethod + async def process_message(self, message): + pass + + async def process_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.connection is None: + raise ValueError("Connection not set.") + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + message = await asyncio.wait_for( + self.connection.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + await self.process_message(message) + except asyncio.CancelledError: + logger.info("Message receiver cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + self.stop() + + @abc.abstractmethod + async def main_loop(self): + pass + + async def connection_receive_messages(self): + """Wrapping the receive messages method to allow catching of validation errors.""" + + if self.connection is None: + raise ValueError("Connection not set.") + + await self.connection.receive_messages() + + async def setup(self, connection: BaseConnection, *args, **kwargs): + await super().setup() + self.connection = connection + + self.create_task(self.connection_receive_messages(), True) + self.create_task(self.process_received_messages(), True) + + async def cleanup(self, *args, **kwargs): + logger.info("Cleanup of Orchestrator") + await super().cleanup() + + await self.connection.stop() + + async def run(self, *args, **kwargs): + self.running = True + + await self.setup(*args, **kwargs) + + await self._stop_event.wait() + + await self.cleanup(*args, **kwargs) + + self.running = False diff --git a/s2-testing/src/s2testing/orchestrator.py b/s2-testing/src/s2testing/orchestrator/test_orchestrator.py similarity index 72% rename from s2-testing/src/s2testing/orchestrator.py rename to s2-testing/src/s2testing/orchestrator/test_orchestrator.py index 39b39de..52351a6 100644 --- a/s2-testing/src/s2testing/orchestrator.py +++ b/s2-testing/src/s2testing/orchestrator/test_orchestrator.py @@ -18,16 +18,18 @@ from s2python.version import S2_VERSION -from .connection import BaseRMConnection +from ..connection import BaseRMConnection +from ..async_task_manager import AsyncTaskManager +from .base import Orchestrator from s2testing.connection import SendOkay from s2testing.controllers import Controller from s2testing.test_suite.test_suite import TestSuite -from .async_task_manager import AsyncTaskManager from s2testing.util import wait_for_event_or_stop logger = logging.getLogger(__name__) -class IntegrationTestOrchestrator(AsyncTaskManager): + +class IntegrationTestOrchestrator(Orchestrator): role: EnergyManagementRole = EnergyManagementRole.CEM connection: Optional["BaseRMConnection"] = None @@ -37,9 +39,6 @@ class IntegrationTestOrchestrator(AsyncTaskManager): controller: Controller controllers: Dict[ProtocolControlType, Controller] - # When set the main loop of Connection will trigger the stopping of all `_tasks` - _stop_event: asyncio.Event - # The functions which handle the Handshake messages. All other messages should be handled by the controllers. handshake_message_handlers: Dict[ Type[S2Message], Callable[[S2Message, Awaitable[None]], CoroutineType] @@ -77,40 +76,18 @@ def set_control_type(self, control_type: ProtocolControlType): controller.resource_manager_details = controller.resource_manager_details self.controller = controller - async def process_received_messages(self): - """AsyncIO task which pops messages off the queue and processes them using the control type.""" - - if self.connection is None: - raise ValueError("Connection not set.") - - try: - while not self._stop_event.is_set(): - try: - # Use a timeout to periodically check for cancellation - msg: S2Message = await asyncio.wait_for( - self.connection.get_next_message(), timeout=1.0 - ) - except asyncio.TimeoutError: - continue # Check stop event and loop again - - # If Handshake message then use the handshake message handlers - if type(msg) in self.handshake_message_handlers: - send_okay = SendOkay(self.connection, msg.message_id) # type: ignore - await self.handshake_message_handlers[type(msg)]( - msg, send_okay.run_async() - ) - await send_okay.ensure_send_async(type(msg)) - elif self.controller is not None: - await self.controller.handle_message(msg, self.connection) # type: ignore - else: - logger.warning("No handler available for %s", msg.message_type) - - except asyncio.CancelledError: - logger.info("Message receiver cancelled.") - except Exception as e: - logger.exception("Message processor encountered an error: %s", e) - finally: - self.stop() + async def process_message(self, message: S2Message): + # If Handshake message then use the handshake message handlers + if type(message) in self.handshake_message_handlers: + send_okay = SendOkay(self.connection, message.message_id) # type: ignore + await self.handshake_message_handlers[type(message)]( + message, send_okay.run_async() + ) + await send_okay.ensure_send_async(type(message)) + elif self.controller is not None: + await self.controller.handle_message(message, self.connection) # type: ignore + else: + logger.warning("No handler available for %s", message.message_type) async def execute_test_suite(self): # Wait until the handshake is complete before starting the testing. @@ -140,12 +117,8 @@ async def main_loop(self): async def connection_receive_messages(self): """Wrapping the receive messages method to allow catching of validation errors.""" - - if self.connection is None: - raise ValueError("Connection not set.") - try: - await self.connection.receive_messages() + await super().connection_receive_messages() except S2ValidationError as e: if self.controller is not None: self.controller.handle_s2_validation_exception(e) @@ -255,29 +228,9 @@ async def handle_rm_details( self._handshake_complete.set() async def setup(self, connection: BaseRMConnection, *args, **kwargs): - await super().setup() - self.connection = connection + await super().setup(connection) self._handshake_complete = asyncio.Event() - # Receives messages and puts them onto the queue. - self.create_task(self.connection_receive_messages(), True) - self.create_task(self.process_received_messages(), True) self.create_task(self.main_loop(), True) - async def cleanup(self, *args, **kwargs): - logger.info("Cleanup of Orchestrator") - await super().cleanup() - - await self.connection.stop() - - async def run(self, *args, **kwargs): - self.running = True - - await self.setup(*args, **kwargs) - - await self._stop_event.wait() - - await self.cleanup(*args, **kwargs) - - self.running = False From 404311d47245cefb9a7b6c7ad6cf29a5148e2da0 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Thu, 1 May 2025 10:25:39 +0200 Subject: [PATCH 13/75] Work in progress. Bit of a mess right now... --- s2-self-cert-server/src/client_connection.py | 115 ----------------- s2-self-cert-server/src/main.py | 122 +++++------------- s2-self-cert-server/src/rm_connection.py | 17 +-- s2-self-cert/cert.yaml | 27 +++- s2-self-cert/src/config.yaml | 2 +- s2-self-cert/src/connection.py | 73 ----------- s2-self-cert/src/main.py | 41 ++++-- s2-self-cert/src/server.py | 32 ++++- .../server_side_certification_orchestrator.py | 46 +++++-- .../src/s2testing/async_task_manager.py | 7 +- s2-testing/src/s2testing/connection.py | 105 ++++++++++++++- .../src/s2testing/orchestrator/__init__.py | 1 + s2-testing/src/s2testing/orchestrator/base.py | 12 +- .../orchestrator/server_orchestrator.py | 112 ++++++++++++++++ .../orchestrator/test_orchestrator.py | 2 +- .../src/s2testing/server_models.py | 10 +- 16 files changed, 383 insertions(+), 341 deletions(-) delete mode 100644 s2-self-cert-server/src/client_connection.py delete mode 100644 s2-self-cert/src/connection.py create mode 100644 s2-testing/src/s2testing/orchestrator/server_orchestrator.py rename s2-self-cert-server/src/model.py => s2-testing/src/s2testing/server_models.py (85%) diff --git a/s2-self-cert-server/src/client_connection.py b/s2-self-cert-server/src/client_connection.py deleted file mode 100644 index 66c477d..0000000 --- a/s2-self-cert-server/src/client_connection.py +++ /dev/null @@ -1,115 +0,0 @@ -import asyncio -import json -import logging -import uuid -from typing import Type - -from fastapi import WebSocket -from starlette.websockets import WebSocketClose -from test.ann_module import pars -from s2python.common import ReceptionStatus, ReceptionStatusValues -from s2python.message import S2Message -from s2python.reception_status_awaiter import ReceptionStatusAwaiter -from s2python.s2_parser import S2Parser -from s2python.s2_validation_error import S2ValidationError -from .model import ( - ControlMessageEnvelope, - MessageEnvelopeTypeEnum, - S2MessageEnvelope, - ServerMessage, - ServerMessageValidationException, -) - -logger = logging.getLogger(__name__) - - -class ClientConnection: - ws: WebSocket - - message_queue: asyncio.Queue - - _stop_event: asyncio.Event - - def __init__(self, ws) -> None: - self.ws = ws - - self._stop_event = asyncio.Event() - - self.message_queue = asyncio.Queue() - - async def send_message(self, message: ServerMessage) -> None: - if self.ws is None: - raise RuntimeError( - "Cannot send messages if websocket connection is not yet established." - ) - - await self.ws.send_json(message.model_dump()) - - async def send_s2_message(self, message: S2Message): - envelope = S2MessageEnvelope(message=message) - await self.send_message(envelope) - - async def handle_received_message(self, message: dict): - try: - message_type = MessageEnvelopeTypeEnum(message["message_type"]) - - parsed_message = None - if message_type == MessageEnvelopeTypeEnum.LOG: - logger.warning("Received log message from client. Ignoring.") - pass - # raise ValueError("Server cannot receive log messages.") - elif message_type == MessageEnvelopeTypeEnum.S2: - parsed_message = S2MessageEnvelope.model_validate(message) - logger.info("Received S2 Message: %s", parsed_message) - elif message_type == MessageEnvelopeTypeEnum.CONTROL: - parsed_message = ControlMessageEnvelope.model_validate(message) - logger.info("Received Control Message: %s", parsed_message) - - if parsed_message is not None: - await self.message_queue.put(parsed_message) - - except KeyError: - raise ServerMessageValidationException("Message must have a message type.") - - async def receive_messages(self): - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - logger.debug("Connection has started to receive messages.") - - # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. - try: - while not self._stop_event.is_set(): - try: - message = await self.ws.receive_json() - # message = await asyncio.wait_for(self.ws.receive_json(), timeout=1) - except asyncio.TimeoutError: - continue - - if isinstance(message, WebSocketClose): - logger.info("Received WebSocket close message.") - self.stop() - break - logger.info("Received Message: %s", message) - await self.handle_received_message(message) - - except asyncio.CancelledError: - logger.exception("Cancelled error thrown.") - except RuntimeError as e: - logger.info("WebSocket connection closed (RuntimeError): %s", e) - except Exception as e: - logger.exception("Exception while receiving message:") - finally: - self.stop() - - async def run(self): - await self.receive_messages() - - await self.ws.close() - - async def get_next_message(self) -> ServerMessage: - return await self.message_queue.get() - - def stop(self): - self._stop_event.set() diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index bd7e25f..decbef9 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -5,24 +5,25 @@ import logging.config from typing import Callable, Dict, Optional from fastapi import UploadFile, WebSocket +from fastapi import WebSocketDisconnect from fastapi import FastAPI from .log import LOGGING_CONFIG from s2python.message import S2Message -from .model import ( +from s2testing.server_models import ( ConfigControlMessage, ControlMessageType, - ServerMessage, + ServerMessageEnvelope, ControlMessage, ControlMessageEnvelope, MessageEnvelopeTypeEnum, S2MessageEnvelope, ServerMessageValidationException, ) -from .client_connection import ClientConnection -from .rm_connection import RMConnection -from s2testing.async_task_manager import AsyncTaskManager +from s2testing.orchestrator import ServerOrchestrator +from s2testing.connection import ServerConnection +from .rm_connection import ServerRMConnection from s2testing.config import Config @@ -41,106 +42,41 @@ def verify_certificate(file: UploadFile): return {"valid": False} -class ServerController: - handlers: Dict[ControlMessageType, Callable] = {} +class WebSocketWrapper: + def __init__(self, websocket: WebSocket): + self.websocket = websocket - config: Optional[Config] = None + async def send(self, message: str): + await self.websocket.send_text(message) - def __init__(self): - self.handlers = {ControlMessageType.CONFIG: self.handle_config_message} - - async def handle_config_message(self, message: ConfigControlMessage): - logger.info("Config Message: %s", message.config) - self.config = message.config - - async def handle_message(self, message: ControlMessage): + async def recv(self): try: - handler: Callable = self.handlers[message.message_type] - - await handler(message) - except KeyError: - logger.warning("No handler for `%s` message type.", message.message_type) - - -class CertificationServerManager(AsyncTaskManager): - client_connection: ClientConnection - rm_connection: RMConnection + msg = await self.websocket.receive_text() + return msg + except WebSocketDisconnect: + logger.exception("WebSocket disconnected.") + return None - controller: Optional[ServerController] = None - def __init__(self, websocket: WebSocket) -> None: - super().__init__() - self.client_connection = ClientConnection(websocket) - self.rm_connection = RMConnection(self.client_connection) +class CertificationServerOrchestrator(ServerOrchestrator): + connection: ServerRMConnection - self.controller = ServerController() - - async def handle_s2_message(self, message: S2Message): - await self.rm_connection.add_message_to_queue(message) - - async def process_received_messages(self): - """AsyncIO task which pops messages off the queue and processes them using the control type.""" - - if self.client_connection is None: - raise ValueError("Connection not set.") - - logger.info("Server processing messages.") - - try: - while not self._stop_event.is_set(): - try: - # Use a timeout to periodically check for cancellation - envelope: ServerMessage = await asyncio.wait_for( - self.client_connection.get_next_message(), timeout=1.0 - ) - except asyncio.TimeoutError: - continue # Check stop event and loop again - - if envelope.message_type == MessageEnvelopeTypeEnum.CONTROL: - await self.controller.handle_message(envelope.message) - elif envelope.message_type == MessageEnvelopeTypeEnum.S2: - await self.handle_s2_message(envelope.message) - else: - raise ValueError("Invalid message type.") - - except asyncio.CancelledError: - logger.info("Message receiver cancelled.") - except Exception as e: - logger.exception("Message processor encountered an error: %s", e) - finally: - self.stop() - - # async def connection_receive_messages(self): - # pass + async def handle_control_message(message): + logger.info("Control Message: %s", message) async def main_loop(self): pass - async def setup(self, *args, **kwargs): - await super().setup() - - self.create_task(self.client_connection.receive_messages(), True) - self.create_task(self.process_received_messages(), True) - # self.create_task(self.main_loop(), True) - - async def run(self): - self.running = True - - await self.setup() - - logger.info("Setup complete %s", self._stop_event) - await self._stop_event.wait() - logger.info("Cleaning up. %s", self._stop_event) - - await self.cleanup() - - self.running = False - @app.websocket("/ws") async def connect_tester(websocket: WebSocket): await websocket.accept() - server = CertificationServerManager(websocket) - await server.run() + wrapper = WebSocketWrapper(websocket) + server = CertificationServerOrchestrator() + client_connection = ServerConnection(wrapper) + # client_connection = StarlettWebsocketServerConnection(websocket) + connection = ServerRMConnection(client_connection) + + await server.run(connection, client_connection) - await websocket.close() + # await websocket.close() diff --git a/s2-self-cert-server/src/rm_connection.py b/s2-self-cert-server/src/rm_connection.py index e072d6a..baf931c 100644 --- a/s2-self-cert-server/src/rm_connection.py +++ b/s2-self-cert-server/src/rm_connection.py @@ -9,35 +9,34 @@ from s2testing.connection import BaseRMConnection from s2python.message import S2Message -from .client_connection import ClientConnection -from .model import ( +from s2testing.connection import ServerConnection +from s2testing.server_models import ( ControlMessageEnvelope, MessageEnvelopeTypeEnum, S2MessageEnvelope, - ServerMessage, ServerMessageValidationException, ) logger = logging.getLogger(__name__) -class RMConnection(BaseRMConnection): +class ServerRMConnection(BaseRMConnection): """ RMConnection is an implementation of BaseRMConnection used for server-side testing. - + This class facilitates communication with the RM (Resource Manager) via the local s2-self-cert instance. S2Messages are encapsulated in an envelope for transmission between the client and server sides. The client side is responsible for unwrapping the envelope and forwarding the S2Message to the physical RM. - + Attributes: receive_queue (asyncio.Queue): Queue which received messages are put onto. client_connection (ClientConnection): The connection to the local instance which is used to send the message via the WS. """ receive_queue: asyncio.Queue - client_connection: ClientConnection + client_connection: ServerConnection - def __init__(self, client_connection: ClientConnection): + def __init__(self, client_connection: ServerConnection): super().__init__() self.receive_queue = asyncio.Queue() @@ -51,5 +50,7 @@ async def receive(self): logger.info(f"Received S2 message: {msg} ") + #TODO + async def add_message_to_queue(self, message: dict): await self.receive_queue.put(message) diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml index caac485..82727b7 100644 --- a/s2-self-cert/cert.yaml +++ b/s2-self-cert/cert.yaml @@ -1,17 +1,32 @@ findings: -- message_type: PowerForecast +- message_type: FRBCActuatorStatus + parameters: + - name: FRBCActuatorStatus Not Provided. + status: FAIL + status: FAIL +- message_type: FRBCSystemDescription parameters: - - name: PowerForecast Provided. + - name: FRBCSystemDescription Provided. status: PASS status: PASS +- message_type: PowerForecast + parameters: + - name: PowerForecast Not Provided. + status: FAIL + status: FAIL - message_type: PowerMeasurement parameters: - - name: PowerMeasurement Provided. + - name: PowerMeasurement Not Provided. + status: FAIL + status: FAIL +- message_type: FRBCStorageStatus + parameters: + - name: FRBCStorageStatus Provided. status: PASS status: PASS -- message_type: PEBCPowerConstraints +- message_type: FRBCUsageForecast parameters: - - name: PEBCPowerConstraints Provided. + - name: FRBCUsageForecast Provided. status: PASS status: PASS -timestamp: 2025-04-29 17:00:55.179552 +timestamp: 2025-04-30 11:38:24.250547 diff --git a/s2-self-cert/src/config.yaml b/s2-self-cert/src/config.yaml index e9c145e..563a27d 100644 --- a/s2-self-cert/src/config.yaml +++ b/s2-self-cert/src/config.yaml @@ -2,7 +2,7 @@ device_details: name: Some Device manufacturer: ACME -mode: testing +mode: certification control_types: no_selection: null pebc: diff --git a/s2-self-cert/src/connection.py b/s2-self-cert/src/connection.py deleted file mode 100644 index ff260fd..0000000 --- a/s2-self-cert/src/connection.py +++ /dev/null @@ -1,73 +0,0 @@ -import asyncio -import json -import logging -import threading -import uuid -from typing import Type - -import websockets -from s2python.common import ReceptionStatus, ReceptionStatusValues -from s2python.message import S2Message -from s2python.reception_status_awaiter import ReceptionStatusAwaiter -from s2python.s2_parser import S2Parser -from s2python.s2_validation_error import S2ValidationError -from websockets.asyncio.connection import Connection as WSConnection -from s2testing.connection import BaseRMConnection - -logger = logging.getLogger(__name__) - - -class Connection(BaseRMConnection): # pylint: disable=too-many-instance-attributes - """ - Manged the websocket connection to the RM. - Puts all received messages onto the message queue so they can be retrieved by other tasks. - Based on the S2Connection class is S2-Python library. - """ - - ws: WSConnection - - def __init__(self, ws) -> None: # pylint: disable=too-many-arguments - super().__init__() - self.ws = ws - - async def send(self, message): - if self.ws is None: - raise RuntimeError( - "Cannot send messages if websocket connection is not yet established." - ) - - await self.ws.send(message) - - async def receive(self): - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - - return await self.ws.recv() - - async def receive_messages(self): - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - logger.debug("Connection has started to receive messages.") - - try: - await super().receive_messages() - except websockets.ConnectionClosedOK: - logger.info("Connection closed normally by remote.") - self._handle_ws_close() - except websockets.ConnectionClosedError as e: - logger.error("Connection closed with error: %s", str(e)) - self._handle_ws_close() - except asyncio.CancelledError: - pass - - def _handle_ws_close(self): - self._stop_event.set() - - async def stop(self): - super().stop() - - await self.ws.close() diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index 5080e20..d5fc035 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -18,11 +18,12 @@ PEBCController, FRBCController, ) -from s2testing.orchestrator import IntegrationTestOrchestrator +from s2testing.orchestrator import IntegrationTestOrchestrator, Orchestrator from s2testing.test_suite import PEBCTestCase, TestSuiteBuilder from s2testing.test_suite.frbc_test_cases import FRBCTestCase from log import LOGGING_CONFIG from server import S2Server +from server_side_certification_orchestrator import ServerSideCertificationOrchestrator logging.config.dictConfig(LOGGING_CONFIG) @@ -48,15 +49,7 @@ def create_controllers_dict_with_config( return controllers -async def main(): - - args = parser.parse_args() - - config: Config = load_config(args.config) - - logger.info("-" * 40) - logger.info(f"Starting in {config.mode} mode...") - +def create_local_test_orchestrator(config: Config) -> Orchestrator: report = ComplianceReport(timestamp=datetime.now()) controllers = create_controllers_dict_with_config(config, report) @@ -72,11 +65,35 @@ async def main(): available_control_types=controllers, test_suite=test_suite, report=report ) - s2_server = S2Server("0.0.0.0", 8000, orchestrator) + return orchestrator + + +def create_server_certification_orchestrator(config: Config) -> Orchestrator: + + return ServerSideCertificationOrchestrator() + + +async def main(): + + args = parser.parse_args() + + config: Config = load_config(args.config) + + if config.mode == "certification": + orchestrator = create_server_certification_orchestrator(config) + elif config.mode == "testing": + orchestrator = create_local_test_orchestrator(config) + else: + raise ValueError("Invalid mode.") + + logger.info("-" * 40) + logger.info(f"Starting in {config.mode} mode...") + + s2_server = S2Server("0.0.0.0", 8000, orchestrator, config.mode) await s2_server.start() - report.export() + # report.export() if __name__ == "__main__": diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index 329b893..47f2803 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -1,11 +1,16 @@ import asyncio import logging import signal +from typing import Literal -from connection import Connection from s2testing.orchestrator import IntegrationTestOrchestrator from websockets.asyncio.connection import Connection as WSConnection from websockets.asyncio.server import serve as ws_serve +from s2testing.connection import ( + BaseConnection, + WebSocketConnection, + WebSocketRMConnection, +) logger = logging.getLogger(__name__) @@ -13,14 +18,23 @@ class S2Server: # Receives incoming S2 Resource Manager WebSocket Connections orchestrator: IntegrationTestOrchestrator + mode: Literal["testing", "certification"] _exit_event: asyncio.Event - def __init__(self, host, port, orchestrator: IntegrationTestOrchestrator): + def __init__( + self, + host, + port, + orchestrator: IntegrationTestOrchestrator, + mode: Literal["testing", "certification"], + ): self._host = host self._port = port self._exit_event = asyncio.Event() + self.mode = mode + self.orchestrator = orchestrator async def handle_incoming_connection(self, websocket: WSConnection): @@ -31,17 +45,21 @@ async def handle_incoming_connection(self, websocket: WSConnection): """ if not self.orchestrator.is_running(): logger.info("Connection to RM opened.") - connection = Connection(websocket) + connection: BaseConnection + if self.mode == "testing": + connection = WebSocketRMConnection(websocket) + elif self.mode == "certification": + connection = WebSocketConnection(websocket) await self.orchestrator.run(connection) logger.info("Connection closed.") - self.stop() + await self.stop() else: logger.warning("This application only accepts one connection.") await websocket.close() - def stop(self): + async def stop(self): logger.info("Stopping server...") self._exit_event.set() @@ -49,7 +67,7 @@ async def start(self): loop = asyncio.get_event_loop() for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, lambda: self.stop()) + loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) async with ws_serve( self.handle_incoming_connection, self._host, self._port @@ -59,5 +77,5 @@ async def start(self): await self._exit_event.wait() logger.info(f"Server stopping.") - self.orchestrator.stop() + await self.orchestrator.stop() logger.info(f"Server stop.") diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py index 56659cb..b948c3b 100644 --- a/s2-self-cert/src/server_side_certification_orchestrator.py +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -1,5 +1,7 @@ import asyncio +import json import logging +import os import uuid from types import CoroutineType from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type @@ -18,33 +20,51 @@ from s2python.version import S2_VERSION -from s2testing.connection import BaseConnection +from s2testing.connection import BaseConnection, ServerConnection from s2testing.async_task_manager import AsyncTaskManager -from s2testing.orchestrator import Orchestrator +from s2testing.orchestrator import Orchestrator, ServerOrchestrator from s2testing.connection import SendOkay from s2testing.controllers import Controller from s2testing.test_suite.test_suite import TestSuite from s2testing.util import wait_for_event_or_stop +from s2testing.server_models import ( + ServerMessageEnvelope, + MessageEnvelopeTypeEnum, + ControlMessage, +) +from websockets.asyncio.client import connect logger = logging.getLogger(__name__) +SERVER_PROTOCOL = os.environ.get("CERTIFICATION_SERVER_PROTOCOL", "ws") +SERVER_HOST = os.environ.get("CERTIFICATION_SERVER_HOST", "localhost") +SERVER_PORT = os.environ.get("CERTIFICATION_SERVER_PORT", "8001") +SERVER_PATH = os.environ.get("CERTIFICATION_SERVER_PORT", "/ws") -class ServerSideCertificationOrchestrator(Orchestrator): - connection: Optional["BaseConnection"] = None - def __init__( - self, - ) -> None: - super().__init__() +class ServerSideCertificationOrchestrator(ServerOrchestrator): - async def process_message(self, message: str): - # TODO: Send the message to the server - pass + async def handle_control_message(message: ControlMessage): + logger.info("Control Message: %s", message) async def main_loop(self): pass + async def connect_to_server(self) -> ServerConnection: + uri = f"{SERVER_PROTOCOL}://{SERVER_HOST}:{SERVER_PORT}{SERVER_PATH}" + logger.info(f"Connecting to server ({uri})...") + + ws = await connect(uri) + + server_connection = ServerConnection(ws) + + logger.info("Connected to server.") + + return server_connection + async def setup(self, connection: BaseConnection, *args, **kwargs): - await super().setup(connection) + server_connection = await self.connect_to_server() + + await super().setup(connection, server_connection) - # self.create_task(self.main_loop(), True) + logger.info("setup complete") diff --git a/s2-testing/src/s2testing/async_task_manager.py b/s2-testing/src/s2testing/async_task_manager.py index c6c8b08..3742b4d 100644 --- a/s2-testing/src/s2testing/async_task_manager.py +++ b/s2-testing/src/s2testing/async_task_manager.py @@ -20,6 +20,7 @@ class AsyncTaskManager: to be running. """ + _tasks = set() _stop_event: asyncio.Event @@ -40,10 +41,10 @@ async def task_wrapper(self, task: Coroutine, stop_on_complete): await task if stop_on_complete: logger.debug("Task execution complete. Stopping.") - self.stop() + await self.stop() except: logger.exception("Exception in task!") - self.stop() + await self.stop() def create_task(self, task: Coroutine, stop_on_complete=False): self._tasks.add(asyncio.create_task(self.task_wrapper(task, stop_on_complete))) @@ -59,7 +60,7 @@ async def cleanup(self): self._tasks.clear() - def stop(self): + async def stop(self): self._stop_event.set() def is_running(self): diff --git a/s2-testing/src/s2testing/connection.py b/s2-testing/src/s2testing/connection.py index fff1c8e..e55831e 100644 --- a/s2-testing/src/s2testing/connection.py +++ b/s2-testing/src/s2testing/connection.py @@ -7,11 +7,23 @@ from typing import Type import websockets +from websockets.asyncio.connection import Connection as WSConnection + from s2python.common import ReceptionStatus, ReceptionStatusValues from s2python.message import S2Message from s2python.reception_status_awaiter import ReceptionStatusAwaiter from s2python.s2_parser import S2Parser from s2python.s2_validation_error import S2ValidationError +from s2testing.server_models import ( + MessageEnvelopeTypeEnum, + LogMessage, + ConfigControlMessage, + ControlMessage, + ControlMessageEnvelope, + LogMessageEnvelope, + S2MessageEnvelope, + ServerMessageEnvelope, +) logger = logging.getLogger(__name__) @@ -84,10 +96,57 @@ async def receive_messages(self): async def get_next_message(self): return await self.message_queue.get() - def stop(self): + async def stop(self): self._stop_event.set() +class WebSocketConnectionMixin(BaseConnection): + # Queue contains strings. + ws: WSConnection + + async def send(self, message): + if self.ws is None: + raise RuntimeError( + "Cannot send messages if websocket connection is not yet established." + ) + + await self.ws.send(message) + + async def receive(self): + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + + return await self.ws.recv() + + async def receive_messages(self): + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + logger.debug("Connection has started to receive messages.") + + try: + await super().receive_messages() + except websockets.ConnectionClosedOK: + logger.info("Connection closed normally by remote.") + self._handle_ws_close() + except websockets.ConnectionClosedError as e: + logger.error("Connection closed with error: %s", str(e)) + self._handle_ws_close() + except asyncio.CancelledError: + pass + + def _handle_ws_close(self): + self._stop_event.set() + + async def stop(self): + await super().stop() + + await self.ws.close() + + class BaseRMConnection(BaseConnection): # pylint: disable=too-many-instance-attributes """ Manged the websocket connection to the RM. @@ -220,3 +279,47 @@ async def process_received_message(self, message: str): await self.reception_status_awaiter.receive_reception_status(s2_msg) else: await self.message_queue.put(s2_msg) + + +class WebSocketConnection(WebSocketConnectionMixin): + + def __init__(self, ws) -> None: + super().__init__() + self.ws = ws + + +class WebSocketRMConnection(WebSocketConnectionMixin, BaseRMConnection): + + def __init__(self, ws) -> None: + super().__init__() + self.ws = ws + + +class ServerConnection(WebSocketConnection): + + async def send_s2_message(self, message: dict): + envelope = S2MessageEnvelope(message=message) + await self.send(envelope) + + async def send_control_message(self, message: ControlMessage): + envelope = ControlMessageEnvelope(message=message) + await self.send(envelope) + + async def send_log_message(self, message: LogMessage): + envelope = LogMessageEnvelope(message=message) + await self.send(envelope) + + async def process_received_message(self, message): + + message_dict = json.loads(message) + + envelope: ServerMessageEnvelope + match message_dict["message_type"]: + case MessageEnvelopeTypeEnum.CONTROL: + envelope = ControlMessageEnvelope.model_validate(message_dict) + case MessageEnvelopeTypeEnum.S2: + envelope = S2MessageEnvelope.model_validate(message_dict) + case MessageEnvelopeTypeEnum.LOG: + envelope = LogMessageEnvelope.model_validate(message_dict) + + await self.message_queue.put(envelope) diff --git a/s2-testing/src/s2testing/orchestrator/__init__.py b/s2-testing/src/s2testing/orchestrator/__init__.py index bb2aeed..3c7bd30 100644 --- a/s2-testing/src/s2testing/orchestrator/__init__.py +++ b/s2-testing/src/s2testing/orchestrator/__init__.py @@ -1,2 +1,3 @@ from .base import Orchestrator from .test_orchestrator import IntegrationTestOrchestrator +from .server_orchestrator import ServerOrchestrator diff --git a/s2-testing/src/s2testing/orchestrator/base.py b/s2-testing/src/s2testing/orchestrator/base.py index 89ede58..c70b390 100644 --- a/s2-testing/src/s2testing/orchestrator/base.py +++ b/s2-testing/src/s2testing/orchestrator/base.py @@ -34,6 +34,7 @@ class Orchestrator(AsyncTaskManager): @abc.abstractmethod async def process_message(self, message): + """Do something with the message that was popped off the connection's queue""" pass async def process_received_messages(self): @@ -58,11 +59,7 @@ async def process_received_messages(self): except Exception as e: logger.exception("Message processor encountered an error: %s", e) finally: - self.stop() - - @abc.abstractmethod - async def main_loop(self): - pass + await self.stop() async def connection_receive_messages(self): """Wrapping the receive messages method to allow catching of validation errors.""" @@ -73,6 +70,11 @@ async def connection_receive_messages(self): await self.connection.receive_messages() async def setup(self, connection: BaseConnection, *args, **kwargs): + """ + Sets connection and start tasks: + 1. Receive connection messages. WS receive messages and put on queue + 2. Process received connection messages. Pop message from queue and do something with it. + """ await super().setup() self.connection = connection diff --git a/s2-testing/src/s2testing/orchestrator/server_orchestrator.py b/s2-testing/src/s2testing/orchestrator/server_orchestrator.py new file mode 100644 index 0000000..15e4b95 --- /dev/null +++ b/s2-testing/src/s2testing/orchestrator/server_orchestrator.py @@ -0,0 +1,112 @@ +import asyncio +import json +import logging +import os +import uuid +from types import CoroutineType +from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type + +from s2testing.certificate.certificate import ComplianceReport +from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + EnergyManagementRole, + Handshake, + HandshakeResponse, + ResourceManagerDetails, + SelectControlType, +) +from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError +from s2python.version import S2_VERSION + + +from s2testing.connection import BaseConnection, ServerConnection +from s2testing.async_task_manager import AsyncTaskManager +from s2testing.orchestrator import Orchestrator +from s2testing.connection import SendOkay +from s2testing.controllers import Controller +from s2testing.test_suite.test_suite import TestSuite +from s2testing.util import wait_for_event_or_stop +from s2testing.server_models import ( + ServerMessageEnvelope, + MessageEnvelopeTypeEnum, + ControlMessage, +) +from websockets.asyncio.client import connect + +logger = logging.getLogger(__name__) + + +class ServerOrchestrator(Orchestrator): + connection: Optional["BaseConnection"] = None + server_connection: Optional["ServerConnection"] = None + + def __init__( + self, + ) -> None: + super().__init__() + + async def process_message(self, message: str): + # Processes the message that was popped off the `connection`. Will only be S2 Messages. + # Therefore just send the S2 Message on with the server connection + try: + # msg_dict: dict = json.loads(message) + await self.server_connection.send_s2_message(message) + except json.JSONDecodeError: + logger.exception("Received malformed JSON.") + + async def handle_control_message(message: ControlMessage): + logger.info("Control Message: %s", message) + + async def process_server_message(self, envelope: ServerMessageEnvelope): + match envelope.message_type: + case MessageEnvelopeTypeEnum.LOG: + # TODO: Maybe allow log level changes? + logger.info(envelope.message.content) + case MessageEnvelopeTypeEnum.S2: + await self.connection.send(envelope.message) + case MessageEnvelopeTypeEnum.CONTROL: + await self.handle_control_message(envelope.message) + + async def process_server_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.server_connection is None: + raise ValueError("Server Connection not set.") + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + message = await asyncio.wait_for( + self.server_connection.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + await self.process_server_message(message) + except asyncio.CancelledError: + logger.info("Message receiver cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + await self.stop() + + async def server_connection_receive_messages(self): + """Wrapping the receive messages method to allow catching of validation errors.""" + + if self.server_connection is None: + raise ValueError("Server connection not set.") + + await self.server_connection.receive_messages() + + async def setup(self, connection, server_connection, *args, **kwargs): + await super().setup(connection, *args, **kwargs) + + self.server_connection = server_connection + + self.create_task(self.server_connection_receive_messages()) + self.create_task(self.process_server_received_messages()) + + async def main_loop(self): + pass diff --git a/s2-testing/src/s2testing/orchestrator/test_orchestrator.py b/s2-testing/src/s2testing/orchestrator/test_orchestrator.py index 52351a6..72f7248 100644 --- a/s2-testing/src/s2testing/orchestrator/test_orchestrator.py +++ b/s2-testing/src/s2testing/orchestrator/test_orchestrator.py @@ -198,7 +198,7 @@ async def send_select_control_type(self): if controller is None: logger.warning("No suitable control types available. Exiting...") - self.stop() + await self.stop() return logger.info("Selecting control type %s", controller) diff --git a/s2-self-cert-server/src/model.py b/s2-testing/src/s2testing/server_models.py similarity index 85% rename from s2-self-cert-server/src/model.py rename to s2-testing/src/s2testing/server_models.py index 47749cd..4b2649f 100644 --- a/s2-self-cert-server/src/model.py +++ b/s2-testing/src/s2testing/server_models.py @@ -15,21 +15,25 @@ class MessageEnvelopeTypeEnum(str, Enum): S2 = "S2" LOG = "LOG" + class ControlMessageType(str, Enum): CONFIG = "CONFIG" CERTIFICATE = "CERTIFICATE" + class LogMessage(BaseModel): level: str content: str class ConfigControlMessage(BaseModel): - message_type : ControlMessageType = ControlMessageType.CONFIG - config : Config + message_type: ControlMessageType = ControlMessageType.CONFIG + config: Config + ControlMessage = Union[ConfigControlMessage] + class ControlMessageEnvelope(BaseModel): message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CONTROL message: ControlMessage @@ -46,4 +50,4 @@ class S2MessageEnvelope(BaseModel): message: dict -ServerMessage = Union[ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope] +ServerMessageEnvelope = Union[ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope] From 3571b61fc4512368cca6e5f6e8b7682312af333f Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Fri, 2 May 2025 10:22:57 +0200 Subject: [PATCH 14/75] Restructured files --- packages/connectivity/.python-version | 1 + .../connectivity}/README.md | 0 packages/connectivity/pyproject.toml | 15 + .../src/connectivity}/__init__.py | 0 packages/connectivity/uv.lock | 324 ++++++++++++++++++ .../s2-python}/.github/workflows/ci.yml | 0 {s2-python => packages/s2-python}/.gitignore | 0 .../s2-python}/.pre-commit-config.yaml | 0 {s2-python => packages/s2-python}/.pylintrc | 0 {s2-python => packages/s2-python}/LICENSE | 0 {s2-python => packages/s2-python}/README.rst | 0 {s2-python => packages/s2-python}/ci/clean.sh | 0 .../s2-python}/ci/distribute.sh | 0 .../s2-python}/ci/generate_s2.sh | 0 .../s2-python}/ci/install_dependencies.sh | 0 {s2-python => packages/s2-python}/ci/lint.sh | 0 .../s2-python}/ci/setup_dev_environment.sh | 0 .../s2-python}/ci/test_unit.sh | 0 .../s2-python}/ci/typecheck.sh | 0 .../s2-python}/ci/update_dependencies.sh | 0 .../s2-python}/dev-requirements.txt | 0 .../development_utilities/gen_templates.py | 0 .../gen_unit_test_template.py | 0 .../generate_s2_message_type_to_class.py | 0 .../development_utilities/get_all_messages.py | 0 .../s2-python}/examples/example_frbc_rm.py | 0 {s2-python => packages/s2-python}/mypy.ini | 0 .../s2-python}/pyproject.toml | 0 .../s2-python}/pyrightconfig.json | 0 {s2-python => packages/s2-python}/setup.cfg | 0 {s2-python => packages/s2-python}/setup.py | 0 .../s2-python}/specification/openapi.yml | 0 .../s2-python}/src/s2python/__init__.py | 0 .../src/s2python/common/__init__.py | 0 .../src/s2python/common/duration.py | 0 .../src/s2python/common/handshake.py | 0 .../src/s2python/common/handshake_response.py | 0 .../common/instruction_status_update.py | 0 .../src/s2python/common/number_range.py | 0 .../src/s2python/common/power_forecast.py | 0 .../s2python/common/power_forecast_element.py | 0 .../s2python/common/power_forecast_value.py | 0 .../src/s2python/common/power_measurement.py | 0 .../src/s2python/common/power_range.py | 0 .../src/s2python/common/power_value.py | 0 .../src/s2python/common/reception_status.py | 0 .../common/resource_manager_details.py | 0 .../src/s2python/common/revoke_object.py | 0 .../s2-python}/src/s2python/common/role.py | 0 .../s2python/common/select_control_type.py | 0 .../src/s2python/common/session_request.py | 0 .../s2-python}/src/s2python/common/support.py | 0 .../s2-python}/src/s2python/common/timer.py | 0 .../src/s2python/common/transition.py | 0 .../s2-python}/src/s2python/ddbc/__init__.py | 0 .../ddbc/ddbc_actuator_description.py | 0 .../src/s2python/ddbc/ddbc_actuator_status.py | 0 .../ddbc/ddbc_average_demand_rate_forecast.py | 0 ...bc_average_demand_rate_forecast_element.py | 0 .../src/s2python/ddbc/ddbc_instruction.py | 0 .../src/s2python/ddbc/ddbc_operation_mode.py | 0 .../s2python/ddbc/ddbc_system_description.py | 0 .../src/s2python/ddbc/ddbc_timer_status.py | 0 .../s2-python}/src/s2python/frbc/__init__.py | 0 .../frbc/frbc_actuator_description.py | 0 .../src/s2python/frbc/frbc_actuator_status.py | 0 .../frbc/frbc_fill_level_target_profile.py | 0 .../frbc_fill_level_target_profile_element.py | 0 .../src/s2python/frbc/frbc_instruction.py | 0 .../s2python/frbc/frbc_leakage_behaviour.py | 0 .../frbc/frbc_leakage_behaviour_element.py | 0 .../src/s2python/frbc/frbc_operation_mode.py | 0 .../frbc/frbc_operation_mode_element.py | 0 .../s2python/frbc/frbc_storage_description.py | 0 .../src/s2python/frbc/frbc_storage_status.py | 0 .../s2python/frbc/frbc_system_description.py | 0 .../src/s2python/frbc/frbc_timer_status.py | 0 .../src/s2python/frbc/frbc_usage_forecast.py | 0 .../frbc/frbc_usage_forecast_element.py | 0 .../s2-python}/src/s2python/frbc/rm.py | 0 .../src/s2python/generated}/__init__.py | 0 .../src/s2python/generated/gen_s2.py | 0 .../s2-python}/src/s2python/message.py | 0 .../s2-python}/src/s2python/ombc/__init__.py | 0 .../src/s2python/ombc/ombc_instruction.py | 0 .../src/s2python/ombc/ombc_operation_mode.py | 0 .../src/s2python/ombc/ombc_status.py | 0 .../s2python/ombc/ombc_system_description.py | 0 .../src/s2python/ombc/ombc_timer_status.py | 0 .../s2-python}/src/s2python/pebc/__init__.py | 0 .../s2python/pebc/pebc_allowed_limit_range.py | 0 .../s2python/pebc/pebc_energy_constraint.py | 0 .../src/s2python/pebc/pebc_instruction.py | 0 .../s2python/pebc/pebc_power_constraints.py | 0 .../src/s2python/pebc/pebc_power_envelope.py | 0 .../pebc/pebc_power_envelope_element.py | 0 .../s2-python}/src/s2python/ppbc/__init__.py | 0 .../ppbc/ppbc_end_interruption_instruction.py | 0 .../ppbc/ppbc_power_profile_definition.py | 0 .../ppbc/ppbc_power_profile_status.py | 0 .../src/s2python/ppbc/ppbc_power_sequence.py | 0 .../ppbc/ppbc_power_sequence_container.py | 0 .../ppbc_power_sequence_container_status.py | 0 .../ppbc/ppbc_power_sequence_element.py | 0 .../ppbc/ppbc_schedule_instruction.py | 0 .../ppbc_start_interruption_instruction.py | 0 .../s2-python}/src/s2python/py.typed | 0 .../src/s2python/reception_status_awaiter.py | 0 .../s2-python}/src/s2python/s2_connection.py | 0 .../src/s2python/s2_control_type.py | 0 .../s2-python}/src/s2python/s2_parser.py | 0 .../src/s2python/s2_validation_error.py | 0 .../s2-python}/src/s2python/utils.py | 0 .../src/s2python/validate_values_mixin.py | 0 .../s2-python}/src/s2python/version.py | 0 .../s2-python/tests/unit}/__init__.py | 0 .../s2-python/tests/unit/common}/__init__.py | 0 .../tests/unit/common/duration_test.py | 0 .../unit/common/handshake_response_test.py | 0 .../tests/unit/common/handshake_test.py | 0 .../common/instruction_status_update_test.py | 0 .../tests/unit/common/number_range_test.py | 0 .../common/power_forecast_element_test.py | 0 .../tests/unit/common/power_forecast_test.py | 0 .../unit/common/power_forecast_value_test.py | 0 .../unit/common/power_measurement_test.py | 0 .../tests/unit/common/power_range_test.py | 0 .../tests/unit/common/power_value_test.py | 0 .../unit/common/reception_status_test.py | 0 .../common/resource_manager_details_test.py | 0 .../tests/unit/common/revoke_object_test.py | 0 .../s2-python}/tests/unit/common/role_test.py | 0 .../unit/common/select_control_type_test.py | 0 .../tests/unit/common/session_request_test.py | 0 .../tests/unit/common/timer_test.py | 0 .../tests/unit/common/transition_test.py | 0 .../frbc/frbc_actuator_description_test.py | 0 .../unit/frbc/frbc_actuator_status_test.py | 0 ..._fill_level_target_profile_element_test.py | 0 .../frbc_fill_level_target_profile_test.py | 0 .../tests/unit/frbc/frbc_instruction_test.py | 0 .../frbc_leakage_behaviour_element_test.py | 0 .../unit/frbc/frbc_leakage_behaviour_test.py | 0 .../frbc/frbc_operation_mode_element_test.py | 0 .../unit/frbc/frbc_operation_mode_test.py | 0 .../frbc/frbc_storage_description_test.py | 0 .../unit/frbc/frbc_storage_status_test.py | 0 .../unit/frbc/frbc_system_description_test.py | 0 .../tests/unit/frbc/frbc_timer_status_test.py | 0 .../frbc/frbc_usage_forecast_element_test.py | 0 .../unit/frbc/frbc_usage_forecast_test.py | 0 .../s2-python}/tests/unit/message_test.py | 0 .../unit/reception_status_awaiter_test.py | 0 .../tests/unit/s2_connection_test.py | 0 .../s2-python}/tests/unit/s2_parser_test.py | 0 .../s2-python}/tests/unit/utils_test.py | 0 {s2-python => packages/s2-python}/tox.ini | 0 .../test-suites/README.md | 0 .../test-suites}/pyproject.toml | 2 +- packages/test-suites/src/__init__.py | 0 .../test-suites/src/testsuites/__init__.py | 0 .../src/testsuites}/async_task_manager.py | 0 .../testsuites}/certificate/certificate.py | 0 .../test-suites/src/testsuites}/config.py | 0 .../test-suites/src/testsuites}/connection.py | 0 .../src/testsuites}/controllers/__init__.py | 0 .../src/testsuites}/controllers/builder.py | 0 .../src/testsuites}/controllers/controller.py | 0 .../controllers/frbc_controller.py | 0 .../controllers/pebc_controller.py | 0 .../src/testsuites}/message_handlers.py | 0 .../src/testsuites}/orchestrator/__init__.py | 0 .../src/testsuites}/orchestrator/base.py | 0 .../orchestrator/server_orchestrator.py | 0 .../orchestrator/test_orchestrator.py | 0 .../src/testsuites}/server_models.py | 0 .../src/testsuites}/test_suite/__init__.py | 0 .../testsuites}/test_suite/base_test_case.py | 0 .../testsuites}/test_suite/frbc_test_cases.py | 0 .../testsuites}/test_suite/pebc_test_cases.py | 0 .../src/testsuites}/test_suite/test_suite.py | 0 .../test-suites/src/testsuites}/util.py | 0 {s2-testing => packages/test-suites}/uv.lock | 0 s2-self-cert-server/pyproject.toml | 7 +- s2-self-cert-server/uv.lock | 46 +-- s2-self-cert/pyproject.toml | 9 +- s2-self-cert/uv.lock | 14 +- s2-testing/src/s2_testing.egg-info/PKG-INFO | 10 - .../src/s2_testing.egg-info/SOURCES.txt | 26 -- .../s2_testing.egg-info/dependency_links.txt | 1 - .../src/s2_testing.egg-info/requires.txt | 4 - .../src/s2_testing.egg-info/top_level.txt | 2 - 192 files changed, 378 insertions(+), 83 deletions(-) create mode 100644 packages/connectivity/.python-version rename {s2-testing => packages/connectivity}/README.md (100%) create mode 100644 packages/connectivity/pyproject.toml rename {s2-python/src/s2python/generated => packages/connectivity/src/connectivity}/__init__.py (100%) create mode 100644 packages/connectivity/uv.lock rename {s2-python => packages/s2-python}/.github/workflows/ci.yml (100%) rename {s2-python => packages/s2-python}/.gitignore (100%) rename {s2-python => packages/s2-python}/.pre-commit-config.yaml (100%) rename {s2-python => packages/s2-python}/.pylintrc (100%) rename {s2-python => packages/s2-python}/LICENSE (100%) rename {s2-python => packages/s2-python}/README.rst (100%) rename {s2-python => packages/s2-python}/ci/clean.sh (100%) rename {s2-python => packages/s2-python}/ci/distribute.sh (100%) rename {s2-python => packages/s2-python}/ci/generate_s2.sh (100%) rename {s2-python => packages/s2-python}/ci/install_dependencies.sh (100%) rename {s2-python => packages/s2-python}/ci/lint.sh (100%) rename {s2-python => packages/s2-python}/ci/setup_dev_environment.sh (100%) rename {s2-python => packages/s2-python}/ci/test_unit.sh (100%) rename {s2-python => packages/s2-python}/ci/typecheck.sh (100%) rename {s2-python => packages/s2-python}/ci/update_dependencies.sh (100%) rename {s2-python => packages/s2-python}/dev-requirements.txt (100%) rename {s2-python => packages/s2-python}/development_utilities/gen_templates.py (100%) rename {s2-python => packages/s2-python}/development_utilities/gen_unit_test_template.py (100%) rename {s2-python => packages/s2-python}/development_utilities/generate_s2_message_type_to_class.py (100%) rename {s2-python => packages/s2-python}/development_utilities/get_all_messages.py (100%) rename {s2-python => packages/s2-python}/examples/example_frbc_rm.py (100%) rename {s2-python => packages/s2-python}/mypy.ini (100%) rename {s2-python => packages/s2-python}/pyproject.toml (100%) rename {s2-python => packages/s2-python}/pyrightconfig.json (100%) rename {s2-python => packages/s2-python}/setup.cfg (100%) rename {s2-python => packages/s2-python}/setup.py (100%) rename {s2-python => packages/s2-python}/specification/openapi.yml (100%) rename {s2-python => packages/s2-python}/src/s2python/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/duration.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/handshake.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/handshake_response.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/instruction_status_update.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/number_range.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/power_forecast.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/power_forecast_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/power_forecast_value.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/power_measurement.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/power_range.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/power_value.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/reception_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/resource_manager_details.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/revoke_object.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/role.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/select_control_type.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/session_request.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/support.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/timer.py (100%) rename {s2-python => packages/s2-python}/src/s2python/common/transition.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_actuator_description.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_actuator_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_operation_mode.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_system_description.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ddbc/ddbc_timer_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_actuator_description.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_actuator_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_fill_level_target_profile.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_fill_level_target_profile_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_leakage_behaviour.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_leakage_behaviour_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_operation_mode.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_operation_mode_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_storage_description.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_storage_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_system_description.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_timer_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_usage_forecast.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/frbc_usage_forecast_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/frbc/rm.py (100%) rename {s2-python/tests/unit => packages/s2-python/src/s2python/generated}/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/generated/gen_s2.py (100%) rename {s2-python => packages/s2-python}/src/s2python/message.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ombc/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ombc/ombc_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ombc/ombc_operation_mode.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ombc/ombc_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ombc/ombc_system_description.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ombc/ombc_timer_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/pebc_allowed_limit_range.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/pebc_energy_constraint.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/pebc_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/pebc_power_constraints.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/pebc_power_envelope.py (100%) rename {s2-python => packages/s2-python}/src/s2python/pebc/pebc_power_envelope_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/__init__.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_end_interruption_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_power_profile_definition.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_power_profile_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_power_sequence.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_power_sequence_container.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_power_sequence_container_status.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_power_sequence_element.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_schedule_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/ppbc/ppbc_start_interruption_instruction.py (100%) rename {s2-python => packages/s2-python}/src/s2python/py.typed (100%) rename {s2-python => packages/s2-python}/src/s2python/reception_status_awaiter.py (100%) rename {s2-python => packages/s2-python}/src/s2python/s2_connection.py (100%) rename {s2-python => packages/s2-python}/src/s2python/s2_control_type.py (100%) rename {s2-python => packages/s2-python}/src/s2python/s2_parser.py (100%) rename {s2-python => packages/s2-python}/src/s2python/s2_validation_error.py (100%) rename {s2-python => packages/s2-python}/src/s2python/utils.py (100%) rename {s2-python => packages/s2-python}/src/s2python/validate_values_mixin.py (100%) rename {s2-python => packages/s2-python}/src/s2python/version.py (100%) rename {s2-python/tests/unit/common => packages/s2-python/tests/unit}/__init__.py (100%) rename {s2-testing/src => packages/s2-python/tests/unit/common}/__init__.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/duration_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/handshake_response_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/handshake_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/instruction_status_update_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/number_range_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/power_forecast_element_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/power_forecast_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/power_forecast_value_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/power_measurement_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/power_range_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/power_value_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/reception_status_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/resource_manager_details_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/revoke_object_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/role_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/select_control_type_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/session_request_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/timer_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/common/transition_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_actuator_description_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_actuator_status_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_fill_level_target_profile_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_instruction_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_leakage_behaviour_element_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_leakage_behaviour_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_operation_mode_element_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_operation_mode_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_storage_description_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_storage_status_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_system_description_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_timer_status_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_usage_forecast_element_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/frbc/frbc_usage_forecast_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/message_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/reception_status_awaiter_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/s2_connection_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/s2_parser_test.py (100%) rename {s2-python => packages/s2-python}/tests/unit/utils_test.py (100%) rename {s2-python => packages/s2-python}/tox.ini (100%) rename s2-testing/src/s2testing/__init__.py => packages/test-suites/README.md (100%) rename {s2-testing => packages/test-suites}/pyproject.toml (93%) create mode 100644 packages/test-suites/src/__init__.py create mode 100644 packages/test-suites/src/testsuites/__init__.py rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/async_task_manager.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/certificate/certificate.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/config.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/connection.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/controllers/__init__.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/controllers/builder.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/controllers/controller.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/controllers/frbc_controller.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/controllers/pebc_controller.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/message_handlers.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/orchestrator/__init__.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/orchestrator/base.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/orchestrator/server_orchestrator.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/orchestrator/test_orchestrator.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/server_models.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/test_suite/__init__.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/test_suite/base_test_case.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/test_suite/frbc_test_cases.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/test_suite/pebc_test_cases.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/test_suite/test_suite.py (100%) rename {s2-testing/src/s2testing => packages/test-suites/src/testsuites}/util.py (100%) rename {s2-testing => packages/test-suites}/uv.lock (100%) delete mode 100644 s2-testing/src/s2_testing.egg-info/PKG-INFO delete mode 100644 s2-testing/src/s2_testing.egg-info/SOURCES.txt delete mode 100644 s2-testing/src/s2_testing.egg-info/dependency_links.txt delete mode 100644 s2-testing/src/s2_testing.egg-info/requires.txt delete mode 100644 s2-testing/src/s2_testing.egg-info/top_level.txt diff --git a/packages/connectivity/.python-version b/packages/connectivity/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/packages/connectivity/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/s2-testing/README.md b/packages/connectivity/README.md similarity index 100% rename from s2-testing/README.md rename to packages/connectivity/README.md diff --git a/packages/connectivity/pyproject.toml b/packages/connectivity/pyproject.toml new file mode 100644 index 0000000..08caf8c --- /dev/null +++ b/packages/connectivity/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "connectivity" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.11.3", + "pyyaml>=6.0.2", + "s2-python", + "websockets>=13.1", +] + +[tool.uv.sources] +s2-python = { path = "../s2-python", editable = true } diff --git a/s2-python/src/s2python/generated/__init__.py b/packages/connectivity/src/connectivity/__init__.py similarity index 100% rename from s2-python/src/s2python/generated/__init__.py rename to packages/connectivity/src/connectivity/__init__.py diff --git a/packages/connectivity/uv.lock b/packages/connectivity/uv.lock new file mode 100644 index 0000000..0090349 --- /dev/null +++ b/packages/connectivity/uv.lock @@ -0,0 +1,324 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540, upload_time = "2025-04-29T20:38:55.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900, upload_time = "2025-04-29T20:38:52.724Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload_time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload_time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload_time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload_time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload_time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload_time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload_time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload_time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload_time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload_time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload_time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload_time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload_time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload_time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload_time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload_time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload_time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload_time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload_time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload_time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload_time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload_time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload_time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload_time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload_time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload_time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload_time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload_time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload_time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload_time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload_time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload_time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload_time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload_time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload_time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload_time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload_time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload_time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload_time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload_time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload_time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload_time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload_time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload_time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload_time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload_time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload_time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload_time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload_time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload_time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload_time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload_time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload_time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload_time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload_time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload_time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload_time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload_time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload_time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload_time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload_time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload_time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload_time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload_time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload_time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload_time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload_time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload_time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload_time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload_time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload_time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload_time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload_time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload_time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload_time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload_time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload_time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload_time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload_time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload_time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload_time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload_time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload_time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload_time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload_time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload_time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload_time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload_time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload_time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload_time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload_time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload_time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload_time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload_time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload_time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload_time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload_time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload_time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload_time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload_time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload_time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload_time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload_time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload_time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload_time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload_time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload_time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload_time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload_time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload_time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload_time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "s2-python" +version = "0.5.0" +source = { editable = "../s2-python" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "datamodel-code-generator", marker = "extra == 'development'" }, + { name = "mypy", marker = "extra == 'testing'" }, + { name = "pip-tools", marker = "extra == 'development'" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pylint", marker = "extra == 'testing'" }, + { name = "pyright", marker = "extra == 'testing'" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-coverage", marker = "extra == 'testing'" }, + { name = "pytest-timer", marker = "extra == 'testing'" }, + { name = "pytz" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "sphinx-tabs", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'development'" }, + { name = "types-pytz", marker = "extra == 'testing'" }, + { name = "websockets", specifier = "~=13.1" }, +] +provides-extras = ["testing", "development", "docs"] + +[[package]] +name = "s2-self-cert-connectivity" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload_time = "2025-02-25T17:27:59.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload_time = "2025-02-25T17:27:57.754Z" }, +] + +[[package]] +name = "websockets" +version = "13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload_time = "2024-09-21T17:34:21.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815, upload_time = "2024-09-21T17:32:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466, upload_time = "2024-09-21T17:32:28.428Z" }, + { url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716, upload_time = "2024-09-21T17:32:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806, upload_time = "2024-09-21T17:32:31.384Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810, upload_time = "2024-09-21T17:32:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125, upload_time = "2024-09-21T17:32:33.398Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532, upload_time = "2024-09-21T17:32:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948, upload_time = "2024-09-21T17:32:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898, upload_time = "2024-09-21T17:32:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706, upload_time = "2024-09-21T17:32:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141, upload_time = "2024-09-21T17:32:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813, upload_time = "2024-09-21T17:32:42.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469, upload_time = "2024-09-21T17:32:43.858Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717, upload_time = "2024-09-21T17:32:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379, upload_time = "2024-09-21T17:32:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376, upload_time = "2024-09-21T17:32:46.987Z" }, + { url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753, upload_time = "2024-09-21T17:32:48.046Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051, upload_time = "2024-09-21T17:32:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489, upload_time = "2024-09-21T17:32:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438, upload_time = "2024-09-21T17:32:52.223Z" }, + { url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710, upload_time = "2024-09-21T17:32:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137, upload_time = "2024-09-21T17:32:54.721Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload_time = "2024-09-21T17:32:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload_time = "2024-09-21T17:32:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload_time = "2024-09-21T17:32:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload_time = "2024-09-21T17:33:00.495Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload_time = "2024-09-21T17:33:02.223Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload_time = "2024-09-21T17:33:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload_time = "2024-09-21T17:33:04.728Z" }, + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload_time = "2024-09-21T17:33:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload_time = "2024-09-21T17:33:06.823Z" }, + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload_time = "2024-09-21T17:33:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload_time = "2024-09-21T17:33:09.202Z" }, + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload_time = "2024-09-21T17:33:10.987Z" }, + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload_time = "2024-09-21T17:33:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload_time = "2024-09-21T17:33:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload_time = "2024-09-21T17:33:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload_time = "2024-09-21T17:33:17.113Z" }, + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload_time = "2024-09-21T17:33:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload_time = "2024-09-21T17:33:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload_time = "2024-09-21T17:33:20.361Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload_time = "2024-09-21T17:33:23.103Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload_time = "2024-09-21T17:33:24.196Z" }, + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload_time = "2024-09-21T17:33:25.96Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499, upload_time = "2024-09-21T17:33:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737, upload_time = "2024-09-21T17:33:56.052Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095, upload_time = "2024-09-21T17:33:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701, upload_time = "2024-09-21T17:33:59.061Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654, upload_time = "2024-09-21T17:34:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192, upload_time = "2024-09-21T17:34:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload_time = "2024-09-21T17:34:19.904Z" }, +] diff --git a/s2-python/.github/workflows/ci.yml b/packages/s2-python/.github/workflows/ci.yml similarity index 100% rename from s2-python/.github/workflows/ci.yml rename to packages/s2-python/.github/workflows/ci.yml diff --git a/s2-python/.gitignore b/packages/s2-python/.gitignore similarity index 100% rename from s2-python/.gitignore rename to packages/s2-python/.gitignore diff --git a/s2-python/.pre-commit-config.yaml b/packages/s2-python/.pre-commit-config.yaml similarity index 100% rename from s2-python/.pre-commit-config.yaml rename to packages/s2-python/.pre-commit-config.yaml diff --git a/s2-python/.pylintrc b/packages/s2-python/.pylintrc similarity index 100% rename from s2-python/.pylintrc rename to packages/s2-python/.pylintrc diff --git a/s2-python/LICENSE b/packages/s2-python/LICENSE similarity index 100% rename from s2-python/LICENSE rename to packages/s2-python/LICENSE diff --git a/s2-python/README.rst b/packages/s2-python/README.rst similarity index 100% rename from s2-python/README.rst rename to packages/s2-python/README.rst diff --git a/s2-python/ci/clean.sh b/packages/s2-python/ci/clean.sh similarity index 100% rename from s2-python/ci/clean.sh rename to packages/s2-python/ci/clean.sh diff --git a/s2-python/ci/distribute.sh b/packages/s2-python/ci/distribute.sh similarity index 100% rename from s2-python/ci/distribute.sh rename to packages/s2-python/ci/distribute.sh diff --git a/s2-python/ci/generate_s2.sh b/packages/s2-python/ci/generate_s2.sh similarity index 100% rename from s2-python/ci/generate_s2.sh rename to packages/s2-python/ci/generate_s2.sh diff --git a/s2-python/ci/install_dependencies.sh b/packages/s2-python/ci/install_dependencies.sh similarity index 100% rename from s2-python/ci/install_dependencies.sh rename to packages/s2-python/ci/install_dependencies.sh diff --git a/s2-python/ci/lint.sh b/packages/s2-python/ci/lint.sh similarity index 100% rename from s2-python/ci/lint.sh rename to packages/s2-python/ci/lint.sh diff --git a/s2-python/ci/setup_dev_environment.sh b/packages/s2-python/ci/setup_dev_environment.sh similarity index 100% rename from s2-python/ci/setup_dev_environment.sh rename to packages/s2-python/ci/setup_dev_environment.sh diff --git a/s2-python/ci/test_unit.sh b/packages/s2-python/ci/test_unit.sh similarity index 100% rename from s2-python/ci/test_unit.sh rename to packages/s2-python/ci/test_unit.sh diff --git a/s2-python/ci/typecheck.sh b/packages/s2-python/ci/typecheck.sh similarity index 100% rename from s2-python/ci/typecheck.sh rename to packages/s2-python/ci/typecheck.sh diff --git a/s2-python/ci/update_dependencies.sh b/packages/s2-python/ci/update_dependencies.sh similarity index 100% rename from s2-python/ci/update_dependencies.sh rename to packages/s2-python/ci/update_dependencies.sh diff --git a/s2-python/dev-requirements.txt b/packages/s2-python/dev-requirements.txt similarity index 100% rename from s2-python/dev-requirements.txt rename to packages/s2-python/dev-requirements.txt diff --git a/s2-python/development_utilities/gen_templates.py b/packages/s2-python/development_utilities/gen_templates.py similarity index 100% rename from s2-python/development_utilities/gen_templates.py rename to packages/s2-python/development_utilities/gen_templates.py diff --git a/s2-python/development_utilities/gen_unit_test_template.py b/packages/s2-python/development_utilities/gen_unit_test_template.py similarity index 100% rename from s2-python/development_utilities/gen_unit_test_template.py rename to packages/s2-python/development_utilities/gen_unit_test_template.py diff --git a/s2-python/development_utilities/generate_s2_message_type_to_class.py b/packages/s2-python/development_utilities/generate_s2_message_type_to_class.py similarity index 100% rename from s2-python/development_utilities/generate_s2_message_type_to_class.py rename to packages/s2-python/development_utilities/generate_s2_message_type_to_class.py diff --git a/s2-python/development_utilities/get_all_messages.py b/packages/s2-python/development_utilities/get_all_messages.py similarity index 100% rename from s2-python/development_utilities/get_all_messages.py rename to packages/s2-python/development_utilities/get_all_messages.py diff --git a/s2-python/examples/example_frbc_rm.py b/packages/s2-python/examples/example_frbc_rm.py similarity index 100% rename from s2-python/examples/example_frbc_rm.py rename to packages/s2-python/examples/example_frbc_rm.py diff --git a/s2-python/mypy.ini b/packages/s2-python/mypy.ini similarity index 100% rename from s2-python/mypy.ini rename to packages/s2-python/mypy.ini diff --git a/s2-python/pyproject.toml b/packages/s2-python/pyproject.toml similarity index 100% rename from s2-python/pyproject.toml rename to packages/s2-python/pyproject.toml diff --git a/s2-python/pyrightconfig.json b/packages/s2-python/pyrightconfig.json similarity index 100% rename from s2-python/pyrightconfig.json rename to packages/s2-python/pyrightconfig.json diff --git a/s2-python/setup.cfg b/packages/s2-python/setup.cfg similarity index 100% rename from s2-python/setup.cfg rename to packages/s2-python/setup.cfg diff --git a/s2-python/setup.py b/packages/s2-python/setup.py similarity index 100% rename from s2-python/setup.py rename to packages/s2-python/setup.py diff --git a/s2-python/specification/openapi.yml b/packages/s2-python/specification/openapi.yml similarity index 100% rename from s2-python/specification/openapi.yml rename to packages/s2-python/specification/openapi.yml diff --git a/s2-python/src/s2python/__init__.py b/packages/s2-python/src/s2python/__init__.py similarity index 100% rename from s2-python/src/s2python/__init__.py rename to packages/s2-python/src/s2python/__init__.py diff --git a/s2-python/src/s2python/common/__init__.py b/packages/s2-python/src/s2python/common/__init__.py similarity index 100% rename from s2-python/src/s2python/common/__init__.py rename to packages/s2-python/src/s2python/common/__init__.py diff --git a/s2-python/src/s2python/common/duration.py b/packages/s2-python/src/s2python/common/duration.py similarity index 100% rename from s2-python/src/s2python/common/duration.py rename to packages/s2-python/src/s2python/common/duration.py diff --git a/s2-python/src/s2python/common/handshake.py b/packages/s2-python/src/s2python/common/handshake.py similarity index 100% rename from s2-python/src/s2python/common/handshake.py rename to packages/s2-python/src/s2python/common/handshake.py diff --git a/s2-python/src/s2python/common/handshake_response.py b/packages/s2-python/src/s2python/common/handshake_response.py similarity index 100% rename from s2-python/src/s2python/common/handshake_response.py rename to packages/s2-python/src/s2python/common/handshake_response.py diff --git a/s2-python/src/s2python/common/instruction_status_update.py b/packages/s2-python/src/s2python/common/instruction_status_update.py similarity index 100% rename from s2-python/src/s2python/common/instruction_status_update.py rename to packages/s2-python/src/s2python/common/instruction_status_update.py diff --git a/s2-python/src/s2python/common/number_range.py b/packages/s2-python/src/s2python/common/number_range.py similarity index 100% rename from s2-python/src/s2python/common/number_range.py rename to packages/s2-python/src/s2python/common/number_range.py diff --git a/s2-python/src/s2python/common/power_forecast.py b/packages/s2-python/src/s2python/common/power_forecast.py similarity index 100% rename from s2-python/src/s2python/common/power_forecast.py rename to packages/s2-python/src/s2python/common/power_forecast.py diff --git a/s2-python/src/s2python/common/power_forecast_element.py b/packages/s2-python/src/s2python/common/power_forecast_element.py similarity index 100% rename from s2-python/src/s2python/common/power_forecast_element.py rename to packages/s2-python/src/s2python/common/power_forecast_element.py diff --git a/s2-python/src/s2python/common/power_forecast_value.py b/packages/s2-python/src/s2python/common/power_forecast_value.py similarity index 100% rename from s2-python/src/s2python/common/power_forecast_value.py rename to packages/s2-python/src/s2python/common/power_forecast_value.py diff --git a/s2-python/src/s2python/common/power_measurement.py b/packages/s2-python/src/s2python/common/power_measurement.py similarity index 100% rename from s2-python/src/s2python/common/power_measurement.py rename to packages/s2-python/src/s2python/common/power_measurement.py diff --git a/s2-python/src/s2python/common/power_range.py b/packages/s2-python/src/s2python/common/power_range.py similarity index 100% rename from s2-python/src/s2python/common/power_range.py rename to packages/s2-python/src/s2python/common/power_range.py diff --git a/s2-python/src/s2python/common/power_value.py b/packages/s2-python/src/s2python/common/power_value.py similarity index 100% rename from s2-python/src/s2python/common/power_value.py rename to packages/s2-python/src/s2python/common/power_value.py diff --git a/s2-python/src/s2python/common/reception_status.py b/packages/s2-python/src/s2python/common/reception_status.py similarity index 100% rename from s2-python/src/s2python/common/reception_status.py rename to packages/s2-python/src/s2python/common/reception_status.py diff --git a/s2-python/src/s2python/common/resource_manager_details.py b/packages/s2-python/src/s2python/common/resource_manager_details.py similarity index 100% rename from s2-python/src/s2python/common/resource_manager_details.py rename to packages/s2-python/src/s2python/common/resource_manager_details.py diff --git a/s2-python/src/s2python/common/revoke_object.py b/packages/s2-python/src/s2python/common/revoke_object.py similarity index 100% rename from s2-python/src/s2python/common/revoke_object.py rename to packages/s2-python/src/s2python/common/revoke_object.py diff --git a/s2-python/src/s2python/common/role.py b/packages/s2-python/src/s2python/common/role.py similarity index 100% rename from s2-python/src/s2python/common/role.py rename to packages/s2-python/src/s2python/common/role.py diff --git a/s2-python/src/s2python/common/select_control_type.py b/packages/s2-python/src/s2python/common/select_control_type.py similarity index 100% rename from s2-python/src/s2python/common/select_control_type.py rename to packages/s2-python/src/s2python/common/select_control_type.py diff --git a/s2-python/src/s2python/common/session_request.py b/packages/s2-python/src/s2python/common/session_request.py similarity index 100% rename from s2-python/src/s2python/common/session_request.py rename to packages/s2-python/src/s2python/common/session_request.py diff --git a/s2-python/src/s2python/common/support.py b/packages/s2-python/src/s2python/common/support.py similarity index 100% rename from s2-python/src/s2python/common/support.py rename to packages/s2-python/src/s2python/common/support.py diff --git a/s2-python/src/s2python/common/timer.py b/packages/s2-python/src/s2python/common/timer.py similarity index 100% rename from s2-python/src/s2python/common/timer.py rename to packages/s2-python/src/s2python/common/timer.py diff --git a/s2-python/src/s2python/common/transition.py b/packages/s2-python/src/s2python/common/transition.py similarity index 100% rename from s2-python/src/s2python/common/transition.py rename to packages/s2-python/src/s2python/common/transition.py diff --git a/s2-python/src/s2python/ddbc/__init__.py b/packages/s2-python/src/s2python/ddbc/__init__.py similarity index 100% rename from s2-python/src/s2python/ddbc/__init__.py rename to packages/s2-python/src/s2python/ddbc/__init__.py diff --git a/s2-python/src/s2python/ddbc/ddbc_actuator_description.py b/packages/s2-python/src/s2python/ddbc/ddbc_actuator_description.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_actuator_description.py rename to packages/s2-python/src/s2python/ddbc/ddbc_actuator_description.py diff --git a/s2-python/src/s2python/ddbc/ddbc_actuator_status.py b/packages/s2-python/src/s2python/ddbc/ddbc_actuator_status.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_actuator_status.py rename to packages/s2-python/src/s2python/ddbc/ddbc_actuator_status.py diff --git a/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py b/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py rename to packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py diff --git a/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py b/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py rename to packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py diff --git a/s2-python/src/s2python/ddbc/ddbc_instruction.py b/packages/s2-python/src/s2python/ddbc/ddbc_instruction.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_instruction.py rename to packages/s2-python/src/s2python/ddbc/ddbc_instruction.py diff --git a/s2-python/src/s2python/ddbc/ddbc_operation_mode.py b/packages/s2-python/src/s2python/ddbc/ddbc_operation_mode.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_operation_mode.py rename to packages/s2-python/src/s2python/ddbc/ddbc_operation_mode.py diff --git a/s2-python/src/s2python/ddbc/ddbc_system_description.py b/packages/s2-python/src/s2python/ddbc/ddbc_system_description.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_system_description.py rename to packages/s2-python/src/s2python/ddbc/ddbc_system_description.py diff --git a/s2-python/src/s2python/ddbc/ddbc_timer_status.py b/packages/s2-python/src/s2python/ddbc/ddbc_timer_status.py similarity index 100% rename from s2-python/src/s2python/ddbc/ddbc_timer_status.py rename to packages/s2-python/src/s2python/ddbc/ddbc_timer_status.py diff --git a/s2-python/src/s2python/frbc/__init__.py b/packages/s2-python/src/s2python/frbc/__init__.py similarity index 100% rename from s2-python/src/s2python/frbc/__init__.py rename to packages/s2-python/src/s2python/frbc/__init__.py diff --git a/s2-python/src/s2python/frbc/frbc_actuator_description.py b/packages/s2-python/src/s2python/frbc/frbc_actuator_description.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_actuator_description.py rename to packages/s2-python/src/s2python/frbc/frbc_actuator_description.py diff --git a/s2-python/src/s2python/frbc/frbc_actuator_status.py b/packages/s2-python/src/s2python/frbc/frbc_actuator_status.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_actuator_status.py rename to packages/s2-python/src/s2python/frbc/frbc_actuator_status.py diff --git a/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py b/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py rename to packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py diff --git a/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py b/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py rename to packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py diff --git a/s2-python/src/s2python/frbc/frbc_instruction.py b/packages/s2-python/src/s2python/frbc/frbc_instruction.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_instruction.py rename to packages/s2-python/src/s2python/frbc/frbc_instruction.py diff --git a/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py b/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_leakage_behaviour.py rename to packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py diff --git a/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py b/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py rename to packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py diff --git a/s2-python/src/s2python/frbc/frbc_operation_mode.py b/packages/s2-python/src/s2python/frbc/frbc_operation_mode.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_operation_mode.py rename to packages/s2-python/src/s2python/frbc/frbc_operation_mode.py diff --git a/s2-python/src/s2python/frbc/frbc_operation_mode_element.py b/packages/s2-python/src/s2python/frbc/frbc_operation_mode_element.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_operation_mode_element.py rename to packages/s2-python/src/s2python/frbc/frbc_operation_mode_element.py diff --git a/s2-python/src/s2python/frbc/frbc_storage_description.py b/packages/s2-python/src/s2python/frbc/frbc_storage_description.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_storage_description.py rename to packages/s2-python/src/s2python/frbc/frbc_storage_description.py diff --git a/s2-python/src/s2python/frbc/frbc_storage_status.py b/packages/s2-python/src/s2python/frbc/frbc_storage_status.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_storage_status.py rename to packages/s2-python/src/s2python/frbc/frbc_storage_status.py diff --git a/s2-python/src/s2python/frbc/frbc_system_description.py b/packages/s2-python/src/s2python/frbc/frbc_system_description.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_system_description.py rename to packages/s2-python/src/s2python/frbc/frbc_system_description.py diff --git a/s2-python/src/s2python/frbc/frbc_timer_status.py b/packages/s2-python/src/s2python/frbc/frbc_timer_status.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_timer_status.py rename to packages/s2-python/src/s2python/frbc/frbc_timer_status.py diff --git a/s2-python/src/s2python/frbc/frbc_usage_forecast.py b/packages/s2-python/src/s2python/frbc/frbc_usage_forecast.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_usage_forecast.py rename to packages/s2-python/src/s2python/frbc/frbc_usage_forecast.py diff --git a/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py b/packages/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py similarity index 100% rename from s2-python/src/s2python/frbc/frbc_usage_forecast_element.py rename to packages/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py diff --git a/s2-python/src/s2python/frbc/rm.py b/packages/s2-python/src/s2python/frbc/rm.py similarity index 100% rename from s2-python/src/s2python/frbc/rm.py rename to packages/s2-python/src/s2python/frbc/rm.py diff --git a/s2-python/tests/unit/__init__.py b/packages/s2-python/src/s2python/generated/__init__.py similarity index 100% rename from s2-python/tests/unit/__init__.py rename to packages/s2-python/src/s2python/generated/__init__.py diff --git a/s2-python/src/s2python/generated/gen_s2.py b/packages/s2-python/src/s2python/generated/gen_s2.py similarity index 100% rename from s2-python/src/s2python/generated/gen_s2.py rename to packages/s2-python/src/s2python/generated/gen_s2.py diff --git a/s2-python/src/s2python/message.py b/packages/s2-python/src/s2python/message.py similarity index 100% rename from s2-python/src/s2python/message.py rename to packages/s2-python/src/s2python/message.py diff --git a/s2-python/src/s2python/ombc/__init__.py b/packages/s2-python/src/s2python/ombc/__init__.py similarity index 100% rename from s2-python/src/s2python/ombc/__init__.py rename to packages/s2-python/src/s2python/ombc/__init__.py diff --git a/s2-python/src/s2python/ombc/ombc_instruction.py b/packages/s2-python/src/s2python/ombc/ombc_instruction.py similarity index 100% rename from s2-python/src/s2python/ombc/ombc_instruction.py rename to packages/s2-python/src/s2python/ombc/ombc_instruction.py diff --git a/s2-python/src/s2python/ombc/ombc_operation_mode.py b/packages/s2-python/src/s2python/ombc/ombc_operation_mode.py similarity index 100% rename from s2-python/src/s2python/ombc/ombc_operation_mode.py rename to packages/s2-python/src/s2python/ombc/ombc_operation_mode.py diff --git a/s2-python/src/s2python/ombc/ombc_status.py b/packages/s2-python/src/s2python/ombc/ombc_status.py similarity index 100% rename from s2-python/src/s2python/ombc/ombc_status.py rename to packages/s2-python/src/s2python/ombc/ombc_status.py diff --git a/s2-python/src/s2python/ombc/ombc_system_description.py b/packages/s2-python/src/s2python/ombc/ombc_system_description.py similarity index 100% rename from s2-python/src/s2python/ombc/ombc_system_description.py rename to packages/s2-python/src/s2python/ombc/ombc_system_description.py diff --git a/s2-python/src/s2python/ombc/ombc_timer_status.py b/packages/s2-python/src/s2python/ombc/ombc_timer_status.py similarity index 100% rename from s2-python/src/s2python/ombc/ombc_timer_status.py rename to packages/s2-python/src/s2python/ombc/ombc_timer_status.py diff --git a/s2-python/src/s2python/pebc/__init__.py b/packages/s2-python/src/s2python/pebc/__init__.py similarity index 100% rename from s2-python/src/s2python/pebc/__init__.py rename to packages/s2-python/src/s2python/pebc/__init__.py diff --git a/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py b/packages/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py similarity index 100% rename from s2-python/src/s2python/pebc/pebc_allowed_limit_range.py rename to packages/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py diff --git a/s2-python/src/s2python/pebc/pebc_energy_constraint.py b/packages/s2-python/src/s2python/pebc/pebc_energy_constraint.py similarity index 100% rename from s2-python/src/s2python/pebc/pebc_energy_constraint.py rename to packages/s2-python/src/s2python/pebc/pebc_energy_constraint.py diff --git a/s2-python/src/s2python/pebc/pebc_instruction.py b/packages/s2-python/src/s2python/pebc/pebc_instruction.py similarity index 100% rename from s2-python/src/s2python/pebc/pebc_instruction.py rename to packages/s2-python/src/s2python/pebc/pebc_instruction.py diff --git a/s2-python/src/s2python/pebc/pebc_power_constraints.py b/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py similarity index 100% rename from s2-python/src/s2python/pebc/pebc_power_constraints.py rename to packages/s2-python/src/s2python/pebc/pebc_power_constraints.py diff --git a/s2-python/src/s2python/pebc/pebc_power_envelope.py b/packages/s2-python/src/s2python/pebc/pebc_power_envelope.py similarity index 100% rename from s2-python/src/s2python/pebc/pebc_power_envelope.py rename to packages/s2-python/src/s2python/pebc/pebc_power_envelope.py diff --git a/s2-python/src/s2python/pebc/pebc_power_envelope_element.py b/packages/s2-python/src/s2python/pebc/pebc_power_envelope_element.py similarity index 100% rename from s2-python/src/s2python/pebc/pebc_power_envelope_element.py rename to packages/s2-python/src/s2python/pebc/pebc_power_envelope_element.py diff --git a/s2-python/src/s2python/ppbc/__init__.py b/packages/s2-python/src/s2python/ppbc/__init__.py similarity index 100% rename from s2-python/src/s2python/ppbc/__init__.py rename to packages/s2-python/src/s2python/ppbc/__init__.py diff --git a/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py b/packages/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py rename to packages/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py diff --git a/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py rename to packages/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py diff --git a/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_power_profile_status.py rename to packages/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_power_sequence.py rename to packages/s2-python/src/s2python/ppbc/ppbc_power_sequence.py diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py rename to packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py rename to packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py diff --git a/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py rename to packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py diff --git a/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py b/packages/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py rename to packages/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py diff --git a/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py b/packages/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py similarity index 100% rename from s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py rename to packages/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py diff --git a/s2-python/src/s2python/py.typed b/packages/s2-python/src/s2python/py.typed similarity index 100% rename from s2-python/src/s2python/py.typed rename to packages/s2-python/src/s2python/py.typed diff --git a/s2-python/src/s2python/reception_status_awaiter.py b/packages/s2-python/src/s2python/reception_status_awaiter.py similarity index 100% rename from s2-python/src/s2python/reception_status_awaiter.py rename to packages/s2-python/src/s2python/reception_status_awaiter.py diff --git a/s2-python/src/s2python/s2_connection.py b/packages/s2-python/src/s2python/s2_connection.py similarity index 100% rename from s2-python/src/s2python/s2_connection.py rename to packages/s2-python/src/s2python/s2_connection.py diff --git a/s2-python/src/s2python/s2_control_type.py b/packages/s2-python/src/s2python/s2_control_type.py similarity index 100% rename from s2-python/src/s2python/s2_control_type.py rename to packages/s2-python/src/s2python/s2_control_type.py diff --git a/s2-python/src/s2python/s2_parser.py b/packages/s2-python/src/s2python/s2_parser.py similarity index 100% rename from s2-python/src/s2python/s2_parser.py rename to packages/s2-python/src/s2python/s2_parser.py diff --git a/s2-python/src/s2python/s2_validation_error.py b/packages/s2-python/src/s2python/s2_validation_error.py similarity index 100% rename from s2-python/src/s2python/s2_validation_error.py rename to packages/s2-python/src/s2python/s2_validation_error.py diff --git a/s2-python/src/s2python/utils.py b/packages/s2-python/src/s2python/utils.py similarity index 100% rename from s2-python/src/s2python/utils.py rename to packages/s2-python/src/s2python/utils.py diff --git a/s2-python/src/s2python/validate_values_mixin.py b/packages/s2-python/src/s2python/validate_values_mixin.py similarity index 100% rename from s2-python/src/s2python/validate_values_mixin.py rename to packages/s2-python/src/s2python/validate_values_mixin.py diff --git a/s2-python/src/s2python/version.py b/packages/s2-python/src/s2python/version.py similarity index 100% rename from s2-python/src/s2python/version.py rename to packages/s2-python/src/s2python/version.py diff --git a/s2-python/tests/unit/common/__init__.py b/packages/s2-python/tests/unit/__init__.py similarity index 100% rename from s2-python/tests/unit/common/__init__.py rename to packages/s2-python/tests/unit/__init__.py diff --git a/s2-testing/src/__init__.py b/packages/s2-python/tests/unit/common/__init__.py similarity index 100% rename from s2-testing/src/__init__.py rename to packages/s2-python/tests/unit/common/__init__.py diff --git a/s2-python/tests/unit/common/duration_test.py b/packages/s2-python/tests/unit/common/duration_test.py similarity index 100% rename from s2-python/tests/unit/common/duration_test.py rename to packages/s2-python/tests/unit/common/duration_test.py diff --git a/s2-python/tests/unit/common/handshake_response_test.py b/packages/s2-python/tests/unit/common/handshake_response_test.py similarity index 100% rename from s2-python/tests/unit/common/handshake_response_test.py rename to packages/s2-python/tests/unit/common/handshake_response_test.py diff --git a/s2-python/tests/unit/common/handshake_test.py b/packages/s2-python/tests/unit/common/handshake_test.py similarity index 100% rename from s2-python/tests/unit/common/handshake_test.py rename to packages/s2-python/tests/unit/common/handshake_test.py diff --git a/s2-python/tests/unit/common/instruction_status_update_test.py b/packages/s2-python/tests/unit/common/instruction_status_update_test.py similarity index 100% rename from s2-python/tests/unit/common/instruction_status_update_test.py rename to packages/s2-python/tests/unit/common/instruction_status_update_test.py diff --git a/s2-python/tests/unit/common/number_range_test.py b/packages/s2-python/tests/unit/common/number_range_test.py similarity index 100% rename from s2-python/tests/unit/common/number_range_test.py rename to packages/s2-python/tests/unit/common/number_range_test.py diff --git a/s2-python/tests/unit/common/power_forecast_element_test.py b/packages/s2-python/tests/unit/common/power_forecast_element_test.py similarity index 100% rename from s2-python/tests/unit/common/power_forecast_element_test.py rename to packages/s2-python/tests/unit/common/power_forecast_element_test.py diff --git a/s2-python/tests/unit/common/power_forecast_test.py b/packages/s2-python/tests/unit/common/power_forecast_test.py similarity index 100% rename from s2-python/tests/unit/common/power_forecast_test.py rename to packages/s2-python/tests/unit/common/power_forecast_test.py diff --git a/s2-python/tests/unit/common/power_forecast_value_test.py b/packages/s2-python/tests/unit/common/power_forecast_value_test.py similarity index 100% rename from s2-python/tests/unit/common/power_forecast_value_test.py rename to packages/s2-python/tests/unit/common/power_forecast_value_test.py diff --git a/s2-python/tests/unit/common/power_measurement_test.py b/packages/s2-python/tests/unit/common/power_measurement_test.py similarity index 100% rename from s2-python/tests/unit/common/power_measurement_test.py rename to packages/s2-python/tests/unit/common/power_measurement_test.py diff --git a/s2-python/tests/unit/common/power_range_test.py b/packages/s2-python/tests/unit/common/power_range_test.py similarity index 100% rename from s2-python/tests/unit/common/power_range_test.py rename to packages/s2-python/tests/unit/common/power_range_test.py diff --git a/s2-python/tests/unit/common/power_value_test.py b/packages/s2-python/tests/unit/common/power_value_test.py similarity index 100% rename from s2-python/tests/unit/common/power_value_test.py rename to packages/s2-python/tests/unit/common/power_value_test.py diff --git a/s2-python/tests/unit/common/reception_status_test.py b/packages/s2-python/tests/unit/common/reception_status_test.py similarity index 100% rename from s2-python/tests/unit/common/reception_status_test.py rename to packages/s2-python/tests/unit/common/reception_status_test.py diff --git a/s2-python/tests/unit/common/resource_manager_details_test.py b/packages/s2-python/tests/unit/common/resource_manager_details_test.py similarity index 100% rename from s2-python/tests/unit/common/resource_manager_details_test.py rename to packages/s2-python/tests/unit/common/resource_manager_details_test.py diff --git a/s2-python/tests/unit/common/revoke_object_test.py b/packages/s2-python/tests/unit/common/revoke_object_test.py similarity index 100% rename from s2-python/tests/unit/common/revoke_object_test.py rename to packages/s2-python/tests/unit/common/revoke_object_test.py diff --git a/s2-python/tests/unit/common/role_test.py b/packages/s2-python/tests/unit/common/role_test.py similarity index 100% rename from s2-python/tests/unit/common/role_test.py rename to packages/s2-python/tests/unit/common/role_test.py diff --git a/s2-python/tests/unit/common/select_control_type_test.py b/packages/s2-python/tests/unit/common/select_control_type_test.py similarity index 100% rename from s2-python/tests/unit/common/select_control_type_test.py rename to packages/s2-python/tests/unit/common/select_control_type_test.py diff --git a/s2-python/tests/unit/common/session_request_test.py b/packages/s2-python/tests/unit/common/session_request_test.py similarity index 100% rename from s2-python/tests/unit/common/session_request_test.py rename to packages/s2-python/tests/unit/common/session_request_test.py diff --git a/s2-python/tests/unit/common/timer_test.py b/packages/s2-python/tests/unit/common/timer_test.py similarity index 100% rename from s2-python/tests/unit/common/timer_test.py rename to packages/s2-python/tests/unit/common/timer_test.py diff --git a/s2-python/tests/unit/common/transition_test.py b/packages/s2-python/tests/unit/common/transition_test.py similarity index 100% rename from s2-python/tests/unit/common/transition_test.py rename to packages/s2-python/tests/unit/common/transition_test.py diff --git a/s2-python/tests/unit/frbc/frbc_actuator_description_test.py b/packages/s2-python/tests/unit/frbc/frbc_actuator_description_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_actuator_description_test.py rename to packages/s2-python/tests/unit/frbc/frbc_actuator_description_test.py diff --git a/s2-python/tests/unit/frbc/frbc_actuator_status_test.py b/packages/s2-python/tests/unit/frbc/frbc_actuator_status_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_actuator_status_test.py rename to packages/s2-python/tests/unit/frbc/frbc_actuator_status_test.py diff --git a/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py rename to packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py diff --git a/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py b/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py rename to packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py diff --git a/s2-python/tests/unit/frbc/frbc_instruction_test.py b/packages/s2-python/tests/unit/frbc/frbc_instruction_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_instruction_test.py rename to packages/s2-python/tests/unit/frbc/frbc_instruction_test.py diff --git a/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py rename to packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py diff --git a/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py b/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py rename to packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py diff --git a/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py rename to packages/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py diff --git a/s2-python/tests/unit/frbc/frbc_operation_mode_test.py b/packages/s2-python/tests/unit/frbc/frbc_operation_mode_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_operation_mode_test.py rename to packages/s2-python/tests/unit/frbc/frbc_operation_mode_test.py diff --git a/s2-python/tests/unit/frbc/frbc_storage_description_test.py b/packages/s2-python/tests/unit/frbc/frbc_storage_description_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_storage_description_test.py rename to packages/s2-python/tests/unit/frbc/frbc_storage_description_test.py diff --git a/s2-python/tests/unit/frbc/frbc_storage_status_test.py b/packages/s2-python/tests/unit/frbc/frbc_storage_status_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_storage_status_test.py rename to packages/s2-python/tests/unit/frbc/frbc_storage_status_test.py diff --git a/s2-python/tests/unit/frbc/frbc_system_description_test.py b/packages/s2-python/tests/unit/frbc/frbc_system_description_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_system_description_test.py rename to packages/s2-python/tests/unit/frbc/frbc_system_description_test.py diff --git a/s2-python/tests/unit/frbc/frbc_timer_status_test.py b/packages/s2-python/tests/unit/frbc/frbc_timer_status_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_timer_status_test.py rename to packages/s2-python/tests/unit/frbc/frbc_timer_status_test.py diff --git a/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py rename to packages/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py diff --git a/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py b/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py similarity index 100% rename from s2-python/tests/unit/frbc/frbc_usage_forecast_test.py rename to packages/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py diff --git a/s2-python/tests/unit/message_test.py b/packages/s2-python/tests/unit/message_test.py similarity index 100% rename from s2-python/tests/unit/message_test.py rename to packages/s2-python/tests/unit/message_test.py diff --git a/s2-python/tests/unit/reception_status_awaiter_test.py b/packages/s2-python/tests/unit/reception_status_awaiter_test.py similarity index 100% rename from s2-python/tests/unit/reception_status_awaiter_test.py rename to packages/s2-python/tests/unit/reception_status_awaiter_test.py diff --git a/s2-python/tests/unit/s2_connection_test.py b/packages/s2-python/tests/unit/s2_connection_test.py similarity index 100% rename from s2-python/tests/unit/s2_connection_test.py rename to packages/s2-python/tests/unit/s2_connection_test.py diff --git a/s2-python/tests/unit/s2_parser_test.py b/packages/s2-python/tests/unit/s2_parser_test.py similarity index 100% rename from s2-python/tests/unit/s2_parser_test.py rename to packages/s2-python/tests/unit/s2_parser_test.py diff --git a/s2-python/tests/unit/utils_test.py b/packages/s2-python/tests/unit/utils_test.py similarity index 100% rename from s2-python/tests/unit/utils_test.py rename to packages/s2-python/tests/unit/utils_test.py diff --git a/s2-python/tox.ini b/packages/s2-python/tox.ini similarity index 100% rename from s2-python/tox.ini rename to packages/s2-python/tox.ini diff --git a/s2-testing/src/s2testing/__init__.py b/packages/test-suites/README.md similarity index 100% rename from s2-testing/src/s2testing/__init__.py rename to packages/test-suites/README.md diff --git a/s2-testing/pyproject.toml b/packages/test-suites/pyproject.toml similarity index 93% rename from s2-testing/pyproject.toml rename to packages/test-suites/pyproject.toml index d3ac840..d7b9804 100644 --- a/s2-testing/pyproject.toml +++ b/packages/test-suites/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "s2-testing" +name = "test-suites" version = "0.1.0" description = "Add your description here" readme = "README.md" diff --git a/packages/test-suites/src/__init__.py b/packages/test-suites/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/test-suites/src/testsuites/__init__.py b/packages/test-suites/src/testsuites/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/s2-testing/src/s2testing/async_task_manager.py b/packages/test-suites/src/testsuites/async_task_manager.py similarity index 100% rename from s2-testing/src/s2testing/async_task_manager.py rename to packages/test-suites/src/testsuites/async_task_manager.py diff --git a/s2-testing/src/s2testing/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py similarity index 100% rename from s2-testing/src/s2testing/certificate/certificate.py rename to packages/test-suites/src/testsuites/certificate/certificate.py diff --git a/s2-testing/src/s2testing/config.py b/packages/test-suites/src/testsuites/config.py similarity index 100% rename from s2-testing/src/s2testing/config.py rename to packages/test-suites/src/testsuites/config.py diff --git a/s2-testing/src/s2testing/connection.py b/packages/test-suites/src/testsuites/connection.py similarity index 100% rename from s2-testing/src/s2testing/connection.py rename to packages/test-suites/src/testsuites/connection.py diff --git a/s2-testing/src/s2testing/controllers/__init__.py b/packages/test-suites/src/testsuites/controllers/__init__.py similarity index 100% rename from s2-testing/src/s2testing/controllers/__init__.py rename to packages/test-suites/src/testsuites/controllers/__init__.py diff --git a/s2-testing/src/s2testing/controllers/builder.py b/packages/test-suites/src/testsuites/controllers/builder.py similarity index 100% rename from s2-testing/src/s2testing/controllers/builder.py rename to packages/test-suites/src/testsuites/controllers/builder.py diff --git a/s2-testing/src/s2testing/controllers/controller.py b/packages/test-suites/src/testsuites/controllers/controller.py similarity index 100% rename from s2-testing/src/s2testing/controllers/controller.py rename to packages/test-suites/src/testsuites/controllers/controller.py diff --git a/s2-testing/src/s2testing/controllers/frbc_controller.py b/packages/test-suites/src/testsuites/controllers/frbc_controller.py similarity index 100% rename from s2-testing/src/s2testing/controllers/frbc_controller.py rename to packages/test-suites/src/testsuites/controllers/frbc_controller.py diff --git a/s2-testing/src/s2testing/controllers/pebc_controller.py b/packages/test-suites/src/testsuites/controllers/pebc_controller.py similarity index 100% rename from s2-testing/src/s2testing/controllers/pebc_controller.py rename to packages/test-suites/src/testsuites/controllers/pebc_controller.py diff --git a/s2-testing/src/s2testing/message_handlers.py b/packages/test-suites/src/testsuites/message_handlers.py similarity index 100% rename from s2-testing/src/s2testing/message_handlers.py rename to packages/test-suites/src/testsuites/message_handlers.py diff --git a/s2-testing/src/s2testing/orchestrator/__init__.py b/packages/test-suites/src/testsuites/orchestrator/__init__.py similarity index 100% rename from s2-testing/src/s2testing/orchestrator/__init__.py rename to packages/test-suites/src/testsuites/orchestrator/__init__.py diff --git a/s2-testing/src/s2testing/orchestrator/base.py b/packages/test-suites/src/testsuites/orchestrator/base.py similarity index 100% rename from s2-testing/src/s2testing/orchestrator/base.py rename to packages/test-suites/src/testsuites/orchestrator/base.py diff --git a/s2-testing/src/s2testing/orchestrator/server_orchestrator.py b/packages/test-suites/src/testsuites/orchestrator/server_orchestrator.py similarity index 100% rename from s2-testing/src/s2testing/orchestrator/server_orchestrator.py rename to packages/test-suites/src/testsuites/orchestrator/server_orchestrator.py diff --git a/s2-testing/src/s2testing/orchestrator/test_orchestrator.py b/packages/test-suites/src/testsuites/orchestrator/test_orchestrator.py similarity index 100% rename from s2-testing/src/s2testing/orchestrator/test_orchestrator.py rename to packages/test-suites/src/testsuites/orchestrator/test_orchestrator.py diff --git a/s2-testing/src/s2testing/server_models.py b/packages/test-suites/src/testsuites/server_models.py similarity index 100% rename from s2-testing/src/s2testing/server_models.py rename to packages/test-suites/src/testsuites/server_models.py diff --git a/s2-testing/src/s2testing/test_suite/__init__.py b/packages/test-suites/src/testsuites/test_suite/__init__.py similarity index 100% rename from s2-testing/src/s2testing/test_suite/__init__.py rename to packages/test-suites/src/testsuites/test_suite/__init__.py diff --git a/s2-testing/src/s2testing/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py similarity index 100% rename from s2-testing/src/s2testing/test_suite/base_test_case.py rename to packages/test-suites/src/testsuites/test_suite/base_test_case.py diff --git a/s2-testing/src/s2testing/test_suite/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py similarity index 100% rename from s2-testing/src/s2testing/test_suite/frbc_test_cases.py rename to packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py diff --git a/s2-testing/src/s2testing/test_suite/pebc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py similarity index 100% rename from s2-testing/src/s2testing/test_suite/pebc_test_cases.py rename to packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py diff --git a/s2-testing/src/s2testing/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py similarity index 100% rename from s2-testing/src/s2testing/test_suite/test_suite.py rename to packages/test-suites/src/testsuites/test_suite/test_suite.py diff --git a/s2-testing/src/s2testing/util.py b/packages/test-suites/src/testsuites/util.py similarity index 100% rename from s2-testing/src/s2testing/util.py rename to packages/test-suites/src/testsuites/util.py diff --git a/s2-testing/uv.lock b/packages/test-suites/uv.lock similarity index 100% rename from s2-testing/uv.lock rename to packages/test-suites/uv.lock diff --git a/s2-self-cert-server/pyproject.toml b/s2-self-cert-server/pyproject.toml index 41b877c..f43a7db 100644 --- a/s2-self-cert-server/pyproject.toml +++ b/s2-self-cert-server/pyproject.toml @@ -8,9 +8,10 @@ dependencies = [ "fastapi[standard]>=0.115.12", "python-multipart>=0.0.20", "s2-python", - "s2-testing", + "test-suites", ] + [tool.uv.sources] -s2-testing = { path = "../s2-testing", editable = true } -s2-python = { path = "../s2-python", editable = true } +test-suites = { path = "../packages/test-suites", editable = true } +s2-python = { path = "../packages/s2-python", editable = true } diff --git a/s2-self-cert-server/uv.lock b/s2-self-cert-server/uv.lock index e295b28..bbd65dc 100644 --- a/s2-self-cert-server/uv.lock +++ b/s2-self-cert-server/uv.lock @@ -516,7 +516,7 @@ wheels = [ [[package]] name = "s2-python" version = "0.5.0" -source = { editable = "../s2-python" } +source = { editable = "../packages/s2-python" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -558,34 +558,15 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "python-multipart" }, { name = "s2-python" }, - { name = "s2-testing" }, + { name = "test-suites" }, ] [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "s2-python", editable = "../s2-python" }, - { name = "s2-testing", editable = "../s2-testing" }, -] - -[[package]] -name = "s2-testing" -version = "0.1.0" -source = { editable = "../s2-testing" } -dependencies = [ - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "s2-python" }, - { name = "websockets" }, -] - -[package.metadata] -requires-dist = [ - { name = "pydantic", specifier = ">=2.11.3" }, - { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "s2-python", editable = "../s2-python" }, - { name = "websockets", specifier = ">=13.1" }, + { name = "s2-python", editable = "../packages/s2-python" }, + { name = "test-suites", editable = "../packages/test-suites" }, ] [[package]] @@ -618,6 +599,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload_time = "2025-04-13T13:56:16.21Z" }, ] +[[package]] +name = "test-suites" +version = "0.1.0" +source = { editable = "../packages/test-suites" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../packages/s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + [[package]] name = "typer" version = "0.15.3" diff --git a/s2-self-cert/pyproject.toml b/s2-self-cert/pyproject.toml index 4f2327f..a70576f 100644 --- a/s2-self-cert/pyproject.toml +++ b/s2-self-cert/pyproject.toml @@ -5,11 +5,8 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.10" -dependencies = [ - "s2-python", - "s2-testing", -] +dependencies = ["s2-python", "test-suites"] [tool.uv.sources] -s2-testing = { path = "../s2-testing", editable = true } -s2-python = { path = "../s2-python", editable = true } +test-suites = { path = "../packages/test-suites", editable = true } +s2-python = { path = "../packages/s2-python", editable = true } diff --git a/s2-self-cert/uv.lock b/s2-self-cert/uv.lock index aa41915..718dd73 100644 --- a/s2-self-cert/uv.lock +++ b/s2-self-cert/uv.lock @@ -190,7 +190,7 @@ wheels = [ [[package]] name = "s2-python" version = "0.5.0" -source = { editable = "../s2-python" } +source = { editable = "../packages/s2-python" } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -230,19 +230,19 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "s2-python" }, - { name = "s2-testing" }, + { name = "test-suites" }, ] [package.metadata] requires-dist = [ - { name = "s2-python", editable = "../s2-python" }, - { name = "s2-testing", editable = "../s2-testing" }, + { name = "s2-python", editable = "../packages/s2-python" }, + { name = "test-suites", editable = "../packages/test-suites" }, ] [[package]] -name = "s2-testing" +name = "test-suites" version = "0.1.0" -source = { editable = "../s2-testing" } +source = { editable = "../packages/test-suites" } dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, @@ -254,7 +254,7 @@ dependencies = [ requires-dist = [ { name = "pydantic", specifier = ">=2.11.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "s2-python", editable = "../s2-python" }, + { name = "s2-python", editable = "../packages/s2-python" }, { name = "websockets", specifier = ">=13.1" }, ] diff --git a/s2-testing/src/s2_testing.egg-info/PKG-INFO b/s2-testing/src/s2_testing.egg-info/PKG-INFO deleted file mode 100644 index 2f8fd90..0000000 --- a/s2-testing/src/s2_testing.egg-info/PKG-INFO +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 2.4 -Name: s2-testing -Version: 0.1.0 -Summary: Add your description here -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -Requires-Dist: pydantic>=2.11.3 -Requires-Dist: pyyaml>=6.0.2 -Requires-Dist: s2-python -Requires-Dist: websockets>=13.1 diff --git a/s2-testing/src/s2_testing.egg-info/SOURCES.txt b/s2-testing/src/s2_testing.egg-info/SOURCES.txt deleted file mode 100644 index c6b58b8..0000000 --- a/s2-testing/src/s2_testing.egg-info/SOURCES.txt +++ /dev/null @@ -1,26 +0,0 @@ -README.md -pyproject.toml -src/__init__.py -src/s2_testing.egg-info/PKG-INFO -src/s2_testing.egg-info/SOURCES.txt -src/s2_testing.egg-info/dependency_links.txt -src/s2_testing.egg-info/requires.txt -src/s2_testing.egg-info/top_level.txt -src/s2testing/__init__.py -src/s2testing/async_task_manager.py -src/s2testing/config.py -src/s2testing/connection.py -src/s2testing/message_handlers.py -src/s2testing/orchestrator.py -src/s2testing/util.py -src/s2testing/certificate/certificate.py -src/s2testing/controllers/__init__.py -src/s2testing/controllers/builder.py -src/s2testing/controllers/controller.py -src/s2testing/controllers/frbc_controller.py -src/s2testing/controllers/pebc_controller.py -src/s2testing/test_suite/__init__.py -src/s2testing/test_suite/base_test_case.py -src/s2testing/test_suite/frbc_test_cases.py -src/s2testing/test_suite/pebc_test_cases.py -src/s2testing/test_suite/test_suite.py \ No newline at end of file diff --git a/s2-testing/src/s2_testing.egg-info/dependency_links.txt b/s2-testing/src/s2_testing.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/s2-testing/src/s2_testing.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/s2-testing/src/s2_testing.egg-info/requires.txt b/s2-testing/src/s2_testing.egg-info/requires.txt deleted file mode 100644 index f633e80..0000000 --- a/s2-testing/src/s2_testing.egg-info/requires.txt +++ /dev/null @@ -1,4 +0,0 @@ -pydantic>=2.11.3 -pyyaml>=6.0.2 -s2-python -websockets>=13.1 diff --git a/s2-testing/src/s2_testing.egg-info/top_level.txt b/s2-testing/src/s2_testing.egg-info/top_level.txt deleted file mode 100644 index e1e55dd..0000000 --- a/s2-testing/src/s2_testing.egg-info/top_level.txt +++ /dev/null @@ -1,2 +0,0 @@ -__init__ -s2testing From 7598e102fd343ba63dd7588e68664efcea2d5685 Mon Sep 17 00:00:00 2001 From: andrewrutherfoord Date: Fri, 2 May 2025 16:25:38 +0200 Subject: [PATCH 15/75] The local one works! A lot has changed --- .../src/connectivity}/async_task_manager.py | 1 + .../connectivity/src/connectivity/channel.py | 72 +++ .../src/connectivity/connection_adapter.py | 68 +++ .../src/connectivity/s2_channel.py | 177 ++++++ .../src/connectivity}/server_models.py | 4 +- packages/connectivity/uv.lock | 38 +- packages/test-suites/pyproject.toml | 5 +- .../test-suites/src/testsuites/connection.py | 325 ---------- .../src/testsuites/controllers/controller.py | 95 ++- .../testsuites/controllers/frbc_controller.py | 6 +- .../testsuites/controllers/pebc_controller.py | 8 +- .../src/testsuites/message_handlers.py | 26 +- .../src/testsuites/orchestrator/__init__.py | 3 - .../src/testsuites/orchestrator/base.py | 99 --- .../orchestrator/server_orchestrator.py | 112 ---- .../orchestrator/test_orchestrator.py | 236 -------- .../src/testsuites/setup_test_orchestrator.py | 50 ++ .../src/testsuites/test_executor.py | 189 ++++++ .../testsuites/test_suite/base_test_case.py | 7 +- .../testsuites/test_suite/frbc_test_cases.py | 16 +- .../testsuites/test_suite/pebc_test_cases.py | 18 +- .../src/testsuites/test_suite/test_suite.py | 17 +- packages/test-suites/uv.lock | 23 +- s2-self-cert-server/pyproject.toml | 4 +- s2-self-cert-server/src/main.py | 12 +- s2-self-cert-server/src/rm_connection.py | 56 -- s2-self-cert-server/src/ws_adapter.py | 42 ++ s2-self-cert-server/uv.lock | 23 +- s2-self-cert/pyproject.toml | 4 +- s2-self-cert/src/config.yaml | 2 +- s2-self-cert/src/log.py | 4 +- s2-self-cert/src/main.py | 30 +- s2-self-cert/src/server.py | 31 +- .../server_side_certification_orchestrator.py | 47 +- s2-self-cert/src/ws_adapter.py | 57 ++ s2-self-cert/uv.lock | 25 +- uv.lock | 565 ++---------------- 37 files changed, 995 insertions(+), 1502 deletions(-) rename packages/{test-suites/src/testsuites => connectivity/src/connectivity}/async_task_manager.py (96%) create mode 100644 packages/connectivity/src/connectivity/channel.py create mode 100644 packages/connectivity/src/connectivity/connection_adapter.py create mode 100644 packages/connectivity/src/connectivity/s2_channel.py rename packages/{test-suites/src/testsuites => connectivity/src/connectivity}/server_models.py (95%) delete mode 100644 packages/test-suites/src/testsuites/connection.py delete mode 100644 packages/test-suites/src/testsuites/orchestrator/__init__.py delete mode 100644 packages/test-suites/src/testsuites/orchestrator/base.py delete mode 100644 packages/test-suites/src/testsuites/orchestrator/server_orchestrator.py delete mode 100644 packages/test-suites/src/testsuites/orchestrator/test_orchestrator.py create mode 100644 packages/test-suites/src/testsuites/setup_test_orchestrator.py create mode 100644 packages/test-suites/src/testsuites/test_executor.py delete mode 100644 s2-self-cert-server/src/rm_connection.py create mode 100644 s2-self-cert-server/src/ws_adapter.py create mode 100644 s2-self-cert/src/ws_adapter.py diff --git a/packages/test-suites/src/testsuites/async_task_manager.py b/packages/connectivity/src/connectivity/async_task_manager.py similarity index 96% rename from packages/test-suites/src/testsuites/async_task_manager.py rename to packages/connectivity/src/connectivity/async_task_manager.py index 3742b4d..c6f1763 100644 --- a/packages/test-suites/src/testsuites/async_task_manager.py +++ b/packages/connectivity/src/connectivity/async_task_manager.py @@ -61,6 +61,7 @@ async def cleanup(self): self._tasks.clear() async def stop(self): + logger.debug("Stop Called in class %s", self.__class__.__name__) self._stop_event.set() def is_running(self): diff --git a/packages/connectivity/src/connectivity/channel.py b/packages/connectivity/src/connectivity/channel.py new file mode 100644 index 0000000..c871861 --- /dev/null +++ b/packages/connectivity/src/connectivity/channel.py @@ -0,0 +1,72 @@ +import asyncio + +import logging + +from .connection_adapter import ( + ConnectionAdapter, + ConnectionClosed, + ConnectionError, + ConnectionProtocolError, +) + + +logger = logging.getLogger(__name__) + + +class ChannelSendError: + pass + + +class Channel: + connection: ConnectionAdapter + message_queue: asyncio.Queue + _stop_event: asyncio.Event + + def __init__(self, connection: ConnectionAdapter) -> None: + self.connection = connection + + self._stop_event = asyncio.Event() + + self.message_queue = asyncio.Queue() + + async def get_next_message(self) -> str: + """Pops the next message off the queue to be processed.""" + return await self.message_queue.get() + + async def send(self, message: str): + return await self.connection.send(message) + + async def receive(self) -> str: + return await self.connection.receive() + + async def process_received_message(self, message: str): + await self.message_queue.put(message) + + async def receive_messages(self): + logger.debug("WebSocket Channel has started to receive messages.") + + try: + while not self._stop_event.is_set(): + # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. + try: + message = await asyncio.wait_for(self.receive(), timeout=1) + except asyncio.TimeoutError: + continue + + await self.process_received_message(message) + except ConnectionClosed: + await self.stop() + except ConnectionError as e: + logger.error("Error whilst receive message from WS: %s", str(e)) + await self.stop() + except asyncio.CancelledError: + logger.warning("Cancelled error.") + + async def run(self): + await self.receive_messages() + + async def stop(self): + self._stop_event.set() + + if await self.connection.open: + await self.connection.close() diff --git a/packages/connectivity/src/connectivity/connection_adapter.py b/packages/connectivity/src/connectivity/connection_adapter.py new file mode 100644 index 0000000..72763ce --- /dev/null +++ b/packages/connectivity/src/connectivity/connection_adapter.py @@ -0,0 +1,68 @@ +import abc +import asyncio + +import logging + +import websockets + +logger = logging.getLogger(__name__) + + +class ConnectionError(Exception): + """Base exception for websocket wrapper errors.""" + + +class ConnectionClosed(ConnectionError): + """Raised when attempting to use a closed websocket.""" + + +class ConnectionProtocolError(ConnectionError): + """Raised for protocol errors.""" + + +class ConnectionError(ConnectionError): + """Raised for connection errors.""" + + +class ConnectionAdapter(abc.ABC): + """ + On server we use FastAPI's websocket and on local we use the websockets package. + These have slightly different interfaces so this just wraps them. + """ + + @abc.abstractmethod + async def receive(self) -> str: + """ + Receive a message from the websocket. + Raises: + ConnectionClosed: if the connection is closed. + ConnectionError: for other errors. + """ + pass + + @abc.abstractmethod + async def send(self, message: str): + """ + Send a message over the websocket. + Raises: + ConnectionClosed: if the connection is closed. + ConnectionError: for other errors. + """ + pass + + @property + @abc.abstractmethod + async def open(self) -> bool: + """ + Returns True if the connection is open. + """ + pass + + @abc.abstractmethod + async def close(self, code: int = 1000, reason: str = ""): + """ + Close the connection. + Raises: + ConnectionError: if closing fails. + """ + pass diff --git a/packages/connectivity/src/connectivity/s2_channel.py b/packages/connectivity/src/connectivity/s2_channel.py new file mode 100644 index 0000000..b6c1d4e --- /dev/null +++ b/packages/connectivity/src/connectivity/s2_channel.py @@ -0,0 +1,177 @@ +import asyncio +import json +from typing import Optional, Type +import uuid +from s2python.common import ReceptionStatus, ReceptionStatusValues +from s2python.message import S2Message +from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from connectivity.async_task_manager import AsyncTaskManager +from connectivity.connection_adapter import ConnectionAdapter + +from .channel import Channel + +import logging + +logger = logging.getLogger(__name__) + + +class SendOkay: + """Mostly copied over from S2-Python library""" + + status_is_send: asyncio.Event + s2_channel: "S2Channel" + subject_message_id: uuid.UUID + + def __init__(self, s2_channel: "S2Channel", subject_message_id: uuid.UUID): + self.status_is_send = asyncio.Event() + self.s2_channel = s2_channel + self.subject_message_id = subject_message_id + + async def run_async(self) -> None: + self.status_is_send.set() + + await self.s2_channel.respond_with_reception_status( + subject_message_id=self.subject_message_id, + status=ReceptionStatusValues.OK, + diagnostic_label="Processed okay.", + ) + + async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: + if not self.status_is_send.is_set(): + logger.warning( + "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " + "Sending it now.", + type_msg, + self.subject_message_id, + ) + await self.run_async() + + +class S2Channel(Channel): + + s2_parser: S2Parser + + reception_status_awaiter: ReceptionStatusAwaiter + + def __init__(self, connection: ConnectionAdapter) -> None: + super().__init__(connection) + + self.reception_status_awaiter = ReceptionStatusAwaiter() + self.s2_parser = S2Parser() + + self.message_queue: asyncio.Queue[S2Message] = asyncio.Queue() + + async def _send_and_forget(self, s2_msg: S2Message) -> None: + message = s2_msg.model_dump_json() + + await self.connection.send(message) + + async def respond_with_reception_status( + self, + subject_message_id: uuid.UUID, + status: ReceptionStatusValues, + diagnostic_label: str, + ) -> None: + logger.debug( + "Responding to message %s with status %s", subject_message_id, status + ) + msg = ReceptionStatus( + subject_message_id=subject_message_id, + status=status, + diagnostic_label=diagnostic_label, + ) + await self._send_and_forget(msg) + + async def send_msg_and_await_reception_status( + self, + s2_msg: S2Message, + timeout_reception_status: float = 5, + raise_on_error: bool = True, + ) -> ReceptionStatus: + await self._send_and_forget(s2_msg) + logger.debug( + "Waiting for ReceptionStatus for %s %s seconds", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + timeout_reception_status, + ) + try: + reception_status = await self.reception_status_awaiter.wait_for_reception_status( + s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] + ) + except TimeoutError: + logger.error( + "Did not receive a reception status on time for %s", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + ) + raise + + if reception_status.status != ReceptionStatusValues.OK and raise_on_error: + raise RuntimeError( + f"ReceptionStatus was not OK but rather {reception_status.status}" + ) + + return reception_status + + async def process_received_message(self, message: str) -> S2Message | None: + try: + s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) + except json.JSONDecodeError: + await self._send_and_forget( + ReceptionStatus( + subject_message_id=uuid.UUID( + "00000000-0000-0000-0000-000000000000" + ), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Not valid json.", + ) + ) + raise + except S2ValidationError as e: + json_msg = json.loads(message) + message_id = json_msg.get("message_id") + if message_id: + await self.respond_with_reception_status( + subject_message_id=message_id, + status=ReceptionStatusValues.OK, # TODO: Put this back to the correct error. + diagnostic_label="", + # status=ReceptionStatusValues.INVALID_MESSAGE, + # diagnostic_label=str(e), + ) + else: + await self.respond_with_reception_status( + subject_message_id=uuid.UUID( + "00000000-0000-0000-0000-000000000000" + ), + status=ReceptionStatusValues.OK, # TODO: Put this back to the correct error. + diagnostic_label="", + # status=ReceptionStatusValues.INVALID_DATA, + # diagnostic_label="Message appears valid json but could not find a message_id field.", + ) + + # Raise the error so that we can handle it in the orchestrator + raise e + else: + if isinstance(s2_msg, ReceptionStatus): + logger.debug( + "Message is a reception status for %s so registering in cache.", + s2_msg.subject_message_id, + ) + await self.reception_status_awaiter.receive_reception_status(s2_msg) + else: + await self.message_queue.put(s2_msg) + + +class ChannelConnection(ConnectionAdapter): + + def __init__(self, channel: Channel): + self.channel = channel + + async def send(self, message: str): + await self.channel.send(message) + + async def receive(self) -> str: + message = await self.channel.get_next_message() + + return message diff --git a/packages/test-suites/src/testsuites/server_models.py b/packages/connectivity/src/connectivity/server_models.py similarity index 95% rename from packages/test-suites/src/testsuites/server_models.py rename to packages/connectivity/src/connectivity/server_models.py index 4b2649f..9aa8dba 100644 --- a/packages/test-suites/src/testsuites/server_models.py +++ b/packages/connectivity/src/connectivity/server_models.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from s2python.message import S2Message -from s2testing.config import Config +from testsuites.config import Config class ServerMessageValidationException(Exception): @@ -47,7 +47,7 @@ class LogMessageEnvelope(BaseModel): class S2MessageEnvelope(BaseModel): message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.S2 # keep it as a dict so that the orchestrator can do the parsing and catch the errors as part of the testing. - message: dict + message: str ServerMessageEnvelope = Union[ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope] diff --git a/packages/connectivity/uv.lock b/packages/connectivity/uv.lock index 0090349..76bb6b3 100644 --- a/packages/connectivity/uv.lock +++ b/packages/connectivity/uv.lock @@ -32,6 +32,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "connectivity" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + [[package]] name = "pydantic" version = "2.11.4" @@ -224,25 +243,6 @@ requires-dist = [ ] provides-extras = ["testing", "development", "docs"] -[[package]] -name = "s2-self-cert-connectivity" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "s2-python" }, - { name = "websockets" }, -] - -[package.metadata] -requires-dist = [ - { name = "pydantic", specifier = ">=2.11.3" }, - { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "s2-python", editable = "../s2-python" }, - { name = "websockets", specifier = ">=13.1" }, -] - [[package]] name = "typing-extensions" version = "4.13.2" diff --git a/packages/test-suites/pyproject.toml b/packages/test-suites/pyproject.toml index d7b9804..e241ffc 100644 --- a/packages/test-suites/pyproject.toml +++ b/packages/test-suites/pyproject.toml @@ -7,9 +7,12 @@ requires-python = ">=3.10" dependencies = [ "pydantic>=2.11.3", "pyyaml>=6.0.2", - "s2-python", "websockets>=13.1", + "s2-python", + "connectivity", ] [tool.uv.sources] s2-python = { path = "../s2-python", editable = true } + +connectivity = { path = "../connectivity", editable = true } diff --git a/packages/test-suites/src/testsuites/connection.py b/packages/test-suites/src/testsuites/connection.py deleted file mode 100644 index e55831e..0000000 --- a/packages/test-suites/src/testsuites/connection.py +++ /dev/null @@ -1,325 +0,0 @@ -import asyncio -import json -import logging -import threading -import uuid -import abc -from typing import Type - -import websockets -from websockets.asyncio.connection import Connection as WSConnection - -from s2python.common import ReceptionStatus, ReceptionStatusValues -from s2python.message import S2Message -from s2python.reception_status_awaiter import ReceptionStatusAwaiter -from s2python.s2_parser import S2Parser -from s2python.s2_validation_error import S2ValidationError -from s2testing.server_models import ( - MessageEnvelopeTypeEnum, - LogMessage, - ConfigControlMessage, - ControlMessage, - ControlMessageEnvelope, - LogMessageEnvelope, - S2MessageEnvelope, - ServerMessageEnvelope, -) - -logger = logging.getLogger(__name__) - - -class SendOkay: - """Mostly copied over from S2-Python library""" - - status_is_send: threading.Event - connection: "BaseRMConnection" - subject_message_id: uuid.UUID - - def __init__(self, connection: "BaseRMConnection", subject_message_id: uuid.UUID): - self.status_is_send = threading.Event() - self.connection = connection - self.subject_message_id = subject_message_id - - async def run_async(self) -> None: - self.status_is_send.set() - - await self.connection.respond_with_reception_status( - subject_message_id=self.subject_message_id, - status=ReceptionStatusValues.OK, - diagnostic_label="Processed okay.", - ) - - async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: - if not self.status_is_send.is_set(): - logger.warning( - "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " - "Sending it now.", - type_msg, - self.subject_message_id, - ) - await self.run_async() - - -class BaseConnection(abc.ABC): - - message_queue: asyncio.Queue - - _stop_event: asyncio.Event - - def __init__(self) -> None: # pylint: disable=too-many-arguments - self._stop_event = asyncio.Event() - - self.message_queue = asyncio.Queue() - - @abc.abstractmethod - async def send(self, message: str): - pass - - @abc.abstractmethod - async def receive(self): - pass - - async def process_received_message(self, message: str): - await self.message_queue.put(message) - - async def receive_messages(self): - while not self._stop_event.is_set(): - # Timeout was added so that this task can exit at some point since if it never receives another message it just sits waiting. - try: - message = await asyncio.wait_for(self.receive(), timeout=1) - logger.debug("Received Message: %s", message) - except asyncio.TimeoutError: - continue - - await self.process_received_message(str(message)) - - async def get_next_message(self): - return await self.message_queue.get() - - async def stop(self): - self._stop_event.set() - - -class WebSocketConnectionMixin(BaseConnection): - # Queue contains strings. - ws: WSConnection - - async def send(self, message): - if self.ws is None: - raise RuntimeError( - "Cannot send messages if websocket connection is not yet established." - ) - - await self.ws.send(message) - - async def receive(self): - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - - return await self.ws.recv() - - async def receive_messages(self): - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - logger.debug("Connection has started to receive messages.") - - try: - await super().receive_messages() - except websockets.ConnectionClosedOK: - logger.info("Connection closed normally by remote.") - self._handle_ws_close() - except websockets.ConnectionClosedError as e: - logger.error("Connection closed with error: %s", str(e)) - self._handle_ws_close() - except asyncio.CancelledError: - pass - - def _handle_ws_close(self): - self._stop_event.set() - - async def stop(self): - await super().stop() - - await self.ws.close() - - -class BaseRMConnection(BaseConnection): # pylint: disable=too-many-instance-attributes - """ - Manged the websocket connection to the RM. - Puts all received messages onto the message queue so they can be retrieved by other tasks. - Based on the S2Connection class is S2-Python library. - """ - - s2_parser: S2Parser - - reception_status_awaiter: ReceptionStatusAwaiter - - _stop_event: asyncio.Event - - def __init__(self) -> None: # pylint: disable=too-many-arguments - super().__init__() - self.reception_status_awaiter = ReceptionStatusAwaiter() - self.s2_parser = S2Parser() - - @abc.abstractmethod - async def send(self, message: str): - pass - - @abc.abstractmethod - async def receive(self): - pass - - async def _send_and_forget(self, s2_msg: S2Message) -> None: - json_msg = s2_msg.to_json() - logger.info(json_msg) - try: - await self.send(json_msg) - except websockets.ConnectionClosedError as e: - logger.error("Unable to send message %s.", s2_msg.message_type) - - async def respond_with_reception_status( - self, - subject_message_id: uuid.UUID, - status: ReceptionStatusValues, - diagnostic_label: str, - ) -> None: - logger.debug( - "Responding to message %s with status %s", subject_message_id, status - ) - msg = ReceptionStatus( - subject_message_id=subject_message_id, - status=status, - diagnostic_label=diagnostic_label, - ) - await self._send_and_forget(msg) - - async def send_msg_and_await_reception_status( - self, - s2_msg: S2Message, - timeout_reception_status: float = 5.0, - raise_on_error: bool = True, - ) -> ReceptionStatus: - await self._send_and_forget(s2_msg) - logger.debug( - "Waiting for ReceptionStatus for %s %s seconds", - s2_msg.message_id, # type: ignore[attr-defined, union-attr] - timeout_reception_status, - ) - try: - reception_status = await self.reception_status_awaiter.wait_for_reception_status( - s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] - ) - except TimeoutError: - logger.error( - "Did not receive a reception status on time for %s", - s2_msg.message_id, # type: ignore[attr-defined, union-attr] - ) - self._stop_event.set() - raise - - if reception_status.status != ReceptionStatusValues.OK and raise_on_error: - raise RuntimeError( - f"ReceptionStatus was not OK but rather {reception_status.status}" - ) - - return reception_status - - async def process_received_message(self, message: str): - try: - s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) - except json.JSONDecodeError: - await self._send_and_forget( - ReceptionStatus( - subject_message_id=uuid.UUID( - "00000000-0000-0000-0000-000000000000" - ), - status=ReceptionStatusValues.INVALID_DATA, - diagnostic_label="Not valid json.", - ) - ) - except S2ValidationError as e: - json_msg = json.loads(message) - message_id = json_msg.get("message_id") - if message_id: - await self.respond_with_reception_status( - subject_message_id=message_id, - status=ReceptionStatusValues.OK, # TODO: Put this back to the correct error. - diagnostic_label="", - # status=ReceptionStatusValues.INVALID_MESSAGE, - # diagnostic_label=str(e), - ) - else: - await self.respond_with_reception_status( - subject_message_id=uuid.UUID( - "00000000-0000-0000-0000-000000000000" - ), - status=ReceptionStatusValues.OK, # TODO: Put this back to the correct error. - diagnostic_label="", - # status=ReceptionStatusValues.INVALID_DATA, - # diagnostic_label="Message appears valid json but could not find a message_id field.", - ) - - # Raise the error so that we can handle it in the orchestrator - raise e - except websockets.ConnectionClosedOK: - logger.info("Connection closed by remote. ") - await self.stop() - else: - logger.debug("Processing message: %s", s2_msg.to_json()) - - if isinstance(s2_msg, ReceptionStatus): - logger.debug( - "Message is a reception status for %s so registering in cache.", - s2_msg.subject_message_id, - ) - await self.reception_status_awaiter.receive_reception_status(s2_msg) - else: - await self.message_queue.put(s2_msg) - - -class WebSocketConnection(WebSocketConnectionMixin): - - def __init__(self, ws) -> None: - super().__init__() - self.ws = ws - - -class WebSocketRMConnection(WebSocketConnectionMixin, BaseRMConnection): - - def __init__(self, ws) -> None: - super().__init__() - self.ws = ws - - -class ServerConnection(WebSocketConnection): - - async def send_s2_message(self, message: dict): - envelope = S2MessageEnvelope(message=message) - await self.send(envelope) - - async def send_control_message(self, message: ControlMessage): - envelope = ControlMessageEnvelope(message=message) - await self.send(envelope) - - async def send_log_message(self, message: LogMessage): - envelope = LogMessageEnvelope(message=message) - await self.send(envelope) - - async def process_received_message(self, message): - - message_dict = json.loads(message) - - envelope: ServerMessageEnvelope - match message_dict["message_type"]: - case MessageEnvelopeTypeEnum.CONTROL: - envelope = ControlMessageEnvelope.model_validate(message_dict) - case MessageEnvelopeTypeEnum.S2: - envelope = S2MessageEnvelope.model_validate(message_dict) - case MessageEnvelopeTypeEnum.LOG: - envelope = LogMessageEnvelope.model_validate(message_dict) - - await self.message_queue.put(envelope) diff --git a/packages/test-suites/src/testsuites/controllers/controller.py b/packages/test-suites/src/testsuites/controllers/controller.py index 21c6791..7a7ba4a 100644 --- a/packages/test-suites/src/testsuites/controllers/controller.py +++ b/packages/test-suites/src/testsuites/controllers/controller.py @@ -1,8 +1,7 @@ import asyncio -from email import message from typing import Awaitable, Callable, Optional, Type -from ..connection import BaseRMConnection -from s2testing.message_handlers import MessageHandler +import uuid +from testsuites.message_handlers import MessageHandler from s2python.common import ( ControlType as ProtocolControlType, PowerForecast, @@ -10,8 +9,17 @@ ResourceManagerDetails, ) from s2python.message import S2Message +from s2python.common import ( + Handshake, + HandshakeResponse, + SelectControlType, + EnergyManagementRole, +) from s2python.s2_validation_error import S2ValidationError +from connectivity.s2_channel import S2Channel, SendOkay +from testsuites.util import wait_for_event_or_stop +from s2python.version import S2_VERSION import logging logger = logging.getLogger(__name__) @@ -20,22 +28,25 @@ class Controller(MessageHandler): control_type: ProtocolControlType + role: EnergyManagementRole = EnergyManagementRole.CEM + resource_manager_details: Optional[ResourceManagerDetails] = None + _resource_manager_details_received: asyncio.Event + messages_received = [] def __init__(self): super().__init__() - def handle_message( - self, - message: S2Message, - connection: "BaseRMConnection", - *args, - **kwargs - ): + self._resource_manager_details_received = asyncio.Event() + + self.add_handler(Handshake, self.handle_handshake) + self.add_handler(ResourceManagerDetails, self.handle_rm_details) + + def handle_message(self, message: S2Message, channel: "S2Channel"): try: - result = super().handle_message(message, connection, *args, **kwargs) + result = super().handle_message(message, channel) except: raise finally: @@ -48,7 +59,6 @@ def handle_s2_validation_exception(self, e: S2ValidationError): def get_received_messages(self, message_type: Type[S2Message]) -> list: def filter_messages(m: S2Message): - logger.info("Filter: %s, %s", type(m), message_type) return type(m) == message_type result = list(filter(filter_messages, self.messages_received)) @@ -64,16 +74,71 @@ def handshake_received(self): # and receives a valid status response, then this method is called. pass + async def perform_handshake(self, channel: S2Channel): + if channel is None: + raise ValueError("Channel not set.") + + await channel.send_msg_and_await_reception_status( + Handshake( + message_id=uuid.uuid4(), # type: ignore + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + + # if not await wait_for_event_or_stop(self._handshake_complete, stop_event): + # return + + async def handle_handshake( + self, + message: Handshake, + channel: "S2Channel", + send_okay: Awaitable[None], + ) -> None: + + if channel is None: + raise ValueError("Channel not set.") + + logger.debug( + "%s supports S2 protocol versions: %s", + message.role, + message.supported_protocol_versions, + ) + if message.supported_protocol_versions is None: + raise ValueError( + "Missing supported protocol versions in handshake message." + ) + + await send_okay + + await channel.send_msg_and_await_reception_status( + HandshakeResponse( + message_id=uuid.uuid4(), + selected_protocol_version=message.supported_protocol_versions[0], + ) + ) + + async def select_control_type(self, channel: "S2Channel"): + await channel.send_msg_and_await_reception_status( + # The controller is updated in executor before sending the selection. So we just use the current instance's type. + SelectControlType(message_id=uuid.uuid4(), control_type=self.control_type) + ) + async def handle_rm_details( self, message: ResourceManagerDetails, - connection: "BaseRMConnection", + channel: "S2Channel", send_okay: Awaitable, ): self.resource_manager_details = message await send_okay + self._resource_manager_details_received.set() + + async def wait_until_rm_details_received(self): + await self._resource_manager_details_received.wait() + class BaseController(Controller): control_type = ProtocolControlType.NOT_CONTROLABLE @@ -87,7 +152,7 @@ def __init__(self): async def handle_power_measurement_message( self, message: PowerMeasurement, - connection: "BaseRMConnection", + channel: "S2Channel", send_okay: Awaitable, ): @@ -96,7 +161,7 @@ async def handle_power_measurement_message( async def handle_power_forecast_message( self, message: PowerForecast, - connection: "BaseRMConnection", + channel: "S2Channel", send_okay: Awaitable, ): diff --git a/packages/test-suites/src/testsuites/controllers/frbc_controller.py b/packages/test-suites/src/testsuites/controllers/frbc_controller.py index b2106e8..37af03f 100644 --- a/packages/test-suites/src/testsuites/controllers/frbc_controller.py +++ b/packages/test-suites/src/testsuites/controllers/frbc_controller.py @@ -1,11 +1,13 @@ import asyncio import logging from typing import Optional -from s2testing.connection import BaseRMConnection from s2python.common import ControlType as ProtocolControlType from s2python.frbc import FRBCSystemDescription from .controller import BaseController + +from connectivity.s2_channel import S2Channel + logger = logging.getLogger(__name__) @@ -23,7 +25,7 @@ def __init__(self): self.add_handler(FRBCSystemDescription, self.handle_system_description_message) async def handle_system_description_message( - self, message: FRBCSystemDescription, connection: "BaseRMConnection", send_okay + self, message: FRBCSystemDescription, channel: "S2Channel", send_okay ): if not self.is_correct_message_type(message, FRBCSystemDescription): raise ValueError("Invalid Message Type.") diff --git a/packages/test-suites/src/testsuites/controllers/pebc_controller.py b/packages/test-suites/src/testsuites/controllers/pebc_controller.py index 498e759..ba2fcbc 100644 --- a/packages/test-suites/src/testsuites/controllers/pebc_controller.py +++ b/packages/test-suites/src/testsuites/controllers/pebc_controller.py @@ -10,7 +10,7 @@ PEBCPowerConstraints, ) from .controller import BaseController -from ..connection import BaseRMConnection +from connectivity.s2_channel import S2Channel import logging @@ -36,7 +36,7 @@ def __init__(self): async def handle_power_constraints_message( self, message: PEBCPowerConstraints, - connection: "BaseRMConnection", + channel: "S2Channel", send_okay: Awaitable, ): logger.info("Received power constraints.") @@ -48,7 +48,7 @@ async def handle_power_constraints_message( async def handle_energy_constraints_message( self, message: PEBCEnergyConstraint, - connection: "BaseRMConnection", + connection: "S2Channel", send_okay: Awaitable, ): await send_okay @@ -56,7 +56,7 @@ async def handle_energy_constraints_message( async def handle_instruction_status_update( self, message: InstructionStatusUpdate, - connection: "BaseRMConnection", + connection: "S2Channel", send_okay: Awaitable, ): await send_okay diff --git a/packages/test-suites/src/testsuites/message_handlers.py b/packages/test-suites/src/testsuites/message_handlers.py index 2cf1715..72f4732 100644 --- a/packages/test-suites/src/testsuites/message_handlers.py +++ b/packages/test-suites/src/testsuites/message_handlers.py @@ -1,15 +1,9 @@ import asyncio -from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type +from typing import Callable, Dict, Optional, Tuple, Type -from s2python.common import ControlType as ProtocolControlType -from s2python.common import EnergyManagementRole, ResourceManagerDetails +from s2python.common import EnergyManagementRole from s2python.message import S2Message -from s2python.s2_connection import SendOkay - -if TYPE_CHECKING: - from connection import Connection - +from connectivity.s2_channel import SendOkay, S2Channel import logging logger = logging.getLogger(__name__) @@ -97,18 +91,14 @@ def is_correct_message_type( def add_handler(self, msg_type: Type[S2Message], handler: Callable): self.handlers[msg_type] = handler - async def handle_message( - self, message: S2Message, connection: "Connection", *args, **kwargs - ): + async def handle_message(self, message: S2Message, channel: "S2Channel"): try: handler = self.handlers[type(message)] - send_okay = SendOkay(connection, message.message_id) # type: ignore[attr-defined, union-attr] + send_okay = SendOkay(channel, message.message_id) # type: ignore[attr-defined, union-attr] - result = await handler( - message, connection, send_okay.run_async(), *args, **kwargs - ) + result = await handler(message, channel, send_okay.run_async()) await send_okay.ensure_send_async(type(message)) @@ -116,12 +106,12 @@ async def handle_message( except KeyError: if self._accept_unhandled_messages: - send_okay = SendOkay(connection, message.message_id) # type: ignore[attr-defined, union-attr] + send_okay = SendOkay(channel, message.message_id) # type: ignore[attr-defined, union-attr] await send_okay.run_async() else: raise MessageHandlerNotFoundError( - f"Command does not exist for message type '{ message.message_type}'" + f"Command does not exist for message type '{ message.message_type }'" ) finally: self.message_awaiter.receive_message(message) diff --git a/packages/test-suites/src/testsuites/orchestrator/__init__.py b/packages/test-suites/src/testsuites/orchestrator/__init__.py deleted file mode 100644 index 3c7bd30..0000000 --- a/packages/test-suites/src/testsuites/orchestrator/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .base import Orchestrator -from .test_orchestrator import IntegrationTestOrchestrator -from .server_orchestrator import ServerOrchestrator diff --git a/packages/test-suites/src/testsuites/orchestrator/base.py b/packages/test-suites/src/testsuites/orchestrator/base.py deleted file mode 100644 index c70b390..0000000 --- a/packages/test-suites/src/testsuites/orchestrator/base.py +++ /dev/null @@ -1,99 +0,0 @@ -import abc -import asyncio -import logging -import uuid -from types import CoroutineType -from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type - -from s2testing.certificate.certificate import ComplianceReport -from s2python.common import ControlType as ProtocolControlType -from s2python.common import ( - EnergyManagementRole, - Handshake, - HandshakeResponse, - ResourceManagerDetails, - SelectControlType, -) -from s2python.message import S2Message -from s2python.s2_validation_error import S2ValidationError -from s2python.version import S2_VERSION - - -from ..connection import BaseConnection -from ..async_task_manager import AsyncTaskManager -from s2testing.connection import SendOkay -from s2testing.controllers import Controller -from s2testing.test_suite.test_suite import TestSuite -from s2testing.util import wait_for_event_or_stop - -logger = logging.getLogger(__name__) - - -class Orchestrator(AsyncTaskManager): - connection: Optional["BaseConnection"] = None - - @abc.abstractmethod - async def process_message(self, message): - """Do something with the message that was popped off the connection's queue""" - pass - - async def process_received_messages(self): - """AsyncIO task which pops messages off the queue and processes them using the control type.""" - - if self.connection is None: - raise ValueError("Connection not set.") - - try: - while not self._stop_event.is_set(): - try: - # Use a timeout to periodically check for cancellation - message = await asyncio.wait_for( - self.connection.get_next_message(), timeout=1.0 - ) - except asyncio.TimeoutError: - continue # Check stop event and loop again - - await self.process_message(message) - except asyncio.CancelledError: - logger.info("Message receiver cancelled.") - except Exception as e: - logger.exception("Message processor encountered an error: %s", e) - finally: - await self.stop() - - async def connection_receive_messages(self): - """Wrapping the receive messages method to allow catching of validation errors.""" - - if self.connection is None: - raise ValueError("Connection not set.") - - await self.connection.receive_messages() - - async def setup(self, connection: BaseConnection, *args, **kwargs): - """ - Sets connection and start tasks: - 1. Receive connection messages. WS receive messages and put on queue - 2. Process received connection messages. Pop message from queue and do something with it. - """ - await super().setup() - self.connection = connection - - self.create_task(self.connection_receive_messages(), True) - self.create_task(self.process_received_messages(), True) - - async def cleanup(self, *args, **kwargs): - logger.info("Cleanup of Orchestrator") - await super().cleanup() - - await self.connection.stop() - - async def run(self, *args, **kwargs): - self.running = True - - await self.setup(*args, **kwargs) - - await self._stop_event.wait() - - await self.cleanup(*args, **kwargs) - - self.running = False diff --git a/packages/test-suites/src/testsuites/orchestrator/server_orchestrator.py b/packages/test-suites/src/testsuites/orchestrator/server_orchestrator.py deleted file mode 100644 index 15e4b95..0000000 --- a/packages/test-suites/src/testsuites/orchestrator/server_orchestrator.py +++ /dev/null @@ -1,112 +0,0 @@ -import asyncio -import json -import logging -import os -import uuid -from types import CoroutineType -from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type - -from s2testing.certificate.certificate import ComplianceReport -from s2python.common import ControlType as ProtocolControlType -from s2python.common import ( - EnergyManagementRole, - Handshake, - HandshakeResponse, - ResourceManagerDetails, - SelectControlType, -) -from s2python.message import S2Message -from s2python.s2_validation_error import S2ValidationError -from s2python.version import S2_VERSION - - -from s2testing.connection import BaseConnection, ServerConnection -from s2testing.async_task_manager import AsyncTaskManager -from s2testing.orchestrator import Orchestrator -from s2testing.connection import SendOkay -from s2testing.controllers import Controller -from s2testing.test_suite.test_suite import TestSuite -from s2testing.util import wait_for_event_or_stop -from s2testing.server_models import ( - ServerMessageEnvelope, - MessageEnvelopeTypeEnum, - ControlMessage, -) -from websockets.asyncio.client import connect - -logger = logging.getLogger(__name__) - - -class ServerOrchestrator(Orchestrator): - connection: Optional["BaseConnection"] = None - server_connection: Optional["ServerConnection"] = None - - def __init__( - self, - ) -> None: - super().__init__() - - async def process_message(self, message: str): - # Processes the message that was popped off the `connection`. Will only be S2 Messages. - # Therefore just send the S2 Message on with the server connection - try: - # msg_dict: dict = json.loads(message) - await self.server_connection.send_s2_message(message) - except json.JSONDecodeError: - logger.exception("Received malformed JSON.") - - async def handle_control_message(message: ControlMessage): - logger.info("Control Message: %s", message) - - async def process_server_message(self, envelope: ServerMessageEnvelope): - match envelope.message_type: - case MessageEnvelopeTypeEnum.LOG: - # TODO: Maybe allow log level changes? - logger.info(envelope.message.content) - case MessageEnvelopeTypeEnum.S2: - await self.connection.send(envelope.message) - case MessageEnvelopeTypeEnum.CONTROL: - await self.handle_control_message(envelope.message) - - async def process_server_received_messages(self): - """AsyncIO task which pops messages off the queue and processes them using the control type.""" - - if self.server_connection is None: - raise ValueError("Server Connection not set.") - - try: - while not self._stop_event.is_set(): - try: - # Use a timeout to periodically check for cancellation - message = await asyncio.wait_for( - self.server_connection.get_next_message(), timeout=1.0 - ) - except asyncio.TimeoutError: - continue # Check stop event and loop again - - await self.process_server_message(message) - except asyncio.CancelledError: - logger.info("Message receiver cancelled.") - except Exception as e: - logger.exception("Message processor encountered an error: %s", e) - finally: - await self.stop() - - async def server_connection_receive_messages(self): - """Wrapping the receive messages method to allow catching of validation errors.""" - - if self.server_connection is None: - raise ValueError("Server connection not set.") - - await self.server_connection.receive_messages() - - async def setup(self, connection, server_connection, *args, **kwargs): - await super().setup(connection, *args, **kwargs) - - self.server_connection = server_connection - - self.create_task(self.server_connection_receive_messages()) - self.create_task(self.process_server_received_messages()) - - async def main_loop(self): - pass diff --git a/packages/test-suites/src/testsuites/orchestrator/test_orchestrator.py b/packages/test-suites/src/testsuites/orchestrator/test_orchestrator.py deleted file mode 100644 index 72f7248..0000000 --- a/packages/test-suites/src/testsuites/orchestrator/test_orchestrator.py +++ /dev/null @@ -1,236 +0,0 @@ -import asyncio -import logging -import uuid -from types import CoroutineType -from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type - -from s2testing.certificate.certificate import ComplianceReport -from s2python.common import ControlType as ProtocolControlType -from s2python.common import ( - EnergyManagementRole, - Handshake, - HandshakeResponse, - ResourceManagerDetails, - SelectControlType, -) -from s2python.message import S2Message -from s2python.s2_validation_error import S2ValidationError -from s2python.version import S2_VERSION - - -from ..connection import BaseRMConnection -from ..async_task_manager import AsyncTaskManager -from .base import Orchestrator -from s2testing.connection import SendOkay -from s2testing.controllers import Controller -from s2testing.test_suite.test_suite import TestSuite -from s2testing.util import wait_for_event_or_stop - -logger = logging.getLogger(__name__) - - -class IntegrationTestOrchestrator(Orchestrator): - role: EnergyManagementRole = EnergyManagementRole.CEM - - connection: Optional["BaseRMConnection"] = None - - resource_manager_details: Optional[ResourceManagerDetails] = None - - controller: Controller - controllers: Dict[ProtocolControlType, Controller] - - # The functions which handle the Handshake messages. All other messages should be handled by the controllers. - handshake_message_handlers: Dict[ - Type[S2Message], Callable[[S2Message, Awaitable[None]], CoroutineType] - ] - - test_suite: TestSuite - - def __init__( - self, - available_control_types: Dict[ProtocolControlType, Controller], - test_suite: TestSuite, - report: ComplianceReport, - ) -> None: - super().__init__() - - self.controllers = available_control_types - - controller = available_control_types.get(ProtocolControlType.NO_SELECTION) - if controller is None: - raise ValueError("A NO_SELECTION controller must be provided.") - self.controller = controller - - self.test_suite = test_suite - - self.handshake_message_handlers = { # type: ignore - Handshake: self.handle_handshake, - ResourceManagerDetails: self.handle_rm_details, - } - - self.report = report - - def set_control_type(self, control_type: ProtocolControlType): - controller = self.controllers[control_type] - # Put the RM Details into the new controller. - controller.resource_manager_details = controller.resource_manager_details - self.controller = controller - - async def process_message(self, message: S2Message): - # If Handshake message then use the handshake message handlers - if type(message) in self.handshake_message_handlers: - send_okay = SendOkay(self.connection, message.message_id) # type: ignore - await self.handshake_message_handlers[type(message)]( - message, send_okay.run_async() - ) - await send_okay.ensure_send_async(type(message)) - elif self.controller is not None: - await self.controller.handle_message(message, self.connection) # type: ignore - else: - logger.warning("No handler available for %s", message.message_type) - - async def execute_test_suite(self): - # Wait until the handshake is complete before starting the testing. - # TODO: Figure out how to include the handshake process in the testing. - - if self.connection is None: - raise ValueError("Connection not set.") - - if self.controller: - await self.test_suite.execute(self.connection, self.controller) - - async def main_loop(self): - await self.initiate_handshake() - - if not await wait_for_event_or_stop(self._handshake_complete, self._stop_event): - return - - logger.info("Handshake Complete!") - - await self.send_select_control_type() - - logger.info("Starting tests!") - - await self.execute_test_suite() - - logger.info(self.report.generate_certificate_dict()) - - async def connection_receive_messages(self): - """Wrapping the receive messages method to allow catching of validation errors.""" - try: - await super().connection_receive_messages() - except S2ValidationError as e: - if self.controller is not None: - self.controller.handle_s2_validation_exception(e) - else: - logger.error("S2 Validation Error encountered: %s", e) - except: - logger.exception("An error occurred whilst receiving messages.") - - async def initiate_handshake(self): - if self.connection is None: - raise ValueError("Connection not set.") - - await self.connection.send_msg_and_await_reception_status( - Handshake( - message_id=uuid.uuid4(), # type: ignore - role=self.role, - supported_protocol_versions=[S2_VERSION], - ) - ) - - self.controller.handshake_acknowledged() - - async def handle_handshake( - self, - message: Handshake, - send_okay: Awaitable[None], - ) -> None: - - if self.connection is None: - raise ValueError("Connection not set.") - - logger.debug( - "%s supports S2 protocol versions: %s", - message.role, - message.supported_protocol_versions, - ) - if message.supported_protocol_versions is None: - raise ValueError( - "Missing supported protocol versions in handshake message." - ) - - await send_okay - - await self.connection.send_msg_and_await_reception_status( - HandshakeResponse( - message_id=uuid.uuid4(), - selected_protocol_version=message.supported_protocol_versions[0], - ) - ) - - self.controller.handshake_received() - - async def send_select_control_type(self): - # TODO: Select the control type in a better way. - logger.info("Selecting Control Type.") - if ( - self.resource_manager_details is None - or self.resource_manager_details.available_control_types is None - ): - raise Exception("Missing Resource Details.") - - if self.connection is None: - raise ValueError("Connection not set.") - - controller: Optional[Controller] = None - - while ( - controller is None - and len(self.resource_manager_details.available_control_types) > 0 - ): - control_type = self.resource_manager_details.available_control_types.pop() - if control_type in self.controllers: - # logger.info( - # "Getting controller %s from %s", control_type, self.controllers - # ) - controller = self.controllers[control_type] - - if controller is None: - logger.warning("No suitable control types available. Exiting...") - await self.stop() - return - - logger.info("Selecting control type %s", controller) - - self.set_control_type(controller.control_type) - - await self.connection.send_msg_and_await_reception_status( - SelectControlType( - message_id=uuid.uuid4(), control_type=controller.control_type - ) - ) - - async def handle_rm_details( - self, - message: ResourceManagerDetails, - send_okay: Awaitable[None], - ): - - self.resource_manager_details = message - - if self.connection is None: - raise ValueError("Connection not set.") - - # Pass it to the no selection controller to use it as part of the test cases. - await self.controller.handle_message(message, self.connection, send_okay) - - self._handshake_complete.set() - - async def setup(self, connection: BaseRMConnection, *args, **kwargs): - await super().setup(connection) - - self._handshake_complete = asyncio.Event() - - self.create_task(self.main_loop(), True) - diff --git a/packages/test-suites/src/testsuites/setup_test_orchestrator.py b/packages/test-suites/src/testsuites/setup_test_orchestrator.py new file mode 100644 index 0000000..d5cbf8b --- /dev/null +++ b/packages/test-suites/src/testsuites/setup_test_orchestrator.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Dict + +from s2python.common import ControlType as ProtocolControlType +from testsuites.certificate.certificate import ComplianceReport +from testsuites.controllers import ( + Controller, + BaseController, + PEBCController, + FRBCController, +) +from testsuites.orchestrator import IntegrationTestOrchestrator, Orchestrator +from testsuites.test_suite import PEBCTestCase, TestSuiteBuilder +from testsuites.test_suite.frbc_test_cases import FRBCTestCase +from testsuites.config import Config + + +def create_controllers_dict_with_config( + config: Config, report: ComplianceReport +) -> Dict[ProtocolControlType, Controller]: + controllers: Dict[ProtocolControlType, Controller] = {} + + controllers[ProtocolControlType.NO_SELECTION] = BaseController() + + if config.control_types.frbc and config.control_types.frbc.enabled: + controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() + + if config.control_types.pebc and config.control_types.pebc.enabled: + controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = PEBCController() + + return controllers + + +def create_test_orchestrator(config: Config) -> Orchestrator: + report = ComplianceReport(timestamp=datetime.now()) + + controllers = create_controllers_dict_with_config(config, report) + + test_suite = ( + TestSuiteBuilder(config.control_types, report) + .with_test_case(PEBCTestCase) + .with_test_case(FRBCTestCase) + .build() + ) + + orchestrator = IntegrationTestOrchestrator( + available_control_types=controllers, test_suite=test_suite, report=report + ) + + return orchestrator diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py new file mode 100644 index 0000000..6d9220d --- /dev/null +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -0,0 +1,189 @@ +import asyncio +import logging +import uuid +from types import CoroutineType +from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type + +from testsuites.certificate.certificate import ComplianceReport +from s2python.common import ControlType as ProtocolControlType +from s2python.common import ( + EnergyManagementRole, + Handshake, + HandshakeResponse, + ResourceManagerDetails, + SelectControlType, +) +from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError + + +from testsuites.controllers import Controller +from testsuites.test_suite.test_suite import TestSuite +from testsuites.util import wait_for_event_or_stop +from connectivity.async_task_manager import AsyncTaskManager +from connectivity.channel import Channel +from connectivity.s2_channel import S2Channel, SendOkay + +logger = logging.getLogger(__name__) + + +class IntegrationTestExecutor(AsyncTaskManager): + + channel: Optional["S2Channel"] = None + + controller: Controller + controllers: Dict[ProtocolControlType, Controller] + + test_suite: TestSuite + + def __init__( + self, + available_control_types: Dict[ProtocolControlType, Controller], + test_suite: TestSuite, + report: ComplianceReport, + ) -> None: + super().__init__() + + self.controllers = available_control_types + + controller = available_control_types.get(ProtocolControlType.NO_SELECTION) + if controller is None: + raise ValueError("A NO_SELECTION controller must be provided.") + self.controller = controller + + self.test_suite = test_suite + + self.report = report + + def set_control_type(self, control_type: ProtocolControlType): + controller = self.controllers[control_type] + # Put the RM Details into the new controller. + controller.resource_manager_details = controller.resource_manager_details + self.controller = controller + + async def process_message(self, message: S2Message): + await self.controller.handle_message(message, self.channel) + + async def process_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.channel is None: + raise ValueError("Channel not set.") + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + message = await asyncio.wait_for( + self.channel.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + logger.info(message) + await self.process_message(message) + except asyncio.CancelledError: + logger.info("Message Channel cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + await self.stop() + + async def execute_test_suite(self): + # Wait until the handshake is complete before starting the testing. + # TODO: Figure out how to include the handshake process in the testing. + + if self.channel is None: + raise ValueError("Channel not set.") + + if self.controller: + await self.test_suite.execute(self.channel, self.controller) + + async def main_loop(self): + logger.info("Starting Main Loop.") + if self.channel is None: + raise ValueError("Channel not set.") + + await self.controller.perform_handshake(self.channel) + + await self.controller.wait_until_rm_details_received() + + logger.info("Handshake Complete!") + + await self.send_select_control_type() + + logger.info("Starting tests!") + + await self.execute_test_suite() + + logger.info(self.report.generate_certificate_dict()) + + logger.info("Exiting Main Loop.") + + async def send_select_control_type(self): + # TODO: Select the control type in a better way. + logger.info("Selecting Control Type.") + if ( + self.controller.resource_manager_details is None + or self.controller.resource_manager_details.available_control_types is None + ): + raise Exception("Missing Resource Details.") + + if self.channel is None: + raise ValueError("Channel not set.") + + control_type = None + + while ( + control_type is None + and len(self.controller.resource_manager_details.available_control_types) + > 0 + ): + control_type = ( + self.controller.resource_manager_details.available_control_types.pop() + ) + if control_type in self.controllers: + break + # logger.info( + # "Getting controller %s from %s", control_type, self.controllers + # ) + + if control_type is None: + logger.warning("No suitable control types available. Exiting...") + await self.stop() + return + + logger.info("Selecting control type %s", control_type) + self.set_control_type(control_type) + + await self.controller.select_control_type(self.channel) + + async def setup(self, channel: S2Channel, *args, **kwargs): + await super().setup() + self.channel = channel + + self._handshake_complete = asyncio.Event() + + # self.create_task(self.main_loop(), True) + self.create_task(self.main_loop(), True) + + self.create_task(self.channel.run(), True) + self.create_task(self.process_received_messages(), True) + + async def cleanup(self, *args, **kwargs): + logger.info("Cleanup of Orchestrator") + await super().cleanup() + + if self.channel is not None: + await self.channel.stop() + + async def run(self, *args, **kwargs): + self.running = True + + await self.setup(*args, **kwargs) + + await self._stop_event.wait() + + await self.cleanup(*args, **kwargs) + + self.running = False diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index e0b92e8..a200194 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -4,7 +4,6 @@ ComplianceStatus, ) from ..config import BaseTestConfig -from ..connection import BaseRMConnection from ..controllers.controller import BaseController from ..test_suite.test_suite import S2TestCase @@ -14,7 +13,7 @@ ControlType as ProtocolControlType, ResourceManagerDetails, ) - +from connectivity.s2_channel import S2Channel class NoSelectionTestCase(S2TestCase): @@ -28,11 +27,11 @@ class NoSelectionTestCase(S2TestCase): def __init__( self, config: BaseTestConfig, - connection: BaseRMConnection, + channel: S2Channel, controller: BaseController, report: ComplianceReport, ): - super().__init__(config, connection, controller, report) + super().__init__(config, channel, controller, report) async def test_validate_rm_details_received(self): finding = ComplianceFinding(message_type=ResourceManagerDetails) diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py index 70a0785..ad0bcdd 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py @@ -3,15 +3,14 @@ import logging import uuid -from s2testing.certificate.certificate import ( +from testsuites.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from s2testing.config import FRBCTestConfig, PEBCTestConfig -from s2testing.connection import BaseRMConnection -from s2testing.controllers.frbc_controller import FRBCController +from testsuites.config import FRBCTestConfig, PEBCTestConfig +from testsuites.controllers.frbc_controller import FRBCController from s2python.common import PowerMeasurement, ControlType as ProtocolControlType from s2python.frbc import ( FRBCActuatorStatus, @@ -20,8 +19,9 @@ FRBCSystemDescription, FRBCUsageForecast, ) -from s2testing.test_suite.base_test_case import NoSelectionTestCase -from s2testing.test_suite.test_suite import S2TestCase +from testsuites.test_suite.base_test_case import NoSelectionTestCase +from testsuites.test_suite.test_suite import S2TestCase +from connectivity.s2_channel import S2Channel logger = logging.getLogger(__name__) @@ -37,11 +37,11 @@ class FRBCTestCase(NoSelectionTestCase): def __init__( self, config: FRBCTestConfig, - connection: BaseRMConnection, + channel: S2Channel, controller: FRBCController, report: ComplianceReport, ): - super().__init__(config, connection, controller, report) + super().__init__(config, channel, controller, report) async def setup(self): await self.controller._system_description_received.wait() diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py index eb4b2f4..02b7ac4 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py @@ -2,16 +2,15 @@ import logging import uuid -from s2testing.certificate.certificate import ( +from testsuites.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from s2testing.config import BaseTestConfig, PEBCTestConfig -from s2testing.connection import BaseRMConnection -from s2testing.controllers.controller import Controller -from s2testing.controllers.pebc_controller import PEBCController +from testsuites.config import BaseTestConfig, PEBCTestConfig +from testsuites.controllers.controller import Controller +from testsuites.controllers.pebc_controller import PEBCController from s2python.common import ( ControlType as ProtocolControlType, PowerMeasurement, @@ -23,8 +22,9 @@ PEBCPowerEnvelope, PEBCPowerEnvelopeElement, ) -from s2testing.test_suite.base_test_case import NoSelectionTestCase -from s2testing.test_suite.test_suite import S2TestCase +from testsuites.test_suite.base_test_case import NoSelectionTestCase +from testsuites.test_suite.test_suite import S2TestCase +from connectivity.s2_channel import S2Channel logger = logging.getLogger(__name__) @@ -37,11 +37,11 @@ class PEBCTestCase(NoSelectionTestCase): def __init__( self, config: PEBCTestConfig, - connection: BaseRMConnection, + channel: S2Channel, controller: PEBCController, report: ComplianceReport, ): - super().__init__(config, connection, controller, report) + super().__init__(config, channel, controller, report) async def setup(self): await self.controller._power_constraints_received.wait() diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index 6f434e5..f0daf40 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -5,18 +5,19 @@ import logging from typing import TYPE_CHECKING, Dict, List, Optional, Type -from s2testing.certificate.certificate import ( +from testsuites.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from s2testing.config import BaseTestConfig, ControlTypeTestConfig -from s2testing.connection import BaseRMConnection -from s2testing.controllers.controller import Controller +from testsuites.config import BaseTestConfig, ControlTypeTestConfig +from testsuites.controllers.controller import Controller from s2python.common import ControlType as ProtocolControlType from s2python.message import S2Message +from connectivity.s2_channel import S2Channel + logger = logging.getLogger(__name__) @@ -29,11 +30,11 @@ class S2TestCase(abc.ABC): def __init__( self, config: BaseTestConfig, - connection: BaseRMConnection, + channel: S2Channel, controller: Controller, report: ComplianceReport, ): - self.connection = connection + self.channel = channel self.controller = controller self.config = config self.report = report @@ -130,7 +131,7 @@ def add_test_case(self, test_case: Type[S2TestCase]): else: self.test_cases[test_case.control_type] = [test_case] - async def execute(self, connection: BaseRMConnection, controller: Controller): + async def execute(self, channel: S2Channel, controller: Controller): control_type = controller.control_type test_cases = self.test_cases.get(control_type, []) logger.info(self.test_cases) @@ -143,7 +144,7 @@ async def execute(self, connection: BaseRMConnection, controller: Controller): control_type = TestCase.control_type test_case = TestCase( self.config.get_control_type_config(control_type), - connection, + channel, controller, self.report, ) diff --git a/packages/test-suites/uv.lock b/packages/test-suites/uv.lock index 7d20b95..0a27ea3 100644 --- a/packages/test-suites/uv.lock +++ b/packages/test-suites/uv.lock @@ -32,6 +32,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "connectivity" +version = "0.1.0" +source = { editable = "../connectivity" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + [[package]] name = "pydantic" version = "2.11.3" @@ -225,10 +244,11 @@ requires-dist = [ provides-extras = ["testing", "development", "docs"] [[package]] -name = "s2-testing" +name = "test-suites" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "connectivity" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "s2-python" }, @@ -237,6 +257,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "connectivity", editable = "../connectivity" }, { name = "pydantic", specifier = ">=2.11.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "s2-python", editable = "../s2-python" }, diff --git a/s2-self-cert-server/pyproject.toml b/s2-self-cert-server/pyproject.toml index f43a7db..8b66bcb 100644 --- a/s2-self-cert-server/pyproject.toml +++ b/s2-self-cert-server/pyproject.toml @@ -7,11 +7,11 @@ requires-python = ">=3.10" dependencies = [ "fastapi[standard]>=0.115.12", "python-multipart>=0.0.20", - "s2-python", + "connectivity", "test-suites", ] [tool.uv.sources] test-suites = { path = "../packages/test-suites", editable = true } -s2-python = { path = "../packages/s2-python", editable = true } +connectivity = { path = "../packages/connectivity", editable = true } diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index decbef9..8b1c96a 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -11,7 +11,7 @@ from .log import LOGGING_CONFIG from s2python.message import S2Message -from s2testing.server_models import ( +from testsuites.server_models import ( ConfigControlMessage, ControlMessageType, ServerMessageEnvelope, @@ -21,10 +21,10 @@ S2MessageEnvelope, ServerMessageValidationException, ) -from s2testing.orchestrator import ServerOrchestrator -from s2testing.connection import ServerConnection +from testsuites.orchestrator import ServerOrchestrator +from testsuites.connection import ServerConnection from .rm_connection import ServerRMConnection -from s2testing.config import Config +from testsuites.config import Config logging.config.dictConfig(LOGGING_CONFIG) @@ -42,7 +42,7 @@ def verify_certificate(file: UploadFile): return {"valid": False} -class WebSocketWrapper: +class WebSocketAdapter: def __init__(self, websocket: WebSocket): self.websocket = websocket @@ -71,7 +71,7 @@ async def main_loop(self): @app.websocket("/ws") async def connect_tester(websocket: WebSocket): await websocket.accept() - wrapper = WebSocketWrapper(websocket) + wrapper = WebSocketAdapter(websocket) server = CertificationServerOrchestrator() client_connection = ServerConnection(wrapper) # client_connection = StarlettWebsocketServerConnection(websocket) diff --git a/s2-self-cert-server/src/rm_connection.py b/s2-self-cert-server/src/rm_connection.py deleted file mode 100644 index baf931c..0000000 --- a/s2-self-cert-server/src/rm_connection.py +++ /dev/null @@ -1,56 +0,0 @@ -import asyncio -import json -import logging -import uuid -from typing import Type - -from fastapi import WebSocket -from starlette.websockets import WebSocketClose -from s2testing.connection import BaseRMConnection -from s2python.message import S2Message - -from s2testing.connection import ServerConnection -from s2testing.server_models import ( - ControlMessageEnvelope, - MessageEnvelopeTypeEnum, - S2MessageEnvelope, - ServerMessageValidationException, -) - -logger = logging.getLogger(__name__) - - -class ServerRMConnection(BaseRMConnection): - """ - RMConnection is an implementation of BaseRMConnection used for server-side testing. - - This class facilitates communication with the RM (Resource Manager) via the local s2-self-cert instance. - S2Messages are encapsulated in an envelope for transmission between the client and server sides. - The client side is responsible for unwrapping the envelope and forwarding the S2Message to the physical RM. - - Attributes: - receive_queue (asyncio.Queue): Queue which received messages are put onto. - client_connection (ClientConnection): The connection to the local instance which is used to send the message via the WS. - """ - - receive_queue: asyncio.Queue - client_connection: ServerConnection - - def __init__(self, client_connection: ServerConnection): - super().__init__() - - self.receive_queue = asyncio.Queue() - self.client_connection = client_connection - - async def send(self, message: S2Message): - await self.client_connection.send_s2_message(message) - - async def receive(self): - msg = await self.receive_queue.get() - - logger.info(f"Received S2 message: {msg} ") - - #TODO - - async def add_message_to_queue(self, message: dict): - await self.receive_queue.put(message) diff --git a/s2-self-cert-server/src/ws_adapter.py b/s2-self-cert-server/src/ws_adapter.py new file mode 100644 index 0000000..ca8204b --- /dev/null +++ b/s2-self-cert-server/src/ws_adapter.py @@ -0,0 +1,42 @@ +from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState +from connectivity.connection_adapter import ( + ConnectionAdapter, + ConnectionClosed, + ConnectionError, +) + + +class FastAPIWebSocketAdapter(ConnectionAdapter): + def __init__(self, websocket: WebSocket): + self.websocket = websocket + + async def receive(self) -> str: + try: + data = await self.websocket.receive_text() + return data + except WebSocketDisconnect: + raise ConnectionClosed("Websocket is closed.") + except RuntimeError as e: + # Starlette raises RuntimeError if the connection is closed + raise ConnectionClosed(f"Websocket is closed: {e}") + except Exception as e: + raise ConnectionError(f"Unknown websocket error: {e}") + + async def send(self, message: str): + try: + await self.websocket.send_text(message) + except RuntimeError as e: + # Starlette raises RuntimeError if the connection is closed + raise ConnectionClosed(f"Websocket is closed: {e}") + except Exception as e: + raise ConnectionError(f"Unknown websocket error: {e}") + + @property + async def open(self) -> bool: + return self.websocket.application_state == WebSocketState.CONNECTED + + async def close(self, code: int = 1000, reason: str = ""): + try: + await self.websocket.close(code=code) + except Exception as e: + raise ConnectionError(f"Error closing websocket: {e}") diff --git a/s2-self-cert-server/uv.lock b/s2-self-cert-server/uv.lock index bbd65dc..e651fc3 100644 --- a/s2-self-cert-server/uv.lock +++ b/s2-self-cert-server/uv.lock @@ -56,6 +56,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "connectivity" +version = "0.1.0" +source = { editable = "../packages/connectivity" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../packages/s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -555,17 +574,17 @@ name = "s2-server" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "connectivity" }, { name = "fastapi", extra = ["standard"] }, { name = "python-multipart" }, - { name = "s2-python" }, { name = "test-suites" }, ] [package.metadata] requires-dist = [ + { name = "connectivity", editable = "../packages/connectivity" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "s2-python", editable = "../packages/s2-python" }, { name = "test-suites", editable = "../packages/test-suites" }, ] diff --git a/s2-self-cert/pyproject.toml b/s2-self-cert/pyproject.toml index a70576f..d1bbc0a 100644 --- a/s2-self-cert/pyproject.toml +++ b/s2-self-cert/pyproject.toml @@ -5,8 +5,8 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.10" -dependencies = ["s2-python", "test-suites"] +dependencies = ["connectivity", "test-suites"] [tool.uv.sources] test-suites = { path = "../packages/test-suites", editable = true } -s2-python = { path = "../packages/s2-python", editable = true } +connectivity = { path = "../packages/connectivity", editable = true } diff --git a/s2-self-cert/src/config.yaml b/s2-self-cert/src/config.yaml index 563a27d..e9c145e 100644 --- a/s2-self-cert/src/config.yaml +++ b/s2-self-cert/src/config.yaml @@ -2,7 +2,7 @@ device_details: name: Some Device manufacturer: ACME -mode: certification +mode: testing control_types: no_selection: null pebc: diff --git a/s2-self-cert/src/log.py b/s2-self-cert/src/log.py index bdc0f12..3760d04 100644 --- a/s2-self-cert/src/log.py +++ b/s2-self-cert/src/log.py @@ -24,8 +24,8 @@ }, "loggers": { "": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, - "connection": {"handlers": ["console"], "level": "INFO", "propagate": False}, - "orchestrator": {"handlers": ["console"], "level": "INFO", "propagate": False}, + "connectivity": {"handlers": ["console"], "level": "INFO", "propagate": False}, + "ws_adapter": {"handlers": ["console"], "level": "INFO", "propagate": False}, "websockets": {"handlers": ["console"], "level": "WARNING", "propagate": True}, "asyncio": {"handlers": ["console"], "level": "WARNING", "propagate": True}, }, diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index d5fc035..f70c8dc 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -10,20 +10,21 @@ from typing import Dict from s2python.common import ControlType as ProtocolControlType -from s2testing.certificate.certificate import ComplianceReport -from s2testing.config import Config, load_config -from s2testing.controllers import ( +from testsuites.certificate.certificate import ComplianceReport +from testsuites.config import Config, load_config +from testsuites.controllers import ( Controller, BaseController, PEBCController, FRBCController, ) -from s2testing.orchestrator import IntegrationTestOrchestrator, Orchestrator -from s2testing.test_suite import PEBCTestCase, TestSuiteBuilder -from s2testing.test_suite.frbc_test_cases import FRBCTestCase +from testsuites.test_executor import IntegrationTestExecutor +from testsuites.test_suite import PEBCTestCase, TestSuiteBuilder +from testsuites.test_suite.frbc_test_cases import FRBCTestCase from log import LOGGING_CONFIG from server import S2Server -from server_side_certification_orchestrator import ServerSideCertificationOrchestrator + +# from server_side_certification_orchestrator import ServerSideCertificationOrchestrator logging.config.dictConfig(LOGGING_CONFIG) @@ -49,7 +50,7 @@ def create_controllers_dict_with_config( return controllers -def create_local_test_orchestrator(config: Config) -> Orchestrator: +def create_local_test_orchestrator(config: Config) -> IntegrationTestExecutor: report = ComplianceReport(timestamp=datetime.now()) controllers = create_controllers_dict_with_config(config, report) @@ -61,16 +62,16 @@ def create_local_test_orchestrator(config: Config) -> Orchestrator: .build() ) - orchestrator = IntegrationTestOrchestrator( + orchestrator = IntegrationTestExecutor( available_control_types=controllers, test_suite=test_suite, report=report ) return orchestrator -def create_server_certification_orchestrator(config: Config) -> Orchestrator: +# def create_server_certification_orchestrator(config: Config) -> IntegrationTestExecutor: - return ServerSideCertificationOrchestrator() +# return ServerSideCertificationOrchestrator() async def main(): @@ -80,16 +81,17 @@ async def main(): config: Config = load_config(args.config) if config.mode == "certification": - orchestrator = create_server_certification_orchestrator(config) + pass + # orchestrator = create_server_certification_orchestrator(config) elif config.mode == "testing": - orchestrator = create_local_test_orchestrator(config) + test_executor = create_local_test_orchestrator(config) else: raise ValueError("Invalid mode.") logger.info("-" * 40) logger.info(f"Starting in {config.mode} mode...") - s2_server = S2Server("0.0.0.0", 8000, orchestrator, config.mode) + s2_server = S2Server("0.0.0.0", 8000, test_executor, config.mode) await s2_server.start() diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index 47f2803..8acb8b6 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -3,21 +3,19 @@ import signal from typing import Literal -from s2testing.orchestrator import IntegrationTestOrchestrator +from testsuites.test_executor import IntegrationTestExecutor from websockets.asyncio.connection import Connection as WSConnection from websockets.asyncio.server import serve as ws_serve -from s2testing.connection import ( - BaseConnection, - WebSocketConnection, - WebSocketRMConnection, -) +from ws_adapter import WebSocketConnectionAdapter +from connectivity.channel import Channel +from connectivity.s2_channel import S2Channel logger = logging.getLogger(__name__) class S2Server: # Receives incoming S2 Resource Manager WebSocket Connections - orchestrator: IntegrationTestOrchestrator + executor: IntegrationTestExecutor mode: Literal["testing", "certification"] _exit_event: asyncio.Event @@ -26,7 +24,7 @@ def __init__( self, host, port, - orchestrator: IntegrationTestOrchestrator, + orchestrator: IntegrationTestExecutor, mode: Literal["testing", "certification"], ): self._host = host @@ -35,7 +33,7 @@ def __init__( self.mode = mode - self.orchestrator = orchestrator + self.executor = orchestrator async def handle_incoming_connection(self, websocket: WSConnection): """ @@ -43,14 +41,13 @@ async def handle_incoming_connection(self, websocket: WSConnection): If the orchestrator already has a connection then it discards the new connection. This system is only meant to handle one connected device. """ - if not self.orchestrator.is_running(): + if not self.executor.is_running(): logger.info("Connection to RM opened.") - connection: BaseConnection - if self.mode == "testing": - connection = WebSocketRMConnection(websocket) - elif self.mode == "certification": - connection = WebSocketConnection(websocket) - await self.orchestrator.run(connection) + connection = WebSocketConnectionAdapter(websocket) + + s2_channel = S2Channel(connection) + + await self.executor.run(s2_channel) logger.info("Connection closed.") @@ -77,5 +74,5 @@ async def start(self): await self._exit_event.wait() logger.info(f"Server stopping.") - await self.orchestrator.stop() + await self.executor.stop() logger.info(f"Server stop.") diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py index b948c3b..6e63cb5 100644 --- a/s2-self-cert/src/server_side_certification_orchestrator.py +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -6,7 +6,7 @@ from types import CoroutineType from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type -from s2testing.certificate.certificate import ComplianceReport +from testsuites.certificate.certificate import ComplianceReport from s2python.common import ControlType as ProtocolControlType from s2python.common import ( EnergyManagementRole, @@ -20,14 +20,11 @@ from s2python.version import S2_VERSION -from s2testing.connection import BaseConnection, ServerConnection -from s2testing.async_task_manager import AsyncTaskManager -from s2testing.orchestrator import Orchestrator, ServerOrchestrator -from s2testing.connection import SendOkay -from s2testing.controllers import Controller -from s2testing.test_suite.test_suite import TestSuite -from s2testing.util import wait_for_event_or_stop -from s2testing.server_models import ( +from connectivity.async_task_manager import AsyncTaskManager +from testsuites.controllers import Controller +from testsuites.test_suite.test_suite import TestSuite +from testsuites.util import wait_for_event_or_stop +from connectivity.server_models import ( ServerMessageEnvelope, MessageEnvelopeTypeEnum, ControlMessage, @@ -42,29 +39,29 @@ SERVER_PATH = os.environ.get("CERTIFICATION_SERVER_PORT", "/ws") -class ServerSideCertificationOrchestrator(ServerOrchestrator): +# class ServerSideCertificationOrchestrator(ServerOrchestrator): - async def handle_control_message(message: ControlMessage): - logger.info("Control Message: %s", message) +# async def handle_control_message(message: ControlMessage): +# logger.info("Control Message: %s", message) - async def main_loop(self): - pass +# async def main_loop(self): +# pass - async def connect_to_server(self) -> ServerConnection: - uri = f"{SERVER_PROTOCOL}://{SERVER_HOST}:{SERVER_PORT}{SERVER_PATH}" - logger.info(f"Connecting to server ({uri})...") +# async def connect_to_server(self) -> ServerConnection: +# uri = f"{SERVER_PROTOCOL}://{SERVER_HOST}:{SERVER_PORT}{SERVER_PATH}" +# logger.info(f"Connecting to server ({uri})...") - ws = await connect(uri) +# ws = await connect(uri) - server_connection = ServerConnection(ws) +# server_connection = ServerConnection(ws) - logger.info("Connected to server.") +# logger.info("Connected to server.") - return server_connection +# return server_connection - async def setup(self, connection: BaseConnection, *args, **kwargs): - server_connection = await self.connect_to_server() +# async def setup(self, connection: BaseConnection, *args, **kwargs): +# server_connection = await self.connect_to_server() - await super().setup(connection, server_connection) +# await super().setup(connection, server_connection) - logger.info("setup complete") +# logger.info("setup complete") diff --git a/s2-self-cert/src/ws_adapter.py b/s2-self-cert/src/ws_adapter.py new file mode 100644 index 0000000..19b7263 --- /dev/null +++ b/s2-self-cert/src/ws_adapter.py @@ -0,0 +1,57 @@ +from websockets.asyncio.connection import Connection as WSConnection + +from websockets.exceptions import ConnectionClosed, WebSocketException +from connectivity.connection_adapter import ( + ConnectionAdapter, + ConnectionError, + ConnectionClosed, + ConnectionProtocolError, +) + +import logging + +logger = logging.getLogger(__name__) + + +class WebSocketConnectionAdapter(ConnectionAdapter): + is_open = True + + def __init__(self, ws_connection: WSConnection): + self.ws_connection = ws_connection + + async def receive(self) -> str: + try: + message = await self.ws_connection.recv() + logger.debug("Received: %s", message) + if isinstance(message, bytes): + return message.decode("utf-8") + return message + except ConnectionClosed: + self.is_open = False + raise ConnectionClosed("Websocket is closed.") + except WebSocketException as e: + raise ConnectionProtocolError(f"Websocket protocol error: {e}") + except Exception as e: + raise ConnectionError(f"Unknown websocket error: {e}") + + async def send(self, message: str): + try: + logger.debug("Sending: %s", message) + await self.ws_connection.send(message) + except ConnectionClosed: + self.is_open = False + raise ConnectionClosed("Websocket is closed.") + except WebSocketException as e: + raise ConnectionProtocolError(f"Websocket protocol error: {e}") + except Exception as e: + raise ConnectionError(f"Unknown websocket error: {e}") + + @property + async def open(self) -> bool: + return self.is_open + + async def close(self, code: int = 1000, reason: str = ""): + try: + await self.ws_connection.close(code=code, reason=reason) + except Exception as e: + raise ConnectionError(f"Error closing websocket: {e}") diff --git a/s2-self-cert/uv.lock b/s2-self-cert/uv.lock index 718dd73..da8aabf 100644 --- a/s2-self-cert/uv.lock +++ b/s2-self-cert/uv.lock @@ -32,6 +32,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "connectivity" +version = "0.1.0" +source = { editable = "../packages/connectivity" } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "../packages/s2-python" }, + { name = "websockets", specifier = ">=13.1" }, +] + [[package]] name = "pydantic" version = "2.11.3" @@ -229,13 +248,13 @@ name = "s2-self-cert" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "s2-python" }, + { name = "connectivity" }, { name = "test-suites" }, ] [package.metadata] requires-dist = [ - { name = "s2-python", editable = "../packages/s2-python" }, + { name = "connectivity", editable = "../packages/connectivity" }, { name = "test-suites", editable = "../packages/test-suites" }, ] @@ -244,6 +263,7 @@ name = "test-suites" version = "0.1.0" source = { editable = "../packages/test-suites" } dependencies = [ + { name = "connectivity" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "s2-python" }, @@ -252,6 +272,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "connectivity", editable = "../packages/connectivity" }, { name = "pydantic", specifier = ">=2.11.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "s2-python", editable = "../packages/s2-python" }, diff --git a/uv.lock b/uv.lock index 21aee1c..9bdb650 100644 --- a/uv.lock +++ b/uv.lock @@ -4,10 +4,9 @@ requires-python = ">=3.10" [manifest] members = [ - "s2-self-cert", + "connectivity", "s2-self-certification", - "s2-server", - "s2-testing", + "test-suites", ] [[package]] @@ -19,30 +18,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload_time = "2025-03-17T00:02:54.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload_time = "2025-03-17T00:02:52.713Z" }, -] - -[[package]] -name = "certifi" -version = "2025.4.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload_time = "2025-04-26T02:12:29.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload_time = "2025-04-26T02:12:27.662Z" }, -] - [[package]] name = "click" version = "8.1.8" @@ -65,250 +40,22 @@ wheels = [ ] [[package]] -name = "dnspython" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload_time = "2024-10-05T20:14:59.362Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload_time = "2024-10-05T20:14:57.687Z" }, -] - -[[package]] -name = "email-validator" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload_time = "2024-06-20T11:30:30.034Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload_time = "2024-06-20T11:30:28.248Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload_time = "2024-07-12T22:26:00.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload_time = "2024-07-12T22:25:58.476Z" }, -] - -[[package]] -name = "fastapi" -version = "0.115.12" -source = { registry = "https://pypi.org/simple" } +name = "connectivity" +version = "0.1.0" +source = { virtual = "packages/connectivity" } dependencies = [ { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload_time = "2025-03-23T22:55:43.822Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload_time = "2025-03-23T22:55:42.101Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "email-validator" }, - { name = "fastapi-cli", extra = ["standard"] }, - { name = "httpx" }, - { name = "jinja2" }, - { name = "python-multipart" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[[package]] -name = "fastapi-cli" -version = "0.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "rich-toolkit" }, - { name = "typer" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload_time = "2024-12-15T14:28:10.028Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload_time = "2024-12-15T14:28:06.18Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "uvicorn", extra = ["standard"] }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httptools" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload_time = "2024-10-16T19:45:08.902Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload_time = "2024-10-16T19:44:06.882Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload_time = "2024-10-16T19:44:08.129Z" }, - { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload_time = "2024-10-16T19:44:09.45Z" }, - { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload_time = "2024-10-16T19:44:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload_time = "2024-10-16T19:44:13.388Z" }, - { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload_time = "2024-10-16T19:44:15.258Z" }, - { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload_time = "2024-10-16T19:44:16.54Z" }, - { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload_time = "2024-10-16T19:44:18.427Z" }, - { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload_time = "2024-10-16T19:44:19.515Z" }, - { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload_time = "2024-10-16T19:44:21.067Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload_time = "2024-10-16T19:44:22.958Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload_time = "2024-10-16T19:44:24.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload_time = "2024-10-16T19:44:26.295Z" }, - { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload_time = "2024-10-16T19:44:29.188Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload_time = "2024-10-16T19:44:30.175Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload_time = "2024-10-16T19:44:31.786Z" }, - { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload_time = "2024-10-16T19:44:32.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload_time = "2024-10-16T19:44:33.974Z" }, - { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload_time = "2024-10-16T19:44:35.111Z" }, - { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload_time = "2024-10-16T19:44:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload_time = "2024-10-16T19:44:37.357Z" }, - { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload_time = "2024-10-16T19:44:38.738Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload_time = "2024-10-16T19:44:39.818Z" }, - { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload_time = "2024-10-16T19:44:41.189Z" }, - { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload_time = "2024-10-16T19:44:42.384Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload_time = "2024-10-16T19:44:43.959Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload_time = "2024-10-16T19:44:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload_time = "2024-10-16T19:44:46.46Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload_time = "2024-10-18T15:20:51.44Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload_time = "2024-10-18T15:20:52.426Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload_time = "2024-10-18T15:20:53.578Z" }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload_time = "2024-10-18T15:20:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload_time = "2024-10-18T15:20:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload_time = "2024-10-18T15:20:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload_time = "2024-10-18T15:20:58.235Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload_time = "2024-10-18T15:20:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload_time = "2024-10-18T15:21:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload_time = "2024-10-18T15:21:01.122Z" }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload_time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload_time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload_time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload_time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload_time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload_time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload_time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload_time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload_time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload_time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload_time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload_time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload_time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload_time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload_time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload_time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload_time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload_time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload_time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload_time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, ] -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "packages/s2-python" }, + { name = "websockets", specifier = ">=13.1" }, ] [[package]] @@ -414,30 +161,12 @@ wheels = [ ] [[package]] -name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload_time = "2025-01-06T17:26:30.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload_time = "2025-01-06T17:26:25.553Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload_time = "2025-03-25T10:14:56.835Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload_time = "2025-03-25T10:14:55.034Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" +name = "pytz" +version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, ] [[package]] @@ -485,120 +214,64 @@ wheels = [ ] [[package]] -name = "rich" -version = "14.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload_time = "2025-03-30T14:15:14.23Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload_time = "2025-03-30T14:15:12.283Z" }, -] - -[[package]] -name = "rich-toolkit" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } +name = "s2-python" +version = "0.5.0" +source = { editable = "packages/s2-python" } dependencies = [ { name = "click" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/69/e328fb8986814147562b2617f22b06723f60b0c85c85afc0408b9f324a97/rich_toolkit-0.14.3.tar.gz", hash = "sha256:b72a342e52253b912681b027e94226e2deea616494420eec0b09a7219a72a0a5", size = 104469, upload_time = "2025-04-23T14:54:52.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/09/e0c7b06657ca1d4317d9e37ea5657a88a20dc3507b2ee6939ace0ff9036e/rich_toolkit-0.14.3-py3-none-any.whl", hash = "sha256:2ec72dcdf1bbb09b6a9286a4eddcd4d43369da3b22fe3f28e5a92143618b8ac6", size = 24258, upload_time = "2025-04-23T14:54:51.443Z" }, -] - -[[package]] -name = "s2-self-cert" -version = "0.1.0" -source = { virtual = "s2-self-cert" } -dependencies = [ - { name = "s2-testing" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "websockets" }, ] [package.metadata] -requires-dist = [{ name = "s2-testing", virtual = "s2-testing" }] +requires-dist = [ + { name = "click" }, + { name = "datamodel-code-generator", marker = "extra == 'development'" }, + { name = "mypy", marker = "extra == 'testing'" }, + { name = "pip-tools", marker = "extra == 'development'" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pylint", marker = "extra == 'testing'" }, + { name = "pyright", marker = "extra == 'testing'" }, + { name = "pytest", marker = "extra == 'testing'" }, + { name = "pytest-coverage", marker = "extra == 'testing'" }, + { name = "pytest-timer", marker = "extra == 'testing'" }, + { name = "pytz" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-fontawesome", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.2" }, + { name = "sphinx-tabs", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, + { name = "tox", marker = "extra == 'development'" }, + { name = "types-pytz", marker = "extra == 'testing'" }, + { name = "websockets", specifier = "~=13.1" }, +] +provides-extras = ["testing", "development", "docs"] [[package]] name = "s2-self-certification" version = "0.1.0" source = { virtual = "." } -dependencies = [ - { name = "s2-testing" }, -] - -[package.metadata] -requires-dist = [{ name = "s2-testing", virtual = "s2-testing" }] [[package]] -name = "s2-server" +name = "test-suites" version = "0.1.0" -source = { virtual = "s2-self-cert-server" } +source = { virtual = "packages/test-suites" } dependencies = [ - { name = "fastapi", extra = ["standard"] }, - { name = "python-multipart" }, - { name = "s2-testing" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "s2-python" }, + { name = "websockets" }, ] [package.metadata] requires-dist = [ - { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, - { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "s2-testing", virtual = "s2-testing" }, -] - -[[package]] -name = "s2-testing" -version = "0.1.0" -source = { virtual = "s2-testing" } - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "starlette" -version = "0.46.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload_time = "2025-04-13T13:56:17.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload_time = "2025-04-13T13:56:16.21Z" }, -] - -[[package]] -name = "typer" -version = "0.15.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload_time = "2025-04-28T21:40:59.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload_time = "2025-04-28T21:40:56.269Z" }, + { name = "pydantic", specifier = ">=2.11.3" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "s2-python", editable = "packages/s2-python" }, + { name = "websockets", specifier = ">=13.1" }, ] [[package]] @@ -622,128 +295,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload_time = "2025-02-25T17:27:57.754Z" }, ] -[[package]] -name = "uvicorn" -version = "0.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815, upload_time = "2025-04-19T06:02:50.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483, upload_time = "2025-04-19T06:02:48.42Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload_time = "2024-10-14T23:38:35.489Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload_time = "2024-10-14T23:37:20.068Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload_time = "2024-10-14T23:37:22.663Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload_time = "2024-10-14T23:37:25.129Z" }, - { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload_time = "2024-10-14T23:37:27.59Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload_time = "2024-10-14T23:37:29.385Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload_time = "2024-10-14T23:37:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload_time = "2024-10-14T23:37:33.612Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload_time = "2024-10-14T23:37:36.11Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload_time = "2024-10-14T23:37:37.683Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload_time = "2024-10-14T23:37:40.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload_time = "2024-10-14T23:37:42.839Z" }, - { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload_time = "2024-10-14T23:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload_time = "2024-10-14T23:37:47.833Z" }, - { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload_time = "2024-10-14T23:37:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload_time = "2024-10-14T23:37:51.703Z" }, - { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload_time = "2024-10-14T23:37:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload_time = "2024-10-14T23:37:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload_time = "2024-10-14T23:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload_time = "2024-10-14T23:38:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload_time = "2024-10-14T23:38:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload_time = "2024-10-14T23:38:04.711Z" }, - { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload_time = "2024-10-14T23:38:06.385Z" }, - { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload_time = "2024-10-14T23:38:08.416Z" }, - { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload_time = "2024-10-14T23:38:10.888Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload_time = "2025-04-08T10:36:26.722Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40", size = 405632, upload_time = "2025-04-08T10:34:41.832Z" }, - { url = "https://files.pythonhosted.org/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb", size = 395734, upload_time = "2025-04-08T10:34:44.236Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11", size = 455008, upload_time = "2025-04-08T10:34:45.617Z" }, - { url = "https://files.pythonhosted.org/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487", size = 459029, upload_time = "2025-04-08T10:34:46.814Z" }, - { url = "https://files.pythonhosted.org/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256", size = 488916, upload_time = "2025-04-08T10:34:48.571Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85", size = 523763, upload_time = "2025-04-08T10:34:50.268Z" }, - { url = "https://files.pythonhosted.org/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358", size = 502891, upload_time = "2025-04-08T10:34:51.419Z" }, - { url = "https://files.pythonhosted.org/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614", size = 454921, upload_time = "2025-04-08T10:34:52.67Z" }, - { url = "https://files.pythonhosted.org/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f", size = 631422, upload_time = "2025-04-08T10:34:53.985Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d", size = 625675, upload_time = "2025-04-08T10:34:55.173Z" }, - { url = "https://files.pythonhosted.org/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff", size = 277921, upload_time = "2025-04-08T10:34:56.318Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92", size = 291526, upload_time = "2025-04-08T10:34:57.95Z" }, - { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336, upload_time = "2025-04-08T10:34:59.359Z" }, - { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977, upload_time = "2025-04-08T10:35:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232, upload_time = "2025-04-08T10:35:01.698Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151, upload_time = "2025-04-08T10:35:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054, upload_time = "2025-04-08T10:35:04.561Z" }, - { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955, upload_time = "2025-04-08T10:35:05.786Z" }, - { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234, upload_time = "2025-04-08T10:35:07.187Z" }, - { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750, upload_time = "2025-04-08T10:35:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591, upload_time = "2025-04-08T10:35:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370, upload_time = "2025-04-08T10:35:12.412Z" }, - { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791, upload_time = "2025-04-08T10:35:13.719Z" }, - { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622, upload_time = "2025-04-08T10:35:15.071Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699, upload_time = "2025-04-08T10:35:16.732Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload_time = "2025-04-08T10:35:17.956Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload_time = "2025-04-08T10:35:19.202Z" }, - { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload_time = "2025-04-08T10:35:20.586Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload_time = "2025-04-08T10:35:21.87Z" }, - { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload_time = "2025-04-08T10:35:23.143Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload_time = "2025-04-08T10:35:24.702Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload_time = "2025-04-08T10:35:25.969Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload_time = "2025-04-08T10:35:27.353Z" }, - { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload_time = "2025-04-08T10:35:28.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload_time = "2025-04-08T10:35:30.42Z" }, - { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload_time = "2025-04-08T10:35:32.023Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload_time = "2025-04-08T10:35:33.225Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload_time = "2025-04-08T10:35:34.568Z" }, - { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload_time = "2025-04-08T10:35:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload_time = "2025-04-08T10:35:37.048Z" }, - { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload_time = "2025-04-08T10:35:38.357Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload_time = "2025-04-08T10:35:39.708Z" }, - { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload_time = "2025-04-08T10:35:41.469Z" }, - { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload_time = "2025-04-08T10:35:43.289Z" }, - { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload_time = "2025-04-08T10:35:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload_time = "2025-04-08T10:35:46.336Z" }, - { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload_time = "2025-04-08T10:35:48.161Z" }, - { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload_time = "2025-04-08T10:35:49.65Z" }, - { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload_time = "2025-04-08T10:35:51.093Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload_time = "2025-04-08T10:35:52.458Z" }, - { url = "https://files.pythonhosted.org/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d", size = 405947, upload_time = "2025-04-08T10:36:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034", size = 397276, upload_time = "2025-04-08T10:36:15.131Z" }, - { url = "https://files.pythonhosted.org/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965", size = 455550, upload_time = "2025-04-08T10:36:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57", size = 455542, upload_time = "2025-04-08T10:36:18.655Z" }, -] - [[package]] name = "websockets" version = "13.1" From d2174c26222e76e92ee34d1712f3a26e5995c682 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Tue, 6 May 2025 16:32:11 +0200 Subject: [PATCH 16/75] The remote one works but has a asyncio recursion error when stopping --- packages/connectivity/server_channel.py | 34 +++ .../src/connectivity/async_task_manager.py | 16 +- .../connectivity/src/connectivity/channel.py | 51 ++++- .../src/connectivity}/config.py | 0 .../src/connectivity/connection_adapter.py | 13 +- .../src/connectivity/s2_channel.py | 30 +-- .../src/connectivity/server_models.py | 32 ++- .../src/testsuites/certificate/certificate.py | 4 - .../src/testsuites/certification_executor.py | 195 ++++++++++++++++++ .../src/testsuites/controllers/controller.py | 6 +- .../src/testsuites/setup_test_orchestrator.py | 2 +- .../src/testsuites/test_executor.py | 73 +++++-- .../src/testsuites/test_suite/__init__.py | 1 + .../testsuites/test_suite/base_test_case.py | 2 +- .../testsuites/test_suite/frbc_test_cases.py | 2 +- .../testsuites/test_suite/pebc_test_cases.py | 2 +- .../src/testsuites/test_suite/test_suite.py | 2 +- packages/test-suites/src/testsuites/util.py | 2 + s2-self-cert-server/src/main.py | 156 ++++++++++---- s2-self-cert-server/src/ws_adapter.py | 2 +- s2-self-cert-server/uv.lock | 2 + s2-self-cert/src/config.yaml | 2 +- s2-self-cert/src/main.py | 67 +----- s2-self-cert/src/server.py | 11 +- .../server_side_certification_orchestrator.py | 58 ++++++ s2-self-cert/src/ws_adapter.py | 2 +- 26 files changed, 588 insertions(+), 179 deletions(-) create mode 100644 packages/connectivity/server_channel.py rename packages/{test-suites/src/testsuites => connectivity/src/connectivity}/config.py (100%) create mode 100644 packages/test-suites/src/testsuites/certification_executor.py diff --git a/packages/connectivity/server_channel.py b/packages/connectivity/server_channel.py new file mode 100644 index 0000000..5f06e02 --- /dev/null +++ b/packages/connectivity/server_channel.py @@ -0,0 +1,34 @@ +import json +from typing import Optional +from connectivity.channel import Channel +from connectivity.server_models import ( + ControlMessageEnvelope, + LogMessageEnvelope, + S2MessageEnvelope, + ServerMessageEnvelope, + MessageEnvelopeTypeEnum, +) + + +class ServerChannel(Channel[ServerMessageEnvelope, str]): + + async def process_received_message(self, message: str) -> ServerMessageEnvelope: + msg_json = json.loads(message) + + envelope: Optional[ServerMessageEnvelope] + match msg_json["message_type"]: + case MessageEnvelopeTypeEnum.CONTROL: + envelope = ControlMessageEnvelope.model_validate_json(message) + case MessageEnvelopeTypeEnum.S2: + envelope = S2MessageEnvelope.model_validate_json(message) + case MessageEnvelopeTypeEnum.LOG: + envelope = LogMessageEnvelope.model_validate_json(message) + + if envelope is None: + raise ValueError("Invalid Envelope.") + + return envelope + + def send(self, message: ServerMessageEnvelope): + str_msg = message.model_dump_json() + return super().send(str_msg) diff --git a/packages/connectivity/src/connectivity/async_task_manager.py b/packages/connectivity/src/connectivity/async_task_manager.py index c6f1763..2989c97 100644 --- a/packages/connectivity/src/connectivity/async_task_manager.py +++ b/packages/connectivity/src/connectivity/async_task_manager.py @@ -1,7 +1,7 @@ import asyncio import logging -from typing import Coroutine +from typing import Coroutine, Set logger = logging.getLogger(__name__) @@ -21,7 +21,7 @@ class AsyncTaskManager: """ - _tasks = set() + _tasks: Set[asyncio.Task] = set() _stop_event: asyncio.Event @@ -37,22 +37,26 @@ async def task_wrapper(self, task: Coroutine, stop_on_complete): TODO: Maybe handle the exceptions in a better way... """ + task_name = getattr(task, '__name__', str(task)) try: await task if stop_on_complete: - logger.debug("Task execution complete. Stopping.") + logger.debug("Task execution complete. Stopping. %s", task_name) await self.stop() + except asyncio.CancelledError: + logger.info("Task %s was cancelled.", task_name) except: - logger.exception("Exception in task!") + logger.exception("Exception in task %s!", task_name) await self.stop() def create_task(self, task: Coroutine, stop_on_complete=False): self._tasks.add(asyncio.create_task(self.task_wrapper(task, stop_on_complete))) - async def setup(self): + async def setup(self, *args, **kwargs): self._stop_event = asyncio.Event() - async def cleanup(self): + async def cleanup(self, *args, **kwargs): + logger.info("Cleanup of %s", self.__class__.__name__) for task in self._tasks: task.cancel() diff --git a/packages/connectivity/src/connectivity/channel.py b/packages/connectivity/src/connectivity/channel.py index c871861..e1601ab 100644 --- a/packages/connectivity/src/connectivity/channel.py +++ b/packages/connectivity/src/connectivity/channel.py @@ -1,6 +1,15 @@ +import abc import asyncio +import json import logging +from typing import Generic, TypeVar + +from connectivity.server_models import ( + MessageEnvelopeTypeEnum, + ServerMessageEnvelope, + parse_envelope, +) from .connection_adapter import ( ConnectionAdapter, @@ -17,30 +26,33 @@ class ChannelSendError: pass -class Channel: - connection: ConnectionAdapter - message_queue: asyncio.Queue +T = TypeVar("T") +RawT = TypeVar("RawT") + + +class Channel(Generic[T, RawT], abc.ABC): + connection: ConnectionAdapter[RawT] + message_queue: asyncio.Queue[T] _stop_event: asyncio.Event - def __init__(self, connection: ConnectionAdapter) -> None: + def __init__(self, connection: ConnectionAdapter[RawT]) -> None: self.connection = connection self._stop_event = asyncio.Event() self.message_queue = asyncio.Queue() - async def get_next_message(self) -> str: - """Pops the next message off the queue to be processed.""" + async def get_next_message(self) -> T: return await self.message_queue.get() - async def send(self, message: str): - return await self.connection.send(message) + async def send(self, message: T): + return await self.connection.send(message) # type: ignore - async def receive(self) -> str: + async def receive(self) -> RawT: return await self.connection.receive() - async def process_received_message(self, message: str): - await self.message_queue.put(message) + async def process_received_message(self, message: RawT): + await self.message_queue.put(message) # type: ignore async def receive_messages(self): logger.debug("WebSocket Channel has started to receive messages.") @@ -70,3 +82,20 @@ async def stop(self): if await self.connection.open: await self.connection.close() + + +class BaseChannel(Channel[str, str]): + """A str, str channel.""" + + +class ServerWebsocketConnectionChannel(Channel[ServerMessageEnvelope, str]): + async def send(self, message: ServerMessageEnvelope): + str_msg: str = message.model_dump_json() + + return await self.connection.send(str_msg) + + async def process_received_message(self, str_msg: str): + + message = parse_envelope(str_msg) + + await self.message_queue.put(message) diff --git a/packages/test-suites/src/testsuites/config.py b/packages/connectivity/src/connectivity/config.py similarity index 100% rename from packages/test-suites/src/testsuites/config.py rename to packages/connectivity/src/connectivity/config.py diff --git a/packages/connectivity/src/connectivity/connection_adapter.py b/packages/connectivity/src/connectivity/connection_adapter.py index 72763ce..ba41105 100644 --- a/packages/connectivity/src/connectivity/connection_adapter.py +++ b/packages/connectivity/src/connectivity/connection_adapter.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import Generic, TypeVar import websockets @@ -20,18 +21,18 @@ class ConnectionProtocolError(ConnectionError): """Raised for protocol errors.""" -class ConnectionError(ConnectionError): - """Raised for connection errors.""" +# The generic type that the messages sent and received should have. +T = TypeVar("T") -class ConnectionAdapter(abc.ABC): +class ConnectionAdapter(Generic[T], abc.ABC): """ On server we use FastAPI's websocket and on local we use the websockets package. - These have slightly different interfaces so this just wraps them. + These have slightly different interfaces so this adapter allows wrapping of the different websocket implementations. """ @abc.abstractmethod - async def receive(self) -> str: + async def receive(self) -> T: """ Receive a message from the websocket. Raises: @@ -41,7 +42,7 @@ async def receive(self) -> str: pass @abc.abstractmethod - async def send(self, message: str): + async def send(self, message: T): """ Send a message over the websocket. Raises: diff --git a/packages/connectivity/src/connectivity/s2_channel.py b/packages/connectivity/src/connectivity/s2_channel.py index b6c1d4e..9fb5cbf 100644 --- a/packages/connectivity/src/connectivity/s2_channel.py +++ b/packages/connectivity/src/connectivity/s2_channel.py @@ -49,7 +49,7 @@ async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: await self.run_async() -class S2Channel(Channel): +class S2Channel(Channel[S2Message, str]): s2_parser: S2Parser @@ -61,12 +61,12 @@ def __init__(self, connection: ConnectionAdapter) -> None: self.reception_status_awaiter = ReceptionStatusAwaiter() self.s2_parser = S2Parser() - self.message_queue: asyncio.Queue[S2Message] = asyncio.Queue() + self.message_queue = asyncio.Queue() - async def _send_and_forget(self, s2_msg: S2Message) -> None: - message = s2_msg.model_dump_json() + async def send(self, message: S2Message): + str_msg = message.model_dump_json() - await self.connection.send(message) + return await self.connection.send(str_msg) async def respond_with_reception_status( self, @@ -82,7 +82,7 @@ async def respond_with_reception_status( status=status, diagnostic_label=diagnostic_label, ) - await self._send_and_forget(msg) + await self.send(msg) async def send_msg_and_await_reception_status( self, @@ -90,7 +90,7 @@ async def send_msg_and_await_reception_status( timeout_reception_status: float = 5, raise_on_error: bool = True, ) -> ReceptionStatus: - await self._send_and_forget(s2_msg) + await self.send(s2_msg) logger.debug( "Waiting for ReceptionStatus for %s %s seconds", s2_msg.message_id, # type: ignore[attr-defined, union-attr] @@ -118,7 +118,7 @@ async def process_received_message(self, message: str) -> S2Message | None: try: s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) except json.JSONDecodeError: - await self._send_and_forget( + await self.send( ReceptionStatus( subject_message_id=uuid.UUID( "00000000-0000-0000-0000-000000000000" @@ -161,17 +161,3 @@ async def process_received_message(self, message: str) -> S2Message | None: await self.reception_status_awaiter.receive_reception_status(s2_msg) else: await self.message_queue.put(s2_msg) - - -class ChannelConnection(ConnectionAdapter): - - def __init__(self, channel: Channel): - self.channel = channel - - async def send(self, message: str): - await self.channel.send(message) - - async def receive(self) -> str: - message = await self.channel.get_next_message() - - return message diff --git a/packages/connectivity/src/connectivity/server_models.py b/packages/connectivity/src/connectivity/server_models.py index 9aa8dba..78285ba 100644 --- a/packages/connectivity/src/connectivity/server_models.py +++ b/packages/connectivity/src/connectivity/server_models.py @@ -1,9 +1,9 @@ from enum import Enum -from typing import Union +import json +from typing import Type, Union from pydantic import BaseModel -from s2python.message import S2Message -from testsuites.config import Config +from connectivity.config import Config class ServerMessageValidationException(Exception): @@ -34,6 +34,10 @@ class ConfigControlMessage(BaseModel): ControlMessage = Union[ConfigControlMessage] +class BaseEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum + + class ControlMessageEnvelope(BaseModel): message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CONTROL message: ControlMessage @@ -50,4 +54,24 @@ class S2MessageEnvelope(BaseModel): message: str -ServerMessageEnvelope = Union[ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope] +ServerMessageEnvelope = Union[ + ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope +] + +envelope_types_dict = { + MessageEnvelopeTypeEnum.CONTROL: ControlMessageEnvelope, + MessageEnvelopeTypeEnum.S2: S2MessageEnvelope, + MessageEnvelopeTypeEnum.LOG: LogMessageEnvelope, +} + + +def parse_envelope(str_msg: str) -> ServerMessageEnvelope: + msg_dict = json.loads(str_msg) + + try: + msg_type = MessageEnvelopeTypeEnum[msg_dict["message_type"]] + envelope_type: Type[ServerMessageEnvelope] = envelope_types_dict[msg_type] + except KeyError: + raise ValueError("Invalid Message Envelope Type.") + + return envelope_type.model_validate_json(str_msg) diff --git a/packages/test-suites/src/testsuites/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py index 1b4aa8b..8bea352 100644 --- a/packages/test-suites/src/testsuites/certificate/certificate.py +++ b/packages/test-suites/src/testsuites/certificate/certificate.py @@ -1,7 +1,5 @@ -from email import message import logging from datetime import datetime -import json from typing import List, Optional, Type from enum import Enum @@ -10,7 +8,6 @@ from s2python.message import S2Message - logger = logging.getLogger(__name__) @@ -44,7 +41,6 @@ def add_parameter( param = ComplianceParameter(name=name, status=status) elif param is None: raise ValueError("Either the param must be set or name and status.") - self.parameters.append(param) diff --git a/packages/test-suites/src/testsuites/certification_executor.py b/packages/test-suites/src/testsuites/certification_executor.py new file mode 100644 index 0000000..be4c485 --- /dev/null +++ b/packages/test-suites/src/testsuites/certification_executor.py @@ -0,0 +1,195 @@ +import asyncio +from typing import ( + Callable, + Dict, + Generic, + Optional, + Type, + TypeVar, + Union, +) +from connectivity.async_task_manager import AsyncTaskManager + +from connectivity.config import Config +from connectivity.channel import Channel +from s2python.message import S2Message +from connectivity.server_models import ( + ServerMessageEnvelope, + S2MessageEnvelope, + LogMessage, + LogMessageEnvelope, + ControlMessage, + ControlMessageEnvelope, +) + +import logging + +from testsuites.certificate.certificate import ComplianceReport + +logger = logging.getLogger(__name__) + +T = TypeVar("T") # Message type +H = TypeVar("H") # Channel type + + +class MessageHandlerNotFoundError(Exception): + pass + + +class MessageHandler(Generic[T]): + handlers: Dict[Type[T], Callable] + _accept_unhandled_messages: bool = True + + def __init__(self): + self.handlers: Dict[Type[T], Callable] = {} + + def add_handler(self, msg_type: Type[T], handler: Callable): + self.handlers[msg_type] = handler + + async def handle_message(self, message: T, *args, **kwargs): + try: + handler = self.handlers[type(message)] + result = await handler(message, *args, **kwargs) # type: ignore + return result + except KeyError: + if self._accept_unhandled_messages: + return + else: + raise MessageHandlerNotFoundError( + f"Command does not exist for message type '{getattr(message, 'message_type', type(message))}'" + ) + + +class ControlMessageHandler(MessageHandler[ControlMessage]): + + def __init__(self): + super().__init__() + + +class AbstractCertificationExecutor(MessageHandler[ControlMessage], AsyncTaskManager): + + s2_channel: Channel[str, str] + server_channel: Channel[ServerMessageEnvelope, str] + + config: Optional[Config] + + report: ComplianceReport + + def __init__(self): + super().__init__() + + self.handlers: Dict[Type[ControlMessage], Callable] = {} + + async def main_loop(self): + logger.info("Starting Main Loop.") + if self.s2_channel is None or self.server_channel: + raise ValueError("Channel not set.") + + async def handle_control_message(self, message: ControlMessage): + logger.info("Control message: %s", message) + + async def process_server_message(self, message: ServerMessageEnvelope): + if type(message) == S2MessageEnvelope: + await self.s2_channel.send(message.message) + elif type(message) == LogMessageEnvelope: + logger.info(message.message.content) + elif type(message) == ControlMessageEnvelope: + await self.handle_control_message(message.message) + + async def process_rm_message(self, message: str): + envelope = S2MessageEnvelope(message=message) + + await self.server_channel.send(envelope) + + async def process_received_message( + self, get_next_message: Callable, process_message: Callable + ): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + message = await asyncio.wait_for(get_next_message(), timeout=1.0) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + await process_message(message) + except asyncio.CancelledError: + logger.info("Message Channel cancelled.") + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + finally: + await self.stop() + + async def send_server_control_message(self, message: ControlMessage): + envelope = ControlMessageEnvelope(message=message) + await self.server_channel.send(envelope) + + async def send_server_s2_message(self, message: Union[str, S2Message]): + if isinstance(message, S2Message): + msg_str = message.model_dump_json() + elif isinstance(message, str): + msg_str = message + else: + raise TypeError( + f"message must be str or S2Message, got {type(message).__name__}" + ) + + envelope = S2MessageEnvelope(message=msg_str) + await self.server_channel.send(envelope) + + async def send_s2_message(self, message: str): + await self.s2_channel.send(message) + + async def send_server_log_message(self, message: LogMessage): + envelope = LogMessageEnvelope(message=message) + await self.server_channel.send(envelope) + + async def get_next_s2_channel_message(self): + # Done like this so the server side can override this. + return await self.s2_channel.get_next_message() + + def get_compliance_report(self): + return self.report + + async def setup( + self, + s2_channel: Channel[str, str], + server_channel: Channel[ServerMessageEnvelope, str], + *args, + **kwargs, + ): + await super().setup() + + self.s2_channel = s2_channel + self.server_channel = server_channel + + self.create_task(self.main_loop(), False) + + self.create_task(self.s2_channel.run(), False) + self.create_task(self.server_channel.run(), False) + + self.create_task( + self.process_received_message( + self.get_next_s2_channel_message, self.process_rm_message + ), + True, + ) + self.create_task( + self.process_received_message( + server_channel.get_next_message, self.process_server_message + ), + True, + ) + + async def run(self, s2_channel, *args, **kwargs): + self.running = True + + await self.setup(s2_channel, *args, **kwargs) + + await self._stop_event.wait() + + await self.cleanup(*args, **kwargs) + + self.running = False diff --git a/packages/test-suites/src/testsuites/controllers/controller.py b/packages/test-suites/src/testsuites/controllers/controller.py index 7a7ba4a..3be83c4 100644 --- a/packages/test-suites/src/testsuites/controllers/controller.py +++ b/packages/test-suites/src/testsuites/controllers/controller.py @@ -44,7 +44,9 @@ def __init__(self): self.add_handler(Handshake, self.handle_handshake) self.add_handler(ResourceManagerDetails, self.handle_rm_details) - def handle_message(self, message: S2Message, channel: "S2Channel"): + def handle_message(self, message: S2Message, channel: Optional[S2Channel]): + if channel is None: + raise ValueError("Channel must be provided.") try: result = super().handle_message(message, channel) except: @@ -135,7 +137,7 @@ async def handle_rm_details( await send_okay self._resource_manager_details_received.set() - + async def wait_until_rm_details_received(self): await self._resource_manager_details_received.wait() diff --git a/packages/test-suites/src/testsuites/setup_test_orchestrator.py b/packages/test-suites/src/testsuites/setup_test_orchestrator.py index d5cbf8b..79530f8 100644 --- a/packages/test-suites/src/testsuites/setup_test_orchestrator.py +++ b/packages/test-suites/src/testsuites/setup_test_orchestrator.py @@ -12,7 +12,7 @@ from testsuites.orchestrator import IntegrationTestOrchestrator, Orchestrator from testsuites.test_suite import PEBCTestCase, TestSuiteBuilder from testsuites.test_suite.frbc_test_cases import FRBCTestCase -from testsuites.config import Config +from connectivity.config import Config def create_controllers_dict_with_config( diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 6d9220d..095fd1f 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -1,34 +1,34 @@ import asyncio +from datetime import datetime import logging -import uuid -from types import CoroutineType -from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type +from typing import Dict, Optional -from testsuites.certificate.certificate import ComplianceReport from s2python.common import ControlType as ProtocolControlType -from s2python.common import ( - EnergyManagementRole, - Handshake, - HandshakeResponse, - ResourceManagerDetails, - SelectControlType, -) from s2python.message import S2Message -from s2python.s2_validation_error import S2ValidationError -from testsuites.controllers import Controller -from testsuites.test_suite.test_suite import TestSuite -from testsuites.util import wait_for_event_or_stop +from testsuites.certificate.certificate import ComplianceReport +from testsuites.test_suite.test_suite import TestSuite, TestSuiteBuilder +from testsuites.test_suite import FRBCTestCase, PEBCTestCase +from testsuites.controllers import ( + Controller, + BaseController, + PEBCController, + FRBCController, +) + + from connectivity.async_task_manager import AsyncTaskManager -from connectivity.channel import Channel -from connectivity.s2_channel import S2Channel, SendOkay +from connectivity.s2_channel import S2Channel +from connectivity.config import Config + logger = logging.getLogger(__name__) class IntegrationTestExecutor(AsyncTaskManager): + # The channel which connects to the S2 RM. channel: Optional["S2Channel"] = None controller: Controller @@ -36,6 +36,8 @@ class IntegrationTestExecutor(AsyncTaskManager): test_suite: TestSuite + report: ComplianceReport + def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], @@ -164,14 +166,12 @@ async def setup(self, channel: S2Channel, *args, **kwargs): self._handshake_complete = asyncio.Event() - # self.create_task(self.main_loop(), True) self.create_task(self.main_loop(), True) self.create_task(self.channel.run(), True) self.create_task(self.process_received_messages(), True) async def cleanup(self, *args, **kwargs): - logger.info("Cleanup of Orchestrator") await super().cleanup() if self.channel is not None: @@ -187,3 +187,38 @@ async def run(self, *args, **kwargs): await self.cleanup(*args, **kwargs) self.running = False + + +def create_controllers_dict_with_config( + config: Config, +) -> Dict[ProtocolControlType, Controller]: + controllers: Dict[ProtocolControlType, Controller] = {} + + controllers[ProtocolControlType.NO_SELECTION] = BaseController() + + if config.control_types.frbc and config.control_types.frbc.enabled: + controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() + + if config.control_types.pebc and config.control_types.pebc.enabled: + controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = PEBCController() + + return controllers + + +def create_test_executor(config: Config) -> IntegrationTestExecutor: + report = ComplianceReport(timestamp=datetime.now()) + + controllers = create_controllers_dict_with_config(config) + + test_suite = ( + TestSuiteBuilder(config.control_types, report) + .with_test_case(PEBCTestCase) + .with_test_case(FRBCTestCase) + .build() + ) + + executor = IntegrationTestExecutor( + available_control_types=controllers, test_suite=test_suite, report=report + ) + + return executor diff --git a/packages/test-suites/src/testsuites/test_suite/__init__.py b/packages/test-suites/src/testsuites/test_suite/__init__.py index 8416f3b..f27ddf8 100644 --- a/packages/test-suites/src/testsuites/test_suite/__init__.py +++ b/packages/test-suites/src/testsuites/test_suite/__init__.py @@ -1,2 +1,3 @@ from .pebc_test_cases import PEBCTestCase +from .frbc_test_cases import FRBCTestCase from .test_suite import S2TestCase, TestSuite, TestSuiteBuilder diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index a200194..11e4493 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -3,7 +3,6 @@ ComplianceReport, ComplianceStatus, ) -from ..config import BaseTestConfig from ..controllers.controller import BaseController from ..test_suite.test_suite import S2TestCase @@ -13,6 +12,7 @@ ControlType as ProtocolControlType, ResourceManagerDetails, ) +from connectivity.config import BaseTestConfig from connectivity.s2_channel import S2Channel class NoSelectionTestCase(S2TestCase): diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py index ad0bcdd..a6d3bb1 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py @@ -9,7 +9,7 @@ ComplianceReport, ComplianceStatus, ) -from testsuites.config import FRBCTestConfig, PEBCTestConfig +from connectivity.config import FRBCTestConfig, PEBCTestConfig from testsuites.controllers.frbc_controller import FRBCController from s2python.common import PowerMeasurement, ControlType as ProtocolControlType from s2python.frbc import ( diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py index 02b7ac4..30aef20 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py @@ -8,7 +8,7 @@ ComplianceReport, ComplianceStatus, ) -from testsuites.config import BaseTestConfig, PEBCTestConfig +from connectivity.config import BaseTestConfig, PEBCTestConfig from testsuites.controllers.controller import Controller from testsuites.controllers.pebc_controller import PEBCController from s2python.common import ( diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index f0daf40..7af44a0 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -11,7 +11,7 @@ ComplianceReport, ComplianceStatus, ) -from testsuites.config import BaseTestConfig, ControlTypeTestConfig +from connectivity.config import BaseTestConfig, ControlTypeTestConfig from testsuites.controllers.controller import Controller from s2python.common import ControlType as ProtocolControlType from s2python.message import S2Message diff --git a/packages/test-suites/src/testsuites/util.py b/packages/test-suites/src/testsuites/util.py index b8cb2d7..b8d7242 100644 --- a/packages/test-suites/src/testsuites/util.py +++ b/packages/test-suites/src/testsuites/util.py @@ -1,5 +1,7 @@ import asyncio +from datetime import datetime import logging +from typing import Dict logger = logging.getLogger(__name__) diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index 8b1c96a..dda0238 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -1,30 +1,36 @@ +from fastapi import FastAPI import asyncio from enum import Enum import json import logging import logging.config -from typing import Callable, Dict, Optional +from connectivity.connection_adapter import ConnectionAdapter from fastapi import UploadFile, WebSocket from fastapi import WebSocketDisconnect +from testsuites.certification_executor import AbstractCertificationExecutor +from connectivity.config import Config +from connectivity.channel import ServerWebsocketConnectionChannel +from connectivity.s2_channel import S2Channel +from testsuites.test_executor import IntegrationTestExecutor, create_test_executor -from fastapi import FastAPI -from .log import LOGGING_CONFIG -from s2python.message import S2Message -from testsuites.server_models import ( - ConfigControlMessage, - ControlMessageType, +from connectivity.server_models import ( ServerMessageEnvelope, + S2MessageEnvelope, + LogMessage, + LogMessageEnvelope, ControlMessage, + ConfigControlMessage, ControlMessageEnvelope, + ControlMessageType, MessageEnvelopeTypeEnum, - S2MessageEnvelope, - ServerMessageValidationException, + BaseEnvelope, ) -from testsuites.orchestrator import ServerOrchestrator -from testsuites.connection import ServerConnection -from .rm_connection import ServerRMConnection -from testsuites.config import Config +from connectivity.channel import Channel + + +from .log import LOGGING_CONFIG +from .ws_adapter import FastAPIWebSocketAdapter logging.config.dictConfig(LOGGING_CONFIG) @@ -42,41 +48,119 @@ def verify_certificate(file: UploadFile): return {"valid": False} -class WebSocketAdapter: - def __init__(self, websocket: WebSocket): - self.websocket = websocket +class MockConnectionAdapter(ConnectionAdapter): + + incoming_queue: asyncio.Queue + outgoing_queue: asyncio.Queue + + def __init__(self) -> None: + self.incoming_queue = asyncio.Queue() + self.outgoing_queue = asyncio.Queue() + + async def receive(self) -> str: + return await self.incoming_queue.get() async def send(self, message: str): - await self.websocket.send_text(message) + return await self.outgoing_queue.put(message) - async def recv(self): - try: - msg = await self.websocket.receive_text() - return msg - except WebSocketDisconnect: - logger.exception("WebSocket disconnected.") - return None + async def get_next_outgoing(self) -> str: + return await self.outgoing_queue.get() + + async def put_incoming(self, message: str): + return await self.incoming_queue.put(message) + + @property + async def open(self) -> bool: + return True + + async def close(self, *args, **kwargs): + pass + + +class MockChannel(Channel[str, str]): + connection: MockConnectionAdapter + + async def send(self, message: str): + logger.info("S2 Message: %s", message) + await self.connection.put_incoming(message) + + async def receive(self) -> str: + return await self.connection.get_next_outgoing() -class CertificationServerOrchestrator(ServerOrchestrator): - connection: ServerRMConnection +from testsuites.certificate.certificate import ComplianceReport - async def handle_control_message(message): - logger.info("Control Message: %s", message) + +class ServerSideCertificationExecutor(AbstractCertificationExecutor): + s2_connection_adapter: ConnectionAdapter + config: Config + + _config_received: asyncio.Event + + test_executor: IntegrationTestExecutor + + report: ComplianceReport + + def __init__(self): + super().__init__() + + self.add_handler(ConfigControlMessage, self.handle_config_message) + + async def handle_config_message(self, message: ConfigControlMessage): + self.config = message.config + + self._config_received.set() + + async def handle_control_message(self, message: ControlMessage): + logger.debug("Control Message: %s", message) + await self.handle_message(message) async def main_loop(self): - pass + + await self._config_received.wait() + + logger.debug("Config Received.") + + self.report = ComplianceReport() + + self.test_executor = create_test_executor(self.config) + + s2_channel = S2Channel(self.s2_connection_adapter) + + try: + await self.test_executor.run(s2_channel) + except: + logger.exception("Error in test executor.") + + logger.info("Test suite complete!") + + report = self.test_executor.report + + logger.info("Report: %s", report) + await asyncio.sleep(10) + logger.info("Main Loop Complete.") + + async def setup( + self, server_channel: Channel[ServerMessageEnvelope, str], *args, **kwargs + ): + self._config_received = asyncio.Event() + + self.s2_connection_adapter = MockConnectionAdapter() + s2_channel_mock = MockChannel(self.s2_connection_adapter) + + return await super().setup(s2_channel_mock, server_channel, *args, **kwargs) @app.websocket("/ws") async def connect_tester(websocket: WebSocket): await websocket.accept() - wrapper = WebSocketAdapter(websocket) - server = CertificationServerOrchestrator() - client_connection = ServerConnection(wrapper) - # client_connection = StarlettWebsocketServerConnection(websocket) - connection = ServerRMConnection(client_connection) + # The wrapper around the FastAPI websocket for consistency and reusability + connection = FastAPIWebSocketAdapter(websocket) + + # The communication channel used to send and receive messages to the client via the above connection. + server_channel = ServerWebsocketConnectionChannel(connection) - await server.run(connection, client_connection) + # The central part! This is what coordinated the execution and the test suit and certification. + executor = ServerSideCertificationExecutor() - # await websocket.close() + await executor.run(server_channel) diff --git a/s2-self-cert-server/src/ws_adapter.py b/s2-self-cert-server/src/ws_adapter.py index ca8204b..bc2f29d 100644 --- a/s2-self-cert-server/src/ws_adapter.py +++ b/s2-self-cert-server/src/ws_adapter.py @@ -6,7 +6,7 @@ ) -class FastAPIWebSocketAdapter(ConnectionAdapter): +class FastAPIWebSocketAdapter(ConnectionAdapter[str]): def __init__(self, websocket: WebSocket): self.websocket = websocket diff --git a/s2-self-cert-server/uv.lock b/s2-self-cert-server/uv.lock index e651fc3..85d40a1 100644 --- a/s2-self-cert-server/uv.lock +++ b/s2-self-cert-server/uv.lock @@ -623,6 +623,7 @@ name = "test-suites" version = "0.1.0" source = { editable = "../packages/test-suites" } dependencies = [ + { name = "connectivity" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "s2-python" }, @@ -631,6 +632,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "connectivity", editable = "../packages/connectivity" }, { name = "pydantic", specifier = ">=2.11.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "s2-python", editable = "../packages/s2-python" }, diff --git a/s2-self-cert/src/config.yaml b/s2-self-cert/src/config.yaml index e9c145e..563a27d 100644 --- a/s2-self-cert/src/config.yaml +++ b/s2-self-cert/src/config.yaml @@ -2,7 +2,7 @@ device_details: name: Some Device manufacturer: ACME -mode: testing +mode: certification control_types: no_selection: null pebc: diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index f70c8dc..863805d 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -4,27 +4,15 @@ import argparse import asyncio -from datetime import datetime import logging import logging.config -from typing import Dict - -from s2python.common import ControlType as ProtocolControlType -from testsuites.certificate.certificate import ComplianceReport -from testsuites.config import Config, load_config -from testsuites.controllers import ( - Controller, - BaseController, - PEBCController, - FRBCController, -) -from testsuites.test_executor import IntegrationTestExecutor -from testsuites.test_suite import PEBCTestCase, TestSuiteBuilder -from testsuites.test_suite.frbc_test_cases import FRBCTestCase + +from connectivity.config import Config, load_config from log import LOGGING_CONFIG from server import S2Server - -# from server_side_certification_orchestrator import ServerSideCertificationOrchestrator +from server_side_certification_orchestrator import CertificationTestExecutor +from testsuites.certification_executor import AbstractCertificationExecutor +from testsuites.test_executor import create_test_executor logging.config.dictConfig(LOGGING_CONFIG) @@ -34,44 +22,8 @@ parser.add_argument("config") -def create_controllers_dict_with_config( - config: Config, report: ComplianceReport -) -> Dict[ProtocolControlType, Controller]: - controllers: Dict[ProtocolControlType, Controller] = {} - - controllers[ProtocolControlType.NO_SELECTION] = BaseController() - - if config.control_types.frbc and config.control_types.frbc.enabled: - controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() - - if config.control_types.pebc and config.control_types.pebc.enabled: - controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = PEBCController() - - return controllers - - -def create_local_test_orchestrator(config: Config) -> IntegrationTestExecutor: - report = ComplianceReport(timestamp=datetime.now()) - - controllers = create_controllers_dict_with_config(config, report) - - test_suite = ( - TestSuiteBuilder(config.control_types, report) - .with_test_case(PEBCTestCase) - .with_test_case(FRBCTestCase) - .build() - ) - - orchestrator = IntegrationTestExecutor( - available_control_types=controllers, test_suite=test_suite, report=report - ) - - return orchestrator - - -# def create_server_certification_orchestrator(config: Config) -> IntegrationTestExecutor: - -# return ServerSideCertificationOrchestrator() +def create_server_certification_executor(config: Config) -> CertificationTestExecutor: + return CertificationTestExecutor(config) async def main(): @@ -81,10 +33,9 @@ async def main(): config: Config = load_config(args.config) if config.mode == "certification": - pass - # orchestrator = create_server_certification_orchestrator(config) + test_executor = create_server_certification_executor(config) elif config.mode == "testing": - test_executor = create_local_test_orchestrator(config) + test_executor = create_test_executor(config) else: raise ValueError("Invalid mode.") diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index 8acb8b6..e00eb81 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -7,15 +7,17 @@ from websockets.asyncio.connection import Connection as WSConnection from websockets.asyncio.server import serve as ws_serve from ws_adapter import WebSocketConnectionAdapter -from connectivity.channel import Channel +from connectivity.channel import Channel, BaseChannel from connectivity.s2_channel import S2Channel +from testsuites.certification_executor import AbstractCertificationExecutor + logger = logging.getLogger(__name__) class S2Server: # Receives incoming S2 Resource Manager WebSocket Connections - executor: IntegrationTestExecutor + executor: AbstractCertificationExecutor mode: Literal["testing", "certification"] _exit_event: asyncio.Event @@ -45,7 +47,10 @@ async def handle_incoming_connection(self, websocket: WSConnection): logger.info("Connection to RM opened.") connection = WebSocketConnectionAdapter(websocket) - s2_channel = S2Channel(connection) + if self.mode == "testing": + s2_channel = S2Channel(connection) + else: + s2_channel = BaseChannel(connection) await self.executor.run(s2_channel) diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py index 6e63cb5..839cdbd 100644 --- a/s2-self-cert/src/server_side_certification_orchestrator.py +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -26,10 +26,34 @@ from testsuites.util import wait_for_event_or_stop from connectivity.server_models import ( ServerMessageEnvelope, + S2MessageEnvelope, + ControlMessageEnvelope, + LogMessageEnvelope, MessageEnvelopeTypeEnum, ControlMessage, ) from websockets.asyncio.client import connect +from connectivity.channel import Channel, ServerWebsocketConnectionChannel +from testsuites.certification_executor import AbstractCertificationExecutor +from connectivity.s2_channel import S2Channel +from connectivity.config import Config +from connectivity.connection_adapter import ConnectionAdapter +from ws_adapter import WebSocketConnectionAdapter + + +from connectivity.server_models import ( + ServerMessageEnvelope, + S2MessageEnvelope, + LogMessage, + LogMessageEnvelope, + ControlMessage, + ConfigControlMessage, + ControlMessageEnvelope, + ControlMessageType, + MessageEnvelopeTypeEnum, + BaseEnvelope, +) + logger = logging.getLogger(__name__) @@ -39,6 +63,40 @@ SERVER_PATH = os.environ.get("CERTIFICATION_SERVER_PORT", "/ws") +class CertificationTestExecutor(AbstractCertificationExecutor): + config: Config + + def __init__(self, config: Config): + super().__init__() + + self.config = config + + async def connect_to_server(self) -> Channel[ServerMessageEnvelope, str]: + uri = f"{SERVER_PROTOCOL}://{SERVER_HOST}:{SERVER_PORT}{SERVER_PATH}" + logger.info(f"Connecting to server ({uri})...") + + ws = await connect(uri) + + connection = WebSocketConnectionAdapter(ws) + channel = ServerWebsocketConnectionChannel(connection) + + logger.info("Connected to server.") + + return channel + + async def main_loop(self): + + await self.send_server_control_message(ConfigControlMessage(config=self.config)) + + logger.info("Config sent.") + + async def setup(self, s2_channel: Channel[str, str], *args, **kwargs): + + server_channel = await self.connect_to_server() + + await super().setup(s2_channel, server_channel) + + # class ServerSideCertificationOrchestrator(ServerOrchestrator): # async def handle_control_message(message: ControlMessage): diff --git a/s2-self-cert/src/ws_adapter.py b/s2-self-cert/src/ws_adapter.py index 19b7263..7696c23 100644 --- a/s2-self-cert/src/ws_adapter.py +++ b/s2-self-cert/src/ws_adapter.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -class WebSocketConnectionAdapter(ConnectionAdapter): +class WebSocketConnectionAdapter(ConnectionAdapter[str]): is_open = True def __init__(self, ws_connection: WSConnection): From 5d065be856e6ede429416bbbdc836678c11ecb00 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Thu, 8 May 2025 10:45:42 +0200 Subject: [PATCH 17/75] Local exits correctly but serer side still errors out --- .../connectivity/src/connectivity/channel.py | 19 +-- .../src/testsuites/test_executor.py | 109 ++++++++++++++---- s2-self-cert-server/src/ws_adapter.py | 6 +- s2-self-cert/cert-plan.yaml | 21 ++++ s2-self-cert/cert.yaml | 33 ++---- s2-self-cert/src/server.py | 2 + s2-self-cert/src/ws_adapter.py | 3 + 7 files changed, 134 insertions(+), 59 deletions(-) create mode 100644 s2-self-cert/cert-plan.yaml diff --git a/packages/connectivity/src/connectivity/channel.py b/packages/connectivity/src/connectivity/channel.py index e1601ab..42649b7 100644 --- a/packages/connectivity/src/connectivity/channel.py +++ b/packages/connectivity/src/connectivity/channel.py @@ -32,7 +32,7 @@ class ChannelSendError: class Channel(Generic[T, RawT], abc.ABC): connection: ConnectionAdapter[RawT] - message_queue: asyncio.Queue[T] + message_queue: asyncio.Queue[T | None] _stop_event: asyncio.Event def __init__(self, connection: ConnectionAdapter[RawT]) -> None: @@ -43,7 +43,10 @@ def __init__(self, connection: ConnectionAdapter[RawT]) -> None: self.message_queue = asyncio.Queue() async def get_next_message(self) -> T: - return await self.message_queue.get() + msg = await self.message_queue.get() + if msg is None: + raise ConnectionClosed("Channel stopped") + return msg async def send(self, message: T): return await self.connection.send(message) # type: ignore @@ -69,18 +72,18 @@ async def receive_messages(self): except ConnectionClosed: await self.stop() except ConnectionError as e: - logger.error("Error whilst receive message from WS: %s", str(e)) + # logger.error("Error whilst receive message from WS: %s", str(e)) await self.stop() - except asyncio.CancelledError: - logger.warning("Cancelled error.") async def run(self): await self.receive_messages() - async def stop(self): - self._stop_event.set() + await self.connection.close() - if await self.connection.open: + async def stop(self): + if not self._stop_event.is_set(): + self.message_queue.put_nowait(None) + self._stop_event.set() await self.connection.close() diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 095fd1f..dc3819d 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -21,12 +21,13 @@ from connectivity.async_task_manager import AsyncTaskManager from connectivity.s2_channel import S2Channel from connectivity.config import Config +from connectivity.connection_adapter import ConnectionClosed, ConnectionError logger = logging.getLogger(__name__) -class IntegrationTestExecutor(AsyncTaskManager): +class IntegrationTestExecutor: # The channel which connects to the S2 RM. channel: Optional["S2Channel"] = None @@ -38,13 +39,15 @@ class IntegrationTestExecutor(AsyncTaskManager): report: ComplianceReport + _stop_event: asyncio.Event + _handshake_complete: asyncio.Event + def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], test_suite: TestSuite, report: ComplianceReport, ) -> None: - super().__init__() self.controllers = available_control_types @@ -57,6 +60,11 @@ def __init__( self.report = report + self._stop_event = asyncio.Event() + self._handshake_complete = asyncio.Event() + + self.running = False + def set_control_type(self, control_type: ProtocolControlType): controller = self.controllers[control_type] # Put the RM Details into the new controller. @@ -86,9 +94,10 @@ async def process_received_messages(self): await self.process_message(message) except asyncio.CancelledError: logger.info("Message Channel cancelled.") + except ConnectionClosed: + pass except Exception as e: logger.exception("Message processor encountered an error: %s", e) - finally: await self.stop() async def execute_test_suite(self): @@ -106,21 +115,32 @@ async def main_loop(self): if self.channel is None: raise ValueError("Channel not set.") - await self.controller.perform_handshake(self.channel) + try: + await self.controller.perform_handshake(self.channel) - await self.controller.wait_until_rm_details_received() + await self.controller.wait_until_rm_details_received() - logger.info("Handshake Complete!") + logger.info("Handshake Complete!") - await self.send_select_control_type() + await self.send_select_control_type() - logger.info("Starting tests!") + logger.info("Starting tests!") - await self.execute_test_suite() + await self.execute_test_suite() - logger.info(self.report.generate_certificate_dict()) + logger.info(self.report.generate_certificate_dict()) - logger.info("Exiting Main Loop.") + logger.info("Exiting Main Loop.") + except asyncio.CancelledError: + logger.warning("Main loop was cancelled.") + raise # Propagate for TaskGroup + except Exception as e: + logger.exception("Exception in main_loop: %s", e) + await self.stop() + raise + finally: + logger.info("Main loop finished. Signaling stop.") + await self.stop() async def send_select_control_type(self): # TODO: Select the control type in a better way. @@ -160,33 +180,74 @@ async def send_select_control_type(self): await self.controller.select_control_type(self.channel) + def is_running(self): + return self.running + + async def stop(self): + logger.debug("Stop Called in class %s", self.__class__.__name__) + self._stop_event.set() + + if self.channel is not None: + await self.channel.stop() + async def setup(self, channel: S2Channel, *args, **kwargs): - await super().setup() self.channel = channel - self._handshake_complete = asyncio.Event() + self._stop_event.clear() + self._handshake_complete.clear() - self.create_task(self.main_loop(), True) + # self.create_task(self.main_loop(), True) - self.create_task(self.channel.run(), True) - self.create_task(self.process_received_messages(), True) + # self.create_task(self.channel.run(), True) + # self.create_task(self.process_received_messages(), True) async def cleanup(self, *args, **kwargs): - await super().cleanup() + pass - if self.channel is not None: - await self.channel.stop() + async def run_channel(self): + if self.channel is None: + raise ValueError("S2 Channel is ot provided") + try: + await self.channel.run() + except ConnectionClosed: + await self.stop() + except ConnectionError: + await self.stop() async def run(self, *args, **kwargs): self.running = True - await self.setup(*args, **kwargs) - await self._stop_event.wait() - - await self.cleanup(*args, **kwargs) + try: + async with asyncio.TaskGroup() as tg: - self.running = False + if self.channel is None: + logger.error( + "Channel not initialized before run, cannot start channel.run task." + ) + await self.stop() + self.running = False + return + + tg.create_task(self.channel.run(), name="ChannelRun") + tg.create_task(self.process_received_messages(), name="MessageProcess") + tg.create_task(self.main_loop(), name="MainLoop") + + logger.info("IntegrationTestExecutor TaskGroup completed successfully.") + + except* Exception as eg: # Catches one or more exceptions from tasks + logger.error(f"ExceptionGroup caught in IntegrationTestExecutor run: {len(eg.exceptions)} exceptions") + for i, exc in enumerate(eg.exceptions): + logger.error(f" Exception {i+1}/{len(eg.exceptions)} in TaskGroup:", exc_info=exc) + await self.stop() # Signal cooperative shutdown for other parts if any + # except asyncio.CancelledError: + # logger.warning("IntegrationTestExecutor run method was cancelled externally.") + # self.stop() + finally: + logger.info("IntegrationTestExecutor run method finishing.") + await self.cleanup() # Perform final cleanup (e.g., channel.stop()) + logger.info("Cleanup finished.") + self.running = False def create_controllers_dict_with_config( diff --git a/s2-self-cert-server/src/ws_adapter.py b/s2-self-cert-server/src/ws_adapter.py index bc2f29d..c768140 100644 --- a/s2-self-cert-server/src/ws_adapter.py +++ b/s2-self-cert-server/src/ws_adapter.py @@ -1,9 +1,5 @@ from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState -from connectivity.connection_adapter import ( - ConnectionAdapter, - ConnectionClosed, - ConnectionError, -) +from connectivity.connection_adapter import ConnectionAdapter, ConnectionClosed, ConnectionError class FastAPIWebSocketAdapter(ConnectionAdapter[str]): diff --git a/s2-self-cert/cert-plan.yaml b/s2-self-cert/cert-plan.yaml new file mode 100644 index 0000000..d62dfe5 --- /dev/null +++ b/s2-self-cert/cert-plan.yaml @@ -0,0 +1,21 @@ +timestamp: 2025-04-30 11:38:24.250547 +device: + manufacturer: ABC + device: Solar MK2 + model: v1.0 +findings: +- test: FRBC System Description Test + parameters: + - name: FRBCSystemDescription Message Received. + status: PASS + status: PASS +- test: Power Forecast Test + parameters: + - name: PowerForecast Message Received. + status: PASS + status: PASS +- test: Power Measurement Test + parameters: + - name: PowerMeasurement Message Received. + status: PASS + status: PASS diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml index 82727b7..d62dfe5 100644 --- a/s2-self-cert/cert.yaml +++ b/s2-self-cert/cert.yaml @@ -1,32 +1,21 @@ +timestamp: 2025-04-30 11:38:24.250547 +device: + manufacturer: ABC + device: Solar MK2 + model: v1.0 findings: -- message_type: FRBCActuatorStatus - parameters: - - name: FRBCActuatorStatus Not Provided. - status: FAIL - status: FAIL -- message_type: FRBCSystemDescription +- test: FRBC System Description Test parameters: - - name: FRBCSystemDescription Provided. + - name: FRBCSystemDescription Message Received. status: PASS status: PASS -- message_type: PowerForecast - parameters: - - name: PowerForecast Not Provided. - status: FAIL - status: FAIL -- message_type: PowerMeasurement +- test: Power Forecast Test parameters: - - name: PowerMeasurement Not Provided. - status: FAIL - status: FAIL -- message_type: FRBCStorageStatus - parameters: - - name: FRBCStorageStatus Provided. + - name: PowerForecast Message Received. status: PASS status: PASS -- message_type: FRBCUsageForecast +- test: Power Measurement Test parameters: - - name: FRBCUsageForecast Provided. + - name: PowerMeasurement Message Received. status: PASS status: PASS -timestamp: 2025-04-30 11:38:24.250547 diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index e00eb81..1bcfa8e 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -48,8 +48,10 @@ async def handle_incoming_connection(self, websocket: WSConnection): connection = WebSocketConnectionAdapter(websocket) if self.mode == "testing": + logger.info("Starting in test mode. All tests are run locally.") s2_channel = S2Channel(connection) else: + logger.info("Starting in certification mode. All tests are run remotely.") s2_channel = BaseChannel(connection) await self.executor.run(s2_channel) diff --git a/s2-self-cert/src/ws_adapter.py b/s2-self-cert/src/ws_adapter.py index 7696c23..a925adf 100644 --- a/s2-self-cert/src/ws_adapter.py +++ b/s2-self-cert/src/ws_adapter.py @@ -20,6 +20,8 @@ def __init__(self, ws_connection: WSConnection): self.ws_connection = ws_connection async def receive(self) -> str: + if not self.is_open: + raise ConnectionClosed("Websocket is closed.") try: message = await self.ws_connection.recv() logger.debug("Received: %s", message) @@ -52,6 +54,7 @@ async def open(self) -> bool: async def close(self, code: int = 1000, reason: str = ""): try: + logger.info("Closing WS.") await self.ws_connection.close(code=code, reason=reason) except Exception as e: raise ConnectionError(f"Error closing websocket: {e}") From 2e773287f0a7914e7a85d70e0f8b2ba1636cda23 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Thu, 8 May 2025 11:06:05 +0200 Subject: [PATCH 18/75] Server Teardown working slightly better but still hangs. --- .../src/connectivity/connection_adapter.py | 2 +- .../src/testsuites/certification_executor.py | 10 ++++++++++ packages/test-suites/src/testsuites/test_executor.py | 2 +- .../src/testsuites/test_suite/test_suite.py | 2 +- s2-self-cert-server/src/main.py | 2 +- s2-self-cert-server/src/ws_adapter.py | 11 ++++++++--- s2-self-cert/src/ws_adapter.py | 2 +- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/connectivity/src/connectivity/connection_adapter.py b/packages/connectivity/src/connectivity/connection_adapter.py index ba41105..9cbb88f 100644 --- a/packages/connectivity/src/connectivity/connection_adapter.py +++ b/packages/connectivity/src/connectivity/connection_adapter.py @@ -53,7 +53,7 @@ async def send(self, message: T): @property @abc.abstractmethod - async def open(self) -> bool: + def open(self) -> bool: """ Returns True if the connection is open. """ diff --git a/packages/test-suites/src/testsuites/certification_executor.py b/packages/test-suites/src/testsuites/certification_executor.py index be4c485..ae4ee5a 100644 --- a/packages/test-suites/src/testsuites/certification_executor.py +++ b/packages/test-suites/src/testsuites/certification_executor.py @@ -22,6 +22,13 @@ ControlMessageEnvelope, ) +from connectivity.connection_adapter import ( + ConnectionAdapter, + ConnectionClosed, + ConnectionError, + ConnectionProtocolError, +) + import logging from testsuites.certificate.certificate import ComplianceReport @@ -115,6 +122,9 @@ async def process_received_message( continue # Check stop event and loop again await process_message(message) + except ConnectionClosed: + # Exit when channel has been closed. + pass except asyncio.CancelledError: logger.info("Message Channel cancelled.") except Exception as e: diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index dc3819d..3f5e65f 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -90,7 +90,7 @@ async def process_received_messages(self): except asyncio.TimeoutError: continue # Check stop event and loop again - logger.info(message) + # logger.info(message) await self.process_message(message) except asyncio.CancelledError: logger.info("Message Channel cancelled.") diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index 7af44a0..6ded0a2 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -103,7 +103,7 @@ async def execute(self): logger.info( "Executing test case %s. Has %s tests.", self.__class__.__name__, - self.get_test_cases(), + len(self.get_test_cases()), ) for name, method in self.get_test_cases(): logger.info(f"Running test case: {name}") diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index dda0238..5577aa9 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -81,7 +81,7 @@ class MockChannel(Channel[str, str]): connection: MockConnectionAdapter async def send(self, message: str): - logger.info("S2 Message: %s", message) + # logger.info("S2 Message: %s", message) await self.connection.put_incoming(message) async def receive(self) -> str: diff --git a/s2-self-cert-server/src/ws_adapter.py b/s2-self-cert-server/src/ws_adapter.py index c768140..edd8817 100644 --- a/s2-self-cert-server/src/ws_adapter.py +++ b/s2-self-cert-server/src/ws_adapter.py @@ -4,6 +4,7 @@ class FastAPIWebSocketAdapter(ConnectionAdapter[str]): def __init__(self, websocket: WebSocket): + self.connected = True self.websocket = websocket async def receive(self) -> str: @@ -11,9 +12,11 @@ async def receive(self) -> str: data = await self.websocket.receive_text() return data except WebSocketDisconnect: + self.connected = False raise ConnectionClosed("Websocket is closed.") except RuntimeError as e: # Starlette raises RuntimeError if the connection is closed + self.connected = False raise ConnectionClosed(f"Websocket is closed: {e}") except Exception as e: raise ConnectionError(f"Unknown websocket error: {e}") @@ -23,16 +26,18 @@ async def send(self, message: str): await self.websocket.send_text(message) except RuntimeError as e: # Starlette raises RuntimeError if the connection is closed + self.connected = False raise ConnectionClosed(f"Websocket is closed: {e}") except Exception as e: raise ConnectionError(f"Unknown websocket error: {e}") @property - async def open(self) -> bool: - return self.websocket.application_state == WebSocketState.CONNECTED + def open(self) -> bool: + return self.websocket.application_state == WebSocketState.CONNECTED and self.connected async def close(self, code: int = 1000, reason: str = ""): try: - await self.websocket.close(code=code) + if self.open: + await self.websocket.close(code=code) except Exception as e: raise ConnectionError(f"Error closing websocket: {e}") diff --git a/s2-self-cert/src/ws_adapter.py b/s2-self-cert/src/ws_adapter.py index a925adf..2b34abb 100644 --- a/s2-self-cert/src/ws_adapter.py +++ b/s2-self-cert/src/ws_adapter.py @@ -49,7 +49,7 @@ async def send(self, message: str): raise ConnectionError(f"Unknown websocket error: {e}") @property - async def open(self) -> bool: + def open(self) -> bool: return self.is_open async def close(self, code: int = 1000, reason: str = ""): From 972541d4064f1082fa4e9527d582f88669176fc0 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Thu, 8 May 2025 13:47:12 +0200 Subject: [PATCH 19/75] Testing system is working! --- .../connectivity/src/connectivity/channel.py | 18 --- .../src/connectivity/server_models.py | 77 ------------ .../src/testsuites/certificate/certificate.py | 11 +- .../src/testsuites/certification_executor.py | 94 +++++++++++--- .../src/testsuites/envelope_models.py | 116 ++++++++++++++++++ .../server_websocket_envelope_channel.py | 15 +++ .../src/testsuites/setup_test_orchestrator.py | 50 -------- .../src/testsuites/test_executor.py | 37 ++++-- .../testsuites/test_suite/base_test_case.py | 7 +- .../testsuites/test_suite/frbc_test_cases.py | 8 +- .../testsuites/test_suite/pebc_test_cases.py | 4 +- .../src/testsuites/test_suite/test_suite.py | 2 +- s2-self-cert-server/src/main.py | 40 ++++-- s2-self-cert/cert.yaml | 26 ++-- s2-self-cert/src/config.yaml | 2 +- s2-self-cert/src/main.py | 9 +- s2-self-cert/src/server.py | 14 ++- .../server_side_certification_orchestrator.py | 35 +++--- 18 files changed, 337 insertions(+), 228 deletions(-) delete mode 100644 packages/connectivity/src/connectivity/server_models.py create mode 100644 packages/test-suites/src/testsuites/envelope_models.py create mode 100644 packages/test-suites/src/testsuites/server_websocket_envelope_channel.py delete mode 100644 packages/test-suites/src/testsuites/setup_test_orchestrator.py diff --git a/packages/connectivity/src/connectivity/channel.py b/packages/connectivity/src/connectivity/channel.py index 42649b7..e5257d4 100644 --- a/packages/connectivity/src/connectivity/channel.py +++ b/packages/connectivity/src/connectivity/channel.py @@ -5,12 +5,6 @@ import logging from typing import Generic, TypeVar -from connectivity.server_models import ( - MessageEnvelopeTypeEnum, - ServerMessageEnvelope, - parse_envelope, -) - from .connection_adapter import ( ConnectionAdapter, ConnectionClosed, @@ -90,15 +84,3 @@ async def stop(self): class BaseChannel(Channel[str, str]): """A str, str channel.""" - -class ServerWebsocketConnectionChannel(Channel[ServerMessageEnvelope, str]): - async def send(self, message: ServerMessageEnvelope): - str_msg: str = message.model_dump_json() - - return await self.connection.send(str_msg) - - async def process_received_message(self, str_msg: str): - - message = parse_envelope(str_msg) - - await self.message_queue.put(message) diff --git a/packages/connectivity/src/connectivity/server_models.py b/packages/connectivity/src/connectivity/server_models.py deleted file mode 100644 index 78285ba..0000000 --- a/packages/connectivity/src/connectivity/server_models.py +++ /dev/null @@ -1,77 +0,0 @@ -from enum import Enum -import json -from typing import Type, Union -from pydantic import BaseModel - -from connectivity.config import Config - - -class ServerMessageValidationException(Exception): - pass - - -class MessageEnvelopeTypeEnum(str, Enum): - CONTROL = "CONTROL" - S2 = "S2" - LOG = "LOG" - - -class ControlMessageType(str, Enum): - CONFIG = "CONFIG" - CERTIFICATE = "CERTIFICATE" - - -class LogMessage(BaseModel): - level: str - content: str - - -class ConfigControlMessage(BaseModel): - message_type: ControlMessageType = ControlMessageType.CONFIG - config: Config - - -ControlMessage = Union[ConfigControlMessage] - - -class BaseEnvelope(BaseModel): - message_type: MessageEnvelopeTypeEnum - - -class ControlMessageEnvelope(BaseModel): - message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CONTROL - message: ControlMessage - - -class LogMessageEnvelope(BaseModel): - message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.LOG - message: LogMessage - - -class S2MessageEnvelope(BaseModel): - message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.S2 - # keep it as a dict so that the orchestrator can do the parsing and catch the errors as part of the testing. - message: str - - -ServerMessageEnvelope = Union[ - ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope -] - -envelope_types_dict = { - MessageEnvelopeTypeEnum.CONTROL: ControlMessageEnvelope, - MessageEnvelopeTypeEnum.S2: S2MessageEnvelope, - MessageEnvelopeTypeEnum.LOG: LogMessageEnvelope, -} - - -def parse_envelope(str_msg: str) -> ServerMessageEnvelope: - msg_dict = json.loads(str_msg) - - try: - msg_type = MessageEnvelopeTypeEnum[msg_dict["message_type"]] - envelope_type: Type[ServerMessageEnvelope] = envelope_types_dict[msg_type] - except KeyError: - raise ValueError("Invalid Message Envelope Type.") - - return envelope_type.model_validate_json(str_msg) diff --git a/packages/test-suites/src/testsuites/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py index 8bea352..197127d 100644 --- a/packages/test-suites/src/testsuites/certificate/certificate.py +++ b/packages/test-suites/src/testsuites/certificate/certificate.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, field_serializer import yaml +from connectivity.config import DeviceDetails from s2python.message import S2Message @@ -27,7 +28,7 @@ def serializer_status(self, status: ComplianceStatus): class ComplianceFinding(BaseModel): - message_type: Type[S2Message] + test: str status: ComplianceStatus = ComplianceStatus.PASS parameters: List[ComplianceParameter] = [] @@ -51,14 +52,12 @@ def add_parameter( def serializer_status(self, status: ComplianceStatus): return status.name - @field_serializer("message_type") - def serializer_message_type(self, message_type: Type[S2Message]): - return message_type.__name__ - class ComplianceReport(BaseModel): timestamp: datetime = datetime.now() findings: List[ComplianceFinding] = [] + device: Optional[DeviceDetails] + signature: Optional[str] = None def add_finding(self, finding: ComplianceFinding): self.findings.append(finding) @@ -67,6 +66,8 @@ def generate_certificate_dict(self) -> dict: return self.model_dump() def export(self, filename="cert.yaml"): + if filename is None: + filename = "cert.yaml" with open(filename, "w") as output: logger.info("Exporting report to `%s`.", filename) cert_data = self.generate_certificate_dict() diff --git a/packages/test-suites/src/testsuites/certification_executor.py b/packages/test-suites/src/testsuites/certification_executor.py index ae4ee5a..d75cd1d 100644 --- a/packages/test-suites/src/testsuites/certification_executor.py +++ b/packages/test-suites/src/testsuites/certification_executor.py @@ -13,7 +13,7 @@ from connectivity.config import Config from connectivity.channel import Channel from s2python.message import S2Message -from connectivity.server_models import ( +from testsuites.envelope_models import ( ServerMessageEnvelope, S2MessageEnvelope, LogMessage, @@ -31,6 +31,7 @@ import logging +from testsuites.test_executor import AbstractExecutor from testsuites.certificate.certificate import ComplianceReport logger = logging.getLogger(__name__) @@ -73,7 +74,7 @@ def __init__(self): super().__init__() -class AbstractCertificationExecutor(MessageHandler[ControlMessage], AsyncTaskManager): +class AbstractCertificationExecutor(AbstractExecutor, MessageHandler[ControlMessage]): s2_channel: Channel[str, str] server_channel: Channel[ServerMessageEnvelope, str] @@ -82,18 +83,25 @@ class AbstractCertificationExecutor(MessageHandler[ControlMessage], AsyncTaskMan report: ComplianceReport + _stop_event: asyncio.Event + def __init__(self): super().__init__() + # ! Control message handlers self.handlers: Dict[Type[ControlMessage], Callable] = {} + self._stop_event = asyncio.Event() + + self.running = False + async def main_loop(self): logger.info("Starting Main Loop.") if self.s2_channel is None or self.server_channel: raise ValueError("Channel not set.") async def handle_control_message(self, message: ControlMessage): - logger.info("Control message: %s", message) + await self.handle_message(message) async def process_server_message(self, message: ServerMessageEnvelope): if type(message) == S2MessageEnvelope: @@ -170,36 +178,88 @@ async def setup( *args, **kwargs, ): - await super().setup() + self._stop_event.clear() self.s2_channel = s2_channel self.server_channel = server_channel - self.create_task(self.main_loop(), False) + def create_tasks(self, tg: asyncio.TaskGroup): + tg.create_task(self.main_loop(), name="MainLoop") - self.create_task(self.s2_channel.run(), False) - self.create_task(self.server_channel.run(), False) + tg.create_task(self.s2_channel.run(), name="S2ChannelRun") + tg.create_task(self.server_channel.run(), name="ServerChannelRun") - self.create_task( + tg.create_task( self.process_received_message( self.get_next_s2_channel_message, self.process_rm_message ), - True, + name="ProcessRMMessages", ) - self.create_task( + tg.create_task( self.process_received_message( - server_channel.get_next_message, self.process_server_message + self.server_channel.get_next_message, self.process_server_message ), - True, + name="ProcessServerMessages", ) - async def run(self, s2_channel, *args, **kwargs): + async def cleanup(self, *args, **kwargs): + pass + + async def stop(self): + if self._stop_event.is_set(): + return + + logger.debug("Stop Called in class %s", self.__class__.__name__) + self._stop_event.set() + + if self.s2_channel is not None: + await self.s2_channel.stop() + + if self.server_channel is not None: + await self.server_channel.stop() + + async def run( + self, + s2_channel: Optional[Channel[str, str]], + server_channel: Optional[Channel[ServerMessageEnvelope, str]], + *args, + **kwargs, + ): + self.running = True - await self.setup(s2_channel, *args, **kwargs) + if s2_channel is None or server_channel is None: + logger.error( + "Channel not initialized before run, cannot start channel.run task." + ) + await self.stop() + self.running = False + return - await self._stop_event.wait() + await self.setup(s2_channel, server_channel, *args, **kwargs) - await self.cleanup(*args, **kwargs) + try: + async with asyncio.TaskGroup() as tg: - self.running = False + self.create_tasks(tg) + + logger.info("IntegrationTestExecutor TaskGroup completed successfully.") + + except* Exception as eg: # Catches one or more exceptions from tasks + logger.error( + f"ExceptionGroup caught in IntegrationTestExecutor run: {len(eg.exceptions)} exceptions" + ) + for i, exc in enumerate(eg.exceptions): + logger.error( + f" Exception {i+1}/{len(eg.exceptions)} in TaskGroup:", + exc_info=exc, + ) + await self.stop() # Signal cooperative shutdown for other parts if any + # except asyncio.CancelledError: + # logger.warning("IntegrationTestExecutor run method was cancelled externally.") + # self.stop() + finally: + logger.info("Certification Test Executor run method finishing.") + await self.cleanup() # Perform final cleanup (e.g., channel.stop()) + logger.info("Cleanup finished.") + self.running = False diff --git a/packages/test-suites/src/testsuites/envelope_models.py b/packages/test-suites/src/testsuites/envelope_models.py new file mode 100644 index 0000000..454763e --- /dev/null +++ b/packages/test-suites/src/testsuites/envelope_models.py @@ -0,0 +1,116 @@ +from enum import Enum +import json +from typing import Dict, Type, Union +from pydantic import BaseModel, ValidationError + +from connectivity.config import Config + +from testsuites.certificate.certificate import ComplianceReport + +import logging + +logger = logging.getLogger(__name__) + + +class ServerMessageValidationException(Exception): + pass + + +class MessageEnvelopeTypeEnum(str, Enum): + CONTROL = "CONTROL" + S2 = "S2" + LOG = "LOG" + + +class ControlMessageType(str, Enum): + CONFIG = "CONFIG" + REPORT = "REPORT" + + +class LogMessage(BaseModel): + level: str + content: str + + +class ConfigControlMessage(BaseModel): + message_type: ControlMessageType = ControlMessageType.CONFIG + config: Config + + +class ReportControlMessage(BaseModel): + message_type: ControlMessageType = ControlMessageType.REPORT + report: ComplianceReport + + +ControlMessage = Union[ConfigControlMessage, ReportControlMessage] + + +class BaseEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum + + +class ControlMessageEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CONTROL + message: ControlMessage + + +class LogMessageEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.LOG + message: LogMessage + + +class S2MessageEnvelope(BaseModel): + message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.S2 + # keep it as a dict so that the orchestrator can do the parsing and catch the errors as part of the testing. + message: str + + +ServerMessageEnvelope = Union[ + ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope +] + +control_types_dict: Dict[ControlMessageType, Type[ControlMessage]] = { + ControlMessageType.CONFIG: ConfigControlMessage, + ControlMessageType.REPORT: ReportControlMessage, +} + +envelope_types_dict: Dict[MessageEnvelopeTypeEnum, Type[ServerMessageEnvelope]] = { + MessageEnvelopeTypeEnum.CONTROL: ControlMessageEnvelope, + MessageEnvelopeTypeEnum.S2: S2MessageEnvelope, + MessageEnvelopeTypeEnum.LOG: LogMessageEnvelope, +} + + +def parse_control_message(message: dict) -> ControlMessage: + logger.info("Parsing control message.") + msg_type: ControlMessageType = ControlMessageType[message["message_type"]] + + message_class = control_types_dict[msg_type] + + return message_class.model_validate(message) + + +def parse_envelope(envelope_json: str) -> ServerMessageEnvelope: + + try: + raw_envelope: dict = json.loads(envelope_json) + + msg_type = MessageEnvelopeTypeEnum[raw_envelope["message_type"]] + + envelope_class: Type[ServerMessageEnvelope] = envelope_types_dict[msg_type] + + if msg_type == MessageEnvelopeTypeEnum.CONTROL: + logger.info("Parsing control message.") + envelope = ControlMessageEnvelope( + message=parse_control_message(raw_envelope["message"]) + ) + logger.info("Control message parsed.") + return envelope + else: + return envelope_class.model_validate_json(envelope_json) + except json.JSONDecodeError: + raise ValueError("JSON decode exception.") + except ValidationError: + raise ValueError("Message Validation Error") + except KeyError: + raise ValueError("Invalid Message Envelope Type.") diff --git a/packages/test-suites/src/testsuites/server_websocket_envelope_channel.py b/packages/test-suites/src/testsuites/server_websocket_envelope_channel.py new file mode 100644 index 0000000..6f958dd --- /dev/null +++ b/packages/test-suites/src/testsuites/server_websocket_envelope_channel.py @@ -0,0 +1,15 @@ +from connectivity.channel import Channel +from .envelope_models import ServerMessageEnvelope, parse_envelope + + +class ServerWebsocketConnectionChannel(Channel[ServerMessageEnvelope, str]): + async def send(self, message: ServerMessageEnvelope): + str_msg: str = message.model_dump_json() + + return await self.connection.send(str_msg) + + async def process_received_message(self, str_msg: str): + + message = parse_envelope(str_msg) + + await self.message_queue.put(message) diff --git a/packages/test-suites/src/testsuites/setup_test_orchestrator.py b/packages/test-suites/src/testsuites/setup_test_orchestrator.py deleted file mode 100644 index 79530f8..0000000 --- a/packages/test-suites/src/testsuites/setup_test_orchestrator.py +++ /dev/null @@ -1,50 +0,0 @@ -from datetime import datetime -from typing import Dict - -from s2python.common import ControlType as ProtocolControlType -from testsuites.certificate.certificate import ComplianceReport -from testsuites.controllers import ( - Controller, - BaseController, - PEBCController, - FRBCController, -) -from testsuites.orchestrator import IntegrationTestOrchestrator, Orchestrator -from testsuites.test_suite import PEBCTestCase, TestSuiteBuilder -from testsuites.test_suite.frbc_test_cases import FRBCTestCase -from connectivity.config import Config - - -def create_controllers_dict_with_config( - config: Config, report: ComplianceReport -) -> Dict[ProtocolControlType, Controller]: - controllers: Dict[ProtocolControlType, Controller] = {} - - controllers[ProtocolControlType.NO_SELECTION] = BaseController() - - if config.control_types.frbc and config.control_types.frbc.enabled: - controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() - - if config.control_types.pebc and config.control_types.pebc.enabled: - controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = PEBCController() - - return controllers - - -def create_test_orchestrator(config: Config) -> Orchestrator: - report = ComplianceReport(timestamp=datetime.now()) - - controllers = create_controllers_dict_with_config(config, report) - - test_suite = ( - TestSuiteBuilder(config.control_types, report) - .with_test_case(PEBCTestCase) - .with_test_case(FRBCTestCase) - .build() - ) - - orchestrator = IntegrationTestOrchestrator( - available_control_types=controllers, test_suite=test_suite, report=report - ) - - return orchestrator diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 3f5e65f..0adb2e4 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -1,3 +1,4 @@ +import abc import asyncio from datetime import datetime import logging @@ -27,7 +28,23 @@ logger = logging.getLogger(__name__) -class IntegrationTestExecutor: +class AbstractExecutor(abc.ABC): + + report: Optional[ComplianceReport] = None + + _stop_event: asyncio.Event + + running = False + + def is_running(self): + return self.running + + @abc.abstractmethod + async def run(self, *args, **kwargs): + pass + + +class IntegrationTestExecutor(AbstractExecutor): # The channel which connects to the S2 RM. channel: Optional["S2Channel"] = None @@ -128,8 +145,6 @@ async def main_loop(self): await self.execute_test_suite() - logger.info(self.report.generate_certificate_dict()) - logger.info("Exiting Main Loop.") except asyncio.CancelledError: logger.warning("Main loop was cancelled.") @@ -229,17 +244,23 @@ async def run(self, *args, **kwargs): self.running = False return + # TODO: Should this use `run_channel()`? tg.create_task(self.channel.run(), name="ChannelRun") tg.create_task(self.process_received_messages(), name="MessageProcess") tg.create_task(self.main_loop(), name="MainLoop") logger.info("IntegrationTestExecutor TaskGroup completed successfully.") - except* Exception as eg: # Catches one or more exceptions from tasks - logger.error(f"ExceptionGroup caught in IntegrationTestExecutor run: {len(eg.exceptions)} exceptions") + except* Exception as eg: # Catches one or more exceptions from tasks + logger.error( + f"ExceptionGroup caught in IntegrationTestExecutor run: {len(eg.exceptions)} exceptions" + ) for i, exc in enumerate(eg.exceptions): - logger.error(f" Exception {i+1}/{len(eg.exceptions)} in TaskGroup:", exc_info=exc) - await self.stop() # Signal cooperative shutdown for other parts if any + logger.error( + f" Exception {i+1}/{len(eg.exceptions)} in TaskGroup:", + exc_info=exc, + ) + await self.stop() # Signal cooperative shutdown for other parts if any # except asyncio.CancelledError: # logger.warning("IntegrationTestExecutor run method was cancelled externally.") # self.stop() @@ -267,7 +288,7 @@ def create_controllers_dict_with_config( def create_test_executor(config: Config) -> IntegrationTestExecutor: - report = ComplianceReport(timestamp=datetime.now()) + report = ComplianceReport(timestamp=datetime.now(), device=config.device_details) controllers = create_controllers_dict_with_config(config) diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index 11e4493..09b1fa0 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -15,6 +15,7 @@ from connectivity.config import BaseTestConfig from connectivity.s2_channel import S2Channel + class NoSelectionTestCase(S2TestCase): control_type = ProtocolControlType.NO_SELECTION @@ -34,7 +35,7 @@ def __init__( super().__init__(config, channel, controller, report) async def test_validate_rm_details_received(self): - finding = ComplianceFinding(message_type=ResourceManagerDetails) + finding = ComplianceFinding(test="Test Receive RM Details") if self.controller.resource_manager_details is not None: finding.add_parameter( @@ -49,7 +50,7 @@ async def test_validate_rm_details_received(self): @S2TestCase.test async def test_receive_power_forecast(self): - finding = ComplianceFinding(message_type=PowerForecast) + finding = ComplianceFinding(test="Test Receive Power Forecast") message = await self.check_receive_message_type(PowerForecast, finding) @@ -57,7 +58,7 @@ async def test_receive_power_forecast(self): @S2TestCase.test async def test_receive_power_measurement(self): - finding = ComplianceFinding(message_type=PowerMeasurement) + finding = ComplianceFinding(test="Test Receive Power Measurement") message = await self.check_receive_message_type(PowerMeasurement, finding) diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py index a6d3bb1..30f10c8 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py @@ -64,7 +64,7 @@ async def wait_for_system_description(self): async def test_receive_frbc_system_description(self): await self.wait_for_system_description() - finding = ComplianceFinding(message_type=FRBCSystemDescription) + finding = ComplianceFinding(test="Test receive FRBCSystemDescription") message = await self.check_receive_message_type(FRBCSystemDescription, finding) @@ -73,7 +73,7 @@ async def test_receive_frbc_system_description(self): @S2TestCase.test async def test_receive_actuator_status(self): - finding = ComplianceFinding(message_type=FRBCActuatorStatus) + finding = ComplianceFinding(test="Test receive FRBCActuatorStatus") message = await self.check_receive_message_type(FRBCActuatorStatus, finding) @@ -82,7 +82,7 @@ async def test_receive_actuator_status(self): @S2TestCase.test async def test_receive_storage_status(self): - finding = ComplianceFinding(message_type=FRBCStorageStatus) + finding = ComplianceFinding(test="Test receive FRBCStorageStatus") message = await self.check_receive_message_type(FRBCStorageStatus, finding) @@ -91,7 +91,7 @@ async def test_receive_storage_status(self): @S2TestCase.test async def test_receive_usage_forecast(self): - finding = ComplianceFinding(message_type=FRBCUsageForecast) + finding = ComplianceFinding(test="Test receive FRBCUsageForecast") message = await self.check_receive_message_type(FRBCUsageForecast, finding) diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py index 30aef20..03c7585 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py @@ -64,10 +64,10 @@ async def wait_until_power_constraints_set(self): async def validate_power_constraints_set(self): await self.wait_until_power_constraints_set() - finding = ComplianceFinding(message_type=PEBCPowerConstraints) + finding = ComplianceFinding(test="Test receive PEBCPowerConstraints") finding.add_parameter( param=ComplianceParameter( - name="PEBCPowerConstraints Provided.", status=ComplianceStatus.PASS + name="PEBCPowerConstraints Received.", status=ComplianceStatus.PASS ) ) diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index 6ded0a2..ec38ffa 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -47,7 +47,7 @@ async def check_receive_message_type( logger.info("Checking for %s", message_type) if report_finding is None: - report_finding = ComplianceFinding(message_type=message_type) + report_finding = ComplianceFinding(test=message_type) message = None try: diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index 5577aa9..5fdc429 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -1,3 +1,4 @@ +from typing import Optional from fastapi import FastAPI import asyncio from enum import Enum @@ -9,18 +10,21 @@ from fastapi import WebSocketDisconnect from testsuites.certification_executor import AbstractCertificationExecutor from connectivity.config import Config -from connectivity.channel import ServerWebsocketConnectionChannel +from testsuites.server_websocket_envelope_channel import ( + ServerWebsocketConnectionChannel, +) from connectivity.s2_channel import S2Channel from testsuites.test_executor import IntegrationTestExecutor, create_test_executor -from connectivity.server_models import ( +from testsuites.envelope_models import ( ServerMessageEnvelope, S2MessageEnvelope, LogMessage, LogMessageEnvelope, ControlMessage, ConfigControlMessage, + ReportControlMessage, ControlMessageEnvelope, ControlMessageType, MessageEnvelopeTypeEnum, @@ -115,13 +119,19 @@ async def handle_control_message(self, message: ControlMessage): logger.debug("Control Message: %s", message) await self.handle_message(message) + async def send_report(self, report: ComplianceReport): + logger.info("Sending report: %s", report) + message = ReportControlMessage(report=report) + + await self.send_server_control_message(message) + async def main_loop(self): await self._config_received.wait() logger.debug("Config Received.") - self.report = ComplianceReport() + self.report = ComplianceReport(device=self.config.device_details) self.test_executor = create_test_executor(self.config) @@ -136,19 +146,27 @@ async def main_loop(self): report = self.test_executor.report - logger.info("Report: %s", report) - await asyncio.sleep(10) - logger.info("Main Loop Complete.") + logger.info("Sending report") + + report.signature = "TEST SERVER SIGNATURE" + + await self.send_report(report) - async def setup( - self, server_channel: Channel[ServerMessageEnvelope, str], *args, **kwargs + logger.info("Report sent. Exiting Main Loop.") + + await self.stop() + + async def run( + self, + server_channel: Optional[Channel[ServerMessageEnvelope, str]], + *args, + **kwargs, ): self._config_received = asyncio.Event() self.s2_connection_adapter = MockConnectionAdapter() s2_channel_mock = MockChannel(self.s2_connection_adapter) - - return await super().setup(s2_channel_mock, server_channel, *args, **kwargs) + return await super().run(s2_channel_mock, server_channel, *args, **kwargs) @app.websocket("/ws") @@ -164,3 +182,5 @@ async def connect_tester(websocket: WebSocket): executor = ServerSideCertificationExecutor() await executor.run(server_channel) + + logger.info("Disconnected WebSocket.") diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml index d62dfe5..9116a9a 100644 --- a/s2-self-cert/cert.yaml +++ b/s2-self-cert/cert.yaml @@ -1,21 +1,21 @@ -timestamp: 2025-04-30 11:38:24.250547 device: - manufacturer: ABC - device: Solar MK2 - model: v1.0 + manufacturer: ABCD + name: Some Device findings: -- test: FRBC System Description Test - parameters: - - name: FRBCSystemDescription Message Received. +- parameters: + - name: PowerForecast Provided. status: PASS status: PASS -- test: Power Forecast Test - parameters: - - name: PowerForecast Message Received. + test: Test Receive Power Forecast +- parameters: + - name: PowerMeasurement Provided. status: PASS status: PASS -- test: Power Measurement Test - parameters: - - name: PowerMeasurement Message Received. + test: Test Receive Power Measurement +- parameters: + - name: PEBCPowerConstraints Received. status: PASS status: PASS + test: Test receive PEBCPowerConstraints +signature: TEST SERVER SIGNATURE +timestamp: 2025-05-08 13:46:35.222056 diff --git a/s2-self-cert/src/config.yaml b/s2-self-cert/src/config.yaml index 563a27d..5895e4b 100644 --- a/s2-self-cert/src/config.yaml +++ b/s2-self-cert/src/config.yaml @@ -1,7 +1,7 @@ device_details: name: Some Device - manufacturer: ACME + manufacturer: ABCD mode: certification control_types: no_selection: null diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index 863805d..b2dd507 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -20,10 +20,15 @@ parser = argparse.ArgumentParser(prog="S2 Self Cert") parser.add_argument("config") +parser.add_argument( + "-o", "--output", default="cert.yaml", help="Output file for the certificate." +) def create_server_certification_executor(config: Config) -> CertificationTestExecutor: - return CertificationTestExecutor(config) + return CertificationTestExecutor( + config, + ) async def main(): @@ -42,7 +47,7 @@ async def main(): logger.info("-" * 40) logger.info(f"Starting in {config.mode} mode...") - s2_server = S2Server("0.0.0.0", 8000, test_executor, config.mode) + s2_server = S2Server("0.0.0.0", 8000, test_executor, config.mode, args.output) await s2_server.start() diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index 1bcfa8e..b71afba 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -1,7 +1,7 @@ import asyncio import logging import signal -from typing import Literal +from typing import Literal, Optional from testsuites.test_executor import IntegrationTestExecutor from websockets.asyncio.connection import Connection as WSConnection @@ -26,8 +26,9 @@ def __init__( self, host, port, - orchestrator: IntegrationTestExecutor, + orchestrator: AbstractCertificationExecutor, mode: Literal["testing", "certification"], + report_output_file: Optional[str] = None, ): self._host = host self._port = port @@ -37,6 +38,8 @@ def __init__( self.executor = orchestrator + self.report_output_file = report_output_file + async def handle_incoming_connection(self, websocket: WSConnection): """ On WS connect it creates the connection instance and adds it to the Orchestrator. @@ -51,11 +54,16 @@ async def handle_incoming_connection(self, websocket: WSConnection): logger.info("Starting in test mode. All tests are run locally.") s2_channel = S2Channel(connection) else: - logger.info("Starting in certification mode. All tests are run remotely.") + logger.info( + "Starting in certification mode. All tests are run remotely." + ) s2_channel = BaseChannel(connection) await self.executor.run(s2_channel) + logger.info("Exporting Compliance Report.") + self.executor.report.export(self.report_output_file) + logger.info("Connection closed.") await self.stop() diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py index 839cdbd..b390e90 100644 --- a/s2-self-cert/src/server_side_certification_orchestrator.py +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -24,16 +24,11 @@ from testsuites.controllers import Controller from testsuites.test_suite.test_suite import TestSuite from testsuites.util import wait_for_event_or_stop -from connectivity.server_models import ( - ServerMessageEnvelope, - S2MessageEnvelope, - ControlMessageEnvelope, - LogMessageEnvelope, - MessageEnvelopeTypeEnum, - ControlMessage, -) from websockets.asyncio.client import connect -from connectivity.channel import Channel, ServerWebsocketConnectionChannel +from connectivity.channel import Channel +from testsuites.server_websocket_envelope_channel import ( + ServerWebsocketConnectionChannel, +) from testsuites.certification_executor import AbstractCertificationExecutor from connectivity.s2_channel import S2Channel from connectivity.config import Config @@ -41,13 +36,14 @@ from ws_adapter import WebSocketConnectionAdapter -from connectivity.server_models import ( +from testsuites.envelope_models import ( ServerMessageEnvelope, S2MessageEnvelope, LogMessage, LogMessageEnvelope, ControlMessage, ConfigControlMessage, + ReportControlMessage, ControlMessageEnvelope, ControlMessageType, MessageEnvelopeTypeEnum, @@ -66,11 +62,23 @@ class CertificationTestExecutor(AbstractCertificationExecutor): config: Config + report: Optional[ComplianceReport] = None + def __init__(self, config: Config): super().__init__() self.config = config + self.add_handler(ReportControlMessage, self.handle_report_control_message) + + async def handle_report_control_message(self, message: ReportControlMessage): + + report: ComplianceReport = message.report + + logger.info("Received report from server: %s", report) + + self.report = report + async def connect_to_server(self) -> Channel[ServerMessageEnvelope, str]: uri = f"{SERVER_PROTOCOL}://{SERVER_HOST}:{SERVER_PORT}{SERVER_PATH}" logger.info(f"Connecting to server ({uri})...") @@ -87,14 +95,13 @@ async def connect_to_server(self) -> Channel[ServerMessageEnvelope, str]: async def main_loop(self): await self.send_server_control_message(ConfigControlMessage(config=self.config)) - - logger.info("Config sent.") - async def setup(self, s2_channel: Channel[str, str], *args, **kwargs): + logger.info("Config sent.") + async def run(self, s2_channel, *args, **kwargs): server_channel = await self.connect_to_server() - await super().setup(s2_channel, server_channel) + return await super().run(s2_channel, server_channel, *args, **kwargs) # class ServerSideCertificationOrchestrator(ServerOrchestrator): From 0fb4af1c8010c2f24525c9e438b715e9d0cc6b3d Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Thu, 8 May 2025 16:40:41 +0200 Subject: [PATCH 20/75] Added the ability for the connecting to a websocket server Previously this system would always wait for something to connect to it but now it also has the ability to connect to a websocket server. This is toggled using a configuration in the config file. --- .../connectivity/src/connectivity/config.py | 7 ++++ .../src/testsuites/certificate/certificate.py | 1 + s2-self-cert/cert-plan.yaml | 21 ------------ s2-self-cert/cert.yaml | 21 ++---------- s2-self-cert/{src => }/config.yaml | 6 +++- s2-self-cert/src/main.py | 23 ++++++++++--- s2-self-cert/src/server.py | 33 ++++++++++++++++--- s2-self-cert/src/test-server.py | 22 +++++++++++++ 8 files changed, 86 insertions(+), 48 deletions(-) delete mode 100644 s2-self-cert/cert-plan.yaml rename s2-self-cert/{src => }/config.yaml (71%) create mode 100644 s2-self-cert/src/test-server.py diff --git a/packages/connectivity/src/connectivity/config.py b/packages/connectivity/src/connectivity/config.py index 9de4da3..a71f423 100644 --- a/packages/connectivity/src/connectivity/config.py +++ b/packages/connectivity/src/connectivity/config.py @@ -46,8 +46,15 @@ class DeviceDetails(BaseModel): manufacturer: str +class ConnectionConfig(BaseModel): + mode: Literal["server", "client"] = "client" + host: str = "0.0.0.0" + port: int = 8000 + + class Config(BaseModel): mode: Literal["testing", "certification"] + connection: ConnectionConfig device_details: Optional[DeviceDetails] control_types: ControlTypeTestConfig diff --git a/packages/test-suites/src/testsuites/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py index 197127d..85675ee 100644 --- a/packages/test-suites/src/testsuites/certificate/certificate.py +++ b/packages/test-suites/src/testsuites/certificate/certificate.py @@ -16,6 +16,7 @@ class ComplianceStatus(Enum): PASS = "PASS" FAIL = "FAIL" N_A = "N/A" + # ? Maybe add failed but not critical type? class ComplianceParameter(BaseModel): diff --git a/s2-self-cert/cert-plan.yaml b/s2-self-cert/cert-plan.yaml deleted file mode 100644 index d62dfe5..0000000 --- a/s2-self-cert/cert-plan.yaml +++ /dev/null @@ -1,21 +0,0 @@ -timestamp: 2025-04-30 11:38:24.250547 -device: - manufacturer: ABC - device: Solar MK2 - model: v1.0 -findings: -- test: FRBC System Description Test - parameters: - - name: FRBCSystemDescription Message Received. - status: PASS - status: PASS -- test: Power Forecast Test - parameters: - - name: PowerForecast Message Received. - status: PASS - status: PASS -- test: Power Measurement Test - parameters: - - name: PowerMeasurement Message Received. - status: PASS - status: PASS diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml index 9116a9a..60936ef 100644 --- a/s2-self-cert/cert.yaml +++ b/s2-self-cert/cert.yaml @@ -1,21 +1,6 @@ device: manufacturer: ABCD name: Some Device -findings: -- parameters: - - name: PowerForecast Provided. - status: PASS - status: PASS - test: Test Receive Power Forecast -- parameters: - - name: PowerMeasurement Provided. - status: PASS - status: PASS - test: Test Receive Power Measurement -- parameters: - - name: PEBCPowerConstraints Received. - status: PASS - status: PASS - test: Test receive PEBCPowerConstraints -signature: TEST SERVER SIGNATURE -timestamp: 2025-05-08 13:46:35.222056 +findings: [] +signature: null +timestamp: 2025-05-08 16:38:59.518045 diff --git a/s2-self-cert/src/config.yaml b/s2-self-cert/config.yaml similarity index 71% rename from s2-self-cert/src/config.yaml rename to s2-self-cert/config.yaml index 5895e4b..43b183e 100644 --- a/s2-self-cert/src/config.yaml +++ b/s2-self-cert/config.yaml @@ -2,7 +2,11 @@ device_details: name: Some Device manufacturer: ABCD -mode: certification +mode: testing +connection: + mode: server + host: 127.0.0.1 + port: 8000 control_types: no_selection: null pebc: diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index b2dd507..fd46ba7 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -9,7 +9,7 @@ from connectivity.config import Config, load_config from log import LOGGING_CONFIG -from server import S2Server +from server import S2WebSocketClient, S2WebSocketServer from server_side_certification_orchestrator import CertificationTestExecutor from testsuites.certification_executor import AbstractCertificationExecutor from testsuites.test_executor import create_test_executor @@ -47,9 +47,24 @@ async def main(): logger.info("-" * 40) logger.info(f"Starting in {config.mode} mode...") - s2_server = S2Server("0.0.0.0", 8000, test_executor, config.mode, args.output) - - await s2_server.start() + if config.connection.mode == "server": + s2_server = S2WebSocketServer( + config.connection.host, + config.connection.port, + test_executor, + config.mode, + args.output, + ) + await s2_server.start() + else: + s2_client = S2WebSocketClient( + config.connection.host, + config.connection.port, + test_executor, + config.mode, + args.output, + ) + await s2_client.start() # report.export() diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index b71afba..703deef 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -4,6 +4,7 @@ from typing import Literal, Optional from testsuites.test_executor import IntegrationTestExecutor +from websockets import connect from websockets.asyncio.connection import Connection as WSConnection from websockets.asyncio.server import serve as ws_serve from ws_adapter import WebSocketConnectionAdapter @@ -15,8 +16,7 @@ logger = logging.getLogger(__name__) -class S2Server: - # Receives incoming S2 Resource Manager WebSocket Connections +class S2WebSocketBase: executor: AbstractCertificationExecutor mode: Literal["testing", "certification"] @@ -40,7 +40,7 @@ def __init__( self.report_output_file = report_output_file - async def handle_incoming_connection(self, websocket: WSConnection): + async def start_with_connection(self, websocket: WSConnection): """ On WS connect it creates the connection instance and adds it to the Orchestrator. If the orchestrator already has a connection then it discards the new connection. @@ -71,6 +71,14 @@ async def handle_incoming_connection(self, websocket: WSConnection): logger.warning("This application only accepts one connection.") await websocket.close() + +class S2WebSocketServer(S2WebSocketBase): + # Receives incoming S2 Resource Manager WebSocket Connections + executor: AbstractCertificationExecutor + mode: Literal["testing", "certification"] + + _exit_event: asyncio.Event + async def stop(self): logger.info("Stopping server...") self._exit_event.set() @@ -82,7 +90,7 @@ async def start(self): loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) async with ws_serve( - self.handle_incoming_connection, self._host, self._port + self.start_with_connection, self._host, self._port ) as ws_server: logger.info(f"Websocket server started at ws://{self._host}:{self._port}") logger.info("Waiting for RM connection...") @@ -91,3 +99,20 @@ async def start(self): await self.executor.stop() logger.info(f"Server stop.") + + +class S2WebSocketClient(S2WebSocketBase): + + async def start(self): + loop = asyncio.get_event_loop() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) + + logger.info(f"Connection to Websocket server at ws://{self._host}:{self._port}") + async with connect("ws://localhost:8765") as websocket: + await self.start_with_connection(websocket) + + async def stop(self): + logger.info("Stopping...") + await self.executor.stop() diff --git a/s2-self-cert/src/test-server.py b/s2-self-cert/src/test-server.py new file mode 100644 index 0000000..d167a47 --- /dev/null +++ b/s2-self-cert/src/test-server.py @@ -0,0 +1,22 @@ +# simple_server.py +import asyncio +import websockets + +async def handler(websocket, path): + print(f"Client connected from {websocket.remote_address}") + try: + async for message in websocket: + print(f"Received message: {message}") + # await websocket.send(f"Echo: {message}") + except websockets.exceptions.ConnectionClosedOK: + print(f"Client {websocket.remote_address} disconnected") + except Exception as e: + print(f"Error with client {websocket.remote_address}: {e}") + +async def main(): + async with websockets.serve(handler, "localhost", 8765): + print("WebSocket server started on ws://localhost:8765") + await asyncio.Future() # run forever + +if __name__ == "__main__": + asyncio.run(main()) From 02d9196719d8750b8c180729998139da3ab24a01 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 09:57:47 +0200 Subject: [PATCH 21/75] Added version checks on server side to ensure client and server have matching versions --- .../src/testsuites/certification_executor.py | 11 ++++++-- .../src/testsuites/envelope_models.py | 18 ++++++++++-- s2-self-cert-server/src/main.py | 23 +++++++++++++++ .../server_side_certification_orchestrator.py | 28 ++++++++++++++----- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/test-suites/src/testsuites/certification_executor.py b/packages/test-suites/src/testsuites/certification_executor.py index d75cd1d..2ccbd63 100644 --- a/packages/test-suites/src/testsuites/certification_executor.py +++ b/packages/test-suites/src/testsuites/certification_executor.py @@ -3,6 +3,7 @@ Callable, Dict, Generic, + Literal, Optional, Type, TypeVar, @@ -160,8 +161,14 @@ async def send_server_s2_message(self, message: Union[str, S2Message]): async def send_s2_message(self, message: str): await self.s2_channel.send(message) - async def send_server_log_message(self, message: LogMessage): - envelope = LogMessageEnvelope(message=message) + async def send_server_log_message( + self, + message: str, + details: Optional[str] = None, + level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO", + ): + log_message = LogMessage(level=level, message=message, details=details) + envelope = LogMessageEnvelope(message=log_message) await self.server_channel.send(envelope) async def get_next_s2_channel_message(self): diff --git a/packages/test-suites/src/testsuites/envelope_models.py b/packages/test-suites/src/testsuites/envelope_models.py index 454763e..2c99ef6 100644 --- a/packages/test-suites/src/testsuites/envelope_models.py +++ b/packages/test-suites/src/testsuites/envelope_models.py @@ -1,6 +1,6 @@ from enum import Enum import json -from typing import Dict, Type, Union +from typing import Dict, Optional, Type, Union from pydantic import BaseModel, ValidationError from connectivity.config import Config @@ -23,13 +23,27 @@ class MessageEnvelopeTypeEnum(str, Enum): class ControlMessageType(str, Enum): + CLIENT_INFO = "CLIENT_INFO" CONFIG = "CONFIG" REPORT = "REPORT" class LogMessage(BaseModel): level: str - content: str + message: str + details: Optional[str] + + +class ClientInfo(BaseModel): + # The version of the `connectivity` package that the client is using + connectivity_version: str + # The version of the `test-suites` package that the client is using + testsuites_version: str + + +class ClientInfoControlMessage(BaseModel): + message_type: ControlMessageType = ControlMessageType.CLIENT_INFO + client_info: ClientInfo class ConfigControlMessage(BaseModel): diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index 5fdc429..d8ee3ab 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -1,3 +1,4 @@ +from importlib.metadata import version from typing import Optional from fastapi import FastAPI import asyncio @@ -18,6 +19,7 @@ from testsuites.envelope_models import ( + ClientInfoControlMessage, ServerMessageEnvelope, S2MessageEnvelope, LogMessage, @@ -115,6 +117,27 @@ async def handle_config_message(self, message: ConfigControlMessage): self._config_received.set() + async def handle_client_info(self, message: ClientInfoControlMessage): + + connectivity_version = version("connectivity") + testsuites_version = version("test-suites") + + client_info = message.client_info + + if connectivity_version != client_info.connectivity_version: + await self.send_server_log_message( + message="Client & Server have mismatched version of package 'connectivity'. Exiting...", + details=f"Expected version {connectivity_version} for package 'connectivity' but received {client_info.connectivity_version}.", + ) + + if testsuites_version != client_info.testsuites_version: + await self.send_server_log_message( + message="Client & Server have mismatched version of package 'test-suites'. Exiting...", + details=f"Expected version {testsuites_version} for package 'test-suites' but received {client_info.testsuites_version}.", + ) + + await self.stop() + async def handle_control_message(self, message: ControlMessage): logger.debug("Control Message: %s", message) await self.handle_message(message) diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py index b390e90..7ef1375 100644 --- a/s2-self-cert/src/server_side_certification_orchestrator.py +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -38,17 +38,13 @@ from testsuites.envelope_models import ( ServerMessageEnvelope, - S2MessageEnvelope, - LogMessage, - LogMessageEnvelope, - ControlMessage, + ClientInfo, + ClientInfoControlMessage, ConfigControlMessage, ReportControlMessage, ControlMessageEnvelope, - ControlMessageType, - MessageEnvelopeTypeEnum, - BaseEnvelope, ) +from importlib.metadata import version logger = logging.getLogger(__name__) @@ -92,8 +88,26 @@ async def connect_to_server(self) -> Channel[ServerMessageEnvelope, str]: return channel + async def send_client_info_message(self): + """Sends information about the client to the server. Currently only checks package version but in future can be used for other information.""" + logger.info("* Sending Client Details to Server *") + logger.debug("Testing Package Version: %s", version("test-suites")) + logger.debug("Connectivity Package Version: %s", version("connectivity")) + + message = ClientInfoControlMessage( + client_info=ClientInfo( + connectivity_version=version("connectivity"), + testsuites_version=version("test-suites"), + ) + ) + + envelope = ControlMessageEnvelope(message=message) + + self.server_channel.send(envelope) + async def main_loop(self): + await self.send_client_info_message() await self.send_server_control_message(ConfigControlMessage(config=self.config)) logger.info("Config sent.") From 3d50592d747194ef362c6348e2349c2b64f1c159 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 10:18:44 +0200 Subject: [PATCH 22/75] Connection config now takes a URI for client mode so that paths can be specified --- .../connectivity/src/connectivity/config.py | 17 ++++++++++++++--- s2-self-cert-server/src/main.py | 4 ++-- s2-self-cert/config.yaml | 3 ++- s2-self-cert/src/main.py | 6 ++---- s2-self-cert/src/server.py | 19 +++++++++++-------- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/connectivity/src/connectivity/config.py b/packages/connectivity/src/connectivity/config.py index a71f423..b5521c5 100644 --- a/packages/connectivity/src/connectivity/config.py +++ b/packages/connectivity/src/connectivity/config.py @@ -2,7 +2,7 @@ from typing import Optional, Literal import yaml -from pydantic import BaseModel +from pydantic import BaseModel, model_validator from s2python.common import ControlType as ProtocolControlType logger = logging.getLogger(__name__) @@ -48,8 +48,19 @@ class DeviceDetails(BaseModel): class ConnectionConfig(BaseModel): mode: Literal["server", "client"] = "client" - host: str = "0.0.0.0" - port: int = 8000 + host: Optional[str] = "0.0.0.0" + port: Optional[int] = 8000 + uri: Optional[str] = None + + @model_validator(mode="after") + def check_mode_fields(cls, values): + if values.mode == "client": + if not values.uri: + raise ValueError("For client mode, 'uri' must be provided.") + elif values.mode == "server": + if not values.host or values.port is None: + raise ValueError("For server mode, 'host' and 'port' must be provided.") + return values class Config(BaseModel): diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index d8ee3ab..c3383de 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -35,8 +35,8 @@ from connectivity.channel import Channel -from .log import LOGGING_CONFIG -from .ws_adapter import FastAPIWebSocketAdapter +from log import LOGGING_CONFIG +from ws_adapter import FastAPIWebSocketAdapter logging.config.dictConfig(LOGGING_CONFIG) diff --git a/s2-self-cert/config.yaml b/s2-self-cert/config.yaml index 43b183e..229c9b7 100644 --- a/s2-self-cert/config.yaml +++ b/s2-self-cert/config.yaml @@ -4,7 +4,8 @@ device_details: manufacturer: ABCD mode: testing connection: - mode: server + mode: client + uri: ws://127.0.0.1:8001/ws host: 127.0.0.1 port: 8000 control_types: diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index fd46ba7..459e104 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -49,8 +49,7 @@ async def main(): if config.connection.mode == "server": s2_server = S2WebSocketServer( - config.connection.host, - config.connection.port, + config.connection, test_executor, config.mode, args.output, @@ -58,8 +57,7 @@ async def main(): await s2_server.start() else: s2_client = S2WebSocketClient( - config.connection.host, - config.connection.port, + config.connection, test_executor, config.mode, args.output, diff --git a/s2-self-cert/src/server.py b/s2-self-cert/src/server.py index 703deef..1ed5105 100644 --- a/s2-self-cert/src/server.py +++ b/s2-self-cert/src/server.py @@ -9,6 +9,7 @@ from websockets.asyncio.server import serve as ws_serve from ws_adapter import WebSocketConnectionAdapter from connectivity.channel import Channel, BaseChannel +from connectivity.config import ConnectionConfig from connectivity.s2_channel import S2Channel from testsuites.certification_executor import AbstractCertificationExecutor @@ -20,20 +21,20 @@ class S2WebSocketBase: executor: AbstractCertificationExecutor mode: Literal["testing", "certification"] + config: ConnectionConfig + _exit_event: asyncio.Event def __init__( self, - host, - port, + config: ConnectionConfig, orchestrator: AbstractCertificationExecutor, mode: Literal["testing", "certification"], report_output_file: Optional[str] = None, ): - self._host = host - self._port = port self._exit_event = asyncio.Event() + self.config = config self.mode = mode self.executor = orchestrator @@ -90,9 +91,11 @@ async def start(self): loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) async with ws_serve( - self.start_with_connection, self._host, self._port + self.start_with_connection, self.config.host, self.config.port ) as ws_server: - logger.info(f"Websocket server started at ws://{self._host}:{self._port}") + logger.info( + f"Websocket server started at ws://{self.config.host}:{self.config.port}" + ) logger.info("Waiting for RM connection...") await self._exit_event.wait() logger.info(f"Server stopping.") @@ -109,8 +112,8 @@ async def start(self): for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop())) - logger.info(f"Connection to Websocket server at ws://{self._host}:{self._port}") - async with connect("ws://localhost:8765") as websocket: + logger.info(f"Connection to Websocket server at {self.config.uri}") + async with connect(self.config.uri) as websocket: await self.start_with_connection(websocket) async def stop(self): From 828e9e1df6823c3dc6ee6200e7317c9c98ae3d04 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 10:20:25 +0200 Subject: [PATCH 23/75] Fix of message awaiter in controllers --- .../src/testsuites/message_handlers.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/test-suites/src/testsuites/message_handlers.py b/packages/test-suites/src/testsuites/message_handlers.py index 72f4732..421ff78 100644 --- a/packages/test-suites/src/testsuites/message_handlers.py +++ b/packages/test-suites/src/testsuites/message_handlers.py @@ -42,20 +42,21 @@ async def wait_for_message(self, message_type: Type[S2Message], timeout: float): return message def receive_message(self, message: S2Message): - if message.message_type in self.awaiting: + if type(message) in self.awaiting: logger.debug( "Received %s message that is being waited for. Setting event.", message.message_type, ) - awaiting = self.awaiting[message.message_type] - if self.awaiting: - # Set the message first before triggering the event to make sure that the - # waiting method gets the message. - awaiting[1] = message # type: ignore - awaiting[0].set() + event = self.awaiting[type(message)][0] + + # Set the message first before triggering the event to make sure that the + # waiting method gets the message. + self.awaiting[type(message)] = (event, message) + + event.set() else: logger.debug( - "Received %s message but nothing waiting for it.", message.message_type + "Received %s message but nothing waiting for it.", type(message) ) From 4afe7b7e119afedc2ebecf7ca2a75b82f9c60e8b Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 11:36:27 +0200 Subject: [PATCH 24/75] Added Certificate Renderer genereated by Copilot to make reading the cert easier during testing --- renderer.html | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 renderer.html diff --git a/renderer.html b/renderer.html new file mode 100644 index 0000000..1e3fe73 --- /dev/null +++ b/renderer.html @@ -0,0 +1,77 @@ + + + + + + + + S2 Self Certifier Certificate + + + + + + +
+

S2 Self Certifier Certificate

+ + +
+
+

Overview

+

Timestamp: {{ timestamp }}

+

Result: TODO

+
+
+
+
+

Device Information

+

Manufacturer: {{ device.manufacturer }}

+

Name: {{ device.name }}

+
+
+
+

Test Findings

+
+
+
{{ finding.test }}
+
    +
  • + {{ parameter.name }}: {{ parameter.status }} +
  • +
+

Status: {{ finding.status }}

+
+
+
+ + + + + \ No newline at end of file From a9f6f6c2067ded430661b44d57afd1272e843e7b Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 11:37:20 +0200 Subject: [PATCH 25/75] Split up test cases into better units and made findings a class param Also implemented more tests on PEBC --- .../src/testsuites/certificate/certificate.py | 5 +- .../src/testsuites/controllers/controller.py | 11 ++ .../testsuites/controllers/pebc_controller.py | 42 ++++- .../src/testsuites/test_executor.py | 18 ++- .../src/testsuites/test_suite/__init__.py | 2 +- .../testsuites/test_suite/base_test_case.py | 21 ++- .../testsuites/test_suite/pebc_test_cases.py | 143 ------------------ .../test_suite/pebc_test_cases/__init__.py | 2 + .../test_suite/pebc_test_cases/base.py | 49 ++++++ .../curtailment_instruction_test_case.py | 139 +++++++++++++++++ .../power_constraints_test_case.py | 45 ++++++ .../src/testsuites/test_suite/test_suite.py | 40 ++++- s2-self-cert/config.yaml | 3 +- 13 files changed, 349 insertions(+), 171 deletions(-) delete mode 100644 packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py create mode 100644 packages/test-suites/src/testsuites/test_suite/pebc_test_cases/__init__.py create mode 100644 packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py create mode 100644 packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py create mode 100644 packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py diff --git a/packages/test-suites/src/testsuites/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py index 85675ee..7a7b662 100644 --- a/packages/test-suites/src/testsuites/certificate/certificate.py +++ b/packages/test-suites/src/testsuites/certificate/certificate.py @@ -15,12 +15,13 @@ class ComplianceStatus(Enum): PASS = "PASS" FAIL = "FAIL" + SOFT_FAIL = "SOFT_FAIL" N_A = "N/A" - # ? Maybe add failed but not critical type? class ComplianceParameter(BaseModel): name: str + detail: Optional[str] = None status: ComplianceStatus @field_serializer("status") @@ -64,7 +65,7 @@ def add_finding(self, finding: ComplianceFinding): self.findings.append(finding) def generate_certificate_dict(self) -> dict: - return self.model_dump() + return self.model_dump(exclude_none=True) def export(self, filename="cert.yaml"): if filename is None: diff --git a/packages/test-suites/src/testsuites/controllers/controller.py b/packages/test-suites/src/testsuites/controllers/controller.py index 3be83c4..406b0b4 100644 --- a/packages/test-suites/src/testsuites/controllers/controller.py +++ b/packages/test-suites/src/testsuites/controllers/controller.py @@ -14,6 +14,8 @@ HandshakeResponse, SelectControlType, EnergyManagementRole, + SessionRequest, + SessionRequestType, ) from s2python.s2_validation_error import S2ValidationError from connectivity.s2_channel import S2Channel, SendOkay @@ -141,6 +143,15 @@ async def handle_rm_details( async def wait_until_rm_details_received(self): await self._resource_manager_details_received.wait() + async def perform_disconnect(self, channel: "S2Channel"): + await channel.send_msg_and_await_reception_status( + SessionRequest( + message_id=uuid.uuid4(), + request=SessionRequestType.TERMINATE, + diagnostic_label="Testing complete.", + ) + ) + class BaseController(Controller): control_type = ProtocolControlType.NOT_CONTROLABLE diff --git a/packages/test-suites/src/testsuites/controllers/pebc_controller.py b/packages/test-suites/src/testsuites/controllers/pebc_controller.py index ba2fcbc..5bc7170 100644 --- a/packages/test-suites/src/testsuites/controllers/pebc_controller.py +++ b/packages/test-suites/src/testsuites/controllers/pebc_controller.py @@ -1,13 +1,15 @@ import asyncio -from typing import TYPE_CHECKING, Awaitable, Optional +import datetime +import json +from typing import TYPE_CHECKING, Awaitable, List, Optional +import uuid -from s2python.common import ( - ControlType as ProtocolControlType, - InstructionStatusUpdate, -) +from s2python.common import ControlType as ProtocolControlType, InstructionStatusUpdate from s2python.pebc import ( PEBCEnergyConstraint, PEBCPowerConstraints, + PEBCInstruction, + PEBCPowerEnvelope, ) from .controller import BaseController from connectivity.s2_channel import S2Channel @@ -39,7 +41,10 @@ async def handle_power_constraints_message( channel: "S2Channel", send_okay: Awaitable, ): - logger.info("Received power constraints.") + # logger.info("Received power constraints.") + # logger.info("----------------------------------------------") + # logger.info(json.dumps(message.model_dump(), default=str, indent=2)) + # logger.info("----------------------------------------------") self.power_constraints = message self._power_constraints_received.set() @@ -61,6 +66,31 @@ async def handle_instruction_status_update( ): await send_okay + async def send_power_envelope_instruction( + self, + channel: "S2Channel", + power_envelopes: List[PEBCPowerEnvelope], + execution_time: datetime.datetime = datetime.datetime.now( + datetime.timezone.utc + ), + ) -> PEBCInstruction: + if self.power_constraints is None: + raise ValueError("Power Constraints not set.") + + instruction = PEBCInstruction( + message_id=uuid.uuid4(), + id=uuid.uuid4(), # ? TODO: Should this be a new UUID? + power_constraints_id=self.power_constraints.id, + power_envelopes=power_envelopes, + # Make it timezone-aware (UTC) + execution_time=execution_time, + abnormal_condition=False, + ) + + await channel.send_msg_and_await_reception_status(instruction, 5) + + return instruction + # class PEBCComplianceReportController(PEBCController): diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 0adb2e4..3eebebf 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -10,7 +10,15 @@ from testsuites.certificate.certificate import ComplianceReport from testsuites.test_suite.test_suite import TestSuite, TestSuiteBuilder -from testsuites.test_suite import FRBCTestCase, PEBCTestCase +from testsuites.test_suite import ( + FRBCTestCase, + ReceivePowerMeasurementTestCase, + ReceivePowerForecastTestCase, +) +from testsuites.test_suite.pebc_test_cases import ( + PEBCCurtailmentInstructionTestCase, + PEBCPowerConstraintsTestCase, +) from testsuites.controllers import ( Controller, BaseController, @@ -145,6 +153,9 @@ async def main_loop(self): await self.execute_test_suite() + logger.info("Sending graceful disconnect.") + await self.controller.perform_disconnect(self.channel) + logger.info("Exiting Main Loop.") except asyncio.CancelledError: logger.warning("Main loop was cancelled.") @@ -294,7 +305,10 @@ def create_test_executor(config: Config) -> IntegrationTestExecutor: test_suite = ( TestSuiteBuilder(config.control_types, report) - .with_test_case(PEBCTestCase) + .with_test_case(ReceivePowerForecastTestCase) + .with_test_case(ReceivePowerMeasurementTestCase) + .with_test_case(PEBCPowerConstraintsTestCase) + .with_test_case(PEBCCurtailmentInstructionTestCase) .with_test_case(FRBCTestCase) .build() ) diff --git a/packages/test-suites/src/testsuites/test_suite/__init__.py b/packages/test-suites/src/testsuites/test_suite/__init__.py index f27ddf8..d03cbb4 100644 --- a/packages/test-suites/src/testsuites/test_suite/__init__.py +++ b/packages/test-suites/src/testsuites/test_suite/__init__.py @@ -1,3 +1,3 @@ -from .pebc_test_cases import PEBCTestCase from .frbc_test_cases import FRBCTestCase +from .base_test_case import ReceivePowerMeasurementTestCase, ReceivePowerForecastTestCase from .test_suite import S2TestCase, TestSuite, TestSuiteBuilder diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index 09b1fa0..72a7dc0 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -15,6 +15,10 @@ from connectivity.config import BaseTestConfig from connectivity.s2_channel import S2Channel +import logging + +logger = logging.getLogger(__name__) + class NoSelectionTestCase(S2TestCase): @@ -25,6 +29,7 @@ class NoSelectionTestCase(S2TestCase): controller: BaseController config: BaseTestConfig + def __init__( self, config: BaseTestConfig, @@ -34,6 +39,8 @@ def __init__( ): super().__init__(config, channel, controller, report) + finding = ComplianceFinding(test="Test Receive RM Details") + async def test_validate_rm_details_received(self): finding = ComplianceFinding(test="Test Receive RM Details") @@ -47,19 +54,17 @@ async def test_validate_rm_details_received(self): self.report.add_finding(finding) +class ReceivePowerForecastTestCase(NoSelectionTestCase): + finding = ComplianceFinding(test="Test Receive Power Forecast") @S2TestCase.test async def test_receive_power_forecast(self): + message = await self.check_receive_message_type(PowerForecast) - finding = ComplianceFinding(test="Test Receive Power Forecast") - message = await self.check_receive_message_type(PowerForecast, finding) - - self.report.add_finding(finding) +class ReceivePowerMeasurementTestCase(NoSelectionTestCase): + finding = ComplianceFinding(test="Test Receive Power Measurement") @S2TestCase.test async def test_receive_power_measurement(self): - finding = ComplianceFinding(test="Test Receive Power Measurement") + message = await self.check_receive_message_type(PowerMeasurement) - message = await self.check_receive_message_type(PowerMeasurement, finding) - - self.report.add_finding(finding) diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py deleted file mode 100644 index 03c7585..0000000 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases.py +++ /dev/null @@ -1,143 +0,0 @@ -import datetime -import logging -import uuid - -from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceParameter, - ComplianceReport, - ComplianceStatus, -) -from connectivity.config import BaseTestConfig, PEBCTestConfig -from testsuites.controllers.controller import Controller -from testsuites.controllers.pebc_controller import PEBCController -from s2python.common import ( - ControlType as ProtocolControlType, - PowerMeasurement, -) -from s2python.pebc import ( - PEBCAllowedLimitRange, - PEBCInstruction, - PEBCPowerConstraints, - PEBCPowerEnvelope, - PEBCPowerEnvelopeElement, -) -from testsuites.test_suite.base_test_case import NoSelectionTestCase -from testsuites.test_suite.test_suite import S2TestCase -from connectivity.s2_channel import S2Channel - -logger = logging.getLogger(__name__) - - -class PEBCTestCase(NoSelectionTestCase): - control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL - controller: PEBCController - config: PEBCTestConfig - - def __init__( - self, - config: PEBCTestConfig, - channel: S2Channel, - controller: PEBCController, - report: ComplianceReport, - ): - super().__init__(config, channel, controller, report) - - async def setup(self): - await self.controller._power_constraints_received.wait() - - async def wait_until_power_constraints_set(self): - power_constraints = self.controller.power_constraints - if ( - power_constraints is None - and not self.controller._power_constraints_received.is_set() - ): - logger.info( - "Waiting. %s, %s", - power_constraints, - self.controller._power_constraints_received, - ) - await self.controller._power_constraints_received.wait() - logger.info("Power Constraints is set.") - - @S2TestCase.test - async def validate_power_constraints_set(self): - await self.wait_until_power_constraints_set() - - finding = ComplianceFinding(test="Test receive PEBCPowerConstraints") - finding.add_parameter( - param=ComplianceParameter( - name="PEBCPowerConstraints Received.", status=ComplianceStatus.PASS - ) - ) - - self.report.add_finding(finding) - - # async def test_set_limit_ranges_instruction(self): - # # for limit_ranges in self.power_constraints.allowed_limit_ranges: - # logger.info("Testing set limit range") - # await self.wait_until_power_constraints_set() - - # power_constraints = self.controller.power_constraints - - # if power_constraints is None: - # raise ValueError("Power Constraints not set.") - - # limit_range: PEBCAllowedLimitRange = power_constraints.allowed_limit_ranges[1] - # logger.info(power_constraints) - # logger.info("Sending Instruction.") - - # # exec_time = datetime.datetime.now() - # exec_time = datetime.datetime.fromisoformat("2025-04-22T09:30:00+00:00") - # # logger.info(exec_time.replace(tzinfo=datetime.timezone.utc)) - # logger.info(exec_time) - - # instruction = PEBCInstruction( - # message_id=uuid.uuid4(), - # id=power_constraints.id, - # power_constraints_id=power_constraints.id, - # power_envelopes=[ - # PEBCPowerEnvelope( - # id="pe_test", # type: ignore - # commodity_quantity=limit_range.commodity_quantity, - # power_envelope_elements=[ - # PEBCPowerEnvelopeElement( - # lower_limit=-2000.00, - # upper_limit=0, - # duration=3600000, # type: ignore - # ) - # ], - # ) - # ], - # # Make it timezone-aware (UTC) - # execution_time=exec_time.replace(tzinfo=datetime.timezone.utc), - # abnormal_condition=False, - # ) - # logger.info(instruction) - # await self.connection.send_msg_and_await_reception_status( - # instruction, raise_on_error=True - # ) - # logger.info("Instruction sent") - - # async def test_receives_interval_power_readings(self): - - # if self.config is None or self.config.status_update_frequency is None: - # raise ValueError("Status Update Frequency required to test status updates.") - - # logger.info("Waiting for power reading.") - # try: - # await self.controller.message_awaiter.wait_for_message( - # PowerMeasurement, - # timeout=float(self.config.status_update_frequency), - # ) - # logger.info("Power reading received.") - # await self.controller.message_awaiter.wait_for_message( - # PowerMeasurement, - # timeout=float( - # self.config.status_update_frequency - # + self.config.status_update_frequency_buffer - # ), - # ) - # logger.info("Power readings test passed.") - # except Exception: - # logger.exception("Did not receive power reading within allowed window.") diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/__init__.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/__init__.py new file mode 100644 index 0000000..c977373 --- /dev/null +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/__init__.py @@ -0,0 +1,2 @@ +from .curtailment_instruction_test_case import PEBCCurtailmentInstructionTestCase +from .power_constraints_test_case import PEBCPowerConstraintsTestCase diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py new file mode 100644 index 0000000..9dd6146 --- /dev/null +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py @@ -0,0 +1,49 @@ +import logging +from typing import Optional + +from testsuites.certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) +from connectivity.config import PEBCTestConfig +from testsuites.controllers.pebc_controller import PEBCController +from s2python.common import ControlType as ProtocolControlType +from testsuites.test_suite.base_test_case import NoSelectionTestCase +from connectivity.s2_channel import S2Channel + +logger = logging.getLogger(__name__) + + +class PEBCTestCase(NoSelectionTestCase): + control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL + controller: PEBCController + config: PEBCTestConfig + + + def __init__( + self, + config: PEBCTestConfig, + channel: S2Channel, + controller: PEBCController, + report: ComplianceReport, + ): + super().__init__(config, channel, controller, report) + + async def setup(self): + await self.controller._power_constraints_received.wait() + + async def wait_until_power_constraints_set(self): + power_constraints = self.controller.power_constraints + if ( + power_constraints is None + and not self.controller._power_constraints_received.is_set() + ): + logger.info( + "Waiting. %s, %s", + power_constraints, + self.controller._power_constraints_received, + ) + await self.controller._power_constraints_received.wait() + logger.info("Power Constraints is set.") diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py new file mode 100644 index 0000000..0e9e726 --- /dev/null +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py @@ -0,0 +1,139 @@ +import datetime +import logging +from typing import Dict, List +import uuid + +from testsuites.certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) +from connectivity.config import BaseTestConfig, PEBCTestConfig +from testsuites.controllers.controller import Controller +from testsuites.controllers.pebc_controller import PEBCController +from s2python.common import ( + ControlType as ProtocolControlType, + PowerMeasurement, + InstructionStatusUpdate, + CommodityQuantity, +) +from s2python.pebc import ( + PEBCAllowedLimitRange, + PEBCInstruction, + PEBCPowerConstraints, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, +) +from testsuites.test_suite.base_test_case import NoSelectionTestCase +from testsuites.test_suite.test_suite import S2TestCase +from connectivity.s2_channel import S2Channel +from .base import PEBCTestCase + +logger = logging.getLogger(__name__) + + +class PEBCCurtailmentInstructionTestCase(PEBCTestCase): + finding = ComplianceFinding(test="Test sending curtailment instruction.") + + def create_power_envelope( + self, commodity_quantity, lower_limit, upper_limit, duration=3600 + ) -> PEBCPowerEnvelope: + return PEBCPowerEnvelope( + id="test_env", # type: ignore + commodity_quantity=commodity_quantity, + power_envelope_elements=[ + PEBCPowerEnvelopeElement( + lower_limit=lower_limit, + upper_limit=upper_limit, + duration=duration, # type: ignore + ) + ], + ) + + async def curtail_commodity_quantity( + self, + power_constraints: PEBCPowerConstraints, + commodity_quantity: CommodityQuantity, + limits: List[PEBCAllowedLimitRange], + duration=3600, + ): + logger.info("Curtailing %s", commodity_quantity) + + power_envelopes = [ + self.create_power_envelope( + commodity_quantity=commodity_quantity, + lower_limit=-200, + upper_limit=0, + duration=duration, + ) + ] + + # Prepare coroutines first so the events are waiting in the awaiter. + # This avoids the case where the message somehow arrives between sending the message and starting to await. + # This is highly unlikely but might as well make sure! + power_measurement_coroutine = self.controller.message_awaiter.wait_for_message( + PowerMeasurement, 60 + ) + status_update_coroutine = self.controller.message_awaiter.wait_for_message( + InstructionStatusUpdate, 10 + ) + + instruction = await self.controller.send_power_envelope_instruction( + self.channel, power_envelopes + ) + + logger.info("Instruction sent") + + status_update = await status_update_coroutine + + if type(status_update) != InstructionStatusUpdate: + self.add_finding_param( + name="InstructionStatusUpdate received.", status=ComplianceStatus.FAIL + ) + return + self.add_finding_param( + name="InstructionStatusUpdate received.", status=ComplianceStatus.PASS + ) + + logger.info("Status Update: %s", status_update) + if status_update.instruction_id == instruction.id: + self.add_finding_param( + name="InstructionStatusUpdate instruction_id matches instruction's ID.", + status=ComplianceStatus.PASS, + ) + else: + self.add_finding_param( + name="InstructionStatusUpdate instruction_id matches instruction's ID.", + status=ComplianceStatus.FAIL, + ) + + message = await power_measurement_coroutine + + logger.info("Received Power Measurement: %s", message) + + @S2TestCase.test + async def test_set_limit_ranges_instruction(self): + logger.info("Testing set limit range") + await self.wait_until_power_constraints_set() + + power_constraints = self.controller.power_constraints + + if power_constraints is None: + raise ValueError("Power Constraints not set.") + + limit_range: PEBCAllowedLimitRange = power_constraints.allowed_limit_ranges[1] + + limit_ranges: Dict[CommodityQuantity, List[PEBCAllowedLimitRange]] = {} + for limit_range in power_constraints.allowed_limit_ranges: + if limit_range.commodity_quantity in limit_ranges: + limit_ranges[limit_range.commodity_quantity].append(limit_range) + else: + limit_ranges[limit_range.commodity_quantity] = [limit_range] + + for commodity_quantity, ranges in limit_ranges.items(): + await self.curtail_commodity_quantity( + power_constraints=power_constraints, + commodity_quantity=commodity_quantity, + limits=ranges, + ) diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py new file mode 100644 index 0000000..7339262 --- /dev/null +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py @@ -0,0 +1,45 @@ +import datetime +import logging +from typing import Dict, List +import uuid + +from testsuites.certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) +from connectivity.config import BaseTestConfig, PEBCTestConfig +from testsuites.controllers.controller import Controller +from testsuites.controllers.pebc_controller import PEBCController +from s2python.common import ( + ControlType as ProtocolControlType, + PowerMeasurement, + InstructionStatusUpdate, + CommodityQuantity, +) +from s2python.pebc import ( + PEBCAllowedLimitRange, + PEBCInstruction, + PEBCPowerConstraints, + PEBCPowerEnvelope, + PEBCPowerEnvelopeElement, +) +from testsuites.test_suite.base_test_case import NoSelectionTestCase +from testsuites.test_suite.test_suite import S2TestCase +from connectivity.s2_channel import S2Channel +from .base import PEBCTestCase + +logger = logging.getLogger(__name__) + + +class PEBCPowerConstraintsTestCase(PEBCTestCase): + finding = ComplianceFinding(test="Test receive PEBCPowerConstraints") + + @S2TestCase.test + async def validate_power_constraints_set(self): + await self.wait_until_power_constraints_set() + + self.add_finding_param( + name="PEBCPowerConstraints Received.", status=ComplianceStatus.PASS + ) diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index ec38ffa..d5cc6db 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -25,6 +25,8 @@ class S2TestCase(abc.ABC): control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION config: BaseTestConfig + finding: ComplianceFinding + TIMEOUT = 5 def __init__( @@ -39,16 +41,34 @@ def __init__( self.config = config self.report = report + finding_set = True + try: + if self.finding is None: + finding_set = False + except: + finding_set = False + + if not finding_set: + raise ValueError( + "Finding must be declared as a constant for a test case class." + ) + + def add_finding_param( + self, + name: str, + detail: Optional[str] = None, + status: ComplianceStatus = ComplianceStatus.PASS, + ): + param = ComplianceParameter(name=name, detail=detail, status=status) + + self.finding.add_parameter(param=param) + async def check_receive_message_type( self, message_type: Type[S2Message], - report_finding: Optional[ComplianceFinding] = None, ): logger.info("Checking for %s", message_type) - if report_finding is None: - report_finding = ComplianceFinding(test=message_type) - message = None try: messages: list = self.controller.get_received_messages(message_type) @@ -67,12 +87,12 @@ async def check_receive_message_type( message = messages[0] if message is not None: - report_finding.add_parameter( + self.add_finding_param( name=f"{message_type.__name__} Provided.", status=ComplianceStatus.PASS, ) else: - report_finding.add_parameter( + self.add_finding_param( name=f"{message_type.__name__} Not Provided.", status=ComplianceStatus.FAIL, ) @@ -133,7 +153,11 @@ def add_test_case(self, test_case: Type[S2TestCase]): async def execute(self, channel: S2Channel, controller: Controller): control_type = controller.control_type - test_cases = self.test_cases.get(control_type, []) + test_cases = self.test_cases.get( + ProtocolControlType.NO_SELECTION, [] + ) + test_cases += self.test_cases.get(control_type, []) + logger.info(self.test_cases) logger.info( "Executing test suite for %s control type. %s test cases to execute.", @@ -150,6 +174,8 @@ async def execute(self, channel: S2Channel, controller: Controller): ) await test_case.execute() + self.report.add_finding(test_case.finding) + class TestSuiteBuilder: def __init__(self, config: ControlTypeTestConfig, report: ComplianceReport): diff --git a/s2-self-cert/config.yaml b/s2-self-cert/config.yaml index 229c9b7..43b183e 100644 --- a/s2-self-cert/config.yaml +++ b/s2-self-cert/config.yaml @@ -4,8 +4,7 @@ device_details: manufacturer: ABCD mode: testing connection: - mode: client - uri: ws://127.0.0.1:8001/ws + mode: server host: 127.0.0.1 port: 8000 control_types: From a31d7cd4862d1e126a7f1ba2a48178c29fe6e3aa Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 15:29:24 +0200 Subject: [PATCH 26/75] Added some PEBC unit tests and fied PEBCPowerConstraints validation --- .../connectivity/src/connectivity/channel.py | 4 +- .../s2python/pebc/pebc_power_constraints.py | 40 ++- .../pebc/pebc_allowed_limit_range_test.py | 87 ++++++ .../unit/pebc/pebc_power_constraints_test.py | 248 ++++++++++++++++++ .../src/testsuites/message_handlers.py | 16 +- .../src/testsuites/test_executor.py | 56 ++-- .../curtailment_instruction_test_case.py | 92 +++++-- s2-self-cert/config.yaml | 1 + s2-self-cert/src/main.py | 2 +- 9 files changed, 480 insertions(+), 66 deletions(-) create mode 100644 packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py create mode 100644 packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py diff --git a/packages/connectivity/src/connectivity/channel.py b/packages/connectivity/src/connectivity/channel.py index e5257d4..ee1c1d8 100644 --- a/packages/connectivity/src/connectivity/channel.py +++ b/packages/connectivity/src/connectivity/channel.py @@ -74,7 +74,10 @@ async def run(self): await self.connection.close() + logger.info("Channel Run complete.") + async def stop(self): + logger.info("Sopping Channel.") if not self._stop_event.is_set(): self.message_queue.put_nowait(None) self._stop_event.set() @@ -83,4 +86,3 @@ async def stop(self): class BaseChannel(Channel[str, str]): """A str, str channel.""" - diff --git a/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py b/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py index fa5cff1..71ab1bc 100644 --- a/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py +++ b/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py @@ -1,9 +1,10 @@ import uuid -from typing import List +from typing import List, Dict, Tuple from typing_extensions import Self from pydantic import model_validator +from s2python.common import CommodityQuantity, NumberRange from s2python.generated.gen_s2 import ( PEBCPowerConstraints as GenPEBCPowerConstraints, PEBCPowerEnvelopeConsequenceType as GenPEBCPowerEnvelopeConsequenceType, @@ -32,15 +33,34 @@ class PEBCPowerConstraints(GenPEBCPowerConstraints, S2MessageComponent): @model_validator(mode="after") def validate_has_one_upper_one_lower_limit_range(self) -> Self: - has_upper = any( - l.limit_type == PEBCPowerEnvelopeLimitType.UPPER_LIMIT - for l in self.allowed_limit_ranges - ) - has_lower = any( - l.limit_type == PEBCPowerEnvelopeLimitType.UPPER_LIMIT - for l in self.allowed_limit_ranges - ) - if not (has_upper and has_lower): + + commodity_type_ranges: Dict[CommodityQuantity, Tuple[bool, bool]] = {} + + for limit_range in self.allowed_limit_ranges: + current: Tuple[bool, bool] = commodity_type_ranges.get( + limit_range.commodity_quantity, (False, False) + ) + + if limit_range.limit_type == PEBCPowerEnvelopeLimitType.UPPER_LIMIT: + current = ( + True, + current[1], + ) + + if limit_range.limit_type == PEBCPowerEnvelopeLimitType.LOWER_LIMIT: + current = ( + current[0], + True, + ) + + commodity_type_ranges[limit_range.commodity_quantity] = current + + valid = True + + for upper, lower in commodity_type_ranges.values(): + valid = valid and upper and lower + + if not (valid): raise ValueError( self, f"There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT.", diff --git a/packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py b/packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py new file mode 100644 index 0000000..c27964e --- /dev/null +++ b/packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py @@ -0,0 +1,87 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.pebc import * +from s2python.s2_validation_error import S2ValidationError + + +class PEBCAllowedLimitRangeTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """ +{ + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": 1000.0 + }, + "abnormal_condition_only": false +} + """ + + # Act + allowed_limit_range = PEBCAllowedLimitRange.from_json(json_str) + + # Assert + self.assertEqual( + allowed_limit_range.commodity_quantity, + CommodityQuantity.ELECTRIC_POWER_L1, + ) + self.assertEqual( + allowed_limit_range.limit_type, + PEBCPowerEnvelopeLimitType.UPPER_LIMIT, + ) + self.assertEqual( + allowed_limit_range.range_boundary.start_of_range, 0.0 + ) + self.assertEqual( + allowed_limit_range.range_boundary.end_of_range, 1000.0 + ) + self.assertEqual( + allowed_limit_range.abnormal_condition_only, False + ) + + def test__to_json__happy_path(self): + # Arrange + allowed_limit_range = PEBCAllowedLimitRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + limit_type=PEBCPowerEnvelopeLimitType.UPPER_LIMIT, + range_boundary=NumberRange( + start_of_range=0.0, end_of_range=1000.0 + ), + abnormal_condition_only=False, + ) + + # Act + json_str = allowed_limit_range.to_json() + + # Assert + expected_json = { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": {"start_of_range": 0.0, "end_of_range": 1000.0}, + "abnormal_condition_only": False, + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__from_json__invalid_range_boundary(self): + # Arrange + json_str = """ +{ + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": { + "start_of_range": 1000.0, + "end_of_range": 0.0 + }, + "abnormal_condition_only": false +} + """ + + # Act & Assert + with self.assertRaises(S2ValidationError) as context: + PEBCAllowedLimitRange.from_json(json_str) diff --git a/packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py b/packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py new file mode 100644 index 0000000..20c739d --- /dev/null +++ b/packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py @@ -0,0 +1,248 @@ +from datetime import timedelta, datetime, timezone as offset +import json +from unittest import TestCase +import uuid + +from s2python.common import * +from s2python.pebc import * +from s2python.s2_validation_error import S2ValidationError + + +class PEBCPowerConstraintsTest(TestCase): + def test__from_json__happy_path(self): + # Arrange + json_str = """ +{ + "message_type": "PEBC.PowerConstraints", + "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", + "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", + "valid_from": "2025-05-12T12:00:00.000000Z", + "valid_until": "2025-05-12T13:00:00.000000Z", + "consequence_type": "VANISH", + "allowed_limit_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": 0.0 + }, + "abnormal_condition_only": false + }, + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "LOWER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": -2000.0 + }, + "abnormal_condition_only": false + } + ] +} + """ + + # Act + pebc_power_constraints: PEBCPowerConstraints = PEBCPowerConstraints.from_json( + json_str + ) + + self.assertEqual( + pebc_power_constraints.id, + uuid.UUID("7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e"), + ) + self.assertEqual( + pebc_power_constraints.message_id, + uuid.UUID("5165cd7f-04bd-4c78-8fdd-b504cb0013a3"), + ) + self.assertEqual(pebc_power_constraints.message_type, "PEBC.PowerConstraints") + self.assertEqual( + pebc_power_constraints.consequence_type, + PEBCPowerEnvelopeConsequenceType.VANISH, + ) + + self.assertEqual( + pebc_power_constraints.valid_from, + datetime( + year=2025, + month=5, + day=12, + hour=12, + minute=0, + second=0, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + self.assertEqual( + pebc_power_constraints.valid_until, + datetime( + year=2025, + month=5, + day=12, + hour=13, + minute=0, + second=0, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + ) + + self.assertEqual(len(pebc_power_constraints.allowed_limit_ranges), 2) + + def test__to_json__happy_path(self): + # Arrange + pebc_power_constraints = PEBCPowerConstraints( + message_type="PEBC.PowerConstraints", + message_id=uuid.UUID("5165cd7f-04bd-4c78-8fdd-b504cb0013a3"), + id=uuid.UUID("7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e"), + valid_from=datetime( + year=2025, + month=5, + day=12, + hour=12, + minute=0, + second=0, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + valid_until=datetime( + year=2025, + month=5, + day=12, + hour=13, + minute=0, + second=0, + tzinfo=offset(offset=timedelta(seconds=0.0)), + ), + consequence_type=PEBCPowerEnvelopeConsequenceType.VANISH, + allowed_limit_ranges=[ + PEBCAllowedLimitRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + limit_type=PEBCPowerEnvelopeLimitType.UPPER_LIMIT, + range_boundary=NumberRange(start_of_range=0.0, end_of_range=0.0), + abnormal_condition_only=False, + ), + PEBCAllowedLimitRange( + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + limit_type=PEBCPowerEnvelopeLimitType.LOWER_LIMIT, + range_boundary=NumberRange( + start_of_range=0.0, end_of_range=-2000.0 + ), + abnormal_condition_only=False, + ), + ], + ) + + # Act + json_str = pebc_power_constraints.to_json() + + # Assert + expected_json = { + "message_type": "PEBC.PowerConstraints", + "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", + "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", + "valid_from": "2025-05-12T12:00:00Z", + "valid_until": "2025-05-12T13:00:00Z", + "consequence_type": "VANISH", + "allowed_limit_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": {"start_of_range": 0.0, "end_of_range": 0.0}, + "abnormal_condition_only": False, + }, + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "LOWER_LIMIT", + "range_boundary": {"start_of_range": 0.0, "end_of_range": -2000.0}, + "abnormal_condition_only": False, + }, + ], + } + self.assertEqual(json.loads(json_str), expected_json) + + def test__from_json__missing_upper_limit(self): + # Arrange + json_str = """ +{ + "message_type": "PEBC.PowerConstraints", + "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", + "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", + "valid_from": "2025-05-12T12:00:00.000000Z", + "valid_until": "2025-05-12T13:00:00.000000Z", + "consequence_type": "VANISH", + "allowed_limit_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "LOWER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": -2000.0 + }, + "abnormal_condition_only": false + } + ] +} + """ + with self.assertRaises(S2ValidationError) as context: + PEBCPowerConstraints.from_json(json_str) + + def test__from_json__missing_lower_limit(self): + # Arrange + json_str = """ +{ + "message_type": "PEBC.PowerConstraints", + "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", + "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", + "valid_from": "2025-05-12T12:00:00.000000Z", + "valid_until": "2025-05-12T13:00:00.000000Z", + "consequence_type": "VANISH", + "allowed_limit_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": 0.0 + }, + "abnormal_condition_only": false + } + ] +} + """ + with self.assertRaises(S2ValidationError) as context: + PEBCPowerConstraints.from_json(json_str) + + def test__from_json__valid_until_before_valid_from(self): + # Arrange + json_str = """ +{ + "message_type": "PEBC.PowerConstraints", + "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", + "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", + "valid_from": "2025-05-12T13:00:00.000000Z", + "valid_until": "2025-05-12T12:00:00.000000Z", + "consequence_type": "VANISH", + "allowed_limit_ranges": [ + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "UPPER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": 0.0 + }, + "abnormal_condition_only": false + }, + { + "commodity_quantity": "ELECTRIC.POWER.L1", + "limit_type": "LOWER_LIMIT", + "range_boundary": { + "start_of_range": 0.0, + "end_of_range": -2000.0 + }, + "abnormal_condition_only": false + } + ] +} + """ + with self.assertRaises(S2ValidationError) as context: + PEBCPowerConstraints.from_json(json_str) diff --git a/packages/test-suites/src/testsuites/message_handlers.py b/packages/test-suites/src/testsuites/message_handlers.py index 421ff78..77cfbb7 100644 --- a/packages/test-suites/src/testsuites/message_handlers.py +++ b/packages/test-suites/src/testsuites/message_handlers.py @@ -43,10 +43,10 @@ async def wait_for_message(self, message_type: Type[S2Message], timeout: float): def receive_message(self, message: S2Message): if type(message) in self.awaiting: - logger.debug( - "Received %s message that is being waited for. Setting event.", - message.message_type, - ) + # logger.debug( + # "Received %s message that is being waited for. Setting event.", + # message.message_type, + # ) event = self.awaiting[type(message)][0] # Set the message first before triggering the event to make sure that the @@ -54,10 +54,10 @@ def receive_message(self, message: S2Message): self.awaiting[type(message)] = (event, message) event.set() - else: - logger.debug( - "Received %s message but nothing waiting for it.", type(message) - ) + # else: + # logger.debug( + # "Received %s message but nothing waiting for it.", type(message) + # ) class MessageHandler: diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 3eebebf..907a3d0 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -67,11 +67,14 @@ class IntegrationTestExecutor(AbstractExecutor): _stop_event: asyncio.Event _handshake_complete: asyncio.Event + logger: logging.Logger + def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], test_suite: TestSuite, report: ComplianceReport, + logger: logging.Logger = logging.getLogger(__name__), ) -> None: self.controllers = available_control_types @@ -85,6 +88,8 @@ def __init__( self.report = report + self.logger = logger + self._stop_event = asyncio.Event() self._handshake_complete = asyncio.Event() @@ -118,11 +123,11 @@ async def process_received_messages(self): # logger.info(message) await self.process_message(message) except asyncio.CancelledError: - logger.info("Message Channel cancelled.") + self.logger.info("Message Channel cancelled.") except ConnectionClosed: pass except Exception as e: - logger.exception("Message processor encountered an error: %s", e) + self.logger.exception("Message processor encountered an error: %s", e) await self.stop() async def execute_test_suite(self): @@ -136,7 +141,7 @@ async def execute_test_suite(self): await self.test_suite.execute(self.channel, self.controller) async def main_loop(self): - logger.info("Starting Main Loop.") + self.logger.info("Starting Main Loop.") if self.channel is None: raise ValueError("Channel not set.") @@ -145,32 +150,32 @@ async def main_loop(self): await self.controller.wait_until_rm_details_received() - logger.info("Handshake Complete!") + self.logger.info("Handshake Complete!") await self.send_select_control_type() - logger.info("Starting tests!") + self.logger.info("Starting tests!") await self.execute_test_suite() - logger.info("Sending graceful disconnect.") + self.logger.info("Sending graceful disconnect.") await self.controller.perform_disconnect(self.channel) - logger.info("Exiting Main Loop.") + self.logger.info("Exiting Main Loop.") except asyncio.CancelledError: - logger.warning("Main loop was cancelled.") + self.logger.warning("Main loop was cancelled.") raise # Propagate for TaskGroup except Exception as e: - logger.exception("Exception in main_loop: %s", e) + self.logger.exception("Exception in main_loop: %s", e) await self.stop() raise finally: - logger.info("Main loop finished. Signaling stop.") + self.logger.info("Main loop finished. Signaling stop.") await self.stop() async def send_select_control_type(self): # TODO: Select the control type in a better way. - logger.info("Selecting Control Type.") + self.logger.info("Selecting Control Type.") if ( self.controller.resource_manager_details is None or self.controller.resource_manager_details.available_control_types is None @@ -197,11 +202,11 @@ async def send_select_control_type(self): # ) if control_type is None: - logger.warning("No suitable control types available. Exiting...") + self.logger.warning("No suitable control types available. Exiting...") await self.stop() return - logger.info("Selecting control type %s", control_type) + self.logger.info("Selecting control type %s", control_type) self.set_control_type(control_type) await self.controller.select_control_type(self.channel) @@ -210,7 +215,7 @@ def is_running(self): return self.running async def stop(self): - logger.debug("Stop Called in class %s", self.__class__.__name__) + self.logger.debug("Stop Called in class %s", self.__class__.__name__) self._stop_event.set() if self.channel is not None: @@ -248,7 +253,7 @@ async def run(self, *args, **kwargs): async with asyncio.TaskGroup() as tg: if self.channel is None: - logger.error( + self.logger.error( "Channel not initialized before run, cannot start channel.run task." ) await self.stop() @@ -260,14 +265,16 @@ async def run(self, *args, **kwargs): tg.create_task(self.process_received_messages(), name="MessageProcess") tg.create_task(self.main_loop(), name="MainLoop") - logger.info("IntegrationTestExecutor TaskGroup completed successfully.") + self.logger.info( + "IntegrationTestExecutor TaskGroup completed successfully." + ) except* Exception as eg: # Catches one or more exceptions from tasks - logger.error( + self.logger.error( f"ExceptionGroup caught in IntegrationTestExecutor run: {len(eg.exceptions)} exceptions" ) for i, exc in enumerate(eg.exceptions): - logger.error( + self.logger.error( f" Exception {i+1}/{len(eg.exceptions)} in TaskGroup:", exc_info=exc, ) @@ -276,9 +283,9 @@ async def run(self, *args, **kwargs): # logger.warning("IntegrationTestExecutor run method was cancelled externally.") # self.stop() finally: - logger.info("IntegrationTestExecutor run method finishing.") + self.logger.info("IntegrationTestExecutor run method finishing.") await self.cleanup() # Perform final cleanup (e.g., channel.stop()) - logger.info("Cleanup finished.") + self.logger.info("Cleanup finished.") self.running = False @@ -298,7 +305,9 @@ def create_controllers_dict_with_config( return controllers -def create_test_executor(config: Config) -> IntegrationTestExecutor: +def create_test_executor( + config: Config, logger: logging.Logger +) -> IntegrationTestExecutor: report = ComplianceReport(timestamp=datetime.now(), device=config.device_details) controllers = create_controllers_dict_with_config(config) @@ -314,7 +323,10 @@ def create_test_executor(config: Config) -> IntegrationTestExecutor: ) executor = IntegrationTestExecutor( - available_control_types=controllers, test_suite=test_suite, report=report + available_control_types=controllers, + test_suite=test_suite, + report=report, + logger=logger, ) return executor diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py index 0e9e726..f5460c6 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py @@ -1,6 +1,8 @@ import datetime +from enum import Enum +import json import logging -from typing import Dict, List +from typing import Dict, List, Tuple import uuid from testsuites.certificate.certificate import ( @@ -17,6 +19,8 @@ PowerMeasurement, InstructionStatusUpdate, CommodityQuantity, + NumberRange, + InstructionStatus, ) from s2python.pebc import ( PEBCAllowedLimitRange, @@ -24,12 +28,15 @@ PEBCPowerConstraints, PEBCPowerEnvelope, PEBCPowerEnvelopeElement, + PEBCPowerEnvelopeLimitType, ) from testsuites.test_suite.base_test_case import NoSelectionTestCase from testsuites.test_suite.test_suite import S2TestCase from connectivity.s2_channel import S2Channel from .base import PEBCTestCase +from itertools import product + logger = logging.getLogger(__name__) @@ -51,36 +58,21 @@ def create_power_envelope( ], ) - async def curtail_commodity_quantity( - self, - power_constraints: PEBCPowerConstraints, - commodity_quantity: CommodityQuantity, - limits: List[PEBCAllowedLimitRange], - duration=3600, + async def send_power_envelope( + self, power_envelope, expected_instruction_status: InstructionStatus ): - logger.info("Curtailing %s", commodity_quantity) - - power_envelopes = [ - self.create_power_envelope( - commodity_quantity=commodity_quantity, - lower_limit=-200, - upper_limit=0, - duration=duration, - ) - ] - # Prepare coroutines first so the events are waiting in the awaiter. # This avoids the case where the message somehow arrives between sending the message and starting to await. # This is highly unlikely but might as well make sure! - power_measurement_coroutine = self.controller.message_awaiter.wait_for_message( - PowerMeasurement, 60 - ) + # power_measurement_coroutine = self.controller.message_awaiter.wait_for_message( + # PowerMeasurement, 60 + # ) status_update_coroutine = self.controller.message_awaiter.wait_for_message( InstructionStatusUpdate, 10 ) instruction = await self.controller.send_power_envelope_instruction( - self.channel, power_envelopes + self.channel, [power_envelope] ) logger.info("Instruction sent") @@ -108,9 +100,61 @@ async def curtail_commodity_quantity( status=ComplianceStatus.FAIL, ) - message = await power_measurement_coroutine + if status_update.status_type != expected_instruction_status: + self.add_finding_param( + name="InstructionStatusUpdate status_type does not matches expected.", + detail=f"Received status {status_update.status_type} but expected {expected_instruction_status} for instruction.", + status=ComplianceStatus.SOFT_FAIL, + ) + + # message = await power_measurement_coroutine + + # logger.info("Received Power Measurement: %s", message) + + async def curtail_commodity_quantity( + self, + power_constraints: PEBCPowerConstraints, + commodity_quantity: CommodityQuantity, + limits: List[PEBCAllowedLimitRange], + duration=3600, + ): + logger.info(power_constraints.model_dump_json()) + logger.info("Curtailing %s", commodity_quantity) + + lower_limits: List[NumberRange] = [] + upper_limits: List[NumberRange] = [] + + for limit in limits: + if limit.limit_type == PEBCPowerEnvelopeLimitType.LOWER_LIMIT: + lower_limits.append(limit.range_boundary) + else: + upper_limits.append(limit.range_boundary) + + limit_range_pairs: List[Tuple[NumberRange, NumberRange]] = list( + product(lower_limits, upper_limits) + ) + logger.info(limit_range_pairs) + + for lower_limit, upper_limit in limit_range_pairs: + + power_envelope = self.create_power_envelope( + commodity_quantity=commodity_quantity, + lower_limit=lower_limit.end_of_range, + upper_limit=upper_limit.start_of_range, + duration=duration, + ) + logger.debug("Curtailing with power envelope: %s", power_envelope) + + await self.send_power_envelope(power_envelope, InstructionStatus.SUCCEEDED) + + power_envelope = self.create_power_envelope( + commodity_quantity=commodity_quantity, + lower_limit=lower_limit.end_of_range - 1, + upper_limit=upper_limit.start_of_range + 1, + duration=duration, + ) - logger.info("Received Power Measurement: %s", message) + await self.send_power_envelope(power_envelope, InstructionStatus.REJECTED) @S2TestCase.test async def test_set_limit_ranges_instruction(self): diff --git a/s2-self-cert/config.yaml b/s2-self-cert/config.yaml index 43b183e..fdec529 100644 --- a/s2-self-cert/config.yaml +++ b/s2-self-cert/config.yaml @@ -7,6 +7,7 @@ connection: mode: server host: 127.0.0.1 port: 8000 + uri: ws://localhost:8001/backend/cem/cem1/rm/pv1/ws control_types: no_selection: null pebc: diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index 459e104..51e8893 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -40,7 +40,7 @@ async def main(): if config.mode == "certification": test_executor = create_server_certification_executor(config) elif config.mode == "testing": - test_executor = create_test_executor(config) + test_executor = create_test_executor(config, logger) else: raise ValueError("Invalid mode.") From d6a6ad96c57872383c20109c68dfdb508a55afe7 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 17:03:59 +0200 Subject: [PATCH 27/75] Removed S2 Python so I can add it as a submodule instead --- packages/s2-python/.github/workflows/ci.yml | 198 -- packages/s2-python/.gitignore | 15 - packages/s2-python/.pre-commit-config.yaml | 34 - packages/s2-python/.pylintrc | 13 - packages/s2-python/LICENSE | 201 -- packages/s2-python/README.rst | 73 - packages/s2-python/ci/clean.sh | 4 - packages/s2-python/ci/distribute.sh | 2 - packages/s2-python/ci/generate_s2.sh | 5 - packages/s2-python/ci/install_dependencies.sh | 4 - packages/s2-python/ci/lint.sh | 4 - .../s2-python/ci/setup_dev_environment.sh | 5 - packages/s2-python/ci/test_unit.sh | 4 - packages/s2-python/ci/typecheck.sh | 5 - packages/s2-python/ci/update_dependencies.sh | 4 - packages/s2-python/dev-requirements.txt | 244 --- .../development_utilities/gen_templates.py | 20 - .../gen_unit_test_template.py | 313 --- .../generate_s2_message_type_to_class.py | 24 - .../development_utilities/get_all_messages.py | 16 - .../s2-python/examples/example_frbc_rm.py | 188 -- packages/s2-python/mypy.ini | 14 - packages/s2-python/pyproject.toml | 3 - packages/s2-python/pyrightconfig.json | 10 - packages/s2-python/setup.cfg | 106 - packages/s2-python/setup.py | 3 - packages/s2-python/specification/openapi.yml | 1762 ----------------- packages/s2-python/src/s2python/__init__.py | 10 - .../s2-python/src/s2python/common/__init__.py | 64 - .../s2-python/src/s2python/common/duration.py | 24 - .../src/s2python/common/handshake.py | 15 - .../src/s2python/common/handshake_response.py | 15 - .../common/instruction_status_update.py | 18 - .../src/s2python/common/number_range.py | 25 - .../src/s2python/common/power_forecast.py | 18 - .../s2python/common/power_forecast_element.py | 20 - .../s2python/common/power_forecast_value.py | 11 - .../src/s2python/common/power_measurement.py | 18 - .../src/s2python/common/power_range.py | 24 - .../src/s2python/common/power_value.py | 11 - .../src/s2python/common/reception_status.py | 15 - .../common/resource_manager_details.py | 25 - .../src/s2python/common/revoke_object.py | 16 - .../s2-python/src/s2python/common/role.py | 11 - .../s2python/common/select_control_type.py | 15 - .../src/s2python/common/session_request.py | 15 - .../s2-python/src/s2python/common/support.py | 27 - .../s2-python/src/s2python/common/timer.py | 17 - .../src/s2python/common/transition.py | 24 - .../s2-python/src/s2python/ddbc/__init__.py | 21 - .../ddbc/ddbc_actuator_description.py | 30 - .../src/s2python/ddbc/ddbc_actuator_status.py | 22 - .../ddbc/ddbc_average_demand_rate_forecast.py | 28 - ...bc_average_demand_rate_forecast_element.py | 21 - .../src/s2python/ddbc/ddbc_instruction.py | 19 - .../src/s2python/ddbc/ddbc_operation_mode.py | 26 - .../s2python/ddbc/ddbc_system_description.py | 29 - .../src/s2python/ddbc/ddbc_timer_status.py | 18 - .../s2-python/src/s2python/frbc/__init__.py | 33 - .../frbc/frbc_actuator_description.py | 149 -- .../src/s2python/frbc/frbc_actuator_status.py | 23 - .../frbc/frbc_fill_level_target_profile.py | 24 - .../frbc_fill_level_target_profile_element.py | 35 - .../src/s2python/frbc/frbc_instruction.py | 18 - .../s2python/frbc/frbc_leakage_behaviour.py | 20 - .../frbc/frbc_leakage_behaviour_element.py | 33 - .../src/s2python/frbc/frbc_operation_mode.py | 49 - .../frbc/frbc_operation_mode_element.py | 27 - .../s2python/frbc/frbc_storage_description.py | 18 - .../src/s2python/frbc/frbc_storage_status.py | 15 - .../s2python/frbc/frbc_system_description.py | 22 - .../src/s2python/frbc/frbc_timer_status.py | 17 - .../src/s2python/frbc/frbc_usage_forecast.py | 18 - .../frbc/frbc_usage_forecast_element.py | 17 - packages/s2-python/src/s2python/frbc/rm.py | 0 .../src/s2python/generated/__init__.py | 0 .../src/s2python/generated/gen_s2.py | 1575 --------------- packages/s2-python/src/s2python/message.py | 145 -- .../s2-python/src/s2python/ombc/__init__.py | 5 - .../src/s2python/ombc/ombc_instruction.py | 19 - .../src/s2python/ombc/ombc_operation_mode.py | 25 - .../src/s2python/ombc/ombc_status.py | 17 - .../s2python/ombc/ombc_system_description.py | 25 - .../src/s2python/ombc/ombc_timer_status.py | 17 - .../s2-python/src/s2python/pebc/__init__.py | 21 - .../s2python/pebc/pebc_allowed_limit_range.py | 43 - .../s2python/pebc/pebc_energy_constraint.py | 25 - .../src/s2python/pebc/pebc_instruction.py | 27 - .../s2python/pebc/pebc_power_constraints.py | 77 - .../src/s2python/pebc/pebc_power_envelope.py | 23 - .../pebc/pebc_power_envelope_element.py | 16 - .../s2-python/src/s2python/ppbc/__init__.py | 21 - .../ppbc/ppbc_end_interruption_instruction.py | 30 - .../ppbc/ppbc_power_profile_definition.py | 25 - .../ppbc/ppbc_power_profile_status.py | 24 - .../src/s2python/ppbc/ppbc_power_sequence.py | 30 - .../ppbc/ppbc_power_sequence_container.py | 25 - .../ppbc_power_sequence_container_status.py | 30 - .../ppbc/ppbc_power_sequence_element.py | 23 - .../ppbc/ppbc_schedule_instruction.py | 31 - .../ppbc_start_interruption_instruction.py | 30 - packages/s2-python/src/s2python/py.typed | 0 .../src/s2python/reception_status_awaiter.py | 60 - .../s2-python/src/s2python/s2_connection.py | 581 ------ .../s2-python/src/s2python/s2_control_type.py | 116 -- packages/s2-python/src/s2python/s2_parser.py | 129 -- .../src/s2python/s2_validation_error.py | 15 - packages/s2-python/src/s2python/utils.py | 8 - .../src/s2python/validate_values_mixin.py | 85 - packages/s2-python/src/s2python/version.py | 3 - packages/s2-python/tests/unit/__init__.py | 0 .../s2-python/tests/unit/common/__init__.py | 0 .../tests/unit/common/duration_test.py | 26 - .../unit/common/handshake_response_test.py | 43 - .../tests/unit/common/handshake_test.py | 44 - .../common/instruction_status_update_test.py | 63 - .../tests/unit/common/number_range_test.py | 74 - .../common/power_forecast_element_test.py | 61 - .../tests/unit/common/power_forecast_test.py | 84 - .../unit/common/power_forecast_value_test.py | 79 - .../unit/common/power_measurement_test.py | 64 - .../tests/unit/common/power_range_test.py | 67 - .../tests/unit/common/power_value_test.py | 32 - .../unit/common/reception_status_test.py | 48 - .../common/resource_manager_details_test.py | 147 -- .../tests/unit/common/revoke_object_test.py | 49 - .../s2-python/tests/unit/common/role_test.py | 28 - .../unit/common/select_control_type_test.py | 43 - .../tests/unit/common/session_request_test.py | 43 - .../s2-python/tests/unit/common/timer_test.py | 88 - .../tests/unit/common/transition_test.py | 156 -- .../frbc/frbc_actuator_description_test.py | 241 --- .../unit/frbc/frbc_actuator_status_test.py | 95 - ..._fill_level_target_profile_element_test.py | 61 - .../frbc_fill_level_target_profile_test.py | 109 - .../tests/unit/frbc/frbc_instruction_test.py | 96 - .../frbc_leakage_behaviour_element_test.py | 68 - .../unit/frbc/frbc_leakage_behaviour_test.py | 107 - .../frbc/frbc_operation_mode_element_test.py | 117 -- .../unit/frbc/frbc_operation_mode_test.py | 139 -- .../frbc/frbc_storage_description_test.py | 77 - .../unit/frbc/frbc_storage_status_test.py | 49 - .../unit/frbc/frbc_system_description_test.py | 359 ---- .../tests/unit/frbc/frbc_timer_status_test.py | 82 - .../frbc/frbc_usage_forecast_element_test.py | 83 - .../unit/frbc/frbc_usage_forecast_test.py | 119 -- packages/s2-python/tests/unit/message_test.py | 63 - .../pebc/pebc_allowed_limit_range_test.py | 87 - .../unit/pebc/pebc_power_constraints_test.py | 248 --- .../unit/reception_status_awaiter_test.py | 172 -- .../tests/unit/s2_connection_test.py | 65 - .../s2-python/tests/unit/s2_parser_test.py | 120 -- packages/s2-python/tests/unit/utils_test.py | 46 - packages/s2-python/tox.ini | 72 - 154 files changed, 11684 deletions(-) delete mode 100644 packages/s2-python/.github/workflows/ci.yml delete mode 100644 packages/s2-python/.gitignore delete mode 100644 packages/s2-python/.pre-commit-config.yaml delete mode 100644 packages/s2-python/.pylintrc delete mode 100644 packages/s2-python/LICENSE delete mode 100644 packages/s2-python/README.rst delete mode 100755 packages/s2-python/ci/clean.sh delete mode 100755 packages/s2-python/ci/distribute.sh delete mode 100755 packages/s2-python/ci/generate_s2.sh delete mode 100755 packages/s2-python/ci/install_dependencies.sh delete mode 100755 packages/s2-python/ci/lint.sh delete mode 100755 packages/s2-python/ci/setup_dev_environment.sh delete mode 100755 packages/s2-python/ci/test_unit.sh delete mode 100755 packages/s2-python/ci/typecheck.sh delete mode 100755 packages/s2-python/ci/update_dependencies.sh delete mode 100644 packages/s2-python/dev-requirements.txt delete mode 100644 packages/s2-python/development_utilities/gen_templates.py delete mode 100644 packages/s2-python/development_utilities/gen_unit_test_template.py delete mode 100644 packages/s2-python/development_utilities/generate_s2_message_type_to_class.py delete mode 100644 packages/s2-python/development_utilities/get_all_messages.py delete mode 100644 packages/s2-python/examples/example_frbc_rm.py delete mode 100644 packages/s2-python/mypy.ini delete mode 100644 packages/s2-python/pyproject.toml delete mode 100644 packages/s2-python/pyrightconfig.json delete mode 100644 packages/s2-python/setup.cfg delete mode 100644 packages/s2-python/setup.py delete mode 100644 packages/s2-python/specification/openapi.yml delete mode 100644 packages/s2-python/src/s2python/__init__.py delete mode 100644 packages/s2-python/src/s2python/common/__init__.py delete mode 100644 packages/s2-python/src/s2python/common/duration.py delete mode 100644 packages/s2-python/src/s2python/common/handshake.py delete mode 100644 packages/s2-python/src/s2python/common/handshake_response.py delete mode 100644 packages/s2-python/src/s2python/common/instruction_status_update.py delete mode 100644 packages/s2-python/src/s2python/common/number_range.py delete mode 100644 packages/s2-python/src/s2python/common/power_forecast.py delete mode 100644 packages/s2-python/src/s2python/common/power_forecast_element.py delete mode 100644 packages/s2-python/src/s2python/common/power_forecast_value.py delete mode 100644 packages/s2-python/src/s2python/common/power_measurement.py delete mode 100644 packages/s2-python/src/s2python/common/power_range.py delete mode 100644 packages/s2-python/src/s2python/common/power_value.py delete mode 100644 packages/s2-python/src/s2python/common/reception_status.py delete mode 100644 packages/s2-python/src/s2python/common/resource_manager_details.py delete mode 100644 packages/s2-python/src/s2python/common/revoke_object.py delete mode 100644 packages/s2-python/src/s2python/common/role.py delete mode 100644 packages/s2-python/src/s2python/common/select_control_type.py delete mode 100644 packages/s2-python/src/s2python/common/session_request.py delete mode 100644 packages/s2-python/src/s2python/common/support.py delete mode 100644 packages/s2-python/src/s2python/common/timer.py delete mode 100644 packages/s2-python/src/s2python/common/transition.py delete mode 100644 packages/s2-python/src/s2python/ddbc/__init__.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_actuator_description.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_actuator_status.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_instruction.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_operation_mode.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_system_description.py delete mode 100644 packages/s2-python/src/s2python/ddbc/ddbc_timer_status.py delete mode 100644 packages/s2-python/src/s2python/frbc/__init__.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_actuator_description.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_actuator_status.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_instruction.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_operation_mode.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_operation_mode_element.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_storage_description.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_storage_status.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_system_description.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_timer_status.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_usage_forecast.py delete mode 100644 packages/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py delete mode 100644 packages/s2-python/src/s2python/frbc/rm.py delete mode 100644 packages/s2-python/src/s2python/generated/__init__.py delete mode 100644 packages/s2-python/src/s2python/generated/gen_s2.py delete mode 100644 packages/s2-python/src/s2python/message.py delete mode 100644 packages/s2-python/src/s2python/ombc/__init__.py delete mode 100644 packages/s2-python/src/s2python/ombc/ombc_instruction.py delete mode 100644 packages/s2-python/src/s2python/ombc/ombc_operation_mode.py delete mode 100644 packages/s2-python/src/s2python/ombc/ombc_status.py delete mode 100644 packages/s2-python/src/s2python/ombc/ombc_system_description.py delete mode 100644 packages/s2-python/src/s2python/ombc/ombc_timer_status.py delete mode 100644 packages/s2-python/src/s2python/pebc/__init__.py delete mode 100644 packages/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py delete mode 100644 packages/s2-python/src/s2python/pebc/pebc_energy_constraint.py delete mode 100644 packages/s2-python/src/s2python/pebc/pebc_instruction.py delete mode 100644 packages/s2-python/src/s2python/pebc/pebc_power_constraints.py delete mode 100644 packages/s2-python/src/s2python/pebc/pebc_power_envelope.py delete mode 100644 packages/s2-python/src/s2python/pebc/pebc_power_envelope_element.py delete mode 100644 packages/s2-python/src/s2python/ppbc/__init__.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_power_sequence.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py delete mode 100644 packages/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py delete mode 100644 packages/s2-python/src/s2python/py.typed delete mode 100644 packages/s2-python/src/s2python/reception_status_awaiter.py delete mode 100644 packages/s2-python/src/s2python/s2_connection.py delete mode 100644 packages/s2-python/src/s2python/s2_control_type.py delete mode 100644 packages/s2-python/src/s2python/s2_parser.py delete mode 100644 packages/s2-python/src/s2python/s2_validation_error.py delete mode 100644 packages/s2-python/src/s2python/utils.py delete mode 100644 packages/s2-python/src/s2python/validate_values_mixin.py delete mode 100644 packages/s2-python/src/s2python/version.py delete mode 100644 packages/s2-python/tests/unit/__init__.py delete mode 100644 packages/s2-python/tests/unit/common/__init__.py delete mode 100644 packages/s2-python/tests/unit/common/duration_test.py delete mode 100644 packages/s2-python/tests/unit/common/handshake_response_test.py delete mode 100644 packages/s2-python/tests/unit/common/handshake_test.py delete mode 100644 packages/s2-python/tests/unit/common/instruction_status_update_test.py delete mode 100644 packages/s2-python/tests/unit/common/number_range_test.py delete mode 100644 packages/s2-python/tests/unit/common/power_forecast_element_test.py delete mode 100644 packages/s2-python/tests/unit/common/power_forecast_test.py delete mode 100644 packages/s2-python/tests/unit/common/power_forecast_value_test.py delete mode 100644 packages/s2-python/tests/unit/common/power_measurement_test.py delete mode 100644 packages/s2-python/tests/unit/common/power_range_test.py delete mode 100644 packages/s2-python/tests/unit/common/power_value_test.py delete mode 100644 packages/s2-python/tests/unit/common/reception_status_test.py delete mode 100644 packages/s2-python/tests/unit/common/resource_manager_details_test.py delete mode 100644 packages/s2-python/tests/unit/common/revoke_object_test.py delete mode 100644 packages/s2-python/tests/unit/common/role_test.py delete mode 100644 packages/s2-python/tests/unit/common/select_control_type_test.py delete mode 100644 packages/s2-python/tests/unit/common/session_request_test.py delete mode 100644 packages/s2-python/tests/unit/common/timer_test.py delete mode 100644 packages/s2-python/tests/unit/common/transition_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_actuator_description_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_actuator_status_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_instruction_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_operation_mode_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_storage_description_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_storage_status_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_system_description_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_timer_status_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py delete mode 100644 packages/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py delete mode 100644 packages/s2-python/tests/unit/message_test.py delete mode 100644 packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py delete mode 100644 packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py delete mode 100644 packages/s2-python/tests/unit/reception_status_awaiter_test.py delete mode 100644 packages/s2-python/tests/unit/s2_connection_test.py delete mode 100644 packages/s2-python/tests/unit/s2_parser_test.py delete mode 100644 packages/s2-python/tests/unit/utils_test.py delete mode 100644 packages/s2-python/tox.ini diff --git a/packages/s2-python/.github/workflows/ci.yml b/packages/s2-python/.github/workflows/ci.yml deleted file mode 100644 index 7ca3906..0000000 --- a/packages/s2-python/.github/workflows/ci.yml +++ /dev/null @@ -1,198 +0,0 @@ -name: tests-and-publish -# this pipeline started as an adaptation of the pipeline of the -# FlexMeasures client (https://github.com/FlexMeasures/flexmeasures-client/) - -on: - push: - # Avoid using all the resources/limits available by checking only - # relevant branches and tags. Other branches can be checked via PRs. - branches: [main] - tags: - - 'v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' # Match tags that resemble a version - - 'v[0-9]+\.[0-9]+\.[0-9]+' # Match tags that resemble a version - pull_request: # Run in every PR - workflow_dispatch: # Allow manually triggering the workflow - schedule: - # Run roughly every 15 days at 00:00 UTC - # (useful to check if updates on dependencies break the package) - - cron: '0 0 1,16 * *' - -concurrency: - group: >- - ${{ github.workflow }}-${{ github.ref_type }}- - ${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} - steps: - - uses: actions/checkout@v4 - with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - id: setup-python - with: {python-version: "3.11"} - # - name: Run static analysis and format checkers - # run: pipx run pre-commit run --all-files --show-diff-on-failure - - name: Build package distribution files - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e clean,build - - name: Record the path of wheel distribution - id: wheel-distribution - run: echo "path=$(ls dist/*.whl)" >> $GITHUB_OUTPUT - - name: Store the distribution files for use in other stages - # `tests` and `publish` will use the same pre-built distributions, - # so we make sure to release the exact same package that was tested - uses: actions/upload-artifact@v4 - with: - name: python-distribution-files - path: dist/ - retention-days: 1 - - test: - needs: prepare - strategy: - matrix: - python: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" # newest Python that is stable - platform: - - ubuntu-latest - # - macos-latest - # - windows-latest - runs-on: ${{ matrix.platform }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - id: setup-python - with: - python-version: ${{ matrix.python }} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: {name: python-distribution-files, path: dist/} - - name: Run tests - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes # pytest args - # - name: Generate coverage report - # run: pipx run coverage lcov -o coverage.lcov - # - name: Upload partial coverage report - # uses: coverallsapp/github-action@master - # with: - # path-to-lcov: coverage.lcov - # github-token: ${{ secrets.GITHUB_TOKEN }} - # flag-name: ${{ matrix.platform }} - py${{ matrix.python }} - # parallel: true - - lint: - needs: prepare - strategy: - matrix: - python: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" # newest Python that is stable - platform: - - ubuntu-latest - # - macos-latest - # - windows-latest - runs-on: ${{ matrix.platform }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - id: setup-python - with: - python-version: ${{ matrix.python }} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: {name: python-distribution-files, path: dist/} - - name: Run tests - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e lint --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - - typecheck: - needs: prepare - strategy: - matrix: - python: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" # newest Python that is stable - platform: - - ubuntu-latest - # - macos-latest - # - windows-latest - runs-on: ${{ matrix.platform }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - id: setup-python - with: - python-version: ${{ matrix.python }} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: {name: python-distribution-files, path: dist/} - - name: Run tests - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e typecheck --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - - finalize: - needs: [test, lint, typecheck] - runs-on: ubuntu-latest - steps: - - run: echo "Finished checks" - - publish: - needs: finalize - if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/project/s2-python/ - permissions: - id-token: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: {python-version: "3.11"} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: {name: python-distribution-files, path: dist/} - - name: Publish Package - uses: pypa/gh-action-pypi-publish@release/v1 - # run: pipx run tox -e publish - - test-publish: - needs: finalize - if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/test') }} - runs-on: ubuntu-latest - environment: - name: testpypi - url: https://test.pypi.org/project/s2-python/ - permissions: - id-token: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: {python-version: "3.11"} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: {name: python-distribution-files, path: dist/} - - name: Publish Package - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - # run: pipx run tox -e publish diff --git a/packages/s2-python/.gitignore b/packages/s2-python/.gitignore deleted file mode 100644 index 5baf340..0000000 --- a/packages/s2-python/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -**.pyc -.idea/ -.vscode/ -.venv/ -.pytest_cache/ -.coverage -unit_test_coverage/ -.mypy_cache/ -venv -*.egg-info -*venv* -.tox/ -dist/ -build/ -%LOCALAPPDATA% diff --git a/packages/s2-python/.pre-commit-config.yaml b/packages/s2-python/.pre-commit-config.yaml deleted file mode 100644 index 1dc4c9d..0000000 --- a/packages/s2-python/.pre-commit-config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -exclude: '^docs/conf.py' - -repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: trailing-whitespace - - id: check-added-large-files - - id: check-ast - - id: check-json - - id: check-merge-conflict - - id: check-xml - - id: check-yaml - - id: debug-statements - - id: end-of-file-fixer - - id: requirements-txt-fixer - - id: mixed-line-ending - args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows - -- repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort - -- repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - language_version: python3 - -- repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 diff --git a/packages/s2-python/.pylintrc b/packages/s2-python/.pylintrc deleted file mode 100644 index 1a254ee..0000000 --- a/packages/s2-python/.pylintrc +++ /dev/null @@ -1,13 +0,0 @@ -[Main] -# Add files or directories matching the regular expressions patterns to the -# ignore-list. The regex matches against paths and can be in Posix or Windows -# format. Because '\\' represents the directory delimiter on Windows systems, -# it can't be used as an escape character. -ignore-paths=src/s2python/generated/ - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use, and will cap the count on Windows to -# avoid hangs. -jobs=1 - -disable=missing-class-docstring,missing-module-docstring,too-few-public-methods,missing-function-docstring,no-member,unsubscriptable-object,line-too-long diff --git a/packages/s2-python/LICENSE b/packages/s2-python/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/packages/s2-python/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/s2-python/README.rst b/packages/s2-python/README.rst deleted file mode 100644 index ed04b60..0000000 --- a/packages/s2-python/README.rst +++ /dev/null @@ -1,73 +0,0 @@ -Python Wrapper for S2 Flexibility Protocol -=========================================== -.. image:: https://img.shields.io/pypi/v/s2-python - :alt: PyPI - Version -.. image:: https://img.shields.io/pypi/pyversions/s2-python - :alt: PyPI - Python Version -.. image:: https://img.shields.io/pypi/l/s2-python - :alt: PyPI - License - -This Python package implements the message validation for the EN50491-12-2 "S2" standard for home and building energy management. This implementation -is based on the asyncapi description of the protocol provided in the `s2-ws-json `_ repository. - -Currently, the package supports the *common* and *FILL RATE BASED CONTROL* types and messages. - -To Install ------------ -You can install this package using pip or any Python dependency manager that collects the packages from Pypi: - -.. code-block:: bash - - pip install s2-python - -The packages on Pypi may be found `here `_ - -Mypy support ------------- -s2-python uses pydantic at its core to define the various S2 messages. As such, the pydantic mypy plugin is required -for type checking to succeed. - -Add to your pyproject.toml: - -.. code-block:: toml - - [tool.mypy] - plugins = ['pydantic.mypy'] - -Example ---------- - -.. code-block:: python - - from s2python.common import PowerRange, CommodityQuantity - - # create s2 messages as Python objects - number_range = PowerRange( - start_of_range=4.0, - end_of_range=5.0, - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - ) - # serialize s2 messages - number_range.to_json() - # deserialize s2 messages - json_str = '{"start_of_range": 4.0, "end_of_range": 5.0, "commodity_quantity": "ELECTRIC.POWER.L1"}' - PowerRange.from_json(json_str) - -Development -------------- - -For development, you can install the required dependencies using the following command: - - pip install -e .[testing,development] - - -The tests can be run using tox: - - tox - -To build the package, you can use tox as well: - - tox -e build,clean - - - diff --git a/packages/s2-python/ci/clean.sh b/packages/s2-python/ci/clean.sh deleted file mode 100755 index 3dec941..0000000 --- a/packages/s2-python/ci/clean.sh +++ /dev/null @@ -1,4 +0,0 @@ -. .venv/bin/activate - -tox -e clean -rm -Rf .pytest_cache/ .tox/ dist/ src/*.egg-info/ unit_test_coverage/ .coverage diff --git a/packages/s2-python/ci/distribute.sh b/packages/s2-python/ci/distribute.sh deleted file mode 100755 index 4aba026..0000000 --- a/packages/s2-python/ci/distribute.sh +++ /dev/null @@ -1,2 +0,0 @@ -. .venv/bin/activate -tox -e build diff --git a/packages/s2-python/ci/generate_s2.sh b/packages/s2-python/ci/generate_s2.sh deleted file mode 100755 index f1ee694..0000000 --- a/packages/s2-python/ci/generate_s2.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - - -. .venv/bin/activate -datamodel-codegen --input specification/openapi.yml --input-file-type openapi --output-model-type pydantic_v2.BaseModel --output src/s2python/generated/gen_s2.py --use-one-literal-as-default diff --git a/packages/s2-python/ci/install_dependencies.sh b/packages/s2-python/ci/install_dependencies.sh deleted file mode 100755 index 1fb6290..0000000 --- a/packages/s2-python/ci/install_dependencies.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh - -. .venv/bin/activate -pip-sync ./dev-requirements.txt diff --git a/packages/s2-python/ci/lint.sh b/packages/s2-python/ci/lint.sh deleted file mode 100755 index c405891..0000000 --- a/packages/s2-python/ci/lint.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh - -. .venv/bin/activate -pylint src/ tests/unit/ examples/ diff --git a/packages/s2-python/ci/setup_dev_environment.sh b/packages/s2-python/ci/setup_dev_environment.sh deleted file mode 100755 index 7e10b51..0000000 --- a/packages/s2-python/ci/setup_dev_environment.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -python3.8 -m venv ./.venv/ -. ./.venv/bin/activate -pip install pip-tools diff --git a/packages/s2-python/ci/test_unit.sh b/packages/s2-python/ci/test_unit.sh deleted file mode 100755 index 492b1ab..0000000 --- a/packages/s2-python/ci/test_unit.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh - -. .venv/bin/activate -PYTHONPATH="$PYTHONPATH:src/" pytest --cov=s2python --cov-report=html:./unit_test_coverage/ -v tests/unit/ diff --git a/packages/s2-python/ci/typecheck.sh b/packages/s2-python/ci/typecheck.sh deleted file mode 100755 index 6864b6a..0000000 --- a/packages/s2-python/ci/typecheck.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh - -. .venv/bin/activate -mypy --config-file mypy.ini src/ ./tests/unit/ examples/ -pyright diff --git a/packages/s2-python/ci/update_dependencies.sh b/packages/s2-python/ci/update_dependencies.sh deleted file mode 100755 index ca11b03..0000000 --- a/packages/s2-python/ci/update_dependencies.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env sh - -. .venv/bin/activate -pip-compile -U --extra=testing --extra=development --extra=docs -o ./dev-requirements.txt setup.cfg diff --git a/packages/s2-python/dev-requirements.txt b/packages/s2-python/dev-requirements.txt deleted file mode 100644 index 5b3daa2..0000000 --- a/packages/s2-python/dev-requirements.txt +++ /dev/null @@ -1,244 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: -# -# pip-compile --extra=development --extra=docs --extra=testing --output-file=./dev-requirements.txt setup.cfg -# -alabaster==0.7.13 - # via sphinx -annotated-types==0.7.0 - # via pydantic -argcomplete==3.5.3 - # via datamodel-code-generator -astroid==3.2.4 - # via pylint -babel==2.17.0 - # via sphinx -black==24.8.0 - # via datamodel-code-generator -build==1.2.2.post1 - # via pip-tools -cachetools==5.5.2 - # via tox -certifi==2025.1.31 - # via requests -cfgv==3.4.0 - # via pre-commit -chardet==5.2.0 - # via tox -charset-normalizer==3.4.1 - # via requests -click==8.1.8 - # via - # black - # pip-tools - # s2-python (setup.cfg) -colorama==0.4.6 - # via tox -coverage[toml]==7.6.1 - # via pytest-cov -datamodel-code-generator==0.27.3 - # via s2-python (setup.cfg) -dill==0.3.9 - # via pylint -distlib==0.3.9 - # via virtualenv -docutils==0.20.1 - # via - # sphinx - # sphinx-rtd-theme - # sphinx-tabs -exceptiongroup==1.2.2 - # via pytest -filelock==3.16.1 - # via - # tox - # virtualenv -genson==1.3.0 - # via datamodel-code-generator -identify==2.6.1 - # via pre-commit -idna==3.10 - # via requests -imagesize==1.4.1 - # via sphinx -importlib-metadata==8.5.0 - # via - # build - # sphinx -inflect==5.6.2 - # via datamodel-code-generator -iniconfig==2.0.0 - # via pytest -isort==5.13.2 - # via - # datamodel-code-generator - # pylint -jinja2==3.1.5 - # via - # datamodel-code-generator - # sphinx -markupsafe==2.1.5 - # via jinja2 -mccabe==0.7.0 - # via pylint -mypy==1.14.1 - # via s2-python (setup.cfg) -mypy-extensions==1.0.0 - # via - # black - # mypy -nodeenv==1.9.1 - # via - # pre-commit - # pyright -packaging==24.2 - # via - # black - # build - # datamodel-code-generator - # pyproject-api - # pytest - # sphinx - # tox -pathspec==0.12.1 - # via black -pip-tools==7.4.1 - # via s2-python (setup.cfg) -platformdirs==4.3.6 - # via - # black - # pylint - # tox - # virtualenv -pluggy==1.5.0 - # via - # pytest - # tox -pre-commit==3.5.0 - # via s2-python (setup.cfg) -pydantic==2.10.6 - # via - # datamodel-code-generator - # s2-python (setup.cfg) -pydantic-core==2.27.2 - # via pydantic -pygments==2.19.1 - # via - # sphinx - # sphinx-tabs -pylint==3.2.7 - # via s2-python (setup.cfg) -pyproject-api==1.8.0 - # via tox -pyproject-hooks==1.2.0 - # via - # build - # pip-tools -pyright==1.1.396 - # via s2-python (setup.cfg) -pytest==8.3.5 - # via - # pytest-cov - # pytest-timer - # s2-python (setup.cfg) -pytest-cov==5.0.0 - # via pytest-cover -pytest-cover==3.0.0 - # via pytest-coverage -pytest-coverage==0.0 - # via s2-python (setup.cfg) -pytest-timer==1.0.0 - # via s2-python (setup.cfg) -pytz==2025.1 - # via - # babel - # s2-python (setup.cfg) -pyyaml==6.0.2 - # via - # datamodel-code-generator - # pre-commit -requests==2.32.3 - # via sphinx -six==1.17.0 - # via sphinxcontrib-httpdomain -snowballstemmer==2.2.0 - # via sphinx -sphinx==7.1.2 - # via - # s2-python (setup.cfg) - # sphinx-copybutton - # sphinx-fontawesome - # sphinx-rtd-theme - # sphinx-tabs - # sphinxcontrib-httpdomain - # sphinxcontrib-jquery -sphinx-copybutton==0.5.2 - # via s2-python (setup.cfg) -sphinx-fontawesome==0.0.6 - # via s2-python (setup.cfg) -sphinx-rtd-theme==3.0.2 - # via s2-python (setup.cfg) -sphinx-tabs==3.4.7 - # via s2-python (setup.cfg) -sphinxcontrib-applehelp==1.0.4 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.1 - # via sphinx -sphinxcontrib-httpdomain==1.8.1 - # via s2-python (setup.cfg) -sphinxcontrib-jquery==4.1 - # via sphinx-rtd-theme -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -tomli==2.2.1 - # via - # black - # build - # coverage - # datamodel-code-generator - # mypy - # pip-tools - # pylint - # pyproject-api - # pytest - # tox -tomlkit==0.13.2 - # via pylint -tox==4.24.1 - # via s2-python (setup.cfg) -types-pytz==2024.2.0.20241221 - # via s2-python (setup.cfg) -typing-extensions==4.12.2 - # via - # annotated-types - # astroid - # black - # mypy - # pydantic - # pydantic-core - # pylint - # pyright - # tox -urllib3==2.2.3 - # via requests -virtualenv==20.29.2 - # via - # pre-commit - # tox -websockets==13.1 - # via s2-python (setup.cfg) -wheel==0.45.1 - # via pip-tools -zipp==3.20.2 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# pip -# setuptools diff --git a/packages/s2-python/development_utilities/gen_templates.py b/packages/s2-python/development_utilities/gen_templates.py deleted file mode 100644 index 73fccba..0000000 --- a/packages/s2-python/development_utilities/gen_templates.py +++ /dev/null @@ -1,20 +0,0 @@ -import inspect -import gen_s2 - -all_members = inspect.getmembers(gen_s2) -all_members.sort(key=lambda t: t[0]) - -for name, member in all_members: - if inspect.isclass(member): - print( - f""" -from s2python.generated.gen_s2 import {name} as Gen{name} -from s2python.validate_values_mixin import catch_and_convert_exceptions, ValidateValuesMixin - - -@catch_and_convert_exceptions -class {name}(Gen{name}, ValidateValuesMixin['{name}']): - class Config(Gen{name}.Config): - validate_assignment = True - """ - ) diff --git a/packages/s2-python/development_utilities/gen_unit_test_template.py b/packages/s2-python/development_utilities/gen_unit_test_template.py deleted file mode 100644 index 87bddc9..0000000 --- a/packages/s2-python/development_utilities/gen_unit_test_template.py +++ /dev/null @@ -1,313 +0,0 @@ -import datetime -import json -import os -from enum import Enum -import inspect -import pprint -import random -from typing import ( - get_type_hints, - Type, - get_origin, - get_args, - Union, - TypeVar, - Callable, - Sequence, -) -import uuid - -import pydantic -from pydantic.types import AwareDatetime - -from s2python import frbc -from s2python.common import Duration, PowerRange, NumberRange -from s2python.generated.gen_s2 import CommodityQuantity - -I = TypeVar("I") - - -def split_words_list(list_: Sequence[I], is_sep: Callable[[I], bool]) -> list[list[I]]: - words = [] - current_word = [] - previous_was_sep = None - for item in list_: - current_is_sep = is_sep(item) - - if previous_was_sep is None: - previous_was_sep = current_is_sep - - if not previous_was_sep and current_is_sep: - # Split detected - words.append(current_word) - current_word = [item] - else: - current_word.append(item) - - previous_was_sep = current_is_sep - words.append(current_word) - return words - - -def is_optional(field_type): - return get_origin(field_type) is Union and type(None) in get_args(field_type) - - -def get_optional_arg(field_type): - return next(type_ for type_ in get_args(field_type) if type_ is not type(None)) - - -def is_list(field_type): - return get_origin(field_type) is list - - -def get_list_arg(field_type): - return get_args(field_type)[0] - - -def is_enum(field_type): - return inspect.isclass(field_type) and issubclass(field_type, Enum) - - -def snake_case(camelcased: str) -> str: - device_type = camelcased[0:4].lower() - class_name = camelcased[4:] - words = split_words_list(class_name, lambda c: c.isupper()) - return "_".join([device_type] + ["".join(word).lower() for word in words]) - - -def message_type_from_class_name(class_name: str) -> str: - return f"{class_name[0:4]}.{class_name[4:]}" - - -def generate_json_test_data_for_field(field_type: Type): - if field_type is Duration: - value = random.randint(0, 39999) - elif field_type is NumberRange: - start = random.random() - offset = random.random() - value = { - "start_of_range": start * 39999, - "end_of_range": (start + offset) * 39999, - } - elif field_type is PowerRange: - start = random.random() - offset = random.random() - value = { - "start_of_range": start * 39999, - "end_of_range": (start + offset) * 39999, - "commodity_quantity": generate_json_test_data_for_field(CommodityQuantity), - } - elif inspect.isclass(field_type) and issubclass(field_type, pydantic.BaseModel): - value = generate_json_test_data_for_class(field_type) - elif is_list(field_type): - value = [generate_json_test_data_for_field(get_list_arg(field_type))] - elif is_optional(field_type): - value = generate_json_test_data_for_field(get_optional_arg(field_type)) - elif is_enum(field_type): - field_type: Enum - value = next(value for value in field_type).value - elif field_type is str: - value = f"some-test-string{random.randint(0, 9999)}" - elif field_type is bool: - value = bool(random.randint(0, 1)) - elif field_type is float: - value = random.random() * 9000.0 - elif field_type in (AwareDatetime, datetime.datetime): - # Generate a timezone-aware datetime - value = datetime.datetime( - year=random.randint(2020, 2023), - month=random.randint(1, 12), - day=random.randint(1, 28), - hour=random.randint(0, 23), - minute=random.randint(0, 59), - second=random.randint(0, 59), - tzinfo=datetime.timezone(datetime.timedelta(hours=random.randint(-12, 14))), - ) - elif field_type is uuid.UUID: - value = uuid.uuid4() - else: - raise RuntimeError(f"Please implement test data for field type {field_type}") - return value - - -def generate_json_test_data_for_class(class_: Type) -> dict: - result = {} - for field_name, field_type in get_type_hints(class_).items(): - if field_name == "message_type": - result[field_name] = message_type_from_class_name(class_.__name__) - else: - result[field_name] = generate_json_test_data_for_field(field_type) - - return result - - -def dump_test_data_as_constructor_field_for(test_data, field_type: Type) -> str: - if field_type is Duration: - value = f"Duration.from_timedelta(timedelta(milliseconds={test_data}))" - elif inspect.isclass(field_type) and issubclass(field_type, pydantic.BaseModel): - value = dump_test_data_as_constructor_for(test_data, field_type) - elif is_list(field_type): - dumped_items = [ - dump_test_data_as_constructor_field_for( - test_data_item, get_list_arg(field_type) - ) - for test_data_item in test_data - ] - value = f'[{",".join(dumped_items)}]' - elif is_optional(field_type): - value = dump_test_data_as_constructor_field_for( - test_data, get_optional_arg(field_type) - ) - elif is_enum(field_type): - field_type: Enum - value = f'{field_type.__name__}.{test_data.replace(".", "_")}' - elif field_type is str: - value = f'"{test_data}"' - elif field_type is bool: - value = str(test_data) - elif field_type is float: - value = str(test_data) - elif field_type is AwareDatetime or field_type is datetime.datetime: - test_data: datetime.datetime - offset: datetime.timedelta = test_data.tzinfo.utcoffset(None) - value = ( - f"datetime(" - f"year={test_data.year}, month={test_data.month}, day={test_data.day}, " - f"hour={test_data.hour}, minute={test_data.minute}, second={test_data.second}, " - f"tzinfo=offset(offset=timedelta(seconds={offset.total_seconds()})))" - ) - elif field_type is uuid.UUID: - value = f'uuid.UUID("{test_data}")' - elif type(field_type).__name__ == "_LiteralGenericAlias": - value = field_type.__args__[0] - else: - raise RuntimeError( - f"Please implement dump test data for field type {field_type}" - ) - return value - - -def dump_test_data_as_constructor_for(test_data: dict, class_: Type) -> str: - result = f"{class_.__name__}" - - first = True - for field_name, field_type in get_type_hints(class_).items(): - value = dump_test_data_as_constructor_field_for( - test_data[field_name], field_type - ) - result += f'{"(" if first else ", "}{field_name}={value}' - first = False - - return result + ")" - - -def dump_test_data_as_json_field_for(test_data, field_type: Type): - if field_type is Duration: - value = test_data - elif inspect.isclass(field_type) and issubclass(field_type, pydantic.BaseModel): - value = dump_test_data_as_json_for(test_data, field_type) - elif is_list(field_type): - value = [ - dump_test_data_as_json_field_for(item, get_list_arg(field_type)) - for item in test_data - ] - elif is_optional(field_type): - value = dump_test_data_as_json_field_for( - test_data, get_optional_arg(field_type) - ) - elif is_enum(field_type): - field_type: Enum - value = test_data - elif field_type is str: - value = test_data - elif field_type is bool: - value = test_data - elif field_type is float: - value = test_data - elif field_type in (AwareDatetime, datetime.datetime): - test_data: datetime.datetime - value = test_data.isoformat() - elif field_type is uuid.UUID: - value = str(test_data) - elif type(field_type).__name__ == "_LiteralGenericAlias": - value = test_data - else: - raise RuntimeError( - f"Please implement dump test data to json for field type {field_type}" - ) - - return value - - -def dump_test_data_as_json_for(test_data: dict, class_: Type) -> dict: - result = {} - - for field_name, field_type in get_type_hints(class_).items(): - result[field_name] = dump_test_data_as_json_field_for( - test_data[field_name], field_type - ) - - return result - - -for class_name, class_ in inspect.getmembers(frbc): - if inspect.isclass(class_) and issubclass(class_, pydantic.BaseModel): - test_data = generate_json_test_data_for_class(class_) - - assert_lines = [] - for field_name, field_type in get_type_hints(class_).items(): - assert_test_data = dump_test_data_as_constructor_field_for( - test_data[field_name], field_type - ) - - assert_lines.append( - f"self.assertEqual({snake_case(class_name)}.{field_name}, {assert_test_data})" - ) - - asserts = "\n ".join(assert_lines) - template = f""" -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class {class_name}Test(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = \"\"\" -{json.dumps(dump_test_data_as_json_for(test_data, class_), indent=4)} - \"\"\" - - # Act - {snake_case(class_name)} = {class_name}.from_json(json_str) - - # Assert - {asserts} - - def test__to_json__happy_path_full(self): - # Arrange - {snake_case(class_name)} = {dump_test_data_as_constructor_for(test_data, class_)} - - # Act - json_str = {snake_case(class_name)}.to_json() - - # Assert - expected_json = {pprint.pformat(dump_test_data_as_json_for(test_data, class_), indent=4)} - self.assertEqual(json.loads(json_str), expected_json) -""" - print(template) - print() - print() - - # Check if the file already exists - if not os.path.exists(f"tests/unit/frbc/{snake_case(class_name)}_test.py"): - with open( - f"tests/unit/frbc/{snake_case(class_name)}_test.py", "w+" - ) as unit_test_file: - unit_test_file.write(template) - print(f"Created tests/unit/frbc/{snake_case(class_name)}_test.py") diff --git a/packages/s2-python/development_utilities/generate_s2_message_type_to_class.py b/packages/s2-python/development_utilities/generate_s2_message_type_to_class.py deleted file mode 100644 index 9ddceaa..0000000 --- a/packages/s2-python/development_utilities/generate_s2_message_type_to_class.py +++ /dev/null @@ -1,24 +0,0 @@ -import inspect -import s2python.generated.gen_s2 - -all_members = inspect.getmembers(s2python.generated.gen_s2) -all_members.sort(key=lambda t: t[0]) - - -print( - """ -from s2python.common import * -from s2python.frbc import * - -TYPE_TO_MESSAGE_CLASS = {""" -) - -for name, member in all_members: - if ( - inspect.isclass(member) - and hasattr(member, "__fields__") - and ("message_type" in member.__fields__) - ): - print(f" '{member.__fields__['message_type'].default}': {name},") - -print("}") diff --git a/packages/s2-python/development_utilities/get_all_messages.py b/packages/s2-python/development_utilities/get_all_messages.py deleted file mode 100644 index 5b70631..0000000 --- a/packages/s2-python/development_utilities/get_all_messages.py +++ /dev/null @@ -1,16 +0,0 @@ -import inspect -import s2python.frbc as frbc -import s2python.common as common - -from pydantic import BaseModel - -all_members = inspect.getmembers(frbc) + inspect.getmembers(common) -all_members.sort(key=lambda t: t[0]) - -for name, member in all_members: - if ( - inspect.isclass(member) - and issubclass(member, BaseModel) - and "message_type" in member.__fields__ - ): - print(f"{name},") diff --git a/packages/s2-python/examples/example_frbc_rm.py b/packages/s2-python/examples/example_frbc_rm.py deleted file mode 100644 index d69473d..0000000 --- a/packages/s2-python/examples/example_frbc_rm.py +++ /dev/null @@ -1,188 +0,0 @@ -import argparse -from functools import partial -import logging -import sys -import uuid -import signal -import datetime -from typing import Callable - -from s2python.common import ( - EnergyManagementRole, - Duration, - Role, - RoleType, - Commodity, - Currency, - NumberRange, - PowerRange, - CommodityQuantity, -) -from s2python.frbc import ( - FRBCInstruction, - FRBCSystemDescription, - FRBCActuatorDescription, - FRBCStorageDescription, - FRBCOperationMode, - FRBCOperationModeElement, - FRBCFillLevelTargetProfile, - FRBCFillLevelTargetProfileElement, - FRBCStorageStatus, - FRBCActuatorStatus, -) -from s2python.s2_connection import S2Connection, AssetDetails -from s2python.s2_control_type import FRBCControlType, NoControlControlType -from s2python.message import S2Message - -logger = logging.getLogger("s2python") -logger.addHandler(logging.StreamHandler(sys.stdout)) -logger.setLevel(logging.DEBUG) - - -class MyFRBCControlType(FRBCControlType): - def handle_instruction( - self, conn: S2Connection, msg: S2Message, send_okay: Callable[[], None] - ) -> None: - if not isinstance(msg, FRBCInstruction): - raise RuntimeError( - f"Expected an FRBCInstruction but received a message of type {type(msg)}." - ) - print(f"I have received the message {msg} from {conn}") - - def activate(self, conn: S2Connection) -> None: - print("The control type FRBC is now activated.") - - print("Time to send a FRBC SystemDescription") - actuator_id = uuid.uuid4() - operation_mode_id = uuid.uuid4() - conn.send_msg_and_await_reception_status_sync( - FRBCSystemDescription( - message_id=uuid.uuid4(), - valid_from=datetime.datetime.now(tz=datetime.timezone.utc), - actuators=[ - FRBCActuatorDescription( - id=actuator_id, - operation_modes=[ - FRBCOperationMode( - id=operation_mode_id, - elements=[ - FRBCOperationModeElement( - fill_level_range=NumberRange( - start_of_range=0.0, end_of_range=100.0 - ), - fill_rate=NumberRange( - start_of_range=-5.0, end_of_range=5.0 - ), - power_ranges=[ - PowerRange( - start_of_range=-200.0, - end_of_range=200.0, - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - ) - ], - ) - ], - diagnostic_label="Load & unload battery", - abnormal_condition_only=False, - ) - ], - transitions=[], - timers=[], - supported_commodities=[Commodity.ELECTRICITY], - ) - ], - storage=FRBCStorageDescription( - fill_level_range=NumberRange(start_of_range=0.0, end_of_range=100.0), - fill_level_label="%", - diagnostic_label="Imaginary battery", - provides_fill_level_target_profile=True, - provides_leakage_behaviour=False, - provides_usage_forecast=False, - ), - ) - ) - print("Also send the target profile") - - conn.send_msg_and_await_reception_status_sync( - FRBCFillLevelTargetProfile( - message_id=uuid.uuid4(), - start_time=datetime.datetime.now(tz=datetime.timezone.utc), - elements=[ - FRBCFillLevelTargetProfileElement( - duration=Duration.from_milliseconds(30_000), - fill_level_range=NumberRange(start_of_range=20.0, end_of_range=30.0), - ), - FRBCFillLevelTargetProfileElement( - duration=Duration.from_milliseconds(300_000), - fill_level_range=NumberRange(start_of_range=40.0, end_of_range=50.0), - ), - ], - ) - ) - - print("Also send the storage status.") - conn.send_msg_and_await_reception_status_sync( - FRBCStorageStatus(message_id=uuid.uuid4(), present_fill_level=10.0) - ) - - print("Also send the actuator status.") - conn.send_msg_and_await_reception_status_sync( - FRBCActuatorStatus( - message_id=uuid.uuid4(), - actuator_id=actuator_id, - active_operation_mode_id=operation_mode_id, - operation_mode_factor=0.5, - ) - ) - - def deactivate(self, conn: S2Connection) -> None: - print("The control type FRBC is now deactivated.") - - -class MyNoControlControlType(NoControlControlType): - def activate(self, conn: S2Connection) -> None: - print("The control type NoControl is now activated.") - - def deactivate(self, conn: S2Connection) -> None: - print("The control type NoControl is now deactivated.") - - -def stop(s2_connection, signal_num, _current_stack_frame): - print(f"Received signal {signal_num}. Will stop S2 connection.") - s2_connection.stop() - - -def start_s2_session(url, client_node_id=str(uuid.uuid4())): - s2_conn = S2Connection( - url=url, - role=EnergyManagementRole.RM, - control_types=[MyFRBCControlType(), MyNoControlControlType()], - asset_details=AssetDetails( - resource_id=client_node_id, - name="Some asset", - instruction_processing_delay=Duration.from_milliseconds(20), - roles=[Role(role=RoleType.ENERGY_CONSUMER, commodity=Commodity.ELECTRICITY)], - currency=Currency.EUR, - provides_forecast=False, - provides_power_measurements=[CommodityQuantity.ELECTRIC_POWER_L1], - ), - reconnect=True, - verify_certificate=False, - ) - signal.signal(signal.SIGINT, partial(stop, s2_conn)) - signal.signal(signal.SIGTERM, partial(stop, s2_conn)) - - s2_conn.start_as_rm() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="A simple S2 reseource manager example.") - parser.add_argument( - "endpoint", - type=str, - help="WebSocket endpoint uri for the server (CEM) e.g. " - "ws://localhost:8080/backend/rm/s2python-frbc/cem/dummy_model/ws", - ) - args = parser.parse_args() - - start_s2_session(args.endpoint) diff --git a/packages/s2-python/mypy.ini b/packages/s2-python/mypy.ini deleted file mode 100644 index 1d4ec90..0000000 --- a/packages/s2-python/mypy.ini +++ /dev/null @@ -1,14 +0,0 @@ -[mypy] -plugins = pydantic.mypy - -[mypy-s2python.*] -disallow_untyped_defs = true -disallow_incomplete_defs = true - -[mypy-s2python.generated.*] -ignore_errors = true -disallow_untyped_defs = false -disallow_incomplete_defs = false - -[mypy-unit.*] -check_untyped_defs = true diff --git a/packages/s2-python/pyproject.toml b/packages/s2-python/pyproject.toml deleted file mode 100644 index 7fd26b9..0000000 --- a/packages/s2-python/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/packages/s2-python/pyrightconfig.json b/packages/s2-python/pyrightconfig.json deleted file mode 100644 index 0df0c10..0000000 --- a/packages/s2-python/pyrightconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "include": [ - "src", - "tests" - ], - - "defineConstant": { - "DEBUG": true - } -} diff --git a/packages/s2-python/setup.cfg b/packages/s2-python/setup.cfg deleted file mode 100644 index f675f05..0000000 --- a/packages/s2-python/setup.cfg +++ /dev/null @@ -1,106 +0,0 @@ -[metadata] -name = s2-python -description = S2 Protocol Python Wrapper -author = Flexiblepower -author_email = info@info.nl -license = APACHE -license_files = LICENSE.txt -long_description = file: README.rst -long_description_content_type = text/x-rst; charset=UTF-8 -url = https://github.com/flexiblepower/s2-ws-json-python -version = 0.5.0 - -# Change if running only on Windows, Mac or Linux (comma-separated) -platforms = Linux - -# Add here all kinds of additional classifiers as defined under -# https://pypi.org/classifiers/ -classifiers = - Development Status :: 4 - Beta - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - - -[options] -zip_safe = False -packages = find_namespace: -include_package_data = True -package_dir = - =src - -# Require a min/specific Python version (comma-separated conditions) -python_requires >= 3.8, <= 3.12 - -# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. -# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in -# new major versions. This works if the required packages follow Semantic Versioning. -# For more information, check out https://semver.org/. -install_requires = - pydantic>=2.8.2 - pytz - click - websockets~=13.1 - -[options.packages.find] -where = src -exclude = - tests - -[options.extras_require] -testing = - pytest - pytest-coverage - pytest-timer - mypy - types-pytz - pylint - pyright - - -development = - pip-tools - datamodel-code-generator - pre-commit - tox - -docs = - sphinx - sphinx-rtd-theme >= 1.2 - sphinx-tabs - sphinx_copybutton - sphinx_fontawesome - sphinxcontrib.httpdomain - -[options.entry_points] -console_scripts = - s2python = s2python.tools.cli:s2python_cmd - -[tool:pytest] -addopts = - --cov=s2python - --cov-report=html:./unit_test_coverage/ - -v tests/unit/ - -testpaths = tests - -[devpi:upload] -# Options for the devpi: PyPI server and packaging tool -# VCS export must be deactivated since we are using setuptools-scm -no_vcs = 1 -formats = bdist_wheel - -[flake8] -# Some sane defaults for the code style checker flake8 -max_line_length = 88 -extend_ignore = E203, W503 -# ^ Black-compatible -# E203 and W503 have edge cases handled by black -exclude = - .tox - build - dist - .eggs - docs/conf.py \ No newline at end of file diff --git a/packages/s2-python/setup.py b/packages/s2-python/setup.py deleted file mode 100644 index 6068493..0000000 --- a/packages/s2-python/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/packages/s2-python/specification/openapi.yml b/packages/s2-python/specification/openapi.yml deleted file mode 100644 index 64c932e..0000000 --- a/packages/s2-python/specification/openapi.yml +++ /dev/null @@ -1,1762 +0,0 @@ -openapi: 3.0.0 -info: - title: s2-ws-json schema's in OpenAPI - version: main - description: "NB: S2-WS-JSON IS NOT A REST API, SO THIS FILE CANNOT BE USED TO GENERATE AN S2-WS-JSON ENDPOINT! S2-ws-json uses WebSockets for communication. An AsyincAPI and JSON-Schema specification is provided as well. This file is provided for developers who prefer OpenAPI code generators for generating data classes in their programming language. In that case you still need to impement the code for using WebSockets yourself." -components: - schemas: - Duration: - type: integer - minimum: 0 - description: "Duration in milliseconds" - ID: - type: string - pattern: "[a-zA-Z0-9\\-_:]{2,64}" - description: "UUID" - Currency: - enum: [AED,ANG,AUD,CHE,CHF,CHW,EUR,GBP,LBP,LKR,LRD,LSL,LYD,MAD,MDL,MGA,MKD,MMK,MNT,MOP,MRO,MUR,MVR,MWK,MXN,MXV,MYR,MZN,NAD,NGN,NIO,NOK,NPR,NZD,OMR,PAB,PEN,PGK,PHP,PKR,PLN,PYG,QAR,RON,RSD,RUB,RWF,SAR,SBD,SCR,SDG,SEK,SGD,SHP,SLL,SOS,SRD,SSP,STD,SYP,SZL,THB,TJS,TMT,TND,TOP,TRY,TTD,TWD,TZS,UAH,UGX,USD,USN,UYI,UYU,UZS,VEF,VND,VUV,WST,XAG,XAU,XBA,XBB,XBC,XBD,XCD,XOF,XPD,XPF,XPT,XSU,XTS,XUA,XXX,YER,ZAR,ZMW,ZWL] - SessionRequestType: - type: string - enum: ["RECONNECT", "TERMINATE"] - description: | - RECONNECT: Please reconnect the WebSocket session. Once reconnected, it starts from scratch with a handshake. - TERMINATE: Disconnect the session (client can try to reconnecting with exponential backoff) - RevokableObjects: - type: string - enum: ["PEBC.PowerConstraints", "PEBC.EnergyConstraint", "PEBC.Instruction", "PPBC.PowerProfileDefinition", "PPBC.ScheduleInstruction", "PPBC.StartInterruptionInstruction", "PPBC.EndInterruptionInstruction", "OMBC.SystemDescription", "OMBC.Instruction", "FRBC.SystemDescription", "FRBC.Instruction", "DDBC.SystemDescription", "DDBC.Instruction"] - description: | - PEBC.PowerConstraints: Object type PEBC.PowerConstraints - PEBC.EnergyConstraint: Object type PEBC.EnergyConstraint - PEBC.Instruction: Object type PEBC.Instruction - PPBC.PowerProfileDefinition: Object type PPBC.PowerProfileDefinition - PPBC.ScheduleInstruction: Object type PPBC.ScheduleInstruction - PPBC.StartInterruptionInstruction: Object type PPBC.StartInterruptionInstruction - PPBC.EndInterruptionInstruction: Object type PPBC.EndInterruptionInstruction - OMBC.SystemDescription: Object type OMBC.SystemDescription - OMBC.Instruction: Object type OMBC.Instruction - FRBC.SystemDescription: Object type FRBC.SystemDescription - FRBC.Instruction: Object type FRBC.Instruction - DDBC.SystemDescription: Object type DDBC.SystemDescription - DDBC.Instruction: Object type DDBC.Instruction - EnergyManagementRole: - type: string - enum: ["CEM", "RM"] - description: | - CEM: Customer Energy Manager - RM: Resource Manager - ReceptionStatusValues: - type: string - enum: ["INVALID_DATA", "INVALID_MESSAGE", "INVALID_CONTENT", "TEMPORARY_ERROR", "PERMANENT_ERROR", "OK"] - description: | - INVALID_DATA: Message not understood (e.g. not valid JSON, no message_id found). Consequence: Message is ignored, proceed if possible - INVALID_MESSAGE: Message was not according to schema. Consequence: Message is ignored, proceed if possible - INVALID_CONTENT: Message contents is invalid (e.g. contains a non-existing ID). Somewhat equivalent to BAD_REQUEST in HTTP.. Consequence: Message is ignored, proceed if possible. - TEMPORARY_ERROR: Receiver encountered an error. Consequence: Try to send to message again - PERMANENT_ERROR: Receiver encountered an error which it cannot recover from. Consequence: Disconnect. - OK: Message processed normally. Consequence: Proceed normally. - PowerValue: - type: object - required: - - commodity_quantity - - value - properties: - commodity_quantity: - $ref: '#/components/schemas/CommodityQuantity' - description: "The power quantity the value refers to" - value: - type: number - description: "Power value expressed in the unit associated with the CommodityQuantity" - additionalProperties: false - PowerForecastValue: - type: object - required: - - value_expected - - commodity_quantity - properties: - value_upper_limit: - type: number - description: "The upper boundary of the range with 100 % certainty the power value is in it" - value_upper_95PPR: - type: number - description: "The upper boundary of the range with 95 % certainty the power value is in it" - value_upper_68PPR: - type: number - description: "The upper boundary of the range with 68 % certainty the power value is in it" - value_expected: - type: number - description: "The expected power value." - value_lower_68PPR: - type: number - description: "The lower boundary of the range with 68 % certainty the power value is in it" - value_lower_95PPR: - type: number - description: "The lower boundary of the range with 95 % certainty the power value is in it" - value_lower_limit: - type: number - description: "The lower boundary of the range with 100 % certainty the power value is in it" - commodity_quantity: - $ref: '#/components/schemas/CommodityQuantity' - description: "The power quantity the value refers to" - additionalProperties: false - PowerRange: - type: object - required: - - start_of_range - - end_of_range - - commodity_quantity - properties: - start_of_range: - type: number - description: "Power value that defines the start of the range." - end_of_range: - type: number - description: "Power value that defines the end of the range." - commodity_quantity: - $ref: '#/components/schemas/CommodityQuantity' - description: "The power quantity the values refer to" - additionalProperties: false - NumberRange: - type: object - required: - - start_of_range - - end_of_range - properties: - start_of_range: - type: number - description: "Number that defines the start of the range" - end_of_range: - type: number - description: "Number that defines the end of the range" - additionalProperties: false - Role: - type: object - required: - - role - - commodity - properties: - role: - $ref: '#/components/schemas/RoleType' - description: "Role type of the Resource Manager for the given commodity" - commodity: - $ref: '#/components/schemas/Commodity' - description: "Commodity the role refers to." - additionalProperties: false - Transition: - type: object - required: - - id - - from - - to - - start_timers - - blocking_timers - - abnormal_condition_only - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the Transition. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used." - from: - $ref: '#/components/schemas/ID' - description: "ID of the OperationMode (exact type differs per ControlType) that should be switched from." - to: - $ref: '#/components/schemas/ID' - description: "ID of the OperationMode (exact type differs per ControlType) that will be switched to." - start_timers: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/ID' - description: "List of IDs of Timers that will be (re)started when this transition is initiated" - blocking_timers: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/ID' - description: "List of IDs of Timers that block this Transition from initiating while at least one of these Timers is not yet finished" - transition_costs: - type: number - description: "Absolute costs for going through this Transition in the currency as described in the ResourceManagerDetails." - transition_duration: - $ref: '#/components/schemas/Duration' - description: "Indicates the time between the initiation of this Transition, and the time at which the device behaves according to the Operation Mode which is defined in the ‘to’ data element. When no value is provided it is assumed the transition duration is negligible." - abnormal_condition_only: - type: boolean - description: "Indicates if this Transition may only be used during an abnormal condition (see Clause )" - additionalProperties: false - Timer: - type: object - required: - - id - - duration - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the Timer. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used." - diagnostic_label: - type: string - description: "Human readable name/description of the Timer. This element is only intended for diagnostic purposes and not for HMI applications." - duration: - $ref: '#/components/schemas/Duration' - description: "The time it takes for the Timer to finish after it has been started" - additionalProperties: false - PowerForecastElement: - type: object - required: - - duration - - power_values - properties: - duration: - $ref: '#/components/schemas/Duration' - description: "Duration of the PowerForecastElement" - power_values: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PowerForecastValue' - description: "The values of power that are expected for the given period of time. There shall be at least one PowerForecastValue, and at most one PowerForecastValue per CommodityQuantity." - additionalProperties: false - PEBCAllowedLimitRange: - type: object - required: - - commodity_quantity - - limit_type - - range_boundary - - abnormal_condition_only - properties: - commodity_quantity: - $ref: '#/components/schemas/CommodityQuantity' - description: "Type of power quantity this PEBC.AllowedLimitRange applies to" - limit_type: - $ref: '#/components/schemas/PEBCPowerEnvelopeLimitType' - description: "Indicates if this ranges applies to the upper limit or the lower limit" - range_boundary: - $ref: '#/components/schemas/NumberRange' - description: "Boundaries of the power range of this PEBC.AllowedLimitRange. The CEM is allowed to choose values within this range for the power envelope for the limit as described in limit_type. The start of the range shall be smaller or equal than the end of the range. " - abnormal_condition_only: - type: boolean - description: "Indicates if this PEBC.AllowedLimitRange may only be used during an abnormal condition" - additionalProperties: false - PEBCPowerEnvelope: - type: object - required: - - id - - commodity_quantity - - power_envelope_elements - properties: - id: - $ref: '#/components/schemas/ID' - description: "Identifier of this PEBC.PowerEnvelope. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - commodity_quantity: - $ref: '#/components/schemas/CommodityQuantity' - description: "Type of power quantity this PEBC.PowerEnvelope applies to" - power_envelope_elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/PEBCPowerEnvelopeElement' - description: "The elements of this PEBC.PowerEnvelope. Shall contain at least one element. Elements must be placed in chronological order." - additionalProperties: false - PEBCPowerEnvelopeElement: - type: object - required: - - duration - - upper_limit - - lower_limit - properties: - duration: - $ref: '#/components/schemas/Duration' - description: "The duration of the element" - upper_limit: - type: number - description: "Upper power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or below the upper_limit. The upper_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type UPPER_LIMIT." - lower_limit: - type: number - description: "Lower power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or above the lower_limit. The lower_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type LOWER_LIMIT." - additionalProperties: false - PPBCPowerSequenceContainer: - type: object - required: - - id - - power_sequences - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequenceContainer. Must be unique in the scope of the PPBC.PowerProfileDefinition in which it is used." - power_sequences: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/PPBCPowerSequence' - description: "List of alternative Sequences where one could be chosen by the CEM" - additionalProperties: false - PPBCPowerSequence: - type: object - required: - - id - - elements - - is_interruptible - - abnormal_condition_only - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequence. Must be unique in the scope of the PPBC.PowerSequnceContainer in which it is used." - elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/PPBCPowerSequenceElement' - description: "List of PPBC.PowerSequenceElements. Shall contain at least one element. Elements must be placed in chronological order." - is_interruptible: - type: boolean - description: "Indicates whether the option of pausing a sequence is available." - max_pause_before: - $ref: '#/components/schemas/Duration' - description: "The maximum duration for which a device can be paused between the end of the previous running sequence and the start of this one" - abnormal_condition_only: - type: boolean - description: "Indicates if this PPBC.PowerSequence may only be used during an abnormal condition" - additionalProperties: false - PPBCPowerSequenceElement: - type: object - required: - - duration - - power_values - properties: - duration: - $ref: '#/components/schemas/Duration' - description: "Duration of the PPBC.PowerSequenceElement." - power_values: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PowerForecastValue' - description: "The value of power and deviations for the given duration. The array should contain at least one PowerForecastValue and at most one PowerForecastValue per CommodityQuantity." - additionalProperties: false - PPBCPowerSequenceContainerStatus: - type: object - required: - - power_profile_id - - sequence_container_id - - status - properties: - power_profile_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerProfileDefinition of which the data element ‘sequence_container_id’ refers to. " - sequence_container_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequenceContainer this PPBC.PowerSequenceContainerStatus provides information about." - selected_sequence_id: - $ref: '#/components/schemas/ID' - description: "ID of selected PPBC.PowerSequence. When no ID is given, no sequence was selected yet." - progress: - $ref: '#/components/schemas/Duration' - description: "Time that has passed since the selected sequence has started. A value must be provided, unless no sequence has been selected or the selected sequence hasn’t started yet." - status: - $ref: '#/components/schemas/PPBCPowerSequenceStatus' - description: "Status of the selected PPBC.PowerSequence" - additionalProperties: false - OMBCOperationMode: - type: object - required: - - id - - power_ranges - - abnormal_condition_only - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the OBMC.OperationMode. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - diagnostic_label: - type: string - description: "Human readable name/description of the OMBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications." - power_ranges: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PowerRange' - description: "The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity." - running_costs: - $ref: '#/components/schemas/NumberRange' - description: "Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails , excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor." - abnormal_condition_only: - type: boolean - description: "Indicates if this OMBC.OperationMode may only be used during an abnormal condition." - additionalProperties: false - FRBCActuatorDescription: - type: object - required: - - id - - supported_commodities - - operation_modes - - transitions - - timers - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the Actuator. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - diagnostic_label: - type: string - description: "Human readable name/description for the actuator. This element is only intended for diagnostic purposes and not for HMI applications." - supported_commodities: - type: array - minItems: 1 - maxItems: 4 - items: - $ref: '#/components/schemas/Commodity' - description: "List of all supported Commodities." - operation_modes: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: '#/components/schemas/FRBCOperationMode' - description: "Provided FRBC.OperationModes associated with this actuator" - transitions: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/Transition' - description: "Possible transitions between FRBC.OperationModes associated with this actuator." - timers: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/Timer' - description: "List of Timers associated with this actuator" - additionalProperties: false - FRBCOperationMode: - type: object - required: - - id - - elements - - abnormal_condition_only - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of the FRBC.OperationMode. Must be unique in the scope of the FRBC.ActuatorDescription in which it is used." - diagnostic_label: - type: string - description: "Human readable name/description of the FRBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications." - elements: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: '#/components/schemas/FRBCOperationModeElement' - description: "List of FRBC.OperationModeElements, which describe the properties of this FRBC.OperationMode depending on the fill_level. The fill_level_ranges of the items in the Array must be contiguous." - abnormal_condition_only: - type: boolean - description: "Indicates if this FRBC.OperationMode may only be used during an abnormal condition" - additionalProperties: false - FRBCOperationModeElement: - type: object - required: - - fill_level_range - - fill_rate - - power_ranges - properties: - fill_level_range: - $ref: '#/components/schemas/NumberRange' - description: "The range of the fill level for which this FRBC.OperationModeElement applies. The start of the NumberRange shall be smaller than the end of the NumberRange." - fill_rate: - $ref: '#/components/schemas/NumberRange' - description: "Indicates the change in fill_level per second. The lower_boundary of the NumberRange is associated with an operation_mode_factor of 0, the upper_boundary is associated with an operation_mode_factor of 1. " - power_ranges: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PowerRange' - description: "The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity." - running_costs: - $ref: '#/components/schemas/NumberRange' - description: "Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor." - additionalProperties: false - FRBCStorageDescription: - type: object - required: - - provides_leakage_behaviour - - provides_fill_level_target_profile - - provides_usage_forecast - - fill_level_range - properties: - diagnostic_label: - type: string - description: "Human readable name/description of the storage (e.g. hot water buffer or battery). This element is only intended for diagnostic purposes and not for HMI applications." - fill_level_label: - type: string - description: "Human readable description of the (physical) units associated with the fill_level (e.g. degrees Celsius or percentage state of charge). This element is only intended for diagnostic purposes and not for HMI applications." - provides_leakage_behaviour: - type: boolean - description: "Indicates whether the Storage could provide details of power leakage behaviour through the FRBC.LeakageBehaviour." - provides_fill_level_target_profile: - type: boolean - description: "Indicates whether the Storage could provide a target profile for the fill level through the FRBC.FillLevelTargetProfile." - provides_usage_forecast: - type: boolean - description: "Indicates whether the Storage could provide a UsageForecast through the FRBC.UsageForecast." - fill_level_range: - $ref: '#/components/schemas/NumberRange' - description: "The range in which the fill_level should remain. It is expected of the CEM to keep the fill_level within this range. When the fill_level is not within this range, the Resource Manager can ignore instructions from the CEM (except during abnormal conditions). " - additionalProperties: false - FRBCLeakageBehaviourElement: - type: object - required: - - fill_level_range - - leakage_rate - properties: - fill_level_range: - $ref: '#/components/schemas/NumberRange' - description: "The fill level range for which this FRBC.LeakageBehaviourElement applies. The start of the range must be less than the end of the range." - leakage_rate: - type: number - description: "Indicates how fast the momentary fill level will decrease per second due to leakage within the given range of the fill level. A positive value indicates that the fill level decreases over time due to leakage." - additionalProperties: false - FRBCUsageForecastElement: - type: object - required: - - duration - - usage_rate_expected - properties: - duration: - $ref: '#/components/schemas/Duration' - description: "Indicator for how long the given usage_rate is valid." - usage_rate_upper_limit: - type: number - description: "The upper limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." - usage_rate_upper_95PPR: - type: number - description: "The upper limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." - usage_rate_upper_68PPR: - type: number - description: "The upper limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." - usage_rate_expected: - type: number - description: "The most likely value for the usage rate; the expected increase or decrease of the fill_level per second. A positive value indicates that the fill level will decrease due to usage." - usage_rate_lower_68PPR: - type: number - description: "The lower limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." - usage_rate_lower_95PPR: - type: number - description: "The lower limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." - usage_rate_lower_limit: - type: number - description: "The lower limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage." - additionalProperties: false - FRBCFillLevelTargetProfileElement: - type: object - required: - - duration - - fill_level_range - properties: - duration: - $ref: '#/components/schemas/Duration' - description: "The duration of the element." - fill_level_range: - $ref: '#/components/schemas/NumberRange' - description: "The target range in which the fill_level must be for the time period during which the element is active. The start of the range must be smaller or equal to the end of the range. The CEM must take best-effort actions to proactively achieve this target." - additionalProperties: false - DDBCActuatorDescription: - type: object - required: - - id - - supported_commodites - - operation_modes - - transitions - - timers - properties: - id: - $ref: '#/components/schemas/ID' - description: "ID of this DDBC.ActuatorDescription. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - diagnostic_label: - type: string - description: "Human readable name/description of the actuator. This element is only intended for diagnostic purposes and not for HMI applications." - supported_commodites: - type: array - minItems: 1 - maxItems: 4 - items: - $ref: '#/components/schemas/Commodity' - description: "Commodities supported by the operation modes of this actuator. There shall be at least one commodity" - operation_modes: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: '#/components/schemas/DDBCOperationMode' - description: "List of all Operation Modes that are available for this actuator. There shall be at least one DDBC.OperationMode." - transitions: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/Transition' - description: "List of Transitions between Operation Modes. Shall contain at least one Transition." - timers: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/Timer' - description: "List of Timers associated with Transitions for this Actuator. Can be empty." - additionalProperties: false - DDBCOperationMode: - type: object - required: - - Id - - power_ranges - - supply_range - - abnormal_condition_only - properties: - Id: - $ref: '#/components/schemas/ID' - description: "ID of this operation mode. Must be unique in the scope of the DDBC.ActuatorDescription in which it is used." - diagnostic_label: - type: string - description: "Human readable name/description of the DDBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications." - power_ranges: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PowerRange' - description: "The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity." - supply_range: - $ref: '#/components/schemas/NumberRange' - description: "The supply rate this DDBC.OperationMode can deliver for the CEM to match the demand rate. The start of the NumberRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1." - running_costs: - $ref: '#/components/schemas/NumberRange' - description: "Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor." - abnormal_condition_only: - type: boolean - description: "Indicates if this DDBC.OperationMode may only be used during an abnormal condition." - additionalProperties: false - DDBCAverageDemandRateForecastElement: - type: object - required: - - duration - - demand_rate_expected - properties: - duration: - $ref: '#/components/schemas/Duration' - description: "Duration of the element" - demand_rate_upper_limit: - type: number - description: "The upper limit of the range with a 100 % probability that the demand rate is within that range" - demand_rate_upper_95PPR: - type: number - description: "The upper limit of the range with a 95 % probability that the demand rate is within that range" - demand_rate_upper_68PPR: - type: number - description: "The upper limit of the range with a 68 % probability that the demand rate is within that range" - demand_rate_expected: - type: number - description: "The most likely value for the demand rate; the expected increase or decrease of the fill_level per second" - demand_rate_lower_68PPR: - type: number - description: "The lower limit of the range with a 68 % probability that the demand rate is within that range" - demand_rate_lower_95PPR: - type: number - description: "The lower limit of the range with a 95 % probability that the demand rate is within that range" - demand_rate_lower_limit: - type: number - description: "The lower limit of the range with a 100 % probability that the demand rate is within that range" - additionalProperties: false - RoleType: - type: string - enum: ["ENERGY_PRODUCER", "ENERGY_CONSUMER", "ENERGY_STORAGE"] - description: | - ENERGY_PRODUCER: Identifier for RoleType Producer - ENERGY_CONSUMER: Identifier for RoleType Consumer - ENERGY_STORAGE: Identifier for RoleType Storage - Commodity: - type: string - enum: ["GAS", "HEAT", "ELECTRICITY", "OIL"] - description: | - GAS: Identifier for Commodity GAS - HEAT: Identifier for Commodity HEAT - ELECTRICITY: Identifier for Commodity ELECTRICITY - OIL: Identifier for Commodity OIL - CommodityQuantity: - type: string - enum: ["ELECTRIC.POWER.L1", "ELECTRIC.POWER.L2", "ELECTRIC.POWER.L3", "ELECTRIC.POWER.3_PHASE_SYMMETRIC", "NATURAL_GAS.FLOW_RATE", "HYDROGEN.FLOW_RATE", "HEAT.TEMPERATURE", "HEAT.FLOW_RATE", "HEAT.THERMAL_POWER", "OIL.FLOW_RATE"] - description: | - ELECTRIC.POWER.L1: Electric power described in Watt on phase 1. If a device utilizes only one phase it should always use L1. - ELECTRIC.POWER.L2: Electric power described in Watt on phase 2. Only applicable for 3 phase devices. - ELECTRIC.POWER.L3: Electric power described in Watt on phase 3. Only applicable for 3 phase devices. - ELECTRIC.POWER.3_PHASE_SYMMETRIC: Electric power described in Watt on when power is equally shared among the three phases. Only applicable for 3 phase devices. - NATURAL_GAS.FLOW_RATE: Gas flow rate described in liters per second - HYDROGEN.FLOW_RATE: Gas flow rate described in grams per second - HEAT.TEMPERATURE: Heat described in degrees Celsius - HEAT.FLOW_RATE: Flow rate of heat carrying gas or liquid in liters per second - HEAT.THERMAL_POWER: Thermal power in Watt - OIL.FLOW_RATE: Oil flow rate described in liters per hour - InstructionStatus: - type: string - enum: ["NEW", "ACCEPTED", "REJECTED", "REVOKED", "STARTED", "SUCCEEDED", "ABORTED"] - description: | - NEW: Instruction was newly created - ACCEPTED: Instruction has been accepted - REJECTED: Instruction was rejected - REVOKED: Instruction was revoked - STARTED: Instruction was executed - SUCCEEDED: Instruction finished successfully - ABORTED: Instruction was aborted. - ControlType: - type: string - enum: ["POWER_ENVELOPE_BASED_CONTROL", "POWER_PROFILE_BASED_CONTROL", "OPERATION_MODE_BASED_CONTROL", "FILL_RATE_BASED_CONTROL", "DEMAND_DRIVEN_BASED_CONTROL", "NOT_CONTROLABLE", "NO_SELECTION"] - description: | - POWER_ENVELOPE_BASED_CONTROL: Identifier for the Power Envelope Based Control type - POWER_PROFILE_BASED_CONTROL: Identifier for the Power Profile Based Control type - OPERATION_MODE_BASED_CONTROL: Identifier for the Operation Mode Based Control type - FILL_RATE_BASED_CONTROL: Identifier for the Demand Driven Based Control type - DEMAND_DRIVEN_BASED_CONTROL: Identifier for the Fill Rate Based Control type - NOT_CONTROLABLE: Identifier that is to be used if no control is possible. Resources of this type can still provide measurements and forecast - NO_SELECTION: Identifier that is to be used if no control type is or has been selected. - PEBCPowerEnvelopeLimitType: - type: string - enum: ["UPPER_LIMIT", "LOWER_LIMIT"] - description: | - UPPER_LIMIT: Indicating the upper limit of a PEBC.PowerEnvelope (see Clause 7.6.2) - LOWER_LIMIT: Indicating the lower limit of a PEBC.PowerEnvelope (see Clause 7.6.2) - PEBCPowerEnvelopeConsequenceType: - type: string - enum: ["VANISH", "DEFER"] - description: | - VANISH: Indicating that the limited load or generated will be lost and not reappear in the future (see Clause 7.6.2) - DEFER: Indicating that the limited load or generation will be postponed to a later moment (see Clause 7.6.2) - PPBCPowerSequenceStatus: - type: string - enum: ["NOT_SCHEDULED", "SCHEDULED", "EXECUTING", "INTERRUPTED", "FINISHED", "ABORTED"] - description: | - NOT_SCHEDULED: No PPBC.PowerSequence within the PPBC.PowerSequenceContainer is scheduled - SCHEDULED: The selected PPBC.PowerSequence is scheduled to be executed in the future - EXECUTING: The selected PPBC.PowerSequence is currently being executed - INTERRUPTED: The selected PPBC.PowerSequence is being executed, but is currently interrupted and will continue afterwards - FINISHED: The selected PPBC.PowerSequence was executed and finished successfully - ABORTED: The selected PPBC.PowerSequence was aborted by the device and will not continue - OMBCTimerStatus: - type: object - required: - - message_type - - message_id - - timer_id - - finished_at - properties: - message_type: - const: OMBC.TimerStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - timer_id: - $ref: '#/components/schemas/ID' - description: "The ID of the timer this message refers to" - finished_at: - type: string - format: date-time - description: "Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past." - additionalProperties: false - FRBCTimerStatus: - type: object - required: - - message_type - - message_id - - timer_id - - actuator_id - - finished_at - properties: - message_type: - const: FRBC.TimerStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - timer_id: - $ref: '#/components/schemas/ID' - description: "The ID of the timer this message refers to" - actuator_id: - $ref: '#/components/schemas/ID' - description: "The ID of the actuator the timer belongs to" - finished_at: - type: string - format: date-time - description: "Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past." - additionalProperties: false - DDBCTimerStatus: - type: object - required: - - message_type - - message_id - - timer_id - - actuator_id - - finished_at - properties: - message_type: - const: DDBC.TimerStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - timer_id: - $ref: '#/components/schemas/ID' - description: "The ID of the timer this message refers to" - actuator_id: - $ref: '#/components/schemas/ID' - description: "The ID of the actuator the timer belongs to" - finished_at: - type: string - format: date-time - description: "Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past." - additionalProperties: false - SelectControlType: - type: object - required: - - message_type - - message_id - - control_type - properties: - message_type: - const: SelectControlType - type: string - message_id: - $ref: '#/components/schemas/ID' - control_type: - $ref: '#/components/schemas/ControlType' - description: "The ControlType to activate. Must be one of the available ControlTypes as defined in the ResourceManagerDetails" - additionalProperties: false - SessionRequest: - type: object - required: - - message_type - - message_id - - request - properties: - message_type: - const: SessionRequest - type: string - message_id: - $ref: '#/components/schemas/ID' - request: - $ref: '#/components/schemas/SessionRequestType' - description: "The type of request" - diagnostic_label: - type: string - description: "Optional field for a human readible descirption for debugging purposes" - additionalProperties: false - RevokeObject: - type: object - required: - - message_type - - message_id - - object_type - - object_id - properties: - message_type: - const: RevokeObject - type: string - message_id: - $ref: '#/components/schemas/ID' - object_type: - $ref: '#/components/schemas/RevokableObjects' - description: "The type of object that needs to be revoked" - object_id: - $ref: '#/components/schemas/ID' - description: "The ID of object that needs to be revoked" - additionalProperties: false - Handshake: - type: object - required: - - message_type - - message_id - - role - properties: - message_type: - const: Handshake - type: string - message_id: - $ref: '#/components/schemas/ID' - role: - $ref: '#/components/schemas/EnergyManagementRole' - description: "The role of the sender of this message" - supported_protocol_versions: - type: array - minItems: 1 - items: - type: string - description: "Protocol versions supported by the sender of this message. This field is mandatory for the RM, but optional for the CEM." - additionalProperties: false - HandshakeResponse: - type: object - required: - - message_type - - message_id - - selected_protocol_version - properties: - message_type: - const: HandshakeResponse - type: string - message_id: - $ref: '#/components/schemas/ID' - selected_protocol_version: - type: string - description: "The protocol version the CEM selected for this session" - additionalProperties: false - ResourceManagerDetails: - type: object - required: - - message_type - - message_id - - resource_id - - roles - - instruction_processing_delay - - available_control_types - - provides_forecast - - provides_power_measurement_types - properties: - message_type: - const: ResourceManagerDetails - type: string - message_id: - $ref: '#/components/schemas/ID' - resource_id: - $ref: '#/components/schemas/ID' - description: "Identifier of the Resource Manager. Must be unique within the scope of the CEM." - name: - type: string - description: "Human readable name given by user" - roles: - type: array - minItems: 1 - maxItems: 3 - items: - $ref: '#/components/schemas/Role' - description: "Each Resource Manager provides one or more energy Roles" - manufacturer: - type: string - description: "Name of Manufacturer" - model: - type: string - description: "Name of the model of the device (provided by the manufacturer)" - serial_number: - type: string - description: "Serial number of the device (provided by the manufacturer)" - firmware_version: - type: string - description: "Version identifier of the firmware used in the device (provided by the manufacturer)" - instruction_processing_delay: - $ref: '#/components/schemas/Duration' - description: "The average time the combination of Resource Manager and HBES/BACS/SASS or (Smart) device needs to process and execute an instruction" - available_control_types: - type: array - minItems: 1 - maxItems: 5 - items: - $ref: '#/components/schemas/ControlType' - description: "The control types supported by this Resource Manager." - currency: - $ref: '#/components/schemas/Currency' - description: "Currency to be used for all information regarding costs. Mandatory if cost information is published." - provides_forecast: - type: boolean - description: "Indicates whether the ResourceManager is able to provide PowerForecasts" - provides_power_measurement_types: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/CommodityQuantity' - description: "Array of all CommodityQuantities that this Resource Manager can provide measurements for. " - additionalProperties: false - PowerMeasurement: - type: object - required: - - message_type - - message_id - - measurement_timestamp - - values - properties: - message_type: - const: PowerMeasurement - type: string - message_id: - $ref: '#/components/schemas/ID' - measurement_timestamp: - type: string - format: date-time - description: "Timestamp when PowerValues were measured." - values: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PowerValue' - description: "Array of measured PowerValues. Must contain at least one item and at most one item per ‘commodity_quantity’ (defined inside the PowerValue)." - additionalProperties: false - ReceptionStatus: - type: object - required: - - message_type - - subject_message_id - - status - properties: - message_type: - const: ReceptionStatus - type: string - subject_message_id: - $ref: '#/components/schemas/ID' - description: "The message this ReceptionStatus refers to" - status: - $ref: '#/components/schemas/ReceptionStatusValues' - description: "Enumeration of status values" - diagnostic_label: - type: string - description: "Diagnostic label that can be used to provide additional information for debugging. However, not for HMI purposes." - additionalProperties: false - InstructionStatusUpdate: - type: object - required: - - message_type - - message_id - - instruction_id - - status_type - - timestamp - properties: - message_type: - const: InstructionStatusUpdate - type: string - message_id: - $ref: '#/components/schemas/ID' - instruction_id: - $ref: '#/components/schemas/ID' - description: "ID of this instruction (as provided by the CEM) " - status_type: - $ref: '#/components/schemas/InstructionStatus' - description: "Present status of this instruction." - timestamp: - type: string - format: date-time - description: "Timestamp when status_type has changed the last time." - additionalProperties: false - PowerForecast: - type: object - required: - - message_type - - message_id - - start_time - - elements - properties: - message_type: - const: PowerForecast - type: string - message_id: - $ref: '#/components/schemas/ID' - start_time: - type: string - format: date-time - description: "Start time of time period that is covered by the profile." - elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/PowerForecastElement' - description: "Elements of which this forecast consists. Contains at least one element. Elements must be placed in chronological order." - additionalProperties: false - PEBCPowerConstraints: - type: object - required: - - message_type - - message_id - - id - - valid_from - - consequence_type - - allowed_limit_ranges - properties: - message_type: - const: PEBC.PowerConstraints - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "Identifier of this PEBC.PowerConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - valid_from: - type: string - format: date-time - description: "Moment this PEBC.PowerConstraints start to be valid" - valid_until: - type: string - format: date-time - description: "Moment until this PEBC.PowerConstraints is valid. If valid_until is not present, there is no determined end time of this PEBC.PowerConstraints." - consequence_type: - $ref: '#/components/schemas/PEBCPowerEnvelopeConsequenceType' - description: "Type of consequence of limiting power" - allowed_limit_ranges: - type: array - minItems: 2 - maxItems: 100 - items: - $ref: '#/components/schemas/PEBCAllowedLimitRange' - description: "The actual constraints. There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT. It is allowed to have multiple PEBC.AllowedLimitRange objects with identical CommodityQuantities and LimitTypes." - additionalProperties: false - PEBCEnergyConstraint: - type: object - required: - - message_type - - message_id - - id - - valid_from - - valid_until - - upper_average_power - - lower_average_power - - commodity_quantity - properties: - message_type: - const: PEBC.EnergyConstraint - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "Identifier of this PEBC.EnergyConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - valid_from: - type: string - format: date-time - description: "Moment this PEBC.EnergyConstraints information starts to be valid" - valid_until: - type: string - format: date-time - description: "Moment until this PEBC.EnergyConstraints information is valid." - upper_average_power: - type: number - description: "Upper average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated upper energy content can be derived. This is the highest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy consumption (in case the number is positive). Must be greater than or equal to lower_average_power, and can be negative in case of energy production." - lower_average_power: - type: number - description: "Lower average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated lower energy content can be derived. This is the lowest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy production (in case the number is negative). Must be greater than or equal to lower_average_power, and can be negative in case of energy production." - commodity_quantity: - $ref: '#/components/schemas/CommodityQuantity' - description: "Type of power quantity which applies to upper_average_power and lower_average_power" - additionalProperties: false - PEBCInstruction: - type: object - required: - - message_type - - message_id - - id - - execution_time - - abnormal_condition - - power_constraints_id - - power_envelopes - properties: - message_type: - const: PEBC.Instruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "Identifier of this PEBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - execution_time: - type: string - format: date-time - description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition." - power_constraints_id: - $ref: '#/components/schemas/ID' - description: "Identifier of the PEBC.PowerConstraints this PEBC.Instruction was based on." - power_envelopes: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/PEBCPowerEnvelope' - description: "The PEBC.PowerEnvelope(s) that should be followed by the Resource Manager. There shall be at least one PEBC.PowerEnvelope, but at most one PEBC.PowerEnvelope for each CommodityQuantity." - additionalProperties: false - PPBCPowerProfileDefinition: - type: object - required: - - message_type - - message_id - - id - - start_time - - end_time - - power_sequences_containers - properties: - message_type: - const: PPBC.PowerProfileDefinition - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerProfileDefinition. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - start_time: - type: string - format: date-time - description: "Indicates the first possible time the first PPBC.PowerSequence could start" - end_time: - type: string - format: date-time - description: "Indicates when the last PPBC.PowerSequence shall be finished at the latest" - power_sequences_containers: - type: array - minItems: 1 - maxItems: 1000 - items: - $ref: '#/components/schemas/PPBCPowerSequenceContainer' - description: "The PPBC.PowerSequenceContainers that make up this PPBC.PowerProfileDefinition. There shall be at least one PPBC.PowerSequenceContainer that includes at least one PPBC.PowerSequence. PPBC.PowerSequenceContainers must be placed in chronological order." - additionalProperties: false - PPBCPowerProfileStatus: - type: object - required: - - message_type - - message_id - - sequence_container_status - properties: - message_type: - const: PPBC.PowerProfileStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - sequence_container_status: - type: array - minItems: 1 - maxItems: 1000 - items: - $ref: '#/components/schemas/PPBCPowerSequenceContainerStatus' - description: "Array with status information for all PPBC.PowerSequenceContainers in the PPBC.PowerProfileDefinition." - additionalProperties: false - PPBCScheduleInstruction: - type: object - required: - - message_type - - message_id - - id - - power_profile_id - - sequence_container_id - - power_sequence_id - - execution_time - - abnormal_condition - properties: - message_type: - const: PPBC.ScheduleInstruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - power_profile_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being selected and scheduled by the CEM." - sequence_container_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being selected and scheduled by the CEM." - power_sequence_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequence that is being selected and scheduled by the CEM." - execution_time: - type: string - format: date-time - description: "Indicates the moment the PPBC.PowerSequence shall start. When the specified execution time is in the past, execution must start as soon as possible." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition" - additionalProperties: false - PPBCStartInterruptionInstruction: - type: object - required: - - message_type - - message_id - - id - - power_profile_id - - sequence_container_id - - power_sequence_id - - execution_time - - abnormal_condition - properties: - message_type: - const: PPBC.StartInterruptionInstruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - power_profile_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being interrupted by the CEM." - sequence_container_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being interrupted by the CEM." - power_sequence_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequence that the CEM wants to interrupt." - execution_time: - type: string - format: date-time - description: "Indicates the moment the PPBC.PowerSequence shall be interrupted. When the specified execution time is in the past, execution must start as soon as possible." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition" - additionalProperties: false - PPBCEndInterruptionInstruction: - type: object - required: - - message_type - - message_id - - id - - power_profile_id - - sequence_container_id - - power_sequence_id - - execution_time - - abnormal_condition - properties: - message_type: - const: PPBC.EndInterruptionInstruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - power_profile_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence interruption is being ended by the CEM." - sequence_container_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence interruption is being ended by the CEM." - power_sequence_id: - $ref: '#/components/schemas/ID' - description: "ID of the PPBC.PowerSequence for which the CEM wants to end the interruption." - execution_time: - type: string - format: date-time - description: "Indicates the moment PPBC.PowerSequence interruption shall end. When the specified execution time is in the past, execution must start as soon as possible." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition" - additionalProperties: false - OMBCSystemDescription: - type: object - required: - - message_type - - message_id - - valid_from - - operation_modes - - transitions - - timers - properties: - message_type: - const: OMBC.SystemDescription - type: string - message_id: - $ref: '#/components/schemas/ID' - valid_from: - type: string - format: date-time - description: "Moment this OMBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past." - operation_modes: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: '#/components/schemas/OMBCOperationMode' - description: "OMBC.OperationModes available for the CEM in order to coordinate the device behaviour." - transitions: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/Transition' - description: "Possible transitions to switch from one OMBC.OperationMode to another." - timers: - type: array - minItems: 0 - maxItems: 1000 - items: - $ref: '#/components/schemas/Timer' - description: "Timers that control when certain transitions can be made." - additionalProperties: false - OMBCStatus: - type: object - required: - - message_type - - message_id - - active_operation_mode_id - - operation_mode_factor - properties: - message_type: - const: OMBC.Status - type: string - message_id: - $ref: '#/components/schemas/ID' - active_operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the active OMBC.OperationMode." - operation_mode_factor: - type: number - description: "The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1." - previous_operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the OMBC.OperationMode that was previously active. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of." - transition_timestamp: - type: string - format: date-time - description: "Time at which the transition from the previous OMBC.OperationMode to the active OMBC.OperationMode was initiated. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of." - additionalProperties: false - OMBCInstruction: - type: object - required: - - message_type - - message_id - - id - - execution_time - - operation_mode_id - - operation_mode_factor - - abnormal_condition - properties: - message_type: - const: OMBC.Instruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - execution_time: - type: string - format: date-time - description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." - operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the OMBC.OperationMode that should be activated" - operation_mode_factor: - type: number - description: "The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition" - additionalProperties: false - FRBCSystemDescription: - type: object - required: - - message_type - - message_id - - valid_from - - actuators - - storage - properties: - message_type: - const: FRBC.SystemDescription - type: string - message_id: - $ref: '#/components/schemas/ID' - valid_from: - type: string - format: date-time - description: "Moment this FRBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past." - actuators: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/FRBCActuatorDescription' - description: "Details of all Actuators." - storage: - $ref: '#/components/schemas/FRBCStorageDescription' - description: "Details of the storage." - additionalProperties: false - FRBCActuatorStatus: - type: object - required: - - message_type - - message_id - - actuator_id - - active_operation_mode_id - - operation_mode_factor - properties: - message_type: - const: FRBC.ActuatorStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - actuator_id: - $ref: '#/components/schemas/ID' - description: "ID of the actuator this messages refers to" - active_operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the FRBC.OperationMode that is presently active." - operation_mode_factor: - type: number - description: "The number indicates the factor with which the FRBC.OperationMode is configured. The factor should be greater than or equal than 0 and less or equal to 1." - previous_operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the FRBC.OperationMode that was active before the present one. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of." - transition_timestamp: - type: string - format: date-time - description: "Time at which the transition from the previous FRBC.OperationMode to the active FRBC.OperationMode was initiated. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of." - additionalProperties: false - FRBCStorageStatus: - type: object - required: - - message_type - - message_id - - present_fill_level - properties: - message_type: - const: FRBC.StorageStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - present_fill_level: - type: number - description: "Present fill level of the Storage" - additionalProperties: false - FRBCLeakageBehaviour: - type: object - required: - - message_type - - message_id - - valid_from - - elements - properties: - message_type: - const: FRBC.LeakageBehaviour - type: string - message_id: - $ref: '#/components/schemas/ID' - valid_from: - type: string - format: date-time - description: "Moment this FRBC.LeakageBehaviour starts to be valid. If the FRBC.LeakageBehaviour is immediately valid, the DateTimeStamp should be now or in the past." - elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/FRBCLeakageBehaviourElement' - description: "List of elements that model the leakage behaviour of the buffer. The fill_level_ranges of the elements must be contiguous." - additionalProperties: false - FRBCInstruction: - type: object - required: - - message_type - - message_id - - id - - actuator_id - - operation_mode - - operation_mode_factor - - execution_time - - abnormal_condition - properties: - message_type: - const: FRBC.Instruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - actuator_id: - $ref: '#/components/schemas/ID' - description: "ID of the actuator this instruction belongs to." - operation_mode: - $ref: '#/components/schemas/ID' - description: "ID of the FRBC.OperationMode that should be activated." - operation_mode_factor: - type: number - description: "The number indicates the factor with which the FRBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1." - execution_time: - type: string - format: date-time - description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition." - additionalProperties: false - FRBCUsageForecast: - type: object - required: - - message_type - - message_id - - start_time - - elements - properties: - message_type: - const: FRBC.UsageForecast - type: string - message_id: - $ref: '#/components/schemas/ID' - start_time: - type: string - format: date-time - description: "Time at which the FRBC.UsageForecast starts." - elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/FRBCUsageForecastElement' - description: "Further elements that model the profile. There shall be at least one element. Elements must be placed in chronological order." - additionalProperties: false - FRBCFillLevelTargetProfile: - type: object - required: - - message_type - - message_id - - start_time - - elements - properties: - message_type: - const: FRBC.FillLevelTargetProfile - type: string - message_id: - $ref: '#/components/schemas/ID' - start_time: - type: string - format: date-time - description: "Time at which the FRBC.FillLevelTargetProfile starts." - elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/FRBCFillLevelTargetProfileElement' - description: "List of different fill levels that have to be targeted within a given duration. There shall be at least one element. Elements must be placed in chronological order." - additionalProperties: false - DDBCSystemDescription: - type: object - required: - - message_type - - message_id - - valid_from - - actuators - - present_demand_rate - - provides_average_demand_rate_forecast - properties: - message_type: - const: DDBC.SystemDescription - type: string - message_id: - $ref: '#/components/schemas/ID' - valid_from: - type: string - format: date-time - description: "Moment this DDBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past." - actuators: - type: array - minItems: 1 - maxItems: 10 - items: - $ref: '#/components/schemas/DDBCActuatorDescription' - description: "List of all available actuators in the system. Must contain at least one DDBC.ActuatorAggregated." - present_demand_rate: - $ref: '#/components/schemas/NumberRange' - description: "Present demand rate that needs to be satisfied by the system" - provides_average_demand_rate_forecast: - type: boolean - description: "Indicates whether the Resource Manager could provide a demand rate forecast through the DDBC.AverageDemandRateForecast." - additionalProperties: false - DDBCActuatorStatus: - type: object - required: - - message_type - - message_id - - actuator_id - - active_operation_mode_id - - operation_mode_factor - properties: - message_type: - const: DDBC.ActuatorStatus - type: string - message_id: - $ref: '#/components/schemas/ID' - actuator_id: - $ref: '#/components/schemas/ID' - description: "ID of the actuator this messages refers to" - active_operation_mode_id: - $ref: '#/components/schemas/ID' - description: "The operation mode that is presently active for this actuator." - operation_mode_factor: - type: number - description: "The number indicates the factor with which the DDBC.OperationMode is configured. The factor should be greater than or equal to 0 and less or equal to 1." - previous_operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the DDBC,OperationMode that was active before the present one. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of." - transition_timestamp: - type: string - format: date-time - description: "Time at which the transition from the previous DDBC.OperationMode to the active DDBC.OperationMode was initiated. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of." - additionalProperties: false - DDBCInstruction: - type: object - required: - - message_type - - message_id - - id - - execution_time - - abnormal_condition - - actuator_id - - operation_mode_id - - operation_mode_factor - properties: - message_type: - const: DDBC.Instruction - type: string - message_id: - $ref: '#/components/schemas/ID' - id: - $ref: '#/components/schemas/ID' - description: "Identifier of this DDBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM." - execution_time: - type: string - format: date-time - description: "Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible." - abnormal_condition: - type: boolean - description: "Indicates if this is an instruction during an abnormal condition" - actuator_id: - $ref: '#/components/schemas/ID' - description: "ID of the actuator this Instruction belongs to." - operation_mode_id: - $ref: '#/components/schemas/ID' - description: "ID of the DDBC.OperationMode" - operation_mode_factor: - type: number - description: "The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1." - additionalProperties: false - DDBCAverageDemandRateForecast: - type: object - required: - - message_type - - message_id - - start_time - - elements - properties: - message_type: - const: DDBC.AverageDemandRateForecast - type: string - message_id: - $ref: '#/components/schemas/ID' - start_time: - type: string - format: date-time - description: "Start time of the profile." - elements: - type: array - minItems: 1 - maxItems: 288 - items: - $ref: '#/components/schemas/DDBCAverageDemandRateForecastElement' - description: "Elements of the profile. Elements must be placed in chronological order." - additionalProperties: false diff --git a/packages/s2-python/src/s2python/__init__.py b/packages/s2-python/src/s2python/__init__.py deleted file mode 100644 index 0ab0a42..0000000 --- a/packages/s2-python/src/s2python/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from importlib.metadata import PackageNotFoundError, version # pragma: no cover - -try: - # Change here if project is renamed and does not equal the package name - dist_name = "s2-python" # pylint: disable=invalid-name - __version__ = version(dist_name) -except PackageNotFoundError: # pragma: no cover - __version__ = "unknown" -finally: - del version, PackageNotFoundError diff --git a/packages/s2-python/src/s2python/common/__init__.py b/packages/s2-python/src/s2python/common/__init__.py deleted file mode 100644 index 785b8c8..0000000 --- a/packages/s2-python/src/s2python/common/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -from s2python.generated.gen_s2 import ( - RoleType, - Currency, - CommodityQuantity, - Commodity, - InstructionStatus, - ReceptionStatusValues, - EnergyManagementRole, - SessionRequestType, - ControlType, - RevokableObjects, -) - -from s2python.common.duration import Duration -from s2python.common.role import Role -from s2python.common.handshake import Handshake -from s2python.common.handshake_response import HandshakeResponse -from s2python.common.instruction_status_update import InstructionStatusUpdate -from s2python.common.number_range import NumberRange -from s2python.common.power_forecast_value import PowerForecastValue -from s2python.common.power_forecast_element import PowerForecastElement -from s2python.common.power_forecast import PowerForecast -from s2python.common.power_value import PowerValue -from s2python.common.power_measurement import PowerMeasurement -from s2python.common.power_range import PowerRange -from s2python.common.reception_status import ReceptionStatus -from s2python.common.resource_manager_details import ResourceManagerDetails -from s2python.common.revoke_object import RevokeObject -from s2python.common.select_control_type import SelectControlType -from s2python.common.session_request import SessionRequest -from s2python.common.timer import Timer -from s2python.common.transition import Transition - -__all__ = [ - "RoleType", - "Currency", - "CommodityQuantity", - "Commodity", - "InstructionStatus", - "ReceptionStatusValues", - "EnergyManagementRole", - "SessionRequestType", - "ControlType", - "RevokableObjects", - "Duration", - "Role", - "Handshake", - "HandshakeResponse", - "InstructionStatusUpdate", - "NumberRange", - "PowerForecastValue", - "PowerForecastElement", - "PowerForecast", - "PowerValue", - "PowerMeasurement", - "PowerRange", - "ReceptionStatus", - "ResourceManagerDetails", - "RevokeObject", - "SelectControlType", - "SessionRequest", - "Timer", - "Transition", -] diff --git a/packages/s2-python/src/s2python/common/duration.py b/packages/s2-python/src/s2python/common/duration.py deleted file mode 100644 index 3fd2cd5..0000000 --- a/packages/s2-python/src/s2python/common/duration.py +++ /dev/null @@ -1,24 +0,0 @@ -from datetime import timedelta -import math - -from s2python.generated.gen_s2 import Duration as GenDuration -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class Duration( # pyright: ignore[reportIncompatibleMethodOverride] - GenDuration, S2MessageComponent -): - def to_timedelta(self) -> timedelta: - return timedelta(milliseconds=self.root) - - @staticmethod - def from_timedelta(duration: timedelta) -> "Duration": - return Duration(root=math.ceil(duration.total_seconds() * 1000)) - - @staticmethod - def from_milliseconds(milliseconds: int) -> "Duration": - return Duration(root=milliseconds) diff --git a/packages/s2-python/src/s2python/common/handshake.py b/packages/s2-python/src/s2python/common/handshake.py deleted file mode 100644 index 55e1c7d..0000000 --- a/packages/s2-python/src/s2python/common/handshake.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import Handshake as GenHandshake -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class Handshake(GenHandshake, S2MessageComponent): - model_config = GenHandshake.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenHandshake.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/handshake_response.py b/packages/s2-python/src/s2python/common/handshake_response.py deleted file mode 100644 index a451e42..0000000 --- a/packages/s2-python/src/s2python/common/handshake_response.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import HandshakeResponse as GenHandshakeResponse -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class HandshakeResponse(GenHandshakeResponse, S2MessageComponent): - model_config = GenHandshakeResponse.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenHandshakeResponse.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/instruction_status_update.py b/packages/s2-python/src/s2python/common/instruction_status_update.py deleted file mode 100644 index 73732e8..0000000 --- a/packages/s2-python/src/s2python/common/instruction_status_update.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import ( - InstructionStatusUpdate as GenInstructionStatusUpdate, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class InstructionStatusUpdate(GenInstructionStatusUpdate, S2MessageComponent): - model_config = GenInstructionStatusUpdate.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenInstructionStatusUpdate.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - instruction_id: uuid.UUID = GenInstructionStatusUpdate.model_fields["instruction_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/number_range.py b/packages/s2-python/src/s2python/common/number_range.py deleted file mode 100644 index 5b6af75..0000000 --- a/packages/s2-python/src/s2python/common/number_range.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) -from s2python.generated.gen_s2 import NumberRange as GenNumberRange - - -@catch_and_convert_exceptions -class NumberRange(GenNumberRange, S2MessageComponent): - model_config = GenNumberRange.model_config - model_config["validate_assignment"] = True - - def __hash__(self) -> int: - return hash(f"{self.start_of_range}|{self.end_of_range}") - - def __eq__(self, other: Any) -> bool: - if isinstance(other, NumberRange): - return ( - self.start_of_range == other.start_of_range - and self.end_of_range == other.end_of_range - ) - - return False diff --git a/packages/s2-python/src/s2python/common/power_forecast.py b/packages/s2-python/src/s2python/common/power_forecast.py deleted file mode 100644 index 5dac5bb..0000000 --- a/packages/s2-python/src/s2python/common/power_forecast.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List -import uuid - -from s2python.common.power_forecast_element import PowerForecastElement -from s2python.generated.gen_s2 import PowerForecast as GenPowerForecast -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PowerForecast(GenPowerForecast, S2MessageComponent): - model_config = GenPowerForecast.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenPowerForecast.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - elements: List[PowerForecastElement] = GenPowerForecast.model_fields["elements"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/power_forecast_element.py b/packages/s2-python/src/s2python/common/power_forecast_element.py deleted file mode 100644 index 7d84a73..0000000 --- a/packages/s2-python/src/s2python/common/power_forecast_element.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import List - -from s2python.generated.gen_s2 import PowerForecastElement as GenPowerForecastElement -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) -from s2python.common.duration import Duration -from s2python.common.power_forecast_value import PowerForecastValue - - -@catch_and_convert_exceptions -class PowerForecastElement(GenPowerForecastElement, S2MessageComponent): - model_config = GenPowerForecastElement.model_config - model_config["validate_assignment"] = True - - duration: Duration = GenPowerForecastElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_values: List[PowerForecastValue] = ( # type: ignore[reportIncompatibleVariableOverride] - GenPowerForecastElement.model_fields["power_values"] # type: ignore[assignment] - ) diff --git a/packages/s2-python/src/s2python/common/power_forecast_value.py b/packages/s2-python/src/s2python/common/power_forecast_value.py deleted file mode 100644 index dbf2896..0000000 --- a/packages/s2-python/src/s2python/common/power_forecast_value.py +++ /dev/null @@ -1,11 +0,0 @@ -from s2python.generated.gen_s2 import PowerForecastValue as GenPowerForecastValue -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PowerForecastValue(GenPowerForecastValue, S2MessageComponent): - model_config = GenPowerForecastValue.model_config - model_config["validate_assignment"] = True diff --git a/packages/s2-python/src/s2python/common/power_measurement.py b/packages/s2-python/src/s2python/common/power_measurement.py deleted file mode 100644 index afd15cf..0000000 --- a/packages/s2-python/src/s2python/common/power_measurement.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List -import uuid - -from s2python.common.power_value import PowerValue -from s2python.generated.gen_s2 import PowerMeasurement as GenPowerMeasurement -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PowerMeasurement(GenPowerMeasurement, S2MessageComponent): - model_config = GenPowerMeasurement.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenPowerMeasurement.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - values: List[PowerValue] = GenPowerMeasurement.model_fields["values"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/power_range.py b/packages/s2-python/src/s2python/common/power_range.py deleted file mode 100644 index e9c1dd2..0000000 --- a/packages/s2-python/src/s2python/common/power_range.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing_extensions import Self - -from pydantic import model_validator - -from s2python.generated.gen_s2 import PowerRange as GenPowerRange -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class PowerRange(GenPowerRange, S2MessageComponent): - model_config = GenPowerRange.model_config - model_config["validate_assignment"] = True - - @model_validator(mode="after") - def validate_start_end_order(self) -> Self: - if self.start_of_range > self.end_of_range: - raise ValueError( - self, "start_of_range should not be higher than end_of_range" - ) - - return self diff --git a/packages/s2-python/src/s2python/common/power_value.py b/packages/s2-python/src/s2python/common/power_value.py deleted file mode 100644 index cb55542..0000000 --- a/packages/s2-python/src/s2python/common/power_value.py +++ /dev/null @@ -1,11 +0,0 @@ -from s2python.generated.gen_s2 import PowerValue as GenPowerValue -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PowerValue(GenPowerValue, S2MessageComponent): - model_config = GenPowerValue.model_config - model_config["validate_assignment"] = True diff --git a/packages/s2-python/src/s2python/common/reception_status.py b/packages/s2-python/src/s2python/common/reception_status.py deleted file mode 100644 index 12bf559..0000000 --- a/packages/s2-python/src/s2python/common/reception_status.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import ReceptionStatus as GenReceptionStatus -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class ReceptionStatus(GenReceptionStatus, S2MessageComponent): - model_config = GenReceptionStatus.model_config - model_config["validate_assignment"] = True - - subject_message_id: uuid.UUID = GenReceptionStatus.model_fields["subject_message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/resource_manager_details.py b/packages/s2-python/src/s2python/common/resource_manager_details.py deleted file mode 100644 index 9333fba..0000000 --- a/packages/s2-python/src/s2python/common/resource_manager_details.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List -import uuid - -from s2python.common.duration import Duration -from s2python.common.role import Role -from s2python.generated.gen_s2 import ( - ResourceManagerDetails as GenResourceManagerDetails, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class ResourceManagerDetails(GenResourceManagerDetails, S2MessageComponent): - model_config = GenResourceManagerDetails.model_config - model_config["validate_assignment"] = True - - instruction_processing_delay: Duration = GenResourceManagerDetails.model_fields[ # type: ignore[assignment,reportIncompatibleVariableOverride] - "instruction_processing_delay" - ] - message_id: uuid.UUID = GenResourceManagerDetails.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - resource_id: uuid.UUID = GenResourceManagerDetails.model_fields["resource_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - roles: List[Role] = GenResourceManagerDetails.model_fields["roles"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/revoke_object.py b/packages/s2-python/src/s2python/common/revoke_object.py deleted file mode 100644 index 7fc6678..0000000 --- a/packages/s2-python/src/s2python/common/revoke_object.py +++ /dev/null @@ -1,16 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import RevokeObject as GenRevokeObject -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class RevokeObject(GenRevokeObject, S2MessageComponent): - model_config = GenRevokeObject.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenRevokeObject.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - object_id: uuid.UUID = GenRevokeObject.model_fields["object_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/role.py b/packages/s2-python/src/s2python/common/role.py deleted file mode 100644 index 7f8f253..0000000 --- a/packages/s2-python/src/s2python/common/role.py +++ /dev/null @@ -1,11 +0,0 @@ -from s2python.generated.gen_s2 import Role as GenRole -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class Role(GenRole, S2MessageComponent): - model_config = GenRole.model_config - model_config["validate_assignment"] = True diff --git a/packages/s2-python/src/s2python/common/select_control_type.py b/packages/s2-python/src/s2python/common/select_control_type.py deleted file mode 100644 index e8ac142..0000000 --- a/packages/s2-python/src/s2python/common/select_control_type.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import SelectControlType as GenSelectControlType -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class SelectControlType(GenSelectControlType, S2MessageComponent): - model_config = GenSelectControlType.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenSelectControlType.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/session_request.py b/packages/s2-python/src/s2python/common/session_request.py deleted file mode 100644 index b5a62a5..0000000 --- a/packages/s2-python/src/s2python/common/session_request.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import SessionRequest as GenSessionRequest -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class SessionRequest(GenSessionRequest, S2MessageComponent): - model_config = GenSessionRequest.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenSessionRequest.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/support.py b/packages/s2-python/src/s2python/common/support.py deleted file mode 100644 index 027f65b..0000000 --- a/packages/s2-python/src/s2python/common/support.py +++ /dev/null @@ -1,27 +0,0 @@ -from s2python.common import CommodityQuantity, Commodity - - -def commodity_has_quantity(commodity: "Commodity", quantity: CommodityQuantity) -> bool: - if commodity == Commodity.HEAT: - result = quantity in [ - CommodityQuantity.HEAT_THERMAL_POWER, - CommodityQuantity.HEAT_TEMPERATURE, - CommodityQuantity.HEAT_FLOW_RATE, - ] - elif commodity == Commodity.ELECTRICITY: - result = quantity in [ - CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, - CommodityQuantity.ELECTRIC_POWER_L1, - CommodityQuantity.ELECTRIC_POWER_L2, - CommodityQuantity.ELECTRIC_POWER_L3, - ] - elif commodity == Commodity.GAS: - result = quantity in [CommodityQuantity.NATURAL_GAS_FLOW_RATE] - elif commodity == Commodity.OIL: - result = quantity in [CommodityQuantity.OIL_FLOW_RATE] - else: - raise RuntimeError( - f"Unsupported commodity {commodity}. Missing implementation." - ) - - return result diff --git a/packages/s2-python/src/s2python/common/timer.py b/packages/s2-python/src/s2python/common/timer.py deleted file mode 100644 index 67e226b..0000000 --- a/packages/s2-python/src/s2python/common/timer.py +++ /dev/null @@ -1,17 +0,0 @@ -import uuid - -from s2python.common.duration import Duration -from s2python.generated.gen_s2 import Timer as GenTimer -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class Timer(GenTimer, S2MessageComponent): - model_config = GenTimer.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenTimer.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - duration: Duration = GenTimer.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/common/transition.py b/packages/s2-python/src/s2python/common/transition.py deleted file mode 100644 index b43334f..0000000 --- a/packages/s2-python/src/s2python/common/transition.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid -from typing import Optional, List - -from s2python.common.duration import Duration -from s2python.generated.gen_s2 import Transition as GenTransition -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class Transition(GenTransition, S2MessageComponent): - model_config = GenTransition.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenTransition.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - from_: uuid.UUID = GenTransition.model_fields["from_"] # type: ignore[assignment,reportIncompatibleVariableOverride] - to: uuid.UUID = GenTransition.model_fields["to"] # type: ignore[assignment,reportIncompatibleVariableOverride] - start_timers: List[uuid.UUID] = GenTransition.model_fields["start_timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] - blocking_timers: List[uuid.UUID] = GenTransition.model_fields["blocking_timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] - transition_duration: Optional[Duration] = GenTransition.model_fields[ # type: ignore[assignment,reportIncompatibleVariableOverride] - "transition_duration" - ] diff --git a/packages/s2-python/src/s2python/ddbc/__init__.py b/packages/s2-python/src/s2python/ddbc/__init__.py deleted file mode 100644 index d154814..0000000 --- a/packages/s2-python/src/s2python/ddbc/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from s2python.ddbc.ddbc_actuator_description import DDBCActuatorDescription -from s2python.ddbc.ddbc_operation_mode import DDBCOperationMode -from s2python.ddbc.ddbc_instruction import DDBCInstruction -from s2python.ddbc.ddbc_actuator_status import DDBCActuatorStatus -from s2python.ddbc.ddbc_average_demand_rate_forecast_element import ( - DDBCAverageDemandRateForecastElement, -) -from s2python.ddbc.ddbc_average_demand_rate_forecast import DDBCAverageDemandRateForecast -from s2python.ddbc.ddbc_system_description import DDBCSystemDescription -from s2python.ddbc.ddbc_timer_status import DDBCTimerStatus - -__all__ = [ - "DDBCActuatorDescription", - "DDBCOperationMode", - "DDBCInstruction", - "DDBCActuatorStatus", - "DDBCAverageDemandRateForecastElement", - "DDBCAverageDemandRateForecast", - "DDBCSystemDescription", - "DDBCTimerStatus", -] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_actuator_description.py b/packages/s2-python/src/s2python/ddbc/ddbc_actuator_description.py deleted file mode 100644 index a460971..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_actuator_description.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import ( - DDBCActuatorDescription as GenDDBCActuatorDescription, -) -from s2python.generated.gen_s2 import Commodity -from s2python.ddbc.ddbc_operation_mode import DDBCOperationMode - -from s2python.common.timer import Timer - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCActuatorDescription(GenDDBCActuatorDescription, S2MessageComponent): - model_config = GenDDBCActuatorDescription.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenDDBCActuatorDescription.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - supported_commodites: List[Commodity] = GenDDBCActuatorDescription.model_fields[ - "supported_commodites" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] - timers: List[Timer] = GenDDBCActuatorDescription.model_fields["timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] - operation_modes: List[DDBCOperationMode] = GenDDBCActuatorDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "operation_modes" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_actuator_status.py b/packages/s2-python/src/s2python/ddbc/ddbc_actuator_status.py deleted file mode 100644 index 57d2ffe..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_actuator_status.py +++ /dev/null @@ -1,22 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import DDBCActuatorStatus as GenDDBCActuatorStatus -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCActuatorStatus(GenDDBCActuatorStatus, S2MessageComponent): - model_config = GenDDBCActuatorStatus.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenDDBCActuatorStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - actuator_id: uuid.UUID = GenDDBCActuatorStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - active_operation_mode_id: uuid.UUID = GenDDBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "active_operation_mode_id" - ] # type: ignore[assignment] - operation_mode_factor: float = GenDDBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "operation_mode_factor" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py b/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py deleted file mode 100644 index 313aadd..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import ( - DDBCAverageDemandRateForecast as GenDDBCAverageDemandRateForecast, -) -from s2python.ddbc.ddbc_average_demand_rate_forecast_element import ( - DDBCAverageDemandRateForecastElement, -) - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCAverageDemandRateForecast( - GenDDBCAverageDemandRateForecast, - S2MessageComponent, -): - model_config = GenDDBCAverageDemandRateForecast.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenDDBCAverageDemandRateForecast.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - elements: List[DDBCAverageDemandRateForecastElement] = ( # type: ignore[reportIncompatibleVariableOverride] - GenDDBCAverageDemandRateForecast.model_fields["elements"] # type: ignore[assignment] - ) diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py b/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py deleted file mode 100644 index 8e8bdee..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_average_demand_rate_forecast_element.py +++ /dev/null @@ -1,21 +0,0 @@ -from s2python.generated.gen_s2 import Duration - -from s2python.generated.gen_s2 import ( - DDBCAverageDemandRateForecastElement as GenDDBCAverageDemandRateForecastElement, -) - -from s2python.validate_values_mixin import catch_and_convert_exceptions, S2MessageComponent - - -@catch_and_convert_exceptions -class DDBCAverageDemandRateForecastElement( - GenDDBCAverageDemandRateForecastElement, - S2MessageComponent, -): - model_config = GenDDBCAverageDemandRateForecastElement.model_config - model_config["validate_assignment"] = True - - duration: Duration = GenDDBCAverageDemandRateForecastElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] - demand_rate_expected: float = GenDDBCAverageDemandRateForecastElement.model_fields[ - "demand_rate_expected" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_instruction.py b/packages/s2-python/src/s2python/ddbc/ddbc_instruction.py deleted file mode 100644 index 2866a33..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_instruction.py +++ /dev/null @@ -1,19 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import DDBCInstruction as GenDDBCInstruction -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCInstruction(GenDDBCInstruction, S2MessageComponent): - model_config = GenDDBCInstruction.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenDDBCInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - actuator_id: uuid.UUID = GenDDBCInstruction.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - operation_mode_id: uuid.UUID = GenDDBCInstruction.model_fields["operation_mode_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - operation_mode_factor: float = GenDDBCInstruction.model_fields["operation_mode_factor"] # type: ignore[assignment,reportIncompatibleVariableOverride] - abnormal_condition: bool = GenDDBCInstruction.model_fields["abnormal_condition"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_operation_mode.py b/packages/s2-python/src/s2python/ddbc/ddbc_operation_mode.py deleted file mode 100644 index d3ddfbd..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_operation_mode.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import DDBCOperationMode as GenDDBCOperationMode - -from s2python.common.power_range import PowerRange -from s2python.common.number_range import NumberRange - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCOperationMode(GenDDBCOperationMode, S2MessageComponent): - model_config = GenDDBCOperationMode.model_config - model_config["validate_assignment"] = True - - # ? Id vs id - id: uuid.UUID = GenDDBCOperationMode.model_fields["Id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_ranges: List[PowerRange] = GenDDBCOperationMode.model_fields["power_ranges"] # type: ignore[assignment,reportIncompatibleVariableOverride] - supply_range: List[NumberRange] = GenDDBCOperationMode.model_fields["supply_range"] # type: ignore[assignment,reportIncompatibleVariableOverride] - abnormal_condition_only: bool = GenDDBCOperationMode.model_fields[ - "abnormal_condition_only" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_system_description.py b/packages/s2-python/src/s2python/ddbc/ddbc_system_description.py deleted file mode 100644 index 007488d..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_system_description.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import ( - DDBCSystemDescription as GenDDBCSystemDescription, -) -from s2python.common.number_range import NumberRange -from s2python.ddbc.ddbc_actuator_description import DDBCActuatorDescription -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCSystemDescription(GenDDBCSystemDescription, S2MessageComponent): - model_config = GenDDBCSystemDescription.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenDDBCSystemDescription.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - actuators: List[DDBCActuatorDescription] = GenDDBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "actuators" - ] # type: ignore[assignment] - present_demand_rate: NumberRange = GenDDBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "present_demand_rate" - ] # type: ignore[assignment] - provides_average_demand_rate_forecast: bool = GenDDBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "provides_average_demand_rate_forecast" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ddbc/ddbc_timer_status.py b/packages/s2-python/src/s2python/ddbc/ddbc_timer_status.py deleted file mode 100644 index 2c4d95e..0000000 --- a/packages/s2-python/src/s2python/ddbc/ddbc_timer_status.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import DDBCTimerStatus as GenDDBCTimerStatus - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class DDBCTimerStatus(GenDDBCTimerStatus, S2MessageComponent): - model_config = GenDDBCTimerStatus.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenDDBCTimerStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - timer_id: uuid.UUID = GenDDBCTimerStatus.model_fields["timer_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - actuator_id: uuid.UUID = GenDDBCTimerStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/__init__.py b/packages/s2-python/src/s2python/frbc/__init__.py deleted file mode 100644 index 687684e..0000000 --- a/packages/s2-python/src/s2python/frbc/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from s2python.frbc.frbc_fill_level_target_profile_element import FRBCFillLevelTargetProfileElement -from s2python.frbc.frbc_fill_level_target_profile import FRBCFillLevelTargetProfile -from s2python.frbc.frbc_instruction import FRBCInstruction -from s2python.frbc.frbc_leakage_behaviour_element import FRBCLeakageBehaviourElement -from s2python.frbc.frbc_leakage_behaviour import FRBCLeakageBehaviour -from s2python.frbc.frbc_usage_forecast_element import FRBCUsageForecastElement -from s2python.frbc.frbc_usage_forecast import FRBCUsageForecast -from s2python.frbc.frbc_operation_mode_element import FRBCOperationModeElement -from s2python.frbc.frbc_operation_mode import FRBCOperationMode -from s2python.frbc.frbc_actuator_description import FRBCActuatorDescription -from s2python.frbc.frbc_actuator_status import FRBCActuatorStatus -from s2python.frbc.frbc_storage_description import FRBCStorageDescription -from s2python.frbc.frbc_storage_status import FRBCStorageStatus -from s2python.frbc.frbc_system_description import FRBCSystemDescription -from s2python.frbc.frbc_timer_status import FRBCTimerStatus - -__all__ = [ - "FRBCFillLevelTargetProfileElement", - "FRBCFillLevelTargetProfile", - "FRBCInstruction", - "FRBCLeakageBehaviourElement", - "FRBCLeakageBehaviour", - "FRBCUsageForecastElement", - "FRBCUsageForecast", - "FRBCOperationModeElement", - "FRBCOperationMode", - "FRBCActuatorDescription", - "FRBCActuatorStatus", - "FRBCStorageDescription", - "FRBCStorageStatus", - "FRBCSystemDescription", - "FRBCTimerStatus", -] diff --git a/packages/s2-python/src/s2python/frbc/frbc_actuator_description.py b/packages/s2-python/src/s2python/frbc/frbc_actuator_description.py deleted file mode 100644 index f666516..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_actuator_description.py +++ /dev/null @@ -1,149 +0,0 @@ -import uuid - -from typing import List -from typing_extensions import Self - -from pydantic import model_validator - -from s2python.common import Transition, Timer, Commodity -from s2python.common.support import commodity_has_quantity -from s2python.frbc.frbc_operation_mode import FRBCOperationMode -from s2python.generated.gen_s2 import ( - FRBCActuatorDescription as GenFRBCActuatorDescription, -) -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class FRBCActuatorDescription(GenFRBCActuatorDescription, S2MessageComponent): - model_config = GenFRBCActuatorDescription.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenFRBCActuatorDescription.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - operation_modes: List[FRBCOperationMode] = GenFRBCActuatorDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "operation_modes" - ] # type: ignore[assignment] - transitions: List[Transition] = GenFRBCActuatorDescription.model_fields["transitions"] # type: ignore[assignment,reportIncompatibleVariableOverride] - timers: List[Timer] = GenFRBCActuatorDescription.model_fields["timers"] # type: ignore[assignment,reportIncompatibleVariableOverride] - supported_commodities: List[Commodity] = GenFRBCActuatorDescription.model_fields[ - "supported_commodities" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] - - @model_validator(mode="after") - def validate_timers_in_transitions(self) -> Self: - timers_by_id = {timer.id: timer for timer in self.timers} - transition: Transition - for transition in self.transitions: - for start_timer_id in transition.start_timers: - if start_timer_id not in timers_by_id: - raise ValueError( - self, - f"{start_timer_id} was referenced as start timer in transition " - f"{transition.id} but was not defined in 'timers'.", - ) - - for blocking_timer_id in transition.blocking_timers: - if blocking_timer_id not in timers_by_id: - raise ValueError( - self, - f"{blocking_timer_id} was referenced as blocking timer in transition " - f"{transition.id} but was not defined in 'timers'.", - ) - - return self - - @model_validator(mode="after") - def validate_timers_unique_ids(self) -> Self: - ids = [] - timer: Timer - for timer in self.timers: - if timer.id in ids: - raise ValueError( - self, f"Id {timer.id} was found multiple times in 'timers'." - ) - ids.append(timer.id) - - return self - - @model_validator(mode="after") - def validate_operation_modes_in_transitions(self) -> Self: - operation_mode_by_id = { - operation_mode.id: operation_mode for operation_mode in self.operation_modes - } - transition: Transition - for transition in self.transitions: - if transition.from_ not in operation_mode_by_id: - raise ValueError( - self, - f"Operation mode {transition.from_} was referenced as 'from' in transition " - f"{transition.id} but was not defined in 'operation_modes'.", - ) - - if transition.to not in operation_mode_by_id: - raise ValueError( - self, - f"Operation mode {transition.to} was referenced as 'to' in transition " - f"{transition.id} but was not defined in 'operation_modes'.", - ) - - return self - - @model_validator(mode="after") - def validate_operation_modes_unique_ids(self) -> Self: - ids = [] - operation_mode: FRBCOperationMode - for operation_mode in self.operation_modes: - if operation_mode.id in ids: - raise ValueError( - self, - f"Id {operation_mode.id} was found multiple times in 'operation_modes'.", - ) - ids.append(operation_mode.id) - - return self - - @model_validator(mode="after") - def validate_operation_mode_elements_have_all_supported_commodities(self) -> Self: - supported_commodities = self.supported_commodities - operation_mode: FRBCOperationMode - for operation_mode in self.operation_modes: - for operation_mode_element in operation_mode.elements: - for commodity in supported_commodities: - power_ranges_for_commodity = [ - power_range - for power_range in operation_mode_element.power_ranges - if commodity_has_quantity( - commodity, power_range.commodity_quantity - ) - ] - - if len(power_ranges_for_commodity) > 1: - raise ValueError( - self, - f"Multiple power ranges defined for commodity {commodity} in operation " - f"mode {operation_mode.id} and element with fill_level_range " - f"{operation_mode_element.fill_level_range}", - ) - if not power_ranges_for_commodity: - raise ValueError( - self, - f"No power ranges defined for commodity {commodity} in operation " - f"mode {operation_mode.id} and element with fill_level_range " - f"{operation_mode_element.fill_level_range}", - ) - return self - - @model_validator(mode="after") - def validate_unique_supported_commodities(self) -> Self: - supported_commodities: List[Commodity] = self.supported_commodities - - for supported_commodity in supported_commodities: - if supported_commodities.count(supported_commodity) > 1: - raise ValueError( - self, - f"Found duplicate {supported_commodity} commodity in 'supported_commodities'", - ) - return self diff --git a/packages/s2-python/src/s2python/frbc/frbc_actuator_status.py b/packages/s2-python/src/s2python/frbc/frbc_actuator_status.py deleted file mode 100644 index ecf3b15..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_actuator_status.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Optional -import uuid - -from s2python.generated.gen_s2 import FRBCActuatorStatus as GenFRBCActuatorStatus -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCActuatorStatus(GenFRBCActuatorStatus, S2MessageComponent): - model_config = GenFRBCActuatorStatus.model_config - model_config["validate_assignment"] = True - - active_operation_mode_id: uuid.UUID = GenFRBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "active_operation_mode_id" - ] # type: ignore[assignment] - actuator_id: uuid.UUID = GenFRBCActuatorStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - message_id: uuid.UUID = GenFRBCActuatorStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - previous_operation_mode_id: Optional[uuid.UUID] = GenFRBCActuatorStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "previous_operation_mode_id" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py b/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py deleted file mode 100644 index f98b33a..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import List -import uuid - -from s2python.frbc.frbc_fill_level_target_profile_element import ( - FRBCFillLevelTargetProfileElement, -) -from s2python.generated.gen_s2 import ( - FRBCFillLevelTargetProfile as GenFRBCFillLevelTargetProfile, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCFillLevelTargetProfile(GenFRBCFillLevelTargetProfile, S2MessageComponent): - model_config = GenFRBCFillLevelTargetProfile.model_config - model_config["validate_assignment"] = True - - elements: List[FRBCFillLevelTargetProfileElement] = GenFRBCFillLevelTargetProfile.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "elements" - ] # type: ignore[assignment] - message_id: uuid.UUID = GenFRBCFillLevelTargetProfile.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py b/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py deleted file mode 100644 index 3183745..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_fill_level_target_profile_element.py +++ /dev/null @@ -1,35 +0,0 @@ -# pylint: disable=duplicate-code - -from typing_extensions import Self - -from pydantic import model_validator - -from s2python.common import Duration, NumberRange -from s2python.generated.gen_s2 import ( - FRBCFillLevelTargetProfileElement as GenFRBCFillLevelTargetProfileElement, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCFillLevelTargetProfileElement(GenFRBCFillLevelTargetProfileElement, S2MessageComponent): - model_config = GenFRBCFillLevelTargetProfileElement.model_config - model_config["validate_assignment"] = True - - duration: Duration = GenFRBCFillLevelTargetProfileElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] - fill_level_range: NumberRange = GenFRBCFillLevelTargetProfileElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "fill_level_range" - ] # type: ignore[assignment] - - @model_validator(mode="after") - def validate_start_end_order(self) -> Self: - if self.fill_level_range.start_of_range > self.fill_level_range.end_of_range: - raise ValueError( - self, - "start_of_range should not be higher than end_of_range for the fill_level_range", - ) - - return self diff --git a/packages/s2-python/src/s2python/frbc/frbc_instruction.py b/packages/s2-python/src/s2python/frbc/frbc_instruction.py deleted file mode 100644 index a7693da..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_instruction.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import FRBCInstruction as GenFRBCInstruction -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCInstruction(GenFRBCInstruction, S2MessageComponent): - model_config = GenFRBCInstruction.model_config - model_config["validate_assignment"] = True - - actuator_id: uuid.UUID = GenFRBCInstruction.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - id: uuid.UUID = GenFRBCInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - message_id: uuid.UUID = GenFRBCInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - operation_mode: uuid.UUID = GenFRBCInstruction.model_fields["operation_mode"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py b/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py deleted file mode 100644 index 98c44a3..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import List -import uuid - -from s2python.frbc.frbc_leakage_behaviour_element import FRBCLeakageBehaviourElement -from s2python.generated.gen_s2 import FRBCLeakageBehaviour as GenFRBCLeakageBehaviour -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCLeakageBehaviour(GenFRBCLeakageBehaviour, S2MessageComponent): - model_config = GenFRBCLeakageBehaviour.model_config - model_config["validate_assignment"] = True - - elements: List[FRBCLeakageBehaviourElement] = GenFRBCLeakageBehaviour.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "elements" - ] # type: ignore[assignment] - message_id: uuid.UUID = GenFRBCLeakageBehaviour.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py b/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py deleted file mode 100644 index 594c594..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_leakage_behaviour_element.py +++ /dev/null @@ -1,33 +0,0 @@ -# pylint: disable=duplicate-code - -from pydantic import model_validator -from typing_extensions import Self - -from s2python.common import NumberRange -from s2python.generated.gen_s2 import ( - FRBCLeakageBehaviourElement as GenFRBCLeakageBehaviourElement, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCLeakageBehaviourElement(GenFRBCLeakageBehaviourElement, S2MessageComponent): - model_config = GenFRBCLeakageBehaviourElement.model_config - model_config["validate_assignment"] = True - - fill_level_range: NumberRange = GenFRBCLeakageBehaviourElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "fill_level_range" - ] # type: ignore[assignment] - - @model_validator(mode="after") - def validate_start_end_order(self) -> Self: - if self.fill_level_range.start_of_range > self.fill_level_range.end_of_range: - raise ValueError( - self, - "start_of_range should not be higher than end_of_range for the fill_level_range", - ) - - return self diff --git a/packages/s2-python/src/s2python/frbc/frbc_operation_mode.py b/packages/s2-python/src/s2python/frbc/frbc_operation_mode.py deleted file mode 100644 index cbee3d4..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_operation_mode.py +++ /dev/null @@ -1,49 +0,0 @@ -# from itertools import pairwise -import uuid -from typing import List, Dict -from typing_extensions import Self - -from pydantic import model_validator - -from s2python.common import NumberRange -from s2python.frbc.frbc_operation_mode_element import FRBCOperationModeElement -from s2python.generated.gen_s2 import FRBCOperationMode as GenFRBCOperationMode -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) -from s2python.utils import pairwise - - -@catch_and_convert_exceptions -class FRBCOperationMode(GenFRBCOperationMode, S2MessageComponent): - model_config = GenFRBCOperationMode.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenFRBCOperationMode.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - elements: List[FRBCOperationModeElement] = GenFRBCOperationMode.model_fields["elements"] # type: ignore[assignment,reportIncompatibleVariableOverride] - - @model_validator(mode="after") - def validate_contiguous_fill_levels_operation_mode_elements(self) -> Self: - elements_by_fill_level_range: Dict[NumberRange, FRBCOperationModeElement] - elements_by_fill_level_range = { - element.fill_level_range: element for element in self.elements - } - - sorted_fill_level_ranges: List[NumberRange] - sorted_fill_level_ranges = list(elements_by_fill_level_range.keys()) - sorted_fill_level_ranges.sort(key=lambda r: r.start_of_range) - - for current_fill_level_range, next_fill_level_range in pairwise( - sorted_fill_level_ranges - ): - if ( - current_fill_level_range.end_of_range - != next_fill_level_range.start_of_range - ): - raise ValueError( - self, - f"Elements with fill level ranges {current_fill_level_range} and " - f"{next_fill_level_range} are closest match to each other but not contiguous.", - ) - return self diff --git a/packages/s2-python/src/s2python/frbc/frbc_operation_mode_element.py b/packages/s2-python/src/s2python/frbc/frbc_operation_mode_element.py deleted file mode 100644 index e22ddb7..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_operation_mode_element.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Optional, List - -from s2python.common import NumberRange, PowerRange -from s2python.generated.gen_s2 import ( - FRBCOperationModeElement as GenFRBCOperationModeElement, -) -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class FRBCOperationModeElement(GenFRBCOperationModeElement, S2MessageComponent): - model_config = GenFRBCOperationModeElement.model_config - model_config["validate_assignment"] = True - - fill_level_range: NumberRange = GenFRBCOperationModeElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "fill_level_range" - ] # type: ignore[assignment] - fill_rate: NumberRange = GenFRBCOperationModeElement.model_fields["fill_rate"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_ranges: List[PowerRange] = GenFRBCOperationModeElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_ranges" - ] # type: ignore[assignment] - running_costs: Optional[NumberRange] = GenFRBCOperationModeElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "running_costs" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/frbc/frbc_storage_description.py b/packages/s2-python/src/s2python/frbc/frbc_storage_description.py deleted file mode 100644 index 7672d85..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_storage_description.py +++ /dev/null @@ -1,18 +0,0 @@ -from s2python.common import NumberRange -from s2python.generated.gen_s2 import ( - FRBCStorageDescription as GenFRBCStorageDescription, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCStorageDescription(GenFRBCStorageDescription, S2MessageComponent): - model_config = GenFRBCStorageDescription.model_config - model_config["validate_assignment"] = True - - fill_level_range: NumberRange = GenFRBCStorageDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "fill_level_range" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/frbc/frbc_storage_status.py b/packages/s2-python/src/s2python/frbc/frbc_storage_status.py deleted file mode 100644 index 04ade4c..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_storage_status.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import FRBCStorageStatus as GenFRBCStorageStatus -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCStorageStatus(GenFRBCStorageStatus, S2MessageComponent): - model_config = GenFRBCStorageStatus.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenFRBCStorageStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_system_description.py b/packages/s2-python/src/s2python/frbc/frbc_system_description.py deleted file mode 100644 index f1efdf8..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_system_description.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import FRBCSystemDescription as GenFRBCSystemDescription -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) -from s2python.frbc.frbc_actuator_description import FRBCActuatorDescription -from s2python.frbc.frbc_storage_description import FRBCStorageDescription - - -@catch_and_convert_exceptions -class FRBCSystemDescription(GenFRBCSystemDescription, S2MessageComponent): - model_config = GenFRBCSystemDescription.model_config - model_config["validate_assignment"] = True - - actuators: List[FRBCActuatorDescription] = GenFRBCSystemDescription.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "actuators" - ] # type: ignore[assignment] - message_id: uuid.UUID = GenFRBCSystemDescription.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - storage: FRBCStorageDescription = GenFRBCSystemDescription.model_fields["storage"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_timer_status.py b/packages/s2-python/src/s2python/frbc/frbc_timer_status.py deleted file mode 100644 index b374750..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_timer_status.py +++ /dev/null @@ -1,17 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import FRBCTimerStatus as GenFRBCTimerStatus -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCTimerStatus(GenFRBCTimerStatus, S2MessageComponent): - model_config = GenFRBCTimerStatus.model_config - model_config["validate_assignment"] = True - - actuator_id: uuid.UUID = GenFRBCTimerStatus.model_fields["actuator_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - message_id: uuid.UUID = GenFRBCTimerStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - timer_id: uuid.UUID = GenFRBCTimerStatus.model_fields["timer_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_usage_forecast.py b/packages/s2-python/src/s2python/frbc/frbc_usage_forecast.py deleted file mode 100644 index 777d7cb..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_usage_forecast.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import FRBCUsageForecast as GenFRBCUsageForecast -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) -from s2python.frbc.frbc_usage_forecast_element import FRBCUsageForecastElement - - -@catch_and_convert_exceptions -class FRBCUsageForecast(GenFRBCUsageForecast, S2MessageComponent): - model_config = GenFRBCUsageForecast.model_config - model_config["validate_assignment"] = True - - elements: List[FRBCUsageForecastElement] = GenFRBCUsageForecast.model_fields["elements"] # type: ignore[assignment,reportIncompatibleVariableOverride] - message_id: uuid.UUID = GenFRBCUsageForecast.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py b/packages/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py deleted file mode 100644 index c81467a..0000000 --- a/packages/s2-python/src/s2python/frbc/frbc_usage_forecast_element.py +++ /dev/null @@ -1,17 +0,0 @@ -from s2python.common import Duration - -from s2python.generated.gen_s2 import ( - FRBCUsageForecastElement as GenFRBCUsageForecastElement, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class FRBCUsageForecastElement(GenFRBCUsageForecastElement, S2MessageComponent): - model_config = GenFRBCUsageForecastElement.model_config - model_config["validate_assignment"] = True - - duration: Duration = GenFRBCUsageForecastElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/frbc/rm.py b/packages/s2-python/src/s2python/frbc/rm.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/s2-python/src/s2python/generated/__init__.py b/packages/s2-python/src/s2python/generated/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/s2-python/src/s2python/generated/gen_s2.py b/packages/s2-python/src/s2python/generated/gen_s2.py deleted file mode 100644 index f09a9f6..0000000 --- a/packages/s2-python/src/s2python/generated/gen_s2.py +++ /dev/null @@ -1,1575 +0,0 @@ -# generated by datamodel-codegen: -# filename: openapi.yml -# timestamp: 2024-07-29T10:18:52+00:00 - -from __future__ import annotations - -from enum import Enum -from typing import List, Optional - -from pydantic import ( - AwareDatetime, - BaseModel, - ConfigDict, - Field, - RootModel, - conint, - constr, -) -from typing_extensions import Literal - - -class Duration(RootModel[conint(ge=0)]): - root: conint(ge=0) = Field( # pyright: ignore[reportInvalidTypeForm] - ..., description="Duration in milliseconds" - ) - - -class ID(RootModel[constr(pattern=r"[a-zA-Z0-9\-_:]{2,64}")]): - root: constr(pattern=r"[a-zA-Z0-9\-_:]{2,64}") = ( # pyright: ignore[reportInvalidTypeForm] - Field(..., description="UUID") - ) - - -class Currency(Enum): - AED = "AED" - ANG = "ANG" - AUD = "AUD" - CHE = "CHE" - CHF = "CHF" - CHW = "CHW" - EUR = "EUR" - GBP = "GBP" - LBP = "LBP" - LKR = "LKR" - LRD = "LRD" - LSL = "LSL" - LYD = "LYD" - MAD = "MAD" - MDL = "MDL" - MGA = "MGA" - MKD = "MKD" - MMK = "MMK" - MNT = "MNT" - MOP = "MOP" - MRO = "MRO" - MUR = "MUR" - MVR = "MVR" - MWK = "MWK" - MXN = "MXN" - MXV = "MXV" - MYR = "MYR" - MZN = "MZN" - NAD = "NAD" - NGN = "NGN" - NIO = "NIO" - NOK = "NOK" - NPR = "NPR" - NZD = "NZD" - OMR = "OMR" - PAB = "PAB" - PEN = "PEN" - PGK = "PGK" - PHP = "PHP" - PKR = "PKR" - PLN = "PLN" - PYG = "PYG" - QAR = "QAR" - RON = "RON" - RSD = "RSD" - RUB = "RUB" - RWF = "RWF" - SAR = "SAR" - SBD = "SBD" - SCR = "SCR" - SDG = "SDG" - SEK = "SEK" - SGD = "SGD" - SHP = "SHP" - SLL = "SLL" - SOS = "SOS" - SRD = "SRD" - SSP = "SSP" - STD = "STD" - SYP = "SYP" - SZL = "SZL" - THB = "THB" - TJS = "TJS" - TMT = "TMT" - TND = "TND" - TOP = "TOP" - TRY = "TRY" - TTD = "TTD" - TWD = "TWD" - TZS = "TZS" - UAH = "UAH" - UGX = "UGX" - USD = "USD" - USN = "USN" - UYI = "UYI" - UYU = "UYU" - UZS = "UZS" - VEF = "VEF" - VND = "VND" - VUV = "VUV" - WST = "WST" - XAG = "XAG" - XAU = "XAU" - XBA = "XBA" - XBB = "XBB" - XBC = "XBC" - XBD = "XBD" - XCD = "XCD" - XOF = "XOF" - XPD = "XPD" - XPF = "XPF" - XPT = "XPT" - XSU = "XSU" - XTS = "XTS" - XUA = "XUA" - XXX = "XXX" - YER = "YER" - ZAR = "ZAR" - ZMW = "ZMW" - ZWL = "ZWL" - - -class SessionRequestType(Enum): - RECONNECT = "RECONNECT" - TERMINATE = "TERMINATE" - - -class RevokableObjects(Enum): - PEBC_PowerConstraints = "PEBC.PowerConstraints" - PEBC_EnergyConstraint = "PEBC.EnergyConstraint" - PEBC_Instruction = "PEBC.Instruction" - PPBC_PowerProfileDefinition = "PPBC.PowerProfileDefinition" - PPBC_ScheduleInstruction = "PPBC.ScheduleInstruction" - PPBC_StartInterruptionInstruction = "PPBC.StartInterruptionInstruction" - PPBC_EndInterruptionInstruction = "PPBC.EndInterruptionInstruction" - OMBC_SystemDescription = "OMBC.SystemDescription" - OMBC_Instruction = "OMBC.Instruction" - FRBC_SystemDescription = "FRBC.SystemDescription" - FRBC_Instruction = "FRBC.Instruction" - DDBC_SystemDescription = "DDBC.SystemDescription" - DDBC_Instruction = "DDBC.Instruction" - - -class EnergyManagementRole(Enum): - CEM = "CEM" - RM = "RM" - - -class ReceptionStatusValues(Enum): - INVALID_DATA = "INVALID_DATA" - INVALID_MESSAGE = "INVALID_MESSAGE" - INVALID_CONTENT = "INVALID_CONTENT" - TEMPORARY_ERROR = "TEMPORARY_ERROR" - PERMANENT_ERROR = "PERMANENT_ERROR" - OK = "OK" - - -class NumberRange(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - start_of_range: float = Field(..., description="Number that defines the start of the range") - end_of_range: float = Field(..., description="Number that defines the end of the range") - - -class Transition(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the Transition. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used.", - ) - from_: ID = Field( - ..., - alias="from", - description="ID of the OperationMode (exact type differs per ControlType) that should be switched from.", - ) - to: ID = Field( - ..., - description="ID of the OperationMode (exact type differs per ControlType) that will be switched to.", - ) - start_timers: List[ID] = Field( - ..., - description="List of IDs of Timers that will be (re)started when this transition is initiated", - max_length=1000, - min_length=0, - ) - blocking_timers: List[ID] = Field( - ..., - description="List of IDs of Timers that block this Transition from initiating while at least one of these Timers is not yet finished", - max_length=1000, - min_length=0, - ) - transition_costs: Optional[float] = Field( - None, - description="Absolute costs for going through this Transition in the currency as described in the ResourceManagerDetails.", - ) - transition_duration: Optional[Duration] = Field( - None, - description="Indicates the time between the initiation of this Transition, and the time at which the device behaves according to the Operation Mode which is defined in the ‘to’ data element. When no value is provided it is assumed the transition duration is negligible.", - ) - abnormal_condition_only: bool = Field( - ..., - description="Indicates if this Transition may only be used during an abnormal condition (see Clause )", - ) - - -class Timer(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the Timer. Must be unique in the scope of the OMBC.SystemDescription, FRBC.ActuatorDescription or DDBC.ActuatorDescription in which it is used.", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description of the Timer. This element is only intended for diagnostic purposes and not for HMI applications.", - ) - duration: Duration = Field( - ..., - description="The time it takes for the Timer to finish after it has been started", - ) - - -class PEBCPowerEnvelopeElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - duration: Duration = Field(..., description="The duration of the element") - upper_limit: float = Field( - ..., - description="Upper power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or below the upper_limit. The upper_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type UPPER_LIMIT.", - ) - lower_limit: float = Field( - ..., - description="Lower power limit according to the commodity_quantity of the containing PEBC.PowerEnvelope. The lower_limit must be smaller or equal to the upper_limit. The Resource Manager is requested to keep the power values for the given commodity quantity equal to or above the lower_limit. The lower_limit shall be in accordance with the constraints provided by the Resource Manager through any PEBC.AllowedLimitRange with limit_type LOWER_LIMIT.", - ) - - -class FRBCStorageDescription(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description of the storage (e.g. hot water buffer or battery). This element is only intended for diagnostic purposes and not for HMI applications.", - ) - fill_level_label: Optional[str] = Field( - None, - description="Human readable description of the (physical) units associated with the fill_level (e.g. degrees Celsius or percentage state of charge). This element is only intended for diagnostic purposes and not for HMI applications.", - ) - provides_leakage_behaviour: bool = Field( - ..., - description="Indicates whether the Storage could provide details of power leakage behaviour through the FRBC.LeakageBehaviour.", - ) - provides_fill_level_target_profile: bool = Field( - ..., - description="Indicates whether the Storage could provide a target profile for the fill level through the FRBC.FillLevelTargetProfile.", - ) - provides_usage_forecast: bool = Field( - ..., - description="Indicates whether the Storage could provide a UsageForecast through the FRBC.UsageForecast.", - ) - fill_level_range: NumberRange = Field( - ..., - description="The range in which the fill_level should remain. It is expected of the CEM to keep the fill_level within this range. When the fill_level is not within this range, the Resource Manager can ignore instructions from the CEM (except during abnormal conditions). ", - ) - - -class FRBCLeakageBehaviourElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - fill_level_range: NumberRange = Field( - ..., - description="The fill level range for which this FRBC.LeakageBehaviourElement applies. The start of the range must be less than the end of the range.", - ) - leakage_rate: float = Field( - ..., - description="Indicates how fast the momentary fill level will decrease per second due to leakage within the given range of the fill level. A positive value indicates that the fill level decreases over time due to leakage.", - ) - - -class FRBCUsageForecastElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - duration: Duration = Field( - ..., description="Indicator for how long the given usage_rate is valid." - ) - usage_rate_upper_limit: Optional[float] = Field( - None, - description="The upper limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", - ) - usage_rate_upper_95PPR: Optional[float] = Field( - None, - description="The upper limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", - ) - usage_rate_upper_68PPR: Optional[float] = Field( - None, - description="The upper limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", - ) - usage_rate_expected: float = Field( - ..., - description="The most likely value for the usage rate; the expected increase or decrease of the fill_level per second. A positive value indicates that the fill level will decrease due to usage.", - ) - usage_rate_lower_68PPR: Optional[float] = Field( - None, - description="The lower limit of the range with a 68 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", - ) - usage_rate_lower_95PPR: Optional[float] = Field( - None, - description="The lower limit of the range with a 95 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", - ) - usage_rate_lower_limit: Optional[float] = Field( - None, - description="The lower limit of the range with a 100 % probability that the usage rate is within that range. A positive value indicates that the fill level will decrease due to usage.", - ) - - -class FRBCFillLevelTargetProfileElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - duration: Duration = Field(..., description="The duration of the element.") - fill_level_range: NumberRange = Field( - ..., - description="The target range in which the fill_level must be for the time period during which the element is active. The start of the range must be smaller or equal to the end of the range. The CEM must take best-effort actions to proactively achieve this target.", - ) - - -class DDBCAverageDemandRateForecastElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - duration: Duration = Field(..., description="Duration of the element") - demand_rate_upper_limit: Optional[float] = Field( - None, - description="The upper limit of the range with a 100 % probability that the demand rate is within that range", - ) - demand_rate_upper_95PPR: Optional[float] = Field( - None, - description="The upper limit of the range with a 95 % probability that the demand rate is within that range", - ) - demand_rate_upper_68PPR: Optional[float] = Field( - None, - description="The upper limit of the range with a 68 % probability that the demand rate is within that range", - ) - demand_rate_expected: float = Field( - ..., - description="The most likely value for the demand rate; the expected increase or decrease of the fill_level per second", - ) - demand_rate_lower_68PPR: Optional[float] = Field( - None, - description="The lower limit of the range with a 68 % probability that the demand rate is within that range", - ) - demand_rate_lower_95PPR: Optional[float] = Field( - None, - description="The lower limit of the range with a 95 % probability that the demand rate is within that range", - ) - demand_rate_lower_limit: Optional[float] = Field( - None, - description="The lower limit of the range with a 100 % probability that the demand rate is within that range", - ) - - -class RoleType(Enum): - ENERGY_PRODUCER = "ENERGY_PRODUCER" - ENERGY_CONSUMER = "ENERGY_CONSUMER" - ENERGY_STORAGE = "ENERGY_STORAGE" - - -class Commodity(Enum): - GAS = "GAS" - HEAT = "HEAT" - ELECTRICITY = "ELECTRICITY" - OIL = "OIL" - - -class CommodityQuantity(Enum): - ELECTRIC_POWER_L1 = "ELECTRIC.POWER.L1" - ELECTRIC_POWER_L2 = "ELECTRIC.POWER.L2" - ELECTRIC_POWER_L3 = "ELECTRIC.POWER.L3" - ELECTRIC_POWER_3_PHASE_SYMMETRIC = "ELECTRIC.POWER.3_PHASE_SYMMETRIC" - NATURAL_GAS_FLOW_RATE = "NATURAL_GAS.FLOW_RATE" - HYDROGEN_FLOW_RATE = "HYDROGEN.FLOW_RATE" - HEAT_TEMPERATURE = "HEAT.TEMPERATURE" - HEAT_FLOW_RATE = "HEAT.FLOW_RATE" - HEAT_THERMAL_POWER = "HEAT.THERMAL_POWER" - OIL_FLOW_RATE = "OIL.FLOW_RATE" - - -class InstructionStatus(Enum): - NEW = "NEW" - ACCEPTED = "ACCEPTED" - REJECTED = "REJECTED" - REVOKED = "REVOKED" - STARTED = "STARTED" - SUCCEEDED = "SUCCEEDED" - ABORTED = "ABORTED" - - -class ControlType(Enum): - POWER_ENVELOPE_BASED_CONTROL = "POWER_ENVELOPE_BASED_CONTROL" - POWER_PROFILE_BASED_CONTROL = "POWER_PROFILE_BASED_CONTROL" - OPERATION_MODE_BASED_CONTROL = "OPERATION_MODE_BASED_CONTROL" - FILL_RATE_BASED_CONTROL = "FILL_RATE_BASED_CONTROL" - DEMAND_DRIVEN_BASED_CONTROL = "DEMAND_DRIVEN_BASED_CONTROL" - NOT_CONTROLABLE = "NOT_CONTROLABLE" - NO_SELECTION = "NO_SELECTION" - - -class PEBCPowerEnvelopeLimitType(Enum): - UPPER_LIMIT = "UPPER_LIMIT" - LOWER_LIMIT = "LOWER_LIMIT" - - -class PEBCPowerEnvelopeConsequenceType(Enum): - VANISH = "VANISH" - DEFER = "DEFER" - - -class PPBCPowerSequenceStatus(Enum): - NOT_SCHEDULED = "NOT_SCHEDULED" - SCHEDULED = "SCHEDULED" - EXECUTING = "EXECUTING" - INTERRUPTED = "INTERRUPTED" - FINISHED = "FINISHED" - ABORTED = "ABORTED" - - -class OMBCTimerStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["OMBC.TimerStatus"] = "OMBC.TimerStatus" - message_id: ID - timer_id: ID = Field(..., description="The ID of the timer this message refers to") - finished_at: AwareDatetime = Field( - ..., - description="Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past.", - ) - - -class FRBCTimerStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.TimerStatus"] = "FRBC.TimerStatus" - message_id: ID - timer_id: ID = Field(..., description="The ID of the timer this message refers to") - actuator_id: ID = Field(..., description="The ID of the actuator the timer belongs to") - finished_at: AwareDatetime = Field( - ..., - description="Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past.", - ) - - -class DDBCTimerStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["DDBC.TimerStatus"] = "DDBC.TimerStatus" - message_id: ID - timer_id: ID = Field(..., description="The ID of the timer this message refers to") - actuator_id: ID = Field(..., description="The ID of the actuator the timer belongs to") - finished_at: AwareDatetime = Field( - ..., - description="Indicates when the Timer will be finished. If the DateTimeStamp is in the future, the timer is not yet finished. If the DateTimeStamp is in the past, the timer is finished. If the timer was never started, the value can be an arbitrary DateTimeStamp in the past.", - ) - - -class SelectControlType(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["SelectControlType"] = "SelectControlType" - message_id: ID - control_type: ControlType = Field( - ..., - description="The ControlType to activate. Must be one of the available ControlTypes as defined in the ResourceManagerDetails", - ) - - -class SessionRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["SessionRequest"] = "SessionRequest" - message_id: ID - request: SessionRequestType = Field(..., description="The type of request") - diagnostic_label: Optional[str] = Field( - None, - description="Optional field for a human readible descirption for debugging purposes", - ) - - -class RevokeObject(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["RevokeObject"] = "RevokeObject" - message_id: ID - object_type: RevokableObjects = Field( - ..., description="The type of object that needs to be revoked" - ) - object_id: ID = Field(..., description="The ID of object that needs to be revoked") - - -class Handshake(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["Handshake"] = "Handshake" - message_id: ID - role: EnergyManagementRole = Field(..., description="The role of the sender of this message") - supported_protocol_versions: Optional[List[str]] = Field( - None, - description="Protocol versions supported by the sender of this message. This field is mandatory for the RM, but optional for the CEM.", - min_length=1, - ) - - -class HandshakeResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["HandshakeResponse"] = "HandshakeResponse" - message_id: ID - selected_protocol_version: str = Field( - ..., description="The protocol version the CEM selected for this session" - ) - - -class ReceptionStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["ReceptionStatus"] = "ReceptionStatus" - subject_message_id: ID = Field(..., description="The message this ReceptionStatus refers to") - status: ReceptionStatusValues = Field(..., description="Enumeration of status values") - diagnostic_label: Optional[str] = Field( - None, - description="Diagnostic label that can be used to provide additional information for debugging. However, not for HMI purposes.", - ) - - -class InstructionStatusUpdate(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["InstructionStatusUpdate"] = "InstructionStatusUpdate" - message_id: ID - instruction_id: ID = Field(..., description="ID of this instruction (as provided by the CEM) ") - status_type: InstructionStatus = Field(..., description="Present status of this instruction.") - timestamp: AwareDatetime = Field( - ..., description="Timestamp when status_type has changed the last time." - ) - - -class PEBCEnergyConstraint(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PEBC.EnergyConstraint"] = "PEBC.EnergyConstraint" - message_id: ID - id: ID = Field( - ..., - description="Identifier of this PEBC.EnergyConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - valid_from: AwareDatetime = Field( - ..., - description="Moment this PEBC.EnergyConstraints information starts to be valid", - ) - valid_until: AwareDatetime = Field( - ..., - description="Moment until this PEBC.EnergyConstraints information is valid.", - ) - upper_average_power: float = Field( - ..., - description="Upper average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated upper energy content can be derived. This is the highest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy consumption (in case the number is positive). Must be greater than or equal to lower_average_power, and can be negative in case of energy production.", - ) - lower_average_power: float = Field( - ..., - description="Lower average power within the time period given by valid_from and valid_until. If the duration is multiplied with this power value, then the associated lower energy content can be derived. This is the lowest amount of energy the resource will consume during that period of time. The Power Envelope created by the CEM must allow at least this much energy production (in case the number is negative). Must be greater than or equal to lower_average_power, and can be negative in case of energy production.", - ) - commodity_quantity: CommodityQuantity = Field( - ..., - description="Type of power quantity which applies to upper_average_power and lower_average_power", - ) - - -class PPBCScheduleInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PPBC.ScheduleInstruction"] = "PPBC.ScheduleInstruction" - message_id: ID - id: ID = Field( - ..., - description="ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - power_profile_id: ID = Field( - ..., - description="ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being selected and scheduled by the CEM.", - ) - sequence_container_id: ID = Field( - ..., - description="ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being selected and scheduled by the CEM.", - ) - power_sequence_id: ID = Field( - ..., - description="ID of the PPBC.PowerSequence that is being selected and scheduled by the CEM.", - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment the PPBC.PowerSequence shall start. When the specified execution time is in the past, execution must start as soon as possible.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition", - ) - - -class PPBCStartInterruptionInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PPBC.StartInterruptionInstruction"] = "PPBC.StartInterruptionInstruction" - message_id: ID - id: ID = Field( - ..., - description="ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - power_profile_id: ID = Field( - ..., - description="ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence is being interrupted by the CEM.", - ) - sequence_container_id: ID = Field( - ..., - description="ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence is being interrupted by the CEM.", - ) - power_sequence_id: ID = Field( - ..., description="ID of the PPBC.PowerSequence that the CEM wants to interrupt." - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment the PPBC.PowerSequence shall be interrupted. When the specified execution time is in the past, execution must start as soon as possible.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition", - ) - - -class PPBCEndInterruptionInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PPBC.EndInterruptionInstruction"] = "PPBC.EndInterruptionInstruction" - message_id: ID - id: ID = Field( - ..., - description="ID of the Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - power_profile_id: ID = Field( - ..., - description="ID of the PPBC.PowerProfileDefinition of which the PPBC.PowerSequence interruption is being ended by the CEM.", - ) - sequence_container_id: ID = Field( - ..., - description="ID of the PPBC.PowerSequnceContainer of which the PPBC.PowerSequence interruption is being ended by the CEM.", - ) - power_sequence_id: ID = Field( - ..., - description="ID of the PPBC.PowerSequence for which the CEM wants to end the interruption.", - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment PPBC.PowerSequence interruption shall end. When the specified execution time is in the past, execution must start as soon as possible.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition", - ) - - -class OMBCStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["OMBC.Status"] = "OMBC.Status" - message_id: ID - active_operation_mode_id: ID = Field(..., description="ID of the active OMBC.OperationMode.") - operation_mode_factor: float = Field( - ..., - description="The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1.", - ) - previous_operation_mode_id: Optional[ID] = Field( - None, - description="ID of the OMBC.OperationMode that was previously active. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of.", - ) - transition_timestamp: Optional[AwareDatetime] = Field( - None, - description="Time at which the transition from the previous OMBC.OperationMode to the active OMBC.OperationMode was initiated. This value shall always be provided, unless the active OMBC.OperationMode is the first OMBC.OperationMode the Resource Manager is aware of.", - ) - - -class OMBCInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["OMBC.Instruction"] = "OMBC.Instruction" - message_id: ID - id: ID = Field( - ..., - description="ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", - ) - operation_mode_id: ID = Field( - ..., description="ID of the OMBC.OperationMode that should be activated" - ) - operation_mode_factor: float = Field( - ..., - description="The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal than 0 and less or equal to 1.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition", - ) - - -class FRBCActuatorStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.ActuatorStatus"] = "FRBC.ActuatorStatus" - message_id: ID - actuator_id: ID = Field(..., description="ID of the actuator this messages refers to") - active_operation_mode_id: ID = Field( - ..., description="ID of the FRBC.OperationMode that is presently active." - ) - operation_mode_factor: float = Field( - ..., - description="The number indicates the factor with which the FRBC.OperationMode is configured. The factor should be greater than or equal than 0 and less or equal to 1.", - ) - previous_operation_mode_id: Optional[ID] = Field( - None, - description="ID of the FRBC.OperationMode that was active before the present one. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of.", - ) - transition_timestamp: Optional[AwareDatetime] = Field( - None, - description="Time at which the transition from the previous FRBC.OperationMode to the active FRBC.OperationMode was initiated. This value shall always be provided, unless the active FRBC.OperationMode is the first FRBC.OperationMode the Resource Manager is aware of.", - ) - - -class FRBCStorageStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.StorageStatus"] = "FRBC.StorageStatus" - message_id: ID - present_fill_level: float = Field(..., description="Present fill level of the Storage") - - -class FRBCLeakageBehaviour(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.LeakageBehaviour"] = "FRBC.LeakageBehaviour" - message_id: ID - valid_from: AwareDatetime = Field( - ..., - description="Moment this FRBC.LeakageBehaviour starts to be valid. If the FRBC.LeakageBehaviour is immediately valid, the DateTimeStamp should be now or in the past.", - ) - elements: List[FRBCLeakageBehaviourElement] = Field( - ..., - description="List of elements that model the leakage behaviour of the buffer. The fill_level_ranges of the elements must be contiguous.", - max_length=288, - min_length=1, - ) - - -class FRBCInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.Instruction"] = "FRBC.Instruction" - message_id: ID - id: ID = Field( - ..., - description="ID of the instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - actuator_id: ID = Field(..., description="ID of the actuator this instruction belongs to.") - operation_mode: ID = Field( - ..., description="ID of the FRBC.OperationMode that should be activated." - ) - operation_mode_factor: float = Field( - ..., - description="The number indicates the factor with which the FRBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1.", - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition.", - ) - - -class FRBCUsageForecast(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.UsageForecast"] = "FRBC.UsageForecast" - message_id: ID - start_time: AwareDatetime = Field( - ..., description="Time at which the FRBC.UsageForecast starts." - ) - elements: List[FRBCUsageForecastElement] = Field( - ..., - description="Further elements that model the profile. There shall be at least one element. Elements must be placed in chronological order.", - max_length=288, - min_length=1, - ) - - -class FRBCFillLevelTargetProfile(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.FillLevelTargetProfile"] = "FRBC.FillLevelTargetProfile" - message_id: ID - start_time: AwareDatetime = Field( - ..., description="Time at which the FRBC.FillLevelTargetProfile starts." - ) - elements: List[FRBCFillLevelTargetProfileElement] = Field( - ..., - description="List of different fill levels that have to be targeted within a given duration. There shall be at least one element. Elements must be placed in chronological order.", - max_length=288, - min_length=1, - ) - - -class DDBCActuatorStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["DDBC.ActuatorStatus"] = "DDBC.ActuatorStatus" - message_id: ID - actuator_id: ID = Field(..., description="ID of the actuator this messages refers to") - active_operation_mode_id: ID = Field( - ..., - description="The operation mode that is presently active for this actuator.", - ) - operation_mode_factor: float = Field( - ..., - description="The number indicates the factor with which the DDBC.OperationMode is configured. The factor should be greater than or equal to 0 and less or equal to 1.", - ) - previous_operation_mode_id: Optional[ID] = Field( - None, - description="ID of the DDBC,OperationMode that was active before the present one. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of.", - ) - transition_timestamp: Optional[AwareDatetime] = Field( - None, - description="Time at which the transition from the previous DDBC.OperationMode to the active DDBC.OperationMode was initiated. This value shall always be provided, unless the active DDBC.OperationMode is the first DDBC.OperationMode the Resource Manager is aware of.", - ) - - -class DDBCInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["DDBC.Instruction"] = "DDBC.Instruction" - message_id: ID - id: ID = Field( - ..., - description="Identifier of this DDBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition", - ) - actuator_id: ID = Field(..., description="ID of the actuator this Instruction belongs to.") - operation_mode_id: ID = Field(..., description="ID of the DDBC.OperationMode") - operation_mode_factor: float = Field( - ..., - description="The number indicates the factor with which the OMBC.OperationMode should be configured. The factor should be greater than or equal to 0 and less or equal to 1.", - ) - - -class DDBCAverageDemandRateForecast(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["DDBC.AverageDemandRateForecast"] = "DDBC.AverageDemandRateForecast" - message_id: ID - start_time: AwareDatetime = Field(..., description="Start time of the profile.") - elements: List[DDBCAverageDemandRateForecastElement] = Field( - ..., - description="Elements of the profile. Elements must be placed in chronological order.", - max_length=288, - min_length=1, - ) - - -class PowerValue(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - commodity_quantity: CommodityQuantity = Field( - ..., description="The power quantity the value refers to" - ) - value: float = Field( - ..., - description="Power value expressed in the unit associated with the CommodityQuantity", - ) - - -class PowerForecastValue(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - value_upper_limit: Optional[float] = Field( - None, - description="The upper boundary of the range with 100 % certainty the power value is in it", - ) - value_upper_95PPR: Optional[float] = Field( - None, - description="The upper boundary of the range with 95 % certainty the power value is in it", - ) - value_upper_68PPR: Optional[float] = Field( - None, - description="The upper boundary of the range with 68 % certainty the power value is in it", - ) - value_expected: float = Field(..., description="The expected power value.") - value_lower_68PPR: Optional[float] = Field( - None, - description="The lower boundary of the range with 68 % certainty the power value is in it", - ) - value_lower_95PPR: Optional[float] = Field( - None, - description="The lower boundary of the range with 95 % certainty the power value is in it", - ) - value_lower_limit: Optional[float] = Field( - None, - description="The lower boundary of the range with 100 % certainty the power value is in it", - ) - commodity_quantity: CommodityQuantity = Field( - ..., description="The power quantity the value refers to" - ) - - -class PowerRange(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - start_of_range: float = Field( - ..., description="Power value that defines the start of the range." - ) - end_of_range: float = Field(..., description="Power value that defines the end of the range.") - commodity_quantity: CommodityQuantity = Field( - ..., description="The power quantity the values refer to" - ) - - -class Role(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - role: RoleType = Field( - ..., description="Role type of the Resource Manager for the given commodity" - ) - commodity: Commodity = Field(..., description="Commodity the role refers to.") - - -class PowerForecastElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - duration: Duration = Field(..., description="Duration of the PowerForecastElement") - power_values: List[PowerForecastValue] = Field( - ..., - description="The values of power that are expected for the given period of time. There shall be at least one PowerForecastValue, and at most one PowerForecastValue per CommodityQuantity.", - max_length=10, - min_length=1, - ) - - -class PEBCAllowedLimitRange(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - commodity_quantity: CommodityQuantity = Field( - ..., description="Type of power quantity this PEBC.AllowedLimitRange applies to" - ) - limit_type: PEBCPowerEnvelopeLimitType = Field( - ..., - description="Indicates if this ranges applies to the upper limit or the lower limit", - ) - range_boundary: NumberRange = Field( - ..., - description="Boundaries of the power range of this PEBC.AllowedLimitRange. The CEM is allowed to choose values within this range for the power envelope for the limit as described in limit_type. The start of the range shall be smaller or equal than the end of the range. ", - ) - abnormal_condition_only: bool = Field( - ..., - description="Indicates if this PEBC.AllowedLimitRange may only be used during an abnormal condition", - ) - - -class PEBCPowerEnvelope(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="Identifier of this PEBC.PowerEnvelope. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - commodity_quantity: CommodityQuantity = Field( - ..., description="Type of power quantity this PEBC.PowerEnvelope applies to" - ) - power_envelope_elements: List[PEBCPowerEnvelopeElement] = Field( - ..., - description="The elements of this PEBC.PowerEnvelope. Shall contain at least one element. Elements must be placed in chronological order.", - max_length=288, - min_length=1, - ) - - -class PPBCPowerSequenceElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - duration: Duration = Field(..., description="Duration of the PPBC.PowerSequenceElement.") - power_values: List[PowerForecastValue] = Field( - ..., - description="The value of power and deviations for the given duration. The array should contain at least one PowerForecastValue and at most one PowerForecastValue per CommodityQuantity.", - max_length=10, - min_length=1, - ) - - -class PPBCPowerSequenceContainerStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - power_profile_id: ID = Field( - ..., - description="ID of the PPBC.PowerProfileDefinition of which the data element ‘sequence_container_id’ refers to. ", - ) - sequence_container_id: ID = Field( - ..., - description="ID of the PPBC.PowerSequenceContainer this PPBC.PowerSequenceContainerStatus provides information about.", - ) - selected_sequence_id: Optional[ID] = Field( - None, - description="ID of selected PPBC.PowerSequence. When no ID is given, no sequence was selected yet.", - ) - progress: Optional[Duration] = Field( - None, - description="Time that has passed since the selected sequence has started. A value must be provided, unless no sequence has been selected or the selected sequence hasn’t started yet.", - ) - status: PPBCPowerSequenceStatus = Field( - ..., description="Status of the selected PPBC.PowerSequence" - ) - - -class OMBCOperationMode(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the OBMC.OperationMode. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description of the OMBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications.", - ) - power_ranges: List[PowerRange] = Field( - ..., - description="The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity.", - max_length=10, - min_length=1, - ) - running_costs: Optional[NumberRange] = Field( - None, - description="Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails , excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor.", - ) - abnormal_condition_only: bool = Field( - ..., - description="Indicates if this OMBC.OperationMode may only be used during an abnormal condition.", - ) - - -class FRBCOperationModeElement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - fill_level_range: NumberRange = Field( - ..., - description="The range of the fill level for which this FRBC.OperationModeElement applies. The start of the NumberRange shall be smaller than the end of the NumberRange.", - ) - fill_rate: NumberRange = Field( - ..., - description="Indicates the change in fill_level per second. The lower_boundary of the NumberRange is associated with an operation_mode_factor of 0, the upper_boundary is associated with an operation_mode_factor of 1. ", - ) - power_ranges: List[PowerRange] = Field( - ..., - description="The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity.", - max_length=10, - min_length=1, - ) - running_costs: Optional[NumberRange] = Field( - None, - description="Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor.", - ) - - -class DDBCOperationMode(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - Id: ID = Field( - ..., - description="ID of this operation mode. Must be unique in the scope of the DDBC.ActuatorDescription in which it is used.", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description of the DDBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications.", - ) - power_ranges: List[PowerRange] = Field( - ..., - description="The power produced or consumed by this operation mode. The start of each PowerRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1. In the array there must be at least one PowerRange, and at most one PowerRange per CommodityQuantity.", - max_length=10, - min_length=1, - ) - supply_range: NumberRange = Field( - ..., - description="The supply rate this DDBC.OperationMode can deliver for the CEM to match the demand rate. The start of the NumberRange is associated with an operation_mode_factor of 0, the end is associated with an operation_mode_factor of 1.", - ) - running_costs: Optional[NumberRange] = Field( - None, - description="Additional costs per second (e.g. wear, services) associated with this operation mode in the currency defined by the ResourceManagerDetails, excluding the commodity cost. The range is expressing uncertainty and is not linked to the operation_mode_factor.", - ) - abnormal_condition_only: bool = Field( - ..., - description="Indicates if this DDBC.OperationMode may only be used during an abnormal condition.", - ) - - -class ResourceManagerDetails(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["ResourceManagerDetails"] = "ResourceManagerDetails" - message_id: ID - resource_id: ID = Field( - ..., - description="Identifier of the Resource Manager. Must be unique within the scope of the CEM.", - ) - name: Optional[str] = Field(None, description="Human readable name given by user") - roles: List[Role] = Field( - ..., - description="Each Resource Manager provides one or more energy Roles", - max_length=3, - min_length=1, - ) - manufacturer: Optional[str] = Field(None, description="Name of Manufacturer") - model: Optional[str] = Field( - None, - description="Name of the model of the device (provided by the manufacturer)", - ) - serial_number: Optional[str] = Field( - None, description="Serial number of the device (provided by the manufacturer)" - ) - firmware_version: Optional[str] = Field( - None, - description="Version identifier of the firmware used in the device (provided by the manufacturer)", - ) - instruction_processing_delay: Duration = Field( - ..., - description="The average time the combination of Resource Manager and HBES/BACS/SASS or (Smart) device needs to process and execute an instruction", - ) - available_control_types: List[ControlType] = Field( - ..., - description="The control types supported by this Resource Manager.", - max_length=5, - min_length=1, - ) - currency: Optional[Currency] = Field( - None, - description="Currency to be used for all information regarding costs. Mandatory if cost information is published.", - ) - provides_forecast: bool = Field( - ..., - description="Indicates whether the ResourceManager is able to provide PowerForecasts", - ) - provides_power_measurement_types: List[CommodityQuantity] = Field( - ..., - description="Array of all CommodityQuantities that this Resource Manager can provide measurements for. ", - max_length=10, - min_length=1, - ) - - -class PowerMeasurement(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PowerMeasurement"] = "PowerMeasurement" - message_id: ID - measurement_timestamp: AwareDatetime = Field( - ..., description="Timestamp when PowerValues were measured." - ) - values: List[PowerValue] = Field( - ..., - description="Array of measured PowerValues. Must contain at least one item and at most one item per ‘commodity_quantity’ (defined inside the PowerValue).", - max_length=10, - min_length=1, - ) - - -class PowerForecast(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PowerForecast"] = "PowerForecast" - message_id: ID - start_time: AwareDatetime = Field( - ..., description="Start time of time period that is covered by the profile." - ) - elements: List[PowerForecastElement] = Field( - ..., - description="Elements of which this forecast consists. Contains at least one element. Elements must be placed in chronological order.", - max_length=288, - min_length=1, - ) - - -class PEBCPowerConstraints(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PEBC.PowerConstraints"] = "PEBC.PowerConstraints" - message_id: ID - id: ID = Field( - ..., - description="Identifier of this PEBC.PowerConstraints. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - valid_from: AwareDatetime = Field( - ..., description="Moment this PEBC.PowerConstraints start to be valid" - ) - valid_until: Optional[AwareDatetime] = Field( - None, - description="Moment until this PEBC.PowerConstraints is valid. If valid_until is not present, there is no determined end time of this PEBC.PowerConstraints.", - ) - consequence_type: PEBCPowerEnvelopeConsequenceType = Field( - ..., description="Type of consequence of limiting power" - ) - allowed_limit_ranges: List[PEBCAllowedLimitRange] = Field( - ..., - description="The actual constraints. There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT. It is allowed to have multiple PEBC.AllowedLimitRange objects with identical CommodityQuantities and LimitTypes.", - max_length=100, - min_length=2, - ) - - -class PEBCInstruction(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PEBC.Instruction"] = "PEBC.Instruction" - message_id: ID - id: ID = Field( - ..., - description="Identifier of this PEBC.Instruction. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - execution_time: AwareDatetime = Field( - ..., - description="Indicates the moment the execution of the instruction shall start. When the specified execution time is in the past, execution must start as soon as possible.", - ) - abnormal_condition: bool = Field( - ..., - description="Indicates if this is an instruction during an abnormal condition.", - ) - power_constraints_id: ID = Field( - ..., - description="Identifier of the PEBC.PowerConstraints this PEBC.Instruction was based on.", - ) - power_envelopes: List[PEBCPowerEnvelope] = Field( - ..., - description="The PEBC.PowerEnvelope(s) that should be followed by the Resource Manager. There shall be at least one PEBC.PowerEnvelope, but at most one PEBC.PowerEnvelope for each CommodityQuantity.", - max_length=10, - min_length=1, - ) - - -class PPBCPowerProfileStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PPBC.PowerProfileStatus"] = "PPBC.PowerProfileStatus" - message_id: ID - sequence_container_status: List[PPBCPowerSequenceContainerStatus] = Field( - ..., - description="Array with status information for all PPBC.PowerSequenceContainers in the PPBC.PowerProfileDefinition.", - max_length=1000, - min_length=1, - ) - - -class OMBCSystemDescription(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["OMBC.SystemDescription"] = "OMBC.SystemDescription" - message_id: ID - valid_from: AwareDatetime = Field( - ..., - description="Moment this OMBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past.", - ) - operation_modes: List[OMBCOperationMode] = Field( - ..., - description="OMBC.OperationModes available for the CEM in order to coordinate the device behaviour.", - max_length=100, - min_length=1, - ) - transitions: List[Transition] = Field( - ..., - description="Possible transitions to switch from one OMBC.OperationMode to another.", - max_length=1000, - min_length=0, - ) - timers: List[Timer] = Field( - ..., - description="Timers that control when certain transitions can be made.", - max_length=1000, - min_length=0, - ) - - -class PPBCPowerSequence(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the PPBC.PowerSequence. Must be unique in the scope of the PPBC.PowerSequnceContainer in which it is used.", - ) - elements: List[PPBCPowerSequenceElement] = Field( - ..., - description="List of PPBC.PowerSequenceElements. Shall contain at least one element. Elements must be placed in chronological order.", - max_length=288, - min_length=1, - ) - is_interruptible: bool = Field( - ..., - description="Indicates whether the option of pausing a sequence is available.", - ) - max_pause_before: Optional[Duration] = Field( - None, - description="The maximum duration for which a device can be paused between the end of the previous running sequence and the start of this one", - ) - abnormal_condition_only: bool = Field( - ..., - description="Indicates if this PPBC.PowerSequence may only be used during an abnormal condition", - ) - - -class FRBCOperationMode(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the FRBC.OperationMode. Must be unique in the scope of the FRBC.ActuatorDescription in which it is used.", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description of the FRBC.OperationMode. This element is only intended for diagnostic purposes and not for HMI applications.", - ) - elements: List[FRBCOperationModeElement] = Field( - ..., - description="List of FRBC.OperationModeElements, which describe the properties of this FRBC.OperationMode depending on the fill_level. The fill_level_ranges of the items in the Array must be contiguous.", - max_length=100, - min_length=1, - ) - abnormal_condition_only: bool = Field( - ..., - description="Indicates if this FRBC.OperationMode may only be used during an abnormal condition", - ) - - -class DDBCActuatorDescription(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of this DDBC.ActuatorDescription. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description of the actuator. This element is only intended for diagnostic purposes and not for HMI applications.", - ) - supported_commodites: List[Commodity] = Field( - ..., - description="Commodities supported by the operation modes of this actuator. There shall be at least one commodity", - max_length=4, - min_length=1, - ) - operation_modes: List[DDBCOperationMode] = Field( - ..., - description="List of all Operation Modes that are available for this actuator. There shall be at least one DDBC.OperationMode.", - max_length=100, - min_length=1, - ) - transitions: List[Transition] = Field( - ..., - description="List of Transitions between Operation Modes. Shall contain at least one Transition.", - max_length=1000, - min_length=0, - ) - timers: List[Timer] = Field( - ..., - description="List of Timers associated with Transitions for this Actuator. Can be empty.", - max_length=1000, - min_length=0, - ) - - -class DDBCSystemDescription(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["DDBC.SystemDescription"] = "DDBC.SystemDescription" - message_id: ID - valid_from: AwareDatetime = Field( - ..., - description="Moment this DDBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past.", - ) - actuators: List[DDBCActuatorDescription] = Field( - ..., - description="List of all available actuators in the system. Must contain at least one DDBC.ActuatorAggregated.", - max_length=10, - min_length=1, - ) - present_demand_rate: NumberRange = Field( - ..., description="Present demand rate that needs to be satisfied by the system" - ) - provides_average_demand_rate_forecast: bool = Field( - ..., - description="Indicates whether the Resource Manager could provide a demand rate forecast through the DDBC.AverageDemandRateForecast.", - ) - - -class PPBCPowerSequenceContainer(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the PPBC.PowerSequenceContainer. Must be unique in the scope of the PPBC.PowerProfileDefinition in which it is used.", - ) - power_sequences: List[PPBCPowerSequence] = Field( - ..., - description="List of alternative Sequences where one could be chosen by the CEM", - max_length=288, - min_length=1, - ) - - -class FRBCActuatorDescription(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: ID = Field( - ..., - description="ID of the Actuator. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - diagnostic_label: Optional[str] = Field( - None, - description="Human readable name/description for the actuator. This element is only intended for diagnostic purposes and not for HMI applications.", - ) - supported_commodities: List[Commodity] = Field( - ..., - description="List of all supported Commodities.", - max_length=4, - min_length=1, - ) - operation_modes: List[FRBCOperationMode] = Field( - ..., - description="Provided FRBC.OperationModes associated with this actuator", - max_length=100, - min_length=1, - ) - transitions: List[Transition] = Field( - ..., - description="Possible transitions between FRBC.OperationModes associated with this actuator.", - max_length=1000, - min_length=0, - ) - timers: List[Timer] = Field( - ..., - description="List of Timers associated with this actuator", - max_length=1000, - min_length=0, - ) - - -class PPBCPowerProfileDefinition(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["PPBC.PowerProfileDefinition"] = "PPBC.PowerProfileDefinition" - message_id: ID - id: ID = Field( - ..., - description="ID of the PPBC.PowerProfileDefinition. Must be unique in the scope of the Resource Manager, for at least the duration of the session between Resource Manager and CEM.", - ) - start_time: AwareDatetime = Field( - ..., - description="Indicates the first possible time the first PPBC.PowerSequence could start", - ) - end_time: AwareDatetime = Field( - ..., - description="Indicates when the last PPBC.PowerSequence shall be finished at the latest", - ) - power_sequences_containers: List[PPBCPowerSequenceContainer] = Field( - ..., - description="The PPBC.PowerSequenceContainers that make up this PPBC.PowerProfileDefinition. There shall be at least one PPBC.PowerSequenceContainer that includes at least one PPBC.PowerSequence. PPBC.PowerSequenceContainers must be placed in chronological order.", - max_length=1000, - min_length=1, - ) - - -class FRBCSystemDescription(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - message_type: Literal["FRBC.SystemDescription"] = "FRBC.SystemDescription" - message_id: ID - valid_from: AwareDatetime = Field( - ..., - description="Moment this FRBC.SystemDescription starts to be valid. If the system description is immediately valid, the DateTimeStamp should be now or in the past.", - ) - actuators: List[FRBCActuatorDescription] = Field( - ..., description="Details of all Actuators.", max_length=10, min_length=1 - ) - storage: FRBCStorageDescription = Field(..., description="Details of the storage.") diff --git a/packages/s2-python/src/s2python/message.py b/packages/s2-python/src/s2python/message.py deleted file mode 100644 index 3467a57..0000000 --- a/packages/s2-python/src/s2python/message.py +++ /dev/null @@ -1,145 +0,0 @@ -from typing import Union - -from s2python.frbc import ( - FRBCActuatorDescription, - FRBCActuatorStatus, - FRBCFillLevelTargetProfile, - FRBCFillLevelTargetProfileElement, - FRBCInstruction, - FRBCLeakageBehaviour, - FRBCLeakageBehaviourElement, - FRBCOperationMode, - FRBCOperationModeElement, - FRBCStorageDescription, - FRBCStorageStatus, - FRBCSystemDescription, - FRBCTimerStatus, - FRBCUsageForecast, - FRBCUsageForecastElement, -) -from s2python.ppbc import ( - PPBCEndInterruptionInstruction, - PPBCPowerProfileDefinition, - PPBCPowerSequenceContainer, - PPBCPowerSequence, - PPBCPowerProfileStatus, - PPBCPowerSequenceContainerStatus, - PPBCPowerSequenceElement, - PPBCScheduleInstruction, - PPBCStartInterruptionInstruction, -) -from s2python.ddbc import ( - DDBCActuatorDescription, - DDBCActuatorStatus, - DDBCAverageDemandRateForecast, - DDBCAverageDemandRateForecastElement, - DDBCInstruction, - DDBCOperationMode, - DDBCSystemDescription, - DDBCTimerStatus, -) -from s2python.ombc import ( - OMBCInstruction, - OMBCOperationMode, - OMBCTimerStatus, - OMBCStatus, - OMBCSystemDescription, -) - -from s2python.pebc import ( - PEBCAllowedLimitRange, - PEBCEnergyConstraint, - PEBCInstruction, - PEBCPowerConstraints, - PEBCPowerEnvelope, - PEBCPowerEnvelopeElement, -) -from s2python.common import ( - Duration, - Handshake, - HandshakeResponse, - InstructionStatusUpdate, - NumberRange, - PowerForecast, - PowerForecastElement, - PowerForecastValue, - PowerMeasurement, - PowerRange, - PowerValue, - ReceptionStatus, - ResourceManagerDetails, - RevokeObject, - Role, - SelectControlType, - SessionRequest, - Timer, - Transition, -) - -S2Message = Union[ - DDBCAverageDemandRateForecast, - DDBCInstruction, - DDBCSystemDescription, - DDBCTimerStatus, - FRBCActuatorStatus, - FRBCFillLevelTargetProfile, - FRBCInstruction, - FRBCLeakageBehaviour, - FRBCStorageStatus, - FRBCSystemDescription, - FRBCTimerStatus, - FRBCUsageForecast, - OMBCSystemDescription, - OMBCStatus, - OMBCTimerStatus, - OMBCInstruction, - PEBCPowerConstraints, - PPBCEndInterruptionInstruction, - PPBCPowerProfileDefinition, - PPBCPowerProfileStatus, - PPBCScheduleInstruction, - PPBCStartInterruptionInstruction, - ResourceManagerDetails, - RevokeObject, - SelectControlType, - SessionRequest, - DDBCActuatorStatus, - PEBCEnergyConstraint, - PEBCInstruction, - Handshake, - HandshakeResponse, - InstructionStatusUpdate, - PowerForecast, - PowerMeasurement, - ReceptionStatus, -] - -S2MessageElement = Union[ - DDBCActuatorDescription, - DDBCAverageDemandRateForecastElement, - DDBCOperationMode, - FRBCActuatorDescription, - FRBCFillLevelTargetProfileElement, - FRBCLeakageBehaviourElement, - FRBCOperationMode, - FRBCOperationModeElement, - FRBCStorageDescription, - FRBCUsageForecastElement, - OMBCOperationMode, - PEBCAllowedLimitRange, - PEBCPowerEnvelope, - PEBCPowerEnvelopeElement, - PPBCPowerSequenceContainer, - PPBCPowerSequence, - PPBCPowerSequenceContainerStatus, - PPBCPowerSequenceElement, - Duration, - NumberRange, - PowerForecastElement, - PowerForecastValue, - PowerRange, - PowerValue, - Role, - Timer, - Transition, -] diff --git a/packages/s2-python/src/s2python/ombc/__init__.py b/packages/s2-python/src/s2python/ombc/__init__.py deleted file mode 100644 index 623f04d..0000000 --- a/packages/s2-python/src/s2python/ombc/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from s2python.ombc.ombc_instruction import OMBCInstruction -from s2python.ombc.ombc_operation_mode import OMBCOperationMode -from s2python.ombc.ombc_status import OMBCStatus -from s2python.ombc.ombc_system_description import OMBCSystemDescription -from s2python.ombc.ombc_timer_status import OMBCTimerStatus diff --git a/packages/s2-python/src/s2python/ombc/ombc_instruction.py b/packages/s2-python/src/s2python/ombc/ombc_instruction.py deleted file mode 100644 index 6131916..0000000 --- a/packages/s2-python/src/s2python/ombc/ombc_instruction.py +++ /dev/null @@ -1,19 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import OMBCInstruction as GenOMBCInstruction -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class OMBCInstruction(GenOMBCInstruction, S2MessageComponent): - model_config = GenOMBCInstruction.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenOMBCInstruction.model_fields["id"] # type: ignore[assignment] - message_id: uuid.UUID = GenOMBCInstruction.model_fields["message_id"] # type: ignore[assignment] - abnormal_condition: bool = GenOMBCInstruction.model_fields["abnormal_condition"] # type: ignore[assignment] - operation_mode_factor: float = GenOMBCInstruction.model_fields["operation_mode_factor"] # type: ignore[assignment] - operation_mode_id: uuid.UUID = GenOMBCInstruction.model_fields["operation_mode_id"] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ombc/ombc_operation_mode.py b/packages/s2-python/src/s2python/ombc/ombc_operation_mode.py deleted file mode 100644 index 4c2b778..0000000 --- a/packages/s2-python/src/s2python/ombc/ombc_operation_mode.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import OMBCOperationMode as GenOMBCOperationMode -from s2python.common.power_range import PowerRange - - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class OMBCOperationMode(GenOMBCOperationMode, S2MessageComponent): - model_config = GenOMBCOperationMode.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenOMBCOperationMode.model_fields["id"] # type: ignore[assignment] - power_ranges: List[PowerRange] = GenOMBCOperationMode.model_fields[ - "power_ranges" - ] # type: ignore[assignment] - abnormal_condition_only: bool = GenOMBCOperationMode.model_fields[ - "abnormal_condition_only" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ombc/ombc_status.py b/packages/s2-python/src/s2python/ombc/ombc_status.py deleted file mode 100644 index c782c25..0000000 --- a/packages/s2-python/src/s2python/ombc/ombc_status.py +++ /dev/null @@ -1,17 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import OMBCStatus as GenOMBCStatus - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class OMBCStatus(GenOMBCStatus, S2MessageComponent): - model_config = GenOMBCStatus.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenOMBCStatus.model_fields["message_id"] # type: ignore[assignment] - operation_mode_factor: float = GenOMBCStatus.model_fields["operation_mode_factor"] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ombc/ombc_system_description.py b/packages/s2-python/src/s2python/ombc/ombc_system_description.py deleted file mode 100644 index efb4826..0000000 --- a/packages/s2-python/src/s2python/ombc/ombc_system_description.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import OMBCSystemDescription as GenOMBCSystemDescription -from s2python.ombc.ombc_operation_mode import OMBCOperationMode -from s2python.common.transition import Transition -from s2python.common.timer import Timer - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class OMBCSystemDescription(GenOMBCSystemDescription, S2MessageComponent): - model_config = GenOMBCSystemDescription.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenOMBCSystemDescription.model_fields["message_id"] # type: ignore[assignment] - operation_modes: List[OMBCOperationMode] = GenOMBCSystemDescription.model_fields[ - "operation_modes" - ] # type: ignore[assignment] - transitions: List[Transition] = GenOMBCSystemDescription.model_fields["transitions"] # type: ignore[assignment] - timers: List[Timer] = GenOMBCSystemDescription.model_fields["timers"] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ombc/ombc_timer_status.py b/packages/s2-python/src/s2python/ombc/ombc_timer_status.py deleted file mode 100644 index 906ea7d..0000000 --- a/packages/s2-python/src/s2python/ombc/ombc_timer_status.py +++ /dev/null @@ -1,17 +0,0 @@ -from uuid import UUID - -from s2python.generated.gen_s2 import OMBCTimerStatus as GenOMBCTimerStatus - -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class OMBCTimerStatus(GenOMBCTimerStatus, S2MessageComponent): - model_config = GenOMBCTimerStatus.model_config - model_config["validate_assignment"] = True - - message_id: UUID = GenOMBCTimerStatus.model_fields["message_id"] # type: ignore[assignment] - timer_id: UUID = GenOMBCTimerStatus.model_fields["timer_id"] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/pebc/__init__.py b/packages/s2-python/src/s2python/pebc/__init__.py deleted file mode 100644 index 27f8801..0000000 --- a/packages/s2-python/src/s2python/pebc/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from s2python.pebc.pebc_allowed_limit_range import PEBCAllowedLimitRange -from s2python.pebc.pebc_power_constraints import PEBCPowerConstraints -from s2python.pebc.pebc_power_envelope import PEBCPowerEnvelope -from s2python.pebc.pebc_power_envelope_element import PEBCPowerEnvelopeElement -from s2python.pebc.pebc_energy_constraint import PEBCEnergyConstraint -from s2python.generated.gen_s2 import ( - PEBCPowerEnvelopeConsequenceType, - PEBCPowerEnvelopeLimitType, -) -from s2python.pebc.pebc_instruction import PEBCInstruction - -__all__ = [ - "PEBCAllowedLimitRange", - "PEBCPowerConstraints", - "PEBCPowerEnvelope", - "PEBCPowerEnvelopeElement", - "PEBCEnergyConstraint", - "PEBCPowerEnvelopeConsequenceType", - "PEBCPowerEnvelopeLimitType", - "PEBCInstruction", -] diff --git a/packages/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py b/packages/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py deleted file mode 100644 index 81e82b6..0000000 --- a/packages/s2-python/src/s2python/pebc/pebc_allowed_limit_range.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing_extensions import Self -from pydantic import model_validator -from s2python.generated.gen_s2 import ( - PEBCAllowedLimitRange as GenPEBCAllowedLimitRange, - PEBCPowerEnvelopeLimitType as GenPEBCPowerEnvelopeLimitType, -) -from s2python.common import CommodityQuantity, NumberRange -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PEBCAllowedLimitRange(GenPEBCAllowedLimitRange, S2MessageComponent): - model_config = GenPEBCAllowedLimitRange.model_config - model_config["validate_assignment"] = True - - commodity_quantity: CommodityQuantity = GenPEBCAllowedLimitRange.model_fields[ - "commodity_quantity" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] - limit_type: GenPEBCPowerEnvelopeLimitType = GenPEBCAllowedLimitRange.model_fields[ - "limit_type" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] - range_boundary: NumberRange = GenPEBCAllowedLimitRange.model_fields["range_boundary"] # type: ignore[assignment,reportIncompatibleVariableOverride] - abnormal_condition_only: bool = [ - GenPEBCAllowedLimitRange.model_fields["abnormal_condition_only"] # type: ignore[assignment,reportIncompatibleVariableOverride] - ] - - @model_validator(mode="after") - def validate_range_boundary(self) -> Self: - # According to the specification "There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT - # and at least one AllowedLimitRange for the LOWER_LIMIT." However for something that produces energy - # end_of_range=-2000 and start_of_range=0 is valid. Therefore absolute value used here. - # TODO: Check that this is the correct interpretation of the wording - if abs(self.range_boundary.start_of_range) > abs( - self.range_boundary.end_of_range - ): - raise ValueError( - self, - f"The start of the range must shall be smaller or equal than the end of the range.", - ) - return self diff --git a/packages/s2-python/src/s2python/pebc/pebc_energy_constraint.py b/packages/s2-python/src/s2python/pebc/pebc_energy_constraint.py deleted file mode 100644 index eaf76fe..0000000 --- a/packages/s2-python/src/s2python/pebc/pebc_energy_constraint.py +++ /dev/null @@ -1,25 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import ( - PEBCEnergyConstraint as GenPEBCEnergyConstraint, -) -from s2python.common import CommodityQuantity -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PEBCEnergyConstraint(GenPEBCEnergyConstraint, S2MessageComponent): - model_config = GenPEBCEnergyConstraint.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenPEBCEnergyConstraint.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - id: uuid.UUID = GenPEBCEnergyConstraint.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - - upper_average_power: float = GenPEBCEnergyConstraint.model_fields["upper_average_power"] # type: ignore[assignment,reportIncompatibleVariableOverride] - lower_average_power: float = GenPEBCEnergyConstraint.model_fields["lower_average_power"] # type: ignore[assignment,reportIncompatibleVariableOverride] - commodity_quantity: CommodityQuantity = [ - GenPEBCEnergyConstraint.model_fields["commodity_quantity"] # type: ignore[assignment,reportIncompatibleVariableOverride] - ] diff --git a/packages/s2-python/src/s2python/pebc/pebc_instruction.py b/packages/s2-python/src/s2python/pebc/pebc_instruction.py deleted file mode 100644 index 320f931..0000000 --- a/packages/s2-python/src/s2python/pebc/pebc_instruction.py +++ /dev/null @@ -1,27 +0,0 @@ -import uuid -from typing import List - -from s2python.generated.gen_s2 import ( - PEBCInstruction as GenPEBCInstruction, -) -from s2python.pebc.pebc_power_envelope import PEBCPowerEnvelope -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PEBCInstruction(GenPEBCInstruction, S2MessageComponent): - model_config = GenPEBCInstruction.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenPEBCInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - id: uuid.UUID = GenPEBCInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_constraints_id: uuid.UUID = [ # type: ignore[reportIncompatibleVariableOverride] - GenPEBCInstruction.model_fields["power_constraints_id"] # type: ignore[assignment] - ] - power_envelopes: List[PEBCPowerEnvelope] = [ # type: ignore[reportIncompatibleVariableOverride] - GenPEBCInstruction.model_fields["power_envelopes"] # type: ignore[assignment] - ] - abnormal_condition: bool = GenPEBCInstruction.model_fields["abnormal_condition"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py b/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py deleted file mode 100644 index 71ab1bc..0000000 --- a/packages/s2-python/src/s2python/pebc/pebc_power_constraints.py +++ /dev/null @@ -1,77 +0,0 @@ -import uuid -from typing import List, Dict, Tuple -from typing_extensions import Self - -from pydantic import model_validator - -from s2python.common import CommodityQuantity, NumberRange -from s2python.generated.gen_s2 import ( - PEBCPowerConstraints as GenPEBCPowerConstraints, - PEBCPowerEnvelopeConsequenceType as GenPEBCPowerEnvelopeConsequenceType, - PEBCPowerEnvelopeLimitType, -) -from s2python.pebc.pebc_allowed_limit_range import PEBCAllowedLimitRange -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PEBCPowerConstraints(GenPEBCPowerConstraints, S2MessageComponent): - model_config = GenPEBCPowerConstraints.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenPEBCPowerConstraints.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - id: uuid.UUID = GenPEBCPowerConstraints.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - consequence_type: GenPEBCPowerEnvelopeConsequenceType = GenPEBCPowerConstraints.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "consequence_type" - ] # type: ignore[assignment] - allowed_limit_ranges: List[PEBCAllowedLimitRange] = GenPEBCPowerConstraints.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "allowed_limit_ranges" - ] # type: ignore[assignment] - - @model_validator(mode="after") - def validate_has_one_upper_one_lower_limit_range(self) -> Self: - - commodity_type_ranges: Dict[CommodityQuantity, Tuple[bool, bool]] = {} - - for limit_range in self.allowed_limit_ranges: - current: Tuple[bool, bool] = commodity_type_ranges.get( - limit_range.commodity_quantity, (False, False) - ) - - if limit_range.limit_type == PEBCPowerEnvelopeLimitType.UPPER_LIMIT: - current = ( - True, - current[1], - ) - - if limit_range.limit_type == PEBCPowerEnvelopeLimitType.LOWER_LIMIT: - current = ( - current[0], - True, - ) - - commodity_type_ranges[limit_range.commodity_quantity] = current - - valid = True - - for upper, lower in commodity_type_ranges.values(): - valid = valid and upper and lower - - if not (valid): - raise ValueError( - self, - f"There shall be at least one PEBC.AllowedLimitRange for the UPPER_LIMIT and at least one AllowedLimitRange for the LOWER_LIMIT.", - ) - - return self - - @model_validator(mode="after") - def validate_valid_until_after_valid_from(self) -> Self: - if self.valid_until is not None and self.valid_until < self.valid_from: - raise ValueError( - self, f"valid_until cannot be set to a value that is before valid_from." - ) - return self diff --git a/packages/s2-python/src/s2python/pebc/pebc_power_envelope.py b/packages/s2-python/src/s2python/pebc/pebc_power_envelope.py deleted file mode 100644 index 8ac20e6..0000000 --- a/packages/s2-python/src/s2python/pebc/pebc_power_envelope.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import List -from s2python.generated.gen_s2 import ( - PEBCPowerEnvelope as GenPEBCPowerEnvelope, -) -from s2python.pebc.pebc_power_envelope_element import PEBCPowerEnvelopeElement -from s2python.common import CommodityQuantity -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PEBCPowerEnvelope(GenPEBCPowerEnvelope, S2MessageComponent): - model_config = GenPEBCPowerEnvelope.model_config - model_config["validate_assignment"] = True - - commodity_quantity: CommodityQuantity = GenPEBCPowerEnvelope.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "commodity_quantity" - ] # type: ignore[assignment] - power_envelope_elements: List[PEBCPowerEnvelopeElement] = GenPEBCPowerEnvelope.model_fields[ # type: ignore[assignment,reportIncompatibleVariableOverride] - "power_envelope_elements" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/pebc/pebc_power_envelope_element.py b/packages/s2-python/src/s2python/pebc/pebc_power_envelope_element.py deleted file mode 100644 index 0e9f53b..0000000 --- a/packages/s2-python/src/s2python/pebc/pebc_power_envelope_element.py +++ /dev/null @@ -1,16 +0,0 @@ -from s2python.generated.gen_s2 import ( - PEBCPowerEnvelopeElement as GenPEBCPowerEnvelopeElement, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PEBCPowerEnvelopeElement(GenPEBCPowerEnvelopeElement, S2MessageComponent): - model_config = GenPEBCPowerEnvelopeElement.model_config - model_config["validate_assignment"] = True - - lower_limit: float = GenPEBCPowerEnvelopeElement.model_fields["lower_limit"] # type: ignore[assignment,reportIncompatibleVariableOverride] - upper_limit: float = GenPEBCPowerEnvelopeElement.model_fields["upper_limit"] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/ppbc/__init__.py b/packages/s2-python/src/s2python/ppbc/__init__.py deleted file mode 100644 index e28a750..0000000 --- a/packages/s2-python/src/s2python/ppbc/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from s2python.ppbc.ppbc_schedule_instruction import PPBCScheduleInstruction -from s2python.ppbc.ppbc_end_interruption_instruction import PPBCEndInterruptionInstruction -from s2python.ppbc.ppbc_power_profile_definition import PPBCPowerProfileDefinition -from s2python.ppbc.ppbc_power_sequence_container import PPBCPowerSequenceContainer -from s2python.ppbc.ppbc_power_sequence import PPBCPowerSequence -from s2python.ppbc.ppbc_power_profile_status import PPBCPowerProfileStatus -from s2python.ppbc.ppbc_power_sequence_container_status import PPBCPowerSequenceContainerStatus -from s2python.ppbc.ppbc_power_sequence_element import PPBCPowerSequenceElement -from s2python.ppbc.ppbc_start_interruption_instruction import PPBCStartInterruptionInstruction - -__all__ = [ - "PPBCScheduleInstruction", - "PPBCEndInterruptionInstruction", - "PPBCPowerProfileDefinition", - "PPBCPowerSequenceContainer", - "PPBCPowerSequence", - "PPBCPowerProfileStatus", - "PPBCPowerSequenceContainerStatus", - "PPBCPowerSequenceElement", - "PPBCStartInterruptionInstruction", -] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py b/packages/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py deleted file mode 100644 index d38a454..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_end_interruption_instruction.py +++ /dev/null @@ -1,30 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import ( - PPBCEndInterruptionInstruction as GenPPBCEndInterruptionInstruction, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class PPBCEndInterruptionInstruction(GenPPBCEndInterruptionInstruction, S2MessageComponent): - model_config = GenPPBCEndInterruptionInstruction.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_profile_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_profile_id" - ] # type: ignore[assignment] - sequence_container_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "sequence_container_id" - ] # type: ignore[assignment] - power_sequence_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_sequence_id" - ] # type: ignore[assignment] - abnormal_condition: bool = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "abnormal_condition" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py deleted file mode 100644 index 53c22c8..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_definition.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import ( - PPBCPowerProfileDefinition as GenPPBCPowerProfileDefinition, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - -from s2python.ppbc.ppbc_power_sequence_container import PPBCPowerSequenceContainer - - -@catch_and_convert_exceptions -class PPBCPowerProfileDefinition(GenPPBCPowerProfileDefinition, S2MessageComponent): - model_config = GenPPBCPowerProfileDefinition.model_config - model_config["validate_assignment"] = True - - message_id: uuid.UUID = GenPPBCPowerProfileDefinition.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - id: uuid.UUID = GenPPBCPowerProfileDefinition.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_sequences_containers: List[PPBCPowerSequenceContainer] = ( # type: ignore[reportIncompatibleVariableOverride] - GenPPBCPowerProfileDefinition.model_fields["power_sequences_containers"] # type: ignore[assignment] - ) diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py deleted file mode 100644 index a43661f..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_power_profile_status.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import List - -from s2python.generated.gen_s2 import ( - PPBCPowerProfileStatus as GenPPBCPowerProfileStatus, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - -from s2python.ppbc.ppbc_power_sequence_container_status import ( - PPBCPowerSequenceContainerStatus, -) - - -@catch_and_convert_exceptions -class PPBCPowerProfileStatus(GenPPBCPowerProfileStatus, S2MessageComponent): - model_config = GenPPBCPowerProfileStatus.model_config - model_config["validate_assignment"] = True - - sequence_container_status: List[PPBCPowerSequenceContainerStatus] = ( # type: ignore[reportIncompatibleVariableOverride] - GenPPBCPowerProfileStatus.model_fields["sequence_container_status"] # type: ignore[assignment] - ) diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence.py deleted file mode 100644 index 9fc545a..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import List -import uuid - -from s2python.generated.gen_s2 import ( - PPBCPowerSequence as GenPPBCPowerSequence, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - -from s2python.ppbc.ppbc_power_sequence_element import PPBCPowerSequenceElement -from s2python.common import Duration - - -@catch_and_convert_exceptions -class PPBCPowerSequence(GenPPBCPowerSequence, S2MessageComponent): - model_config = GenPPBCPowerSequence.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenPPBCPowerSequence.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - elements: List[PPBCPowerSequenceElement] = GenPPBCPowerSequence.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "elements" - ] # type: ignore[assignment] - is_interruptible: bool = GenPPBCPowerSequence.model_fields["is_interruptible"] # type: ignore[assignment,reportIncompatibleVariableOverride] - max_pause_before: Duration = GenPPBCPowerSequence.model_fields["max_pause_before"] # type: ignore[assignment,reportIncompatibleVariableOverride] - abnormal_condition_only: bool = GenPPBCPowerSequence.model_fields[ - "abnormal_condition_only" - ] # type: ignore[assignment,reportIncompatibleVariableOverride] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py deleted file mode 100644 index c5edcf1..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List -import uuid - - -from s2python.generated.gen_s2 import ( - PPBCPowerSequenceContainer as GenPPBCPowerSequenceContainer, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - -from s2python.ppbc.ppbc_power_sequence import PPBCPowerSequence - - -@catch_and_convert_exceptions -class PPBCPowerSequenceContainer(GenPPBCPowerSequenceContainer, S2MessageComponent): - model_config = GenPPBCPowerSequenceContainer.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenPPBCPowerSequenceContainer.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_sequences: List[PPBCPowerSequence] = GenPPBCPowerSequenceContainer.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_sequences" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py deleted file mode 100644 index 81fb48a..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_container_status.py +++ /dev/null @@ -1,30 +0,0 @@ -import uuid -from typing import Union - -from s2python.generated.gen_s2 import ( - PPBCPowerSequenceContainerStatus as GenPPBCPowerSequenceContainerStatus, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class PPBCPowerSequenceContainerStatus(GenPPBCPowerSequenceContainerStatus, S2MessageComponent): - model_config = GenPPBCPowerSequenceContainerStatus.model_config - model_config["validate_assignment"] = True - - power_profile_id: uuid.UUID = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_profile_id" # type: ignore[assignment] - ] - sequence_container_id: uuid.UUID = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "sequence_container_id" # type: ignore[assignment] - ] - selected_sequence_id: Union[uuid.UUID, None] = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "selected_sequence_id" - ] # type: ignore[assignment] - progress: Union[uuid.UUID, None] = GenPPBCPowerSequenceContainerStatus.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "progress" # type: ignore[assignment] - ] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py b/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py deleted file mode 100644 index 444bd08..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_power_sequence_element.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import List - -from s2python.generated.gen_s2 import ( - PPBCPowerSequenceElement as GenPPBCPowerSequenceElement, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - -from s2python.common import Duration, PowerForecastValue - - -@catch_and_convert_exceptions -class PPBCPowerSequenceElement(GenPPBCPowerSequenceElement, S2MessageComponent): - model_config = GenPPBCPowerSequenceElement.model_config - model_config["validate_assignment"] = True - - duration: Duration = GenPPBCPowerSequenceElement.model_fields["duration"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_values: List[PowerForecastValue] = GenPPBCPowerSequenceElement.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_values" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py b/packages/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py deleted file mode 100644 index 2434e93..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_schedule_instruction.py +++ /dev/null @@ -1,31 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import ( - PPBCScheduleInstruction as GenPPBCScheduleInstruction, -) -from s2python.validate_values_mixin import ( - catch_and_convert_exceptions, - S2MessageComponent, -) - - -@catch_and_convert_exceptions -class PPBCScheduleInstruction(GenPPBCScheduleInstruction, S2MessageComponent): - model_config = GenPPBCScheduleInstruction.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenPPBCScheduleInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - - power_profile_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_profile_id" - ] # type: ignore[assignment] - - message_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - - sequence_container_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "sequence_container_id" - ] # type: ignore[assignment] - - power_sequence_id: uuid.UUID = GenPPBCScheduleInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_sequence_id" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py b/packages/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py deleted file mode 100644 index a9fdea6..0000000 --- a/packages/s2-python/src/s2python/ppbc/ppbc_start_interruption_instruction.py +++ /dev/null @@ -1,30 +0,0 @@ -import uuid - -from s2python.generated.gen_s2 import ( - PPBCStartInterruptionInstruction as GenPPBCStartInterruptionInstruction, -) - -from s2python.validate_values_mixin import ( - S2MessageComponent, - catch_and_convert_exceptions, -) - - -@catch_and_convert_exceptions -class PPBCStartInterruptionInstruction(GenPPBCStartInterruptionInstruction, S2MessageComponent): - model_config = GenPPBCStartInterruptionInstruction.model_config - model_config["validate_assignment"] = True - - id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] - power_profile_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_profile_id" - ] # type: ignore[assignment] - sequence_container_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "sequence_container_id" - ] # type: ignore[assignment] - power_sequence_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "power_sequence_id" - ] # type: ignore[assignment] - abnormal_condition: bool = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] - "abnormal_condition" - ] # type: ignore[assignment] diff --git a/packages/s2-python/src/s2python/py.typed b/packages/s2-python/src/s2python/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/packages/s2-python/src/s2python/reception_status_awaiter.py b/packages/s2-python/src/s2python/reception_status_awaiter.py deleted file mode 100644 index 5c4bd42..0000000 --- a/packages/s2-python/src/s2python/reception_status_awaiter.py +++ /dev/null @@ -1,60 +0,0 @@ -"""ReceptationStatusAwaiter class which notifies any coroutine waiting for a certain reception status message. - -Copied from -https://github.com/flexiblepower/s2-analyzer/blob/main/backend/s2_analyzer_backend/reception_status_awaiter.py under -Apache2 license on 31-08-2024. -""" - -import asyncio -import uuid -from typing import Dict - -from s2python.common import ReceptionStatus - - -class ReceptionStatusAwaiter: - received: Dict[uuid.UUID, ReceptionStatus] - awaiting: Dict[uuid.UUID, asyncio.Event] - - def __init__(self) -> None: - self.received = {} - self.awaiting = {} - - async def wait_for_reception_status( - self, message_id: uuid.UUID, timeout_reception_status: float - ) -> ReceptionStatus: - if message_id in self.received: - reception_status = self.received[message_id] - else: - if message_id in self.awaiting: - received_event = self.awaiting[message_id] - else: - received_event = asyncio.Event() - self.awaiting[message_id] = received_event - - await asyncio.wait_for(received_event.wait(), timeout_reception_status) - reception_status = self.received[message_id] - - if message_id in self.awaiting: - del self.awaiting[message_id] - - return reception_status - - async def receive_reception_status(self, reception_status: ReceptionStatus) -> None: - if not isinstance(reception_status, ReceptionStatus): - raise RuntimeError( - f"Expected a ReceptionStatus but received message {reception_status}" - ) - - if reception_status.subject_message_id in self.received: - raise RuntimeError( - f"ReceptationStatus for message_subject_id {reception_status.subject_message_id} has already " - f"been received!" - ) - - self.received[reception_status.subject_message_id] = reception_status - awaiting = self.awaiting.get(reception_status.subject_message_id) - - if awaiting: - awaiting.set() - del self.awaiting[reception_status.subject_message_id] diff --git a/packages/s2-python/src/s2python/s2_connection.py b/packages/s2-python/src/s2python/s2_connection.py deleted file mode 100644 index ba497d4..0000000 --- a/packages/s2-python/src/s2python/s2_connection.py +++ /dev/null @@ -1,581 +0,0 @@ -import asyncio -import json -import logging -import time -import threading -import uuid -import ssl -from dataclasses import dataclass -from typing import Any, Optional, List, Type, Dict, Callable, Awaitable, Union - -import websockets -from websockets.asyncio.client import ( - ClientConnection as WSConnection, - connect as ws_connect, -) - -from s2python.common import ( - ReceptionStatusValues, - ReceptionStatus, - Handshake, - EnergyManagementRole, - Role, - HandshakeResponse, - ResourceManagerDetails, - Duration, - Currency, - SelectControlType, -) -from s2python.generated.gen_s2 import CommodityQuantity -from s2python.reception_status_awaiter import ReceptionStatusAwaiter -from s2python.s2_control_type import S2ControlType -from s2python.s2_parser import S2Parser -from s2python.s2_validation_error import S2ValidationError -from s2python.message import S2Message -from s2python.version import S2_VERSION - -logger = logging.getLogger("s2python") - - -@dataclass -class AssetDetails: # pylint: disable=too-many-instance-attributes - resource_id: uuid.UUID - - provides_forecast: bool - provides_power_measurements: List[CommodityQuantity] - - instruction_processing_delay: Duration - roles: List[Role] - currency: Optional[Currency] = None - - name: Optional[str] = None - manufacturer: Optional[str] = None - model: Optional[str] = None - firmware_version: Optional[str] = None - serial_number: Optional[str] = None - - def to_resource_manager_details( - self, control_types: List[S2ControlType] - ) -> ResourceManagerDetails: - return ResourceManagerDetails( - available_control_types=[ - control_type.get_protocol_control_type() - for control_type in control_types - ], - currency=self.currency, - firmware_version=self.firmware_version, - instruction_processing_delay=self.instruction_processing_delay, - manufacturer=self.manufacturer, - message_id=uuid.uuid4(), - model=self.model, - name=self.name, - provides_forecast=self.provides_forecast, - provides_power_measurement_types=self.provides_power_measurements, - resource_id=self.resource_id, - roles=self.roles, - serial_number=self.serial_number, - ) - - -S2MessageHandler = Union[ - Callable[["S2Connection", S2Message, Callable[[], None]], None], - Callable[["S2Connection", S2Message, Awaitable[None]], Awaitable[None]], -] - - -class SendOkay: - status_is_send: threading.Event - connection: "S2Connection" - subject_message_id: uuid.UUID - - def __init__(self, connection: "S2Connection", subject_message_id: uuid.UUID): - self.status_is_send = threading.Event() - self.connection = connection - self.subject_message_id = subject_message_id - - async def run_async(self) -> None: - self.status_is_send.set() - - await self.connection.respond_with_reception_status( - subject_message_id=self.subject_message_id, - status=ReceptionStatusValues.OK, - diagnostic_label="Processed okay.", - ) - - def run_sync(self) -> None: - self.status_is_send.set() - - self.connection.respond_with_reception_status_sync( - subject_message_id=self.subject_message_id, - status=ReceptionStatusValues.OK, - diagnostic_label="Processed okay.", - ) - - async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: - if not self.status_is_send.is_set(): - logger.warning( - "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " - "Sending it now.", - type_msg, - self.subject_message_id, - ) - await self.run_async() - - def ensure_send_sync(self, type_msg: Type[S2Message]) -> None: - if not self.status_is_send.is_set(): - logger.warning( - "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " - "Sending it now.", - type_msg, - self.subject_message_id, - ) - self.run_sync() - - -class MessageHandlers: - handlers: Dict[Type[S2Message], S2MessageHandler] - - def __init__(self) -> None: - self.handlers = {} - - async def handle_message(self, connection: "S2Connection", msg: S2Message) -> None: - """Handle the S2 message using the registered handler. - - :param connection: The S2 conncetion the `msg` is received from. - :param msg: The S2 message - """ - handler = self.handlers.get(type(msg)) - if handler is not None: - send_okay = SendOkay(connection, msg.message_id) # type: ignore[attr-defined, union-attr] - - try: - if asyncio.iscoroutinefunction(handler): - await handler(connection, msg, send_okay.run_async()) # type: ignore[arg-type] - await send_okay.ensure_send_async(type(msg)) - else: - - def do_message() -> None: - handler(connection, msg, send_okay.run_sync) # type: ignore[arg-type] - send_okay.ensure_send_sync(type(msg)) - - eventloop = asyncio.get_event_loop() - await eventloop.run_in_executor(executor=None, func=do_message) - except Exception: - if not send_okay.status_is_send.is_set(): - await connection.respond_with_reception_status( - subject_message_id=msg.message_id, # type: ignore[attr-defined, union-attr] - status=ReceptionStatusValues.PERMANENT_ERROR, - diagnostic_label=f"While processing message {msg.message_id} " # type: ignore[attr-defined, union-attr] # pylint: disable=line-too-long - f"an unrecoverable error occurred.", - ) - raise - else: - logger.warning( - "Received a message of type %s but no handler is registered. Ignoring the message.", - type(msg), - ) - - def register_handler( - self, msg_type: Type[S2Message], handler: S2MessageHandler - ) -> None: - """Register a coroutine function or a normal function as the handler for a specific S2 message type. - - :param msg_type: The S2 message type to attach the handler to. - :param handler: The function (asynchronuous or normal) which should handle the S2 message. - """ - self.handlers[msg_type] = handler - - -class S2Connection: # pylint: disable=too-many-instance-attributes - url: str - reconnect: bool - reception_status_awaiter: ReceptionStatusAwaiter - ws: Optional[WSConnection] - s2_parser: S2Parser - control_types: List[S2ControlType] - role: EnergyManagementRole - asset_details: AssetDetails - - _thread: threading.Thread - - _handlers: MessageHandlers - _current_control_type: Optional[S2ControlType] - _received_messages: asyncio.Queue - - _eventloop: asyncio.AbstractEventLoop - _stop_event: asyncio.Event - _restart_connection_event: asyncio.Event - _verify_certificate: bool - _bearer_token: Optional[str] - - def __init__( # pylint: disable=too-many-arguments - self, - url: str, - role: EnergyManagementRole, - control_types: List[S2ControlType], - asset_details: AssetDetails, - reconnect: bool = False, - verify_certificate: bool = True, - bearer_token: Optional[str] = None, - ) -> None: - self.url = url - self.reconnect = reconnect - self.reception_status_awaiter = ReceptionStatusAwaiter() - self.ws = None - self.s2_parser = S2Parser() - - self._handlers = MessageHandlers() - self._current_control_type = None - - self._eventloop = asyncio.new_event_loop() - - self.control_types = control_types - self.role = role - self.asset_details = asset_details - self._verify_certificate = verify_certificate - - self._handlers.register_handler( - SelectControlType, self.handle_select_control_type_as_rm - ) - self._handlers.register_handler(Handshake, self.handle_handshake) - self._handlers.register_handler(HandshakeResponse, self.handle_handshake_response_as_rm) - self._bearer_token = bearer_token - - def start_as_rm(self) -> None: - self._run_eventloop(self._run_as_rm()) - - def _run_eventloop(self, main_task: Awaitable[None]) -> None: - self._thread = threading.current_thread() - logger.debug("Starting eventloop") - try: - self._eventloop.run_until_complete(main_task) - except asyncio.CancelledError: - pass - logger.debug("S2 connection thread has stopped.") - - def stop(self) -> None: - """Stops the S2 connection. - - Note: Ensure this method is called from a different thread than the thread running the S2 connection. - Otherwise it will block waiting on the coroutine _do_stop to terminate successfully but it can't run - the coroutine. A `RuntimeError` will be raised to prevent the indefinite block. - """ - if threading.current_thread() == self._thread: - raise RuntimeError( - "Do not call stop from the thread running the S2 connection. This results in an infinite block!" - ) - if self._eventloop.is_running(): - asyncio.run_coroutine_threadsafe(self._do_stop(), self._eventloop).result() - self._thread.join() - logger.info("Stopped the S2 connection.") - - async def _do_stop(self) -> None: - logger.info("Will stop the S2 connection.") - self._stop_event.set() - - async def _run_as_rm(self) -> None: - logger.debug("Connecting as S2 resource manager.") - - self._stop_event = asyncio.Event() - - first_run = True - - while (first_run or self.reconnect) and not self._stop_event.is_set(): - first_run = False - self._restart_connection_event = asyncio.Event() - await self._connect_and_run() - time.sleep(1) - - logger.debug("Finished S2 connection eventloop.") - - async def _connect_and_run(self) -> None: - self._received_messages = asyncio.Queue() - await self._connect_ws() - if self.ws: - - async def wait_till_stop() -> None: - await self._stop_event.wait() - - async def wait_till_connection_restart() -> None: - await self._restart_connection_event.wait() - - background_tasks = [ - self._eventloop.create_task(self._receive_messages()), - self._eventloop.create_task(wait_till_stop()), - self._eventloop.create_task(self._connect_as_rm()), - self._eventloop.create_task(wait_till_connection_restart()), - ] - - (done, pending) = await asyncio.wait( - background_tasks, return_when=asyncio.FIRST_COMPLETED - ) - if self._current_control_type: - self._current_control_type.deactivate(self) - self._current_control_type = None - - for task in done: - try: - await task - except asyncio.CancelledError: - pass - except ( - websockets.ConnectionClosedError, - websockets.ConnectionClosedOK, - ): - logger.info("The other party closed the websocket connection.") - - for task in pending: - try: - task.cancel() - await task - except asyncio.CancelledError: - pass - - await self.ws.close() - await self.ws.wait_closed() - - async def _connect_ws(self) -> None: - try: - # set up connection arguments for SSL and bearer token, if required - connection_kwargs: Dict[str, Any] = {} - if self.url.startswith("wss://") and not self._verify_certificate: - connection_kwargs["ssl"] = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - connection_kwargs["ssl"].check_hostname = False - connection_kwargs["ssl"].verify_mode = ssl.CERT_NONE - - if self._bearer_token: - connection_kwargs["additional_headers"] = { - "Authorization": f"Bearer {self._bearer_token}" - } - - self.ws = await ws_connect(uri=self.url, **connection_kwargs) - except (EOFError, OSError) as e: - logger.info("Could not connect due to: %s", str(e)) - - async def _connect_as_rm(self) -> None: - await self.send_msg_and_await_reception_status_async( - Handshake( - message_id=uuid.uuid4(), - role=self.role, - supported_protocol_versions=[S2_VERSION], - ) - ) - logger.debug( - "Send handshake to CEM. Expecting Handshake and HandshakeResponse from CEM." - ) - - await self._handle_received_messages() - - async def handle_handshake( - self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] - ) -> None: - if not isinstance(message, Handshake): - logger.error( - "Handler for Handshake received a message of the wrong type: %s", - type(message), - ) - return - - logger.debug( - "%s supports S2 protocol versions: %s", - message.role, - message.supported_protocol_versions, - ) - await send_okay - - async def handle_handshake_response_as_rm( - self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] - ) -> None: - if not isinstance(message, HandshakeResponse): - logger.error( - "Handler for HandshakeResponse received a message of the wrong type: %s", - type(message), - ) - return - - logger.debug("Received HandshakeResponse %s", message.to_json()) - - logger.debug( - "CEM selected to use version %s", message.selected_protocol_version - ) - await send_okay - logger.debug("Handshake complete. Sending first ResourceManagerDetails.") - - await self.send_msg_and_await_reception_status_async( - self.asset_details.to_resource_manager_details(self.control_types) - ) - - async def handle_select_control_type_as_rm( - self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] - ) -> None: - if not isinstance(message, SelectControlType): - logger.error( - "Handler for SelectControlType received a message of the wrong type: %s", - type(message), - ) - return - - await send_okay - - logger.debug( - "CEM selected control type %s. Activating control type.", - message.control_type, - ) - - control_types_by_protocol_name = { - c.get_protocol_control_type(): c for c in self.control_types - } - selected_control_type: Optional[S2ControlType] = ( - control_types_by_protocol_name.get(message.control_type) - ) - - if self._current_control_type is not None: - await self._eventloop.run_in_executor( - None, self._current_control_type.deactivate, self - ) - - self._current_control_type = selected_control_type - - if self._current_control_type is not None: - await self._eventloop.run_in_executor( - None, self._current_control_type.activate, self - ) - self._current_control_type.register_handlers(self._handlers) - - async def _receive_messages(self) -> None: - """Receives all incoming messages in the form of a generator. - - Will also receive the ReceptionStatus messages but instead of yielding these messages, they are routed - to any calls of `send_msg_and_await_reception_status`. - """ - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - - logger.info("S2 connection has started to receive messages.") - - async for message in self.ws: - try: - s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) - except json.JSONDecodeError: - await self._send_and_forget( - ReceptionStatus( - subject_message_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), - status=ReceptionStatusValues.INVALID_DATA, - diagnostic_label="Not valid json.", - ) - ) - except S2ValidationError as e: - json_msg = json.loads(message) - message_id = json_msg.get("message_id") - if message_id: - await self.respond_with_reception_status( - subject_message_id=message_id, - status=ReceptionStatusValues.INVALID_MESSAGE, - diagnostic_label=str(e), - ) - else: - await self.respond_with_reception_status( - subject_message_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), - status=ReceptionStatusValues.INVALID_DATA, - diagnostic_label="Message appears valid json but could not find a message_id field.", - ) - else: - logger.debug("Received message %s", s2_msg.to_json()) - - if isinstance(s2_msg, ReceptionStatus): - logger.debug( - "Message is a reception status for %s so registering in cache.", - s2_msg.subject_message_id, - ) - await self.reception_status_awaiter.receive_reception_status(s2_msg) - else: - await self._received_messages.put(s2_msg) - - async def _send_and_forget(self, s2_msg: S2Message) -> None: - if self.ws is None: - raise RuntimeError( - "Cannot send messages if websocket connection is not yet established." - ) - - json_msg = s2_msg.to_json() - logger.debug("Sending message %s", json_msg) - try: - await self.ws.send(json_msg) - except websockets.ConnectionClosedError as e: - logger.error("Unable to send message %s due to %s", s2_msg, str(e)) - self._restart_connection_event.set() - - async def respond_with_reception_status( - self, subject_message_id: uuid.UUID, status: ReceptionStatusValues, diagnostic_label: str - ) -> None: - logger.debug( - "Responding to message %s with status %s", subject_message_id, status - ) - await self._send_and_forget( - ReceptionStatus( - subject_message_id=subject_message_id, - status=status, - diagnostic_label=diagnostic_label, - ) - ) - - def respond_with_reception_status_sync( - self, subject_message_id: uuid.UUID, status: ReceptionStatusValues, diagnostic_label: str - ) -> None: - asyncio.run_coroutine_threadsafe( - self.respond_with_reception_status( - subject_message_id, status, diagnostic_label - ), - self._eventloop, - ).result() - - async def send_msg_and_await_reception_status_async( - self, - s2_msg: S2Message, - timeout_reception_status: float = 5.0, - raise_on_error: bool = True, - ) -> ReceptionStatus: - await self._send_and_forget(s2_msg) - logger.debug( - "Waiting for ReceptionStatus for %s %s seconds", - s2_msg.message_id, # type: ignore[attr-defined, union-attr] - timeout_reception_status, - ) - try: - reception_status = await self.reception_status_awaiter.wait_for_reception_status( - s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] - ) - except TimeoutError: - logger.error( - "Did not receive a reception status on time for %s", - s2_msg.message_id, # type: ignore[attr-defined, union-attr] - ) - self._stop_event.set() - raise - - if reception_status.status != ReceptionStatusValues.OK and raise_on_error: - raise RuntimeError( - f"ReceptionStatus was not OK but rather {reception_status.status}" - ) - - return reception_status - - def send_msg_and_await_reception_status_sync( - self, - s2_msg: S2Message, - timeout_reception_status: float = 5.0, - raise_on_error: bool = True, - ) -> ReceptionStatus: - return asyncio.run_coroutine_threadsafe( - self.send_msg_and_await_reception_status_async( - s2_msg, timeout_reception_status, raise_on_error - ), - self._eventloop, - ).result() - - async def _handle_received_messages(self) -> None: - while True: - msg = await self._received_messages.get() - await self._handlers.handle_message(self, msg) diff --git a/packages/s2-python/src/s2python/s2_control_type.py b/packages/s2-python/src/s2python/s2_control_type.py deleted file mode 100644 index 135f775..0000000 --- a/packages/s2-python/src/s2python/s2_control_type.py +++ /dev/null @@ -1,116 +0,0 @@ -import abc -import typing - -from s2python.common import ControlType as ProtocolControlType -from s2python.frbc import FRBCInstruction -from s2python.ppbc import PPBCScheduleInstruction -from s2python.ombc import OMBCInstruction -from s2python.message import S2Message - -if typing.TYPE_CHECKING: - from s2python.s2_connection import S2Connection, MessageHandlers - - -class S2ControlType(abc.ABC): - @abc.abstractmethod - def get_protocol_control_type(self) -> ProtocolControlType: ... - - @abc.abstractmethod - def register_handlers(self, handlers: "MessageHandlers") -> None: ... - - @abc.abstractmethod - def activate(self, conn: "S2Connection") -> None: ... - - @abc.abstractmethod - def deactivate(self, conn: "S2Connection") -> None: ... - - -class FRBCControlType(S2ControlType): - def get_protocol_control_type(self) -> ProtocolControlType: - return ProtocolControlType.FILL_RATE_BASED_CONTROL - - def register_handlers(self, handlers: "MessageHandlers") -> None: - handlers.register_handler(FRBCInstruction, self.handle_instruction) - - @abc.abstractmethod - def handle_instruction( - self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None] - ) -> None: ... - - @abc.abstractmethod - def activate(self, conn: "S2Connection") -> None: - """Overwrite with the actual dctivation logic of your Resource Manager for this particular control type.""" - - @abc.abstractmethod - def deactivate(self, conn: "S2Connection") -> None: - """Overwrite with the actual deactivation logic of your Resource Manager for this particular control type.""" - - -class PPBCControlType(S2ControlType): - def get_protocol_control_type(self) -> ProtocolControlType: - return ProtocolControlType.POWER_PROFILE_BASED_CONTROL - - def register_handlers(self, handlers: "MessageHandlers") -> None: - handlers.register_handler(PPBCScheduleInstruction, self.handle_instruction) - - @abc.abstractmethod - def handle_instruction( - self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None] - ) -> None: ... - - @abc.abstractmethod - def activate(self, conn: "S2Connection") -> None: - """Overwrite with the actual dctivation logic of your Resource Manager for this particular control type.""" - - @abc.abstractmethod - def deactivate(self, conn: "S2Connection") -> None: - """Overwrite with the actual deactivation logic of your Resource Manager for this particular control type.""" - - -class OMBCControlType(S2ControlType): - def get_protocol_control_type(self) -> ProtocolControlType: - return ProtocolControlType.OPERATION_MODE_BASED_CONTROL - - def register_handlers(self, handlers: "MessageHandlers") -> None: - handlers.register_handler(OMBCInstruction, self.handle_instruction) - - @abc.abstractmethod - def handle_instruction( - self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None] - ) -> None: ... - - @abc.abstractmethod - def activate(self, conn: "S2Connection") -> None: - """Overwrite with the actual dctivation logic of your Resource Manager for this particular control type.""" - - @abc.abstractmethod - def deactivate(self, conn: "S2Connection") -> None: - """Overwrite with the actual deactivation logic of your Resource Manager for this particular control type.""" - - -class PEBCControlType(S2ControlType): - def get_protocol_control_type(self) -> ProtocolControlType: - return ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL - - def register_handlers(self, handlers: "MessageHandlers") -> None: - pass - - @abc.abstractmethod - def activate(self, conn: "S2Connection") -> None: ... - - @abc.abstractmethod - def deactivate(self, conn: "S2Connection") -> None: ... - - -class NoControlControlType(S2ControlType): - def get_protocol_control_type(self) -> ProtocolControlType: - return ProtocolControlType.NOT_CONTROLABLE - - def register_handlers(self, handlers: "MessageHandlers") -> None: - pass - - @abc.abstractmethod - def activate(self, conn: "S2Connection") -> None: ... - - @abc.abstractmethod - def deactivate(self, conn: "S2Connection") -> None: ... diff --git a/packages/s2-python/src/s2python/s2_parser.py b/packages/s2-python/src/s2python/s2_parser.py deleted file mode 100644 index 8bf1ddf..0000000 --- a/packages/s2-python/src/s2python/s2_parser.py +++ /dev/null @@ -1,129 +0,0 @@ -import json -import logging -from typing import Optional, TypeVar, Union, Type, Dict - -from s2python.common import ( - Handshake, - HandshakeResponse, - InstructionStatusUpdate, - PowerForecast, - PowerMeasurement, - ReceptionStatus, - ResourceManagerDetails, - RevokeObject, - SelectControlType, - SessionRequest, -) -from s2python.frbc import ( - FRBCActuatorStatus, - FRBCFillLevelTargetProfile, - FRBCInstruction, - FRBCLeakageBehaviour, - FRBCStorageStatus, - FRBCSystemDescription, - FRBCTimerStatus, - FRBCUsageForecast, -) -from s2python.pebc import ( - PEBCPowerConstraints, - PEBCEnergyConstraint, - PEBCInstruction, - PEBCPowerEnvelope, - PEBCPowerEnvelopeElement, -) -from s2python.ppbc import PPBCScheduleInstruction - -from s2python.message import S2Message -from s2python.validate_values_mixin import S2MessageComponent -from s2python.s2_validation_error import S2ValidationError - - -LOGGER = logging.getLogger(__name__) -S2MessageType = str - -M = TypeVar("M", bound=S2MessageComponent) - - -# May be generated with development_utilities/generate_s2_message_type_to_class.py -TYPE_TO_MESSAGE_CLASS: Dict[str, Type[S2Message]] = { - "FRBC.ActuatorStatus": FRBCActuatorStatus, - "FRBC.FillLevelTargetProfile": FRBCFillLevelTargetProfile, - "FRBC.Instruction": FRBCInstruction, - "FRBC.LeakageBehaviour": FRBCLeakageBehaviour, - "FRBC.StorageStatus": FRBCStorageStatus, - "FRBC.SystemDescription": FRBCSystemDescription, - "FRBC.TimerStatus": FRBCTimerStatus, - "FRBC.UsageForecast": FRBCUsageForecast, - "PPBC.ScheduleInstruction": PPBCScheduleInstruction, - "PEBC.PowerConstraints": PEBCPowerConstraints, - "Handshake": Handshake, - "HandshakeResponse": HandshakeResponse, - "InstructionStatusUpdate": InstructionStatusUpdate, - "PowerForecast": PowerForecast, - "PowerMeasurement": PowerMeasurement, - "ReceptionStatus": ReceptionStatus, - "ResourceManagerDetails": ResourceManagerDetails, - "RevokeObject": RevokeObject, - "SelectControlType": SelectControlType, - "SessionRequest": SessionRequest, -} - - -class S2Parser: - @staticmethod - def _parse_json_if_required(unparsed_message: Union[dict, str, bytes]) -> dict: - if isinstance(unparsed_message, (str, bytes)): - return json.loads(unparsed_message) - return unparsed_message - - @staticmethod - def parse_as_any_message(unparsed_message: Union[dict, str, bytes]) -> S2Message: - """Parse the message as any S2 python message regardless of message type. - - :param unparsed_message: The message as a JSON-formatted string or as a json-parsed dictionary. - :raises: S2ValidationError, json.JSONDecodeError - :return: The parsed S2 message if no errors were found. - """ - message_json = S2Parser._parse_json_if_required(unparsed_message) - message_type = S2Parser.parse_message_type(message_json) - - if message_type not in TYPE_TO_MESSAGE_CLASS: - raise S2ValidationError( - None, - message_json, - f"Unable to parse {message_type} as an S2 message. Type unknown.", - None, - ) - - return TYPE_TO_MESSAGE_CLASS[message_type].model_validate(message_json) - - @staticmethod - def parse_as_message( - unparsed_message: Union[dict, str, bytes], as_message: Type[M] - ) -> M: - """Parse the message to a specific S2 python message. - - :param unparsed_message: The message as a JSON-formatted string or as a JSON-parsed dictionary. - :param as_message: The type of message that is expected within the `message` - :raises: S2ValidationError, json.JSONDecodeError - :return: The parsed S2 message if no errors were found. - """ - message_json = S2Parser._parse_json_if_required(unparsed_message) - return as_message.from_dict(message_json) - - @staticmethod - def parse_message_type( - unparsed_message: Union[dict, str, bytes], - ) -> Optional[S2MessageType]: - """Parse only the message type from the unparsed message. - - This is useful to call before `parse_as_message` to retrieve the message type and allows for strictly-typed - parsing. - - :param unparsed_message: The message as a JSON-formatted string or as a JSON-parsed dictionary. - :raises: json.JSONDecodeError - :return: The parsed S2 message type if no errors were found. - """ - message_json = S2Parser._parse_json_if_required(unparsed_message) - - return message_json.get("message_type") diff --git a/packages/s2-python/src/s2python/s2_validation_error.py b/packages/s2-python/src/s2python/s2_validation_error.py deleted file mode 100644 index dc43419..0000000 --- a/packages/s2-python/src/s2python/s2_validation_error.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass -from typing import Union, Type, Optional - -from pydantic import ValidationError -from pydantic.v1.error_wrappers import ValidationError as ValidationErrorV1 - - -@dataclass -class S2ValidationError(Exception): - class_: Optional[Type] - obj: object - msg: str - pydantic_validation_error: Union[ - ValidationErrorV1, ValidationError, TypeError, None - ] diff --git a/packages/s2-python/src/s2python/utils.py b/packages/s2-python/src/s2python/utils.py deleted file mode 100644 index b4f78ed..0000000 --- a/packages/s2-python/src/s2python/utils.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Generator, Tuple, List, TypeVar - -P = TypeVar("P") - - -def pairwise(arr: List[P]) -> Generator[Tuple[P, P], None, None]: - for i in range(max(len(arr) - 1, 0)): - yield arr[i], arr[i + 1] diff --git a/packages/s2-python/src/s2python/validate_values_mixin.py b/packages/s2-python/src/s2python/validate_values_mixin.py deleted file mode 100644 index 6026b0d..0000000 --- a/packages/s2-python/src/s2python/validate_values_mixin.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import ( - TypeVar, - Type, - Callable, - Any, - Union, - AbstractSet, - Mapping, - List, - Dict, -) - -from typing_extensions import Self - -from pydantic.v1.error_wrappers import display_errors # pylint: disable=no-name-in-module - -from pydantic import ( # pylint: disable=no-name-in-module - BaseModel, - ValidationError, -) - -from s2python.s2_validation_error import S2ValidationError - - -IntStr = Union[int, str] -AbstractSetIntStr = AbstractSet[IntStr] -MappingIntStrAny = Mapping[IntStr, Any] - - -class S2MessageComponent(BaseModel): - def to_json(self) -> str: - try: - return self.model_dump_json(by_alias=True, exclude_none=True) - except (ValidationError, TypeError) as e: - raise S2ValidationError( - type(self), self, "Pydantic raised a format validation error.", e - ) from e - - def to_dict(self) -> Dict[str, Any]: - return self.model_dump() - - @classmethod - def from_json(cls, json_str: str) -> Self: - gen_model = cls.model_validate_json(json_str) - return gen_model - - @classmethod - def from_dict(cls, json_dict: Dict[str, Any]) -> Self: - gen_model = cls.model_validate(json_dict) - return gen_model - - -def convert_to_s2exception(f: Callable) -> Callable: - def inner(*args: List[Any], **kwargs: Dict[str, Any]) -> Any: - try: - return f(*args, **kwargs) - except ValidationError as e: - if isinstance(args[0], BaseModel): - class_type = type(args[0]) - args = args[1:] - else: - class_type = None - - raise S2ValidationError(class_type, args, display_errors(e.errors()), e) from e # type: ignore[arg-type] - except TypeError as e: - raise S2ValidationError(None, args, str(e), e) from e - - inner.__doc__ = f.__doc__ - inner.__annotations__ = f.__annotations__ - - return inner - - -S = TypeVar("S", bound=S2MessageComponent) - - -def catch_and_convert_exceptions(input_class: Type[S]) -> Type[S]: - input_class.__init__ = convert_to_s2exception(input_class.__init__) # type: ignore[method-assign] - input_class.__setattr__ = convert_to_s2exception(input_class.__setattr__) # type: ignore[method-assign] - input_class.model_validate_json = convert_to_s2exception( # type: ignore[method-assign] - input_class.model_validate_json - ) - input_class.model_validate = convert_to_s2exception(input_class.model_validate) # type: ignore[method-assign] - - return input_class diff --git a/packages/s2-python/src/s2python/version.py b/packages/s2-python/src/s2python/version.py deleted file mode 100644 index 3789fe8..0000000 --- a/packages/s2-python/src/s2python/version.py +++ /dev/null @@ -1,3 +0,0 @@ -VERSION = "0.2.0" - -S2_VERSION = "0.0.2-beta" diff --git a/packages/s2-python/tests/unit/__init__.py b/packages/s2-python/tests/unit/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/s2-python/tests/unit/common/__init__.py b/packages/s2-python/tests/unit/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/packages/s2-python/tests/unit/common/duration_test.py b/packages/s2-python/tests/unit/common/duration_test.py deleted file mode 100644 index a256bf7..0000000 --- a/packages/s2-python/tests/unit/common/duration_test.py +++ /dev/null @@ -1,26 +0,0 @@ -from datetime import timedelta -from unittest import TestCase - -from s2python.common import Duration - - -class DurationTest(TestCase): - def test__from_timedelta__happy_path(self): - # Arrange - duration_timedelta = timedelta(seconds=10) - - # Act - duration = Duration.from_timedelta(duration_timedelta) - - # Assert - self.assertEqual(duration.root, 10_000) - - def test__to_timedelta__happy_path(self): - # Arrange - duration = Duration(root=20_000) - - # Act - duration_timedelta = duration.to_timedelta() - - # Assert - self.assertEqual(duration_timedelta, timedelta(milliseconds=20_000)) diff --git a/packages/s2-python/tests/unit/common/handshake_response_test.py b/packages/s2-python/tests/unit/common/handshake_response_test.py deleted file mode 100644 index 5e628da..0000000 --- a/packages/s2-python/tests/unit/common/handshake_response_test.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -import uuid -from unittest import TestCase - -from s2python.common import HandshakeResponse - - -class HandshakeResponseTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = ( - '{"message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", "message_type": "HandshakeResponse", ' - '"selected_protocol_version": "v1"}' - ) - - # Act - handshake_response: HandshakeResponse = HandshakeResponse.from_json(json_str) - - # Assert - self.assertEqual( - handshake_response.message_id, - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - ) - self.assertEqual(handshake_response.message_type, "HandshakeResponse") - self.assertEqual(handshake_response.selected_protocol_version, "v1") - - def test__to_json__happy_path(self): - # Arrange - handshake_response = HandshakeResponse( - message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - selected_protocol_version="v1", - ) - - # Act - json_str = handshake_response.to_json() - - # Assert - expected_json = { - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "message_type": "HandshakeResponse", - "selected_protocol_version": "v1", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/handshake_test.py b/packages/s2-python/tests/unit/common/handshake_test.py deleted file mode 100644 index 715e360..0000000 --- a/packages/s2-python/tests/unit/common/handshake_test.py +++ /dev/null @@ -1,44 +0,0 @@ -import json -import uuid -from unittest import TestCase - -from s2python.common import Handshake, EnergyManagementRole - - -class HandshakeTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = ( - '{"message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", "message_type": "Handshake", "role": "RM", ' - '"supported_protocol_versions": ["v1", "v2"]}' - ) - - # Act - handshake = Handshake.from_json(json_str) - - # Assert - self.assertEqual( - handshake.message_id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3") - ) - self.assertEqual(handshake.role, EnergyManagementRole.RM) - self.assertEqual(handshake.supported_protocol_versions, ["v1", "v2"]) - - def test__to_json__happy_path(self): - # Arrange - handshake = Handshake( - message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - role=EnergyManagementRole.CEM, - supported_protocol_versions=["v3"], - ) - - # Act - json_str = handshake.to_json() - - # Assert - expected_json = { - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "message_type": "Handshake", - "role": "CEM", - "supported_protocol_versions": ["v3"], - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/instruction_status_update_test.py b/packages/s2-python/tests/unit/common/instruction_status_update_test.py deleted file mode 100644 index 91b81f2..0000000 --- a/packages/s2-python/tests/unit/common/instruction_status_update_test.py +++ /dev/null @@ -1,63 +0,0 @@ -from datetime import datetime, timezone as offset, timedelta -import json -import uuid -from unittest import TestCase - -from pytz import timezone - -from s2python.common import InstructionStatusUpdate, InstructionStatus - - -class InstructionStatusUpdateTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{"message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "message_type": "InstructionStatusUpdate", - "instruction_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced4", - "status_type": "SUCCEEDED", - "timestamp": "2023-08-02T12:48:42+01:00"} - """ - - # Act - instruction_status_update = InstructionStatusUpdate.from_json(json_str) - - # Assert - self.assertEqual( - instruction_status_update.message_id, - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - ) - self.assertEqual( - instruction_status_update.instruction_id, - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4"), - ) - self.assertEqual( - instruction_status_update.status_type, InstructionStatus.SUCCEEDED - ) - self.assertEqual( - instruction_status_update.timestamp, - datetime(2023, 8, 2, 12, 48, 42, tzinfo=offset(timedelta(hours=1))), - ) - - def test__to_json__happy_path(self): - # Arrange - instruction_status_update = InstructionStatusUpdate( - message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - instruction_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4"), - status_type=InstructionStatus.SUCCEEDED, - timestamp=timezone("Europe/Amsterdam").localize( - datetime(2023, 8, 2, 12, 48, 42) - ), - ) - - # Act - json_str = instruction_status_update.to_json() - - # Assert - expected_json = { - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "message_type": "InstructionStatusUpdate", - "instruction_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced4", - "status_type": "SUCCEEDED", - "timestamp": "2023-08-02T12:48:42+02:00", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/number_range_test.py b/packages/s2-python/tests/unit/common/number_range_test.py deleted file mode 100644 index e597113..0000000 --- a/packages/s2-python/tests/unit/common/number_range_test.py +++ /dev/null @@ -1,74 +0,0 @@ -import json -from unittest import TestCase - -from s2python.common import NumberRange -from s2python.s2_validation_error import S2ValidationError - - -class NumberRangeTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = '{"start_of_range": 4.0, "end_of_range": 5.0}' - - # Act - number_range = NumberRange.from_json(json_str) - - # Assert - expected_start_of_range = 4.0 - expected_end_of_range = 5.0 - self.assertEqual(number_range.start_of_range, expected_start_of_range) - self.assertEqual(number_range.end_of_range, expected_end_of_range) - - def test__from_json__happy_path_equals(self): - # Arrange - json_str = '{"start_of_range": 4.0, "end_of_range": 5.0}' - - # Act - number_range = NumberRange.from_json(json_str) - - # Assert - self.assertEqual( - number_range, NumberRange(start_of_range=4.0, end_of_range=5.0) - ) - - def test__from_json__format_validation_error(self): - # Arrange - json_str = '{"start_of_range": 4.0}' - - # Act / Assert - with self.assertRaises(S2ValidationError): - NumberRange.from_json(json_str) - - def test__from_json__end_of_range_smaller_than_start(self): - # Arrange - json_str = '{"start_of_range": 6.0, "end_of_range": 5.0}' - - # Act - number_range = NumberRange.from_json(json_str) - - # Assert - self.assertEqual( - number_range, NumberRange(start_of_range=6.0, end_of_range=5.0) - ) - - def test__to_json__happy_path(self): - # Arrange - number_range = NumberRange(start_of_range=4.0, end_of_range=5.0) - - # Act - json_str = number_range.to_json() - - # Assert - expected_json = {"start_of_range": 4.0, "end_of_range": 5.0} - self.assertEqual(json.loads(json_str), expected_json) - - def test__to_json__end_of_range_smaller_than_start(self): - # Arrange - number_range = NumberRange(start_of_range=6.0, end_of_range=5.0) - - # Act - json_str = number_range.to_json() - - # Assert - expected_json = {"start_of_range": 6.0, "end_of_range": 5.0} - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/power_forecast_element_test.py b/packages/s2-python/tests/unit/common/power_forecast_element_test.py deleted file mode 100644 index 4f68f45..0000000 --- a/packages/s2-python/tests/unit/common/power_forecast_element_test.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -from datetime import timedelta -from unittest import TestCase - -from s2python.common import ( - PowerForecastElement, - Duration, - PowerForecastValue, - CommodityQuantity, -) - - -class PowerForecastElementTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = ( - '{"duration": 4000, "power_values": [{"commodity_quantity": "NATURAL_GAS.FLOW_RATE", ' - '"value_expected": 500.2}]}' - ) - - # Act - power_forecast_element = PowerForecastElement.from_json(json_str) - - # Assert - self.assertEqual( - power_forecast_element.duration, - Duration.from_timedelta(timedelta(seconds=4)), - ) - self.assertEqual( - power_forecast_element.power_values, - [ - PowerForecastValue( # pyright: ignore[reportCallIssue] - commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, - value_expected=500.2, - ) - ], - ) - - def test__to_json__happy_path(self): - # Arrange - power_forecast_element = PowerForecastElement( - power_values=[ - PowerForecastValue( # pyright: ignore[reportCallIssue] - commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, - value_expected=500.2, - ) - ], - duration=Duration.from_timedelta(timedelta(seconds=4)), - ) - - # Act - json_str = power_forecast_element.to_json() - - # Assert - expected_json = { - "duration": 4000, - "power_values": [ - {"commodity_quantity": "NATURAL_GAS.FLOW_RATE", "value_expected": 500.2} - ], - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/power_forecast_test.py b/packages/s2-python/tests/unit/common/power_forecast_test.py deleted file mode 100644 index 044365e..0000000 --- a/packages/s2-python/tests/unit/common/power_forecast_test.py +++ /dev/null @@ -1,84 +0,0 @@ -import uuid -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase - -from s2python.common import ( - PowerForecast, - Duration, - PowerForecastValue, - PowerForecastElement, - CommodityQuantity, -) - - -class PowerForecastTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """ - {"elements": [{"duration": 4000, "power_values": [{"commodity_quantity": "NATURAL_GAS.FLOW_RATE", "value_expected": 500.2}]}], - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced9", - "message_type": "PowerForecast", - "start_time": "2023-08-02T12:48:42+01:00"} - """ - - # Act - power_forecast = PowerForecast.from_json(json_str) - - # Assert - power_forecast_element = PowerForecastElement( - power_values=[ - PowerForecastValue( # pyright: ignore[reportCallIssue] - commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, - value_expected=500.2, - ) - ], - duration=Duration.from_timedelta(timedelta(seconds=4)), - ) - self.assertEqual(power_forecast.elements, [power_forecast_element]) - self.assertEqual( - power_forecast.message_id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced9") - ) - self.assertEqual( - power_forecast.start_time, - datetime(2023, 8, 2, 12, 48, 42, tzinfo=offset(timedelta(hours=1))), - ) - - def test__to_json__happy_path(self): - # Arrange - power_forecast_element = PowerForecastElement( - power_values=[ - PowerForecastValue( # pyright: ignore[reportCallIssue] - commodity_quantity=CommodityQuantity.NATURAL_GAS_FLOW_RATE, - value_expected=500.2, - ) - ], - duration=Duration.from_timedelta(timedelta(seconds=4)), - ) - power_forecast = PowerForecast( - elements=[power_forecast_element], - message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced9"), - start_time=datetime(2023, 8, 2, 12, 48, 42, tzinfo=offset(timedelta(hours=2))), - ) - - # Act - json_str = power_forecast.to_json() - - # Assert - expected_json = { - "elements": [ - { - "duration": 4000, - "power_values": [ - { - "commodity_quantity": "NATURAL_GAS.FLOW_RATE", - "value_expected": 500.2, - } - ], - } - ], - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced9", - "message_type": "PowerForecast", - "start_time": "2023-08-02T12:48:42+02:00", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/power_forecast_value_test.py b/packages/s2-python/tests/unit/common/power_forecast_value_test.py deleted file mode 100644 index 4a94e0e..0000000 --- a/packages/s2-python/tests/unit/common/power_forecast_value_test.py +++ /dev/null @@ -1,79 +0,0 @@ -import json -from unittest import TestCase - -from s2python.common import PowerForecastValue, CommodityQuantity - - -class PowerForecastValueTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{"commodity_quantity": "HEAT.FLOW_RATE", - "value_lower_limit": 450.3, - "value_lower_95PPR": 470.4, - "value_lower_68PPR": 480.3, - "value_expected": 500.2, - "value_upper_68PPR": 510.3, - "value_upper_95PPR": 515.9, - "value_upper_limit": 600}""" - - # Act - power_forecast_value: PowerForecastValue = PowerForecastValue.from_json( - json_str - ) - - # Assert - self.assertEqual( - power_forecast_value.commodity_quantity, CommodityQuantity.HEAT_FLOW_RATE - ) - self.assertEqual(power_forecast_value.value_lower_limit, 450.3) - self.assertEqual(power_forecast_value.value_lower_95PPR, 470.4) - self.assertEqual(power_forecast_value.value_lower_68PPR, 480.3) - self.assertEqual(power_forecast_value.value_expected, 500.2) - self.assertEqual(power_forecast_value.value_upper_68PPR, 510.3) - self.assertEqual(power_forecast_value.value_upper_95PPR, 515.9) - self.assertEqual(power_forecast_value.value_upper_limit, 600) - - def test__to_json__happy_path(self): - # Arrange - power_forecast_value = PowerForecastValue( # pyright: ignore[reportCallIssue] - commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, - value_lower_limit=450.3, - value_lower_95PPR=470.4, - value_lower_68PPR=480.3, - value_expected=500.2, - value_upper_68PPR=510.3, - value_upper_95PPR=515.9, - value_upper_limit=600, - ) - - # Act - json_str = power_forecast_value.to_json() - - # Assert - expected_json = { - "commodity_quantity": "HEAT.TEMPERATURE", - "value_lower_limit": 450.3, - "value_lower_95PPR": 470.4, - "value_lower_68PPR": 480.3, - "value_expected": 500.2, - "value_upper_68PPR": 510.3, - "value_upper_95PPR": 515.9, - "value_upper_limit": 600, - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__to_json__only_value_expected(self): - # Arrange - power_forecast_value = PowerForecastValue( # pyright: ignore[reportCallIssue] - commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, value_expected=500.2 - ) - - # Act - json_str = power_forecast_value.to_json() - - # Assert - expected_json = { - "commodity_quantity": "HEAT.TEMPERATURE", - "value_expected": 500.2, - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/power_measurement_test.py b/packages/s2-python/tests/unit/common/power_measurement_test.py deleted file mode 100644 index a8c555c..0000000 --- a/packages/s2-python/tests/unit/common/power_measurement_test.py +++ /dev/null @@ -1,64 +0,0 @@ -from datetime import datetime, timezone as offset, timedelta -import json -import uuid -from unittest import TestCase - -from s2python.common import PowerMeasurement, PowerValue, CommodityQuantity - - -class PowerMeasurementTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """ - {"values": [{"commodity_quantity": "OIL.FLOW_RATE", "value": 42.42}], - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced8", - "message_type": "PowerMeasurement", - "measurement_timestamp": "2023-08-03T12:48:42+01:00"} - """ - - # Act - power_measurement: PowerMeasurement = PowerMeasurement.from_json(json_str) - - # Assert - self.assertEqual( - power_measurement.message_id, - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced8"), - ) - self.assertEqual( - power_measurement.measurement_timestamp, - datetime(2023, 8, 3, 12, 48, 42, tzinfo=offset(timedelta(hours=1))), - ) - self.assertEqual( - power_measurement.values, - [ - PowerValue( - commodity_quantity=CommodityQuantity.OIL_FLOW_RATE, value=42.42 - ) - ], - ) - - def test__to_json__happy_path(self): - # Arrange - power_measurement = PowerMeasurement( - values=[ - PowerValue( - commodity_quantity=CommodityQuantity.OIL_FLOW_RATE, value=42.42 - ) - ], - message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced8"), - measurement_timestamp=datetime( - 2023, 8, 3, 12, 48, 42, tzinfo=offset(timedelta(hours=1)) - ), - ) - - # Act - json_str = power_measurement.to_json() - - # Assert - expected_json = { - "values": [{"commodity_quantity": "OIL.FLOW_RATE", "value": 42.42}], - "message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced8", - "message_type": "PowerMeasurement", - "measurement_timestamp": "2023-08-03T12:48:42+01:00", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/power_range_test.py b/packages/s2-python/tests/unit/common/power_range_test.py deleted file mode 100644 index 7cbaed4..0000000 --- a/packages/s2-python/tests/unit/common/power_range_test.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -from unittest import TestCase - -from s2python.common import PowerRange, CommodityQuantity -from s2python.s2_validation_error import S2ValidationError - - -class PowerRangeTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = '{"start_of_range": 4.0, "end_of_range": 5.0, "commodity_quantity": "ELECTRIC.POWER.L1"}' - - # Act - power_range: PowerRange = PowerRange.from_json(json_str) - - # Assert - expected_start_of_range = 4.0 - expected_end_of_range = 5.0 - self.assertEqual(power_range.start_of_range, expected_start_of_range) - self.assertEqual(power_range.end_of_range, expected_end_of_range) - self.assertEqual( - power_range.commodity_quantity, CommodityQuantity.ELECTRIC_POWER_L1 - ) - - def test__from_json__format_validation_error(self): - # Arrange - json_str = '{"start_of_range": 4.0}' - - # Act / Assert - with self.assertRaises(S2ValidationError): - PowerRange.from_json(json_str) - - def test__from_json__value_validation_error(self): - # Arrange - json_str = '{"start_of_range": 6.0, "end_of_range": 5.0, "commodity_quantity": "ELECTRIC.POWER.L1"}' - - # Act / Assert - with self.assertRaises(S2ValidationError): - PowerRange.from_json(json_str) - - def test__to_json__happy_path(self): - # Arrange - number_range = PowerRange( - start_of_range=4.0, - end_of_range=5.0, - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - ) - - # Act - json_str = number_range.to_json() - - # Assert - expected_json = { - "start_of_range": 4.0, - "end_of_range": 5.0, - "commodity_quantity": "ELECTRIC.POWER.L1", - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__to_json__value_validation_error(self): - # Arrange/ Act / Assert - with self.assertRaises(S2ValidationError): - PowerRange( - start_of_range=6.0, - end_of_range=5.0, - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - ) diff --git a/packages/s2-python/tests/unit/common/power_value_test.py b/packages/s2-python/tests/unit/common/power_value_test.py deleted file mode 100644 index 637e729..0000000 --- a/packages/s2-python/tests/unit/common/power_value_test.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -from unittest import TestCase - -from s2python.common import PowerValue, CommodityQuantity - - -class PowerValueTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = '{"commodity_quantity": "OIL.FLOW_RATE", "value": 43.43}' - - # Act - power_value: PowerValue = PowerValue.from_json(json_str) - - # Assert - self.assertEqual( - power_value.commodity_quantity, CommodityQuantity.OIL_FLOW_RATE - ) - self.assertEqual(power_value.value, 43.43) - - def test__to_json__happy_path(self): - # Arrange - power_value = PowerValue( - commodity_quantity=CommodityQuantity.OIL_FLOW_RATE, value=43.43 - ) - - # Act - json_str = power_value.to_json() - - # Assert - expected_json = {"commodity_quantity": "OIL.FLOW_RATE", "value": 43.43} - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/reception_status_test.py b/packages/s2-python/tests/unit/common/reception_status_test.py deleted file mode 100644 index 6229b4e..0000000 --- a/packages/s2-python/tests/unit/common/reception_status_test.py +++ /dev/null @@ -1,48 +0,0 @@ -import json -import uuid -from unittest import TestCase - -from s2python.common import ReceptionStatus, ReceptionStatusValues - - -class ReceptionStatusTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """ - { "diagnostic_label": "blablabla", - "message_type": "ReceptionStatus", - "status": "TEMPORARY_ERROR", - "subject_message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced5" - }""" - - # Act - reception_status: ReceptionStatus = ReceptionStatus.from_json(json_str) - - # Assert - self.assertEqual(reception_status.diagnostic_label, "blablabla") - self.assertEqual(reception_status.status, ReceptionStatusValues.TEMPORARY_ERROR) - self.assertEqual( - reception_status.subject_message_id, - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - ) - - def test__to_json__happy_path(self): - # Arrange - reception_status = ReceptionStatus( - diagnostic_label="Dagobert Duck is king!", - message_type="ReceptionStatus", - status=ReceptionStatusValues.OK, - subject_message_id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - ) - - # Act - json_str = reception_status.to_json() - - # Assert - expected_json = { - "diagnostic_label": "Dagobert Duck is king!", - "message_type": "ReceptionStatus", - "status": "OK", - "subject_message_id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced5", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/resource_manager_details_test.py b/packages/s2-python/tests/unit/common/resource_manager_details_test.py deleted file mode 100644 index b5b5843..0000000 --- a/packages/s2-python/tests/unit/common/resource_manager_details_test.py +++ /dev/null @@ -1,147 +0,0 @@ -import json -import uuid -from datetime import timedelta -from unittest import TestCase - -from s2python.common import ( - ResourceManagerDetails, - CommodityQuantity, - ControlType, - Currency, - Duration, - Commodity, - Role, - RoleType, -) - - -class ResourceManagerDetailsTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{ - "available_control_types": ["POWER_ENVELOPE_BASED_CONTROL", "NOT_CONTROLABLE", "FILL_RATE_BASED_CONTROL"], - "currency": "CHE", - "firmware_version": "5.4.2v", - "instruction_processing_delay": 342, - "manufacturer": "Dagobert inc.", - "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "ResourceManagerDetails", - "model": "Safe", - "name": "Dagobert's safe", - "provides_forecast": true, - "provides_power_measurement_types": ["HEAT.THERMAL_POWER", "ELECTRIC.POWER.3_PHASE_SYMMETRIC"], - "resource_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", - "roles": [{"commodity": "HEAT", "role": "ENERGY_PRODUCER"}, {"commodity": "ELECTRICITY", "role": "ENERGY_CONSUMER"}], - "serial_number": "safe_batch6_model432" - } - """ - - # Act - resource_manager_details: ResourceManagerDetails = ( - ResourceManagerDetails.from_json(json_str) - ) - - # Assert - self.assertEqual( - resource_manager_details.available_control_types, - [ - ControlType.POWER_ENVELOPE_BASED_CONTROL, - ControlType.NOT_CONTROLABLE, - ControlType.FILL_RATE_BASED_CONTROL, - ], - ) - self.assertEqual(resource_manager_details.currency, Currency.CHE) - self.assertEqual(resource_manager_details.firmware_version, "5.4.2v") - self.assertEqual( - resource_manager_details.instruction_processing_delay, - Duration.from_timedelta(timedelta(milliseconds=342)), - ) - self.assertEqual(resource_manager_details.manufacturer, "Dagobert inc.") - self.assertEqual( - resource_manager_details.message_id, - uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - ) - self.assertEqual(resource_manager_details.model, "Safe") - self.assertEqual(resource_manager_details.name, "Dagobert's safe") - self.assertEqual(resource_manager_details.provides_forecast, True) - self.assertEqual( - resource_manager_details.provides_power_measurement_types, - [ - CommodityQuantity.HEAT_THERMAL_POWER, - CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, - ], - ) - self.assertEqual( - resource_manager_details.resource_id, - uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6"), - ) - self.assertEqual( - resource_manager_details.roles, - [ - Role(commodity=Commodity.HEAT, role=RoleType.ENERGY_PRODUCER), - Role(commodity=Commodity.ELECTRICITY, role=RoleType.ENERGY_CONSUMER), - ], - ) - self.assertEqual(resource_manager_details.serial_number, "safe_batch6_model432") - - def test__to_json__happy_path(self): - # Arrange - resource_manager_details = ResourceManagerDetails( - available_control_types=[ - ControlType.POWER_ENVELOPE_BASED_CONTROL, - ControlType.NOT_CONTROLABLE, - ControlType.FILL_RATE_BASED_CONTROL, - ], - currency=Currency.CHE, - firmware_version="5.4.2v", - instruction_processing_delay=Duration.from_timedelta( - timedelta(milliseconds=342) - ), - manufacturer="Dagobert inc.", - message_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - model="Safe", - name="Dagobert's safe", - provides_forecast=True, - provides_power_measurement_types=[ - CommodityQuantity.HEAT_THERMAL_POWER, - CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, - ], - resource_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6"), - roles=[ - Role(commodity=Commodity.HEAT, role=RoleType.ENERGY_PRODUCER), - Role(commodity=Commodity.ELECTRICITY, role=RoleType.ENERGY_CONSUMER), - ], - serial_number="safe_batch6_model432", - ) - - # Act - json_str = resource_manager_details.to_json() - - # Assert - expected_json = { - "available_control_types": [ - "POWER_ENVELOPE_BASED_CONTROL", - "NOT_CONTROLABLE", - "FILL_RATE_BASED_CONTROL", - ], - "currency": "CHE", - "firmware_version": "5.4.2v", - "instruction_processing_delay": 342, - "manufacturer": "Dagobert inc.", - "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "ResourceManagerDetails", - "model": "Safe", - "name": "Dagobert's safe", - "provides_forecast": True, - "provides_power_measurement_types": [ - "HEAT.THERMAL_POWER", - "ELECTRIC.POWER.3_PHASE_SYMMETRIC", - ], - "resource_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", - "roles": [ - {"commodity": "HEAT", "role": "ENERGY_PRODUCER"}, - {"commodity": "ELECTRICITY", "role": "ENERGY_CONSUMER"}, - ], - "serial_number": "safe_batch6_model432", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/revoke_object_test.py b/packages/s2-python/tests/unit/common/revoke_object_test.py deleted file mode 100644 index f146d74..0000000 --- a/packages/s2-python/tests/unit/common/revoke_object_test.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -import uuid -from unittest import TestCase - -from s2python.common import RevokeObject, RevokableObjects - - -class RevokeObjectTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{ - "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "RevokeObject", - "object_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", - "object_type": "FRBC.Instruction" - } - """ - - # Act - revoke_object: RevokeObject = RevokeObject.from_json(json_str) - - # Assert - self.assertEqual( - revoke_object.message_id, uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5") - ) - self.assertEqual( - revoke_object.object_id, uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6") - ) - self.assertEqual(revoke_object.object_type, RevokableObjects.FRBC_Instruction) - - def test__to_json__happy_path(self): - # Arrange - revoke_object = RevokeObject( - message_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - object_id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced6"), - object_type=RevokableObjects.FRBC_Instruction, - ) - - # Act - json_str = revoke_object.to_json() - - # Assert - expected_json = { - "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "RevokeObject", - "object_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced6", - "object_type": "FRBC.Instruction", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/role_test.py b/packages/s2-python/tests/unit/common/role_test.py deleted file mode 100644 index ce9e896..0000000 --- a/packages/s2-python/tests/unit/common/role_test.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -from unittest import TestCase - -from s2python.common import Role, Commodity, RoleType - - -class RoleTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = '{"commodity": "HEAT", "role": "ENERGY_STORAGE"}' - - # Act - role: Role = Role.from_json(json_str) - - # Assert - self.assertEqual(role.commodity, Commodity.HEAT) - self.assertEqual(role.role, RoleType.ENERGY_STORAGE) - - def test__to_json__happy_path(self): - # Arrange - role = Role(commodity=Commodity.HEAT, role=RoleType.ENERGY_STORAGE) - - # Act - json_str = role.to_json() - - # Assert - expected_json = {"commodity": "HEAT", "role": "ENERGY_STORAGE"} - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/select_control_type_test.py b/packages/s2-python/tests/unit/common/select_control_type_test.py deleted file mode 100644 index 8f8fae2..0000000 --- a/packages/s2-python/tests/unit/common/select_control_type_test.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -import uuid -from unittest import TestCase - -from s2python.common import SelectControlType, ControlType - - -class SelectControlTypeTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{ - "control_type": "OPERATION_MODE_BASED_CONTROL", - "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "SelectControlType" - }""" - - # Act - select_control_type: SelectControlType = SelectControlType.from_json(json_str) - - # Assert - self.assertEqual( - select_control_type.message_id, - uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - ) - self.assertEqual(select_control_type.control_type, ControlType.OPERATION_MODE_BASED_CONTROL) - - def test__to_json__happy_path(self): - # Arrange - select_control_type = SelectControlType( - message_id=uuid.UUID("3bdec96b-be3b-4ba9-afa1-c4a0632cced5"), - control_type=ControlType.DEMAND_DRIVEN_BASED_CONTROL, - ) - - # Act - json_str = select_control_type.to_json() - - # Assert - expected_json = { - "control_type": "DEMAND_DRIVEN_BASED_CONTROL", - "message_id": "3bdec96b-be3b-4ba9-afa1-c4a0632cced5", - "message_type": "SelectControlType", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/session_request_test.py b/packages/s2-python/tests/unit/common/session_request_test.py deleted file mode 100644 index 4813bcf..0000000 --- a/packages/s2-python/tests/unit/common/session_request_test.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -import uuid -from unittest import TestCase - -from s2python.common import SessionRequest, SessionRequestType - - -class SessionRequestTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{ - "request": "TERMINATE", - "message_id": "3bdec96b-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "SessionRequest" - }""" - - # Act - session_request: SessionRequest = SessionRequest.from_json(json_str) - - # Assert - self.assertEqual( - session_request.message_id, - uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - ) - self.assertEqual(session_request.request, SessionRequestType.TERMINATE) - - def test__to_json__happy_path(self): - # Arrange - session_request = SessionRequest( # pyright: ignore[reportCallIssue] - message_id=uuid.UUID("3bdec96e-be3b-4ba9-afa0-c4a0632cced5"), - request=SessionRequestType.RECONNECT, - ) - - # Act - json_str = session_request.to_json() - - # Assert - expected_json = { - "request": "RECONNECT", - "message_id": "3bdec96e-be3b-4ba9-afa0-c4a0632cced5", - "message_type": "SessionRequest", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/common/timer_test.py b/packages/s2-python/tests/unit/common/timer_test.py deleted file mode 100644 index e319b8c..0000000 --- a/packages/s2-python/tests/unit/common/timer_test.py +++ /dev/null @@ -1,88 +0,0 @@ -import json -import uuid -from datetime import timedelta -from unittest import TestCase - -from s2python.common import Timer -from s2python.common.duration import Duration -from s2python.s2_validation_error import S2ValidationError - - -class TimerTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = '{"id": "2bdec96b-be3b-4ba9-afa0-c4a0632ccedf", "duration": 5000, "diagnostic_label": "some_label"}' - - # Act - timer = Timer.from_json(json_str) - - # Assert - expected_id = uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf") - expected_duration = timedelta(seconds=5) - expected_diagnostic_label = "some_label" - self.assertEqual(timer.id, expected_id) - self.assertEqual(timer.duration.to_timedelta(), expected_duration) - self.assertEqual(timer.diagnostic_label, expected_diagnostic_label) - - def test_optional_parameters(self): - # Arrange / Act - timer = Timer( # pyright: ignore[reportCallIssue] - id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf"), - duration=Duration.from_timedelta(timedelta(seconds=5)), - ) - - # Assert - expected_id = uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf") - expected_duration = timedelta(seconds=5) - - self.assertIsNone(timer.diagnostic_label) - self.assertEqual(timer.id, expected_id) - self.assertEqual(timer.duration.to_timedelta(), expected_duration) - - def test__from_json__format_validation_error(self): - # Arrange - json_str = ( - '{"id": "2bdec96b-be3b-4ba9-afa0-c4a0632ccedf", "diagnostic_label": "some_label"}' - ) - - # Act / Assert - with self.assertRaises(S2ValidationError): - Timer.from_json(json_str) - - def test__to_json__happy_path(self): - # Arrange - timer = Timer( - id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf"), - duration=Duration.from_timedelta(timedelta(seconds=5)), - diagnostic_label="some_label", - ) - - # Act - json_str = timer.to_json() - - # Assert - expected_json = { - "id": "2bdec96b-be3b-4ba9-afa0-c4a0632ccedf", - "diagnostic_label": "some_label", - "duration": 5000, - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__assignment__overriden_duration_field(self): - # Arrange - timer = Timer( - id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf"), - duration=Duration.from_timedelta(timedelta(seconds=5)), - diagnostic_label="some_label", - ) - - # Act - timer.duration = Duration.from_timedelta(timedelta(seconds=4)) - - # Assert - expected_id = uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632ccedf") - expected_duration = timedelta(seconds=4) - expected_diagnostic_label = "some_label" - self.assertEqual(timer.id, expected_id) - self.assertEqual(timer.duration.to_timedelta(), expected_duration) - self.assertEqual(timer.diagnostic_label, expected_diagnostic_label) diff --git a/packages/s2-python/tests/unit/common/transition_test.py b/packages/s2-python/tests/unit/common/transition_test.py deleted file mode 100644 index c81dbd2..0000000 --- a/packages/s2-python/tests/unit/common/transition_test.py +++ /dev/null @@ -1,156 +0,0 @@ -import uuid -from datetime import timedelta -import json -from unittest import TestCase - -from s2python.common import Transition, Duration -from s2python.s2_validation_error import S2ValidationError - - -class TransitionTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ - { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", - "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", - "start_timers": ["2bdec96b-be3b-4ba9-afa0-c4a0632cced4", "2bdec96b-be3b-4ba9-afa0-c4a0632cced5"], - "blocking_timers": ["2bdec96b-be3b-4ba9-afa0-c4a0632cced4"], - "transition_costs": 4.3, - "transition_duration": 1500, - "abnormal_condition_only": false} - """ - - # Act - transition: Transition = Transition.from_json(json_str) - - # Assert - self.assertEqual( - transition.id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3") - ) - self.assertEqual( - transition.from_, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2") - ) - self.assertEqual( - transition.to, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1") - ) - self.assertEqual( - transition.start_timers, - [ - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4"), - uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced5"), - ], - ) - self.assertEqual( - transition.blocking_timers, - [uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced4")], - ) - self.assertEqual(transition.transition_costs, 4.3) - assert transition.transition_duration is not None - self.assertEqual( - transition.transition_duration.to_timedelta(), timedelta(seconds=1.5) - ) - self.assertEqual(transition.abnormal_condition_only, False) - - def test__from_json__happy_path_min(self): - # Arrange - json_str = """ - { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", - "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", - "start_timers": [], - "blocking_timers": [], - "abnormal_condition_only": true} - """ - - # Act - transition: Transition = Transition.from_json(json_str) - - # Assert - self.assertEqual( - transition.id, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3") - ) - self.assertEqual( - transition.from_, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2") - ) - self.assertEqual( - transition.to, uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1") - ) - self.assertEqual(transition.start_timers, []) - self.assertEqual(transition.blocking_timers, []) - self.assertEqual(transition.transition_costs, None) - self.assertEqual(transition.transition_duration, None) - self.assertEqual(transition.abnormal_condition_only, True) - - def test__from_json__format_validation_error(self): - # Arrange - json_str = """ - { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3" } - """ - - # Act / Assert - with self.assertRaises(S2ValidationError): - Transition.from_json(json_str) - - def test__from_json__value_validation_error_neg_duration(self): - # Arrange - json_str = """ - { "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", - "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", - "start_timers": [], - "blocking_timers": [], - "transition_duration": -1500, - "abnormal_condition_only": true} - """ - - # Act / Assert - with self.assertRaises(S2ValidationError): - Transition.from_json(json_str) - - def test__to_json__happy_path(self): - # Arrange - # BUG We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. - # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to - # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 - transition = Transition( - **{ - "id": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - "from": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2"), - "to": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1"), - "start_timers": [], - "blocking_timers": [], - "transition_duration": Duration.from_timedelta( - timedelta(minutes=1, seconds=1) - ), - "abnormal_condition_only": False, - } - ) - - # Act - json_str = transition.to_json() - - # Assert - expected_json = { - "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "from": "2bdec96b-be3b-4ba9-afa0-c4a0632cced2", - "to": "2bdec96b-be3b-4ba9-afa0-c4a0632cced1", - "start_timers": [], - "blocking_timers": [], - "transition_duration": 61000, - "abnormal_condition_only": False, - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__to_json__value_validation_error_neg_duration(self): - # Arrange/ Act / Assert - with self.assertRaises(S2ValidationError): - Transition( # pyright: ignore[reportCallIssue] - id=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - from_=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced2"), - to=uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced1"), - start_timers=[], - blocking_timers=[], - transition_duration=Duration(root=-5000), - abnormal_condition_only=False, - ) diff --git a/packages/s2-python/tests/unit/frbc/frbc_actuator_description_test.py b/packages/s2-python/tests/unit/frbc/frbc_actuator_description_test.py deleted file mode 100644 index 1b4f31d..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_actuator_description_test.py +++ /dev/null @@ -1,241 +0,0 @@ -import json -import uuid -from datetime import timedelta -from unittest import TestCase - -from s2python.common import ( - Transition, - Duration, - Timer, - NumberRange, - PowerRange, - CommodityQuantity, - Commodity, -) -from s2python.frbc import ( - FRBCActuatorDescription, - FRBCOperationMode, - FRBCOperationModeElement, -) - - -class FRBCActuatorDescriptionTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """{ - "diagnostic_label": "some name of actuator", - "id": "3bdec96b-be3b-4ba9-afa0-c4a0632dded5", - "operation_modes": [{ - "abnormal_condition_only": false, - "diagnostic_label": "om1", - "id": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", - "elements": [{ "fill_level_range": {"start_of_range": 4.0, "end_of_range": 5.0}, - "fill_rate": {"start_of_range": 0.13, "end_of_range": 10342.569}, - "power_ranges": [{"start_of_range": 400, "end_of_range": 6000, "commodity_quantity": "HEAT.TEMPERATURE"}, - {"start_of_range": 500, "end_of_range": 7000, "commodity_quantity": "ELECTRIC.POWER.L1"}], - "running_costs": {"start_of_range": 4.3, "end_of_range": 4.6}}] - }], - "supported_commodities": ["HEAT", "ELECTRICITY"], - "timers": [{ - "diagnostic_label": "timer1", - "duration": 2300, - "id": "3bdec10b-be3b-4ba9-afa0-c4a0632ffed6" - }], - "transitions": [{ "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "from": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", - "to": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", - "start_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], - "blocking_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], - "transition_costs": 4.3, - "transition_duration": 1500, - "abnormal_condition_only": false}] - }""" - - # Act - frbc_actuator_description: FRBCActuatorDescription = ( - FRBCActuatorDescription.from_json(json_str) - ) - - # Assert - expected_timer = Timer( - id=uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"), - diagnostic_label="timer1", - duration=Duration.from_timedelta(timedelta(seconds=2.3)), - ) - - # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. - # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to - # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 - expected_transition = Transition( - **{ - "id": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - "from": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), - "to": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), - "start_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], - "blocking_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], - "transition_costs": 4.3, - "transition_duration": Duration.from_milliseconds(1500), - "abnormal_condition_only": False, - } - ) - expected_operation_mode_element = FRBCOperationModeElement( - fill_level_range=NumberRange(start_of_range=4.0, end_of_range=5.0), - fill_rate=NumberRange(start_of_range=0.13, end_of_range=10342.569), - power_ranges=[ - PowerRange( - start_of_range=400, - end_of_range=6000, - commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, - ), - PowerRange( - start_of_range=500, - end_of_range=7000, - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - ), - ], - running_costs=NumberRange(start_of_range=4.3, end_of_range=4.6), - ) - expected_operation_mode = FRBCOperationMode( - abnormal_condition_only=False, - diagnostic_label="om1", - id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), - elements=[expected_operation_mode_element], - ) - - self.assertEqual( - frbc_actuator_description.diagnostic_label, "some name of actuator" - ) - self.assertEqual( - frbc_actuator_description.id, - uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632dded5"), - ) - self.assertEqual( - frbc_actuator_description.supported_commodities, - [Commodity.HEAT, Commodity.ELECTRICITY], - ) - self.assertEqual( - frbc_actuator_description.operation_modes, [expected_operation_mode] - ) - self.assertEqual(frbc_actuator_description.timers, [expected_timer]) - self.assertEqual(frbc_actuator_description.transitions, [expected_transition]) - - def test__to_json__happy_path(self): - # Arrange - timer = Timer( - id=uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"), - diagnostic_label="timer1", - duration=Duration.from_timedelta(timedelta(seconds=2.3)), - ) - - # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. - # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to - # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 - transition = Transition( - **{ - "id": uuid.UUID("2bdec96b-be3b-4ba9-afa0-c4a0632cced3"), - "from": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), - "to": uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), - "start_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], - "blocking_timers": [uuid.UUID("3bdec10b-be3b-4ba9-afa0-c4a0632ffed6")], - "transition_costs": 4.3, - "transition_duration": Duration.from_milliseconds(1500), - "abnormal_condition_only": False, - } - ) - operation_mode_element = FRBCOperationModeElement( - fill_level_range=NumberRange(start_of_range=4.0, end_of_range=5.0), - fill_rate=NumberRange(start_of_range=0.13, end_of_range=10342.569), - power_ranges=[ - PowerRange( - start_of_range=400, - end_of_range=6000, - commodity_quantity=CommodityQuantity.HEAT_TEMPERATURE, - ), - PowerRange( - start_of_range=500, - end_of_range=7000, - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - ), - ], - running_costs=NumberRange(start_of_range=4.3, end_of_range=4.6), - ) - operation_mode = FRBCOperationMode( - abnormal_condition_only=False, - diagnostic_label="om1", - id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632ffed5"), - elements=[operation_mode_element], - ) - - frbc_actuator_description = FRBCActuatorDescription( - diagnostic_label="some name of actuator", - id=uuid.UUID("3bdec96b-be3b-4ba9-afa0-c4a0632dded5"), - supported_commodities=[Commodity.HEAT, Commodity.ELECTRICITY], - operation_modes=[operation_mode], - timers=[timer], - transitions=[transition], - ) - - # Act - json_str = frbc_actuator_description.to_json() - - # Assert - expected_json = { - "diagnostic_label": "some name of actuator", - "id": "3bdec96b-be3b-4ba9-afa0-c4a0632dded5", - "operation_modes": [ - { - "abnormal_condition_only": False, - "diagnostic_label": "om1", - "id": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", - "elements": [ - { - "fill_level_range": { - "start_of_range": 4.0, - "end_of_range": 5.0, - }, - "fill_rate": { - "start_of_range": 0.13, - "end_of_range": 10342.569, - }, - "power_ranges": [ - { - "start_of_range": 400, - "end_of_range": 6000, - "commodity_quantity": "HEAT.TEMPERATURE", - }, - { - "start_of_range": 500, - "end_of_range": 7000, - "commodity_quantity": "ELECTRIC.POWER.L1", - }, - ], - "running_costs": { - "start_of_range": 4.3, - "end_of_range": 4.6, - }, - } - ], - } - ], - "supported_commodities": ["HEAT", "ELECTRICITY"], - "timers": [ - { - "diagnostic_label": "timer1", - "duration": 2300, - "id": "3bdec10b-be3b-4ba9-afa0-c4a0632ffed6", - } - ], - "transitions": [ - { - "id": "2bdec96b-be3b-4ba9-afa0-c4a0632cced3", - "from": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", - "to": "3bdec96b-be3b-4ba9-afa0-c4a0632ffed5", - "start_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], - "blocking_timers": ["3bdec10b-be3b-4ba9-afa0-c4a0632ffed6"], - "transition_costs": 4.3, - "transition_duration": 1500, - "abnormal_condition_only": False, - } - ], - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_actuator_status_test.py b/packages/s2-python/tests/unit/frbc/frbc_actuator_status_test.py deleted file mode 100644 index 2538381..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_actuator_status_test.py +++ /dev/null @@ -1,95 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCActuatorStatusTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "active_operation_mode_id": "395dcbc5-5c7f-415e-8727-e48fc53761bc", - "actuator_id": "1cee425e-861b-417a-8208-bb6d53aafb00", - "message_id": "07f3d559-63c5-4369-a9e0-deed4195f651", - "message_type": "FRBC.ActuatorStatus", - "operation_mode_factor": 6919.960475850124, - "previous_operation_mode_id": "2ed8f7de-cbaa-4cab-9d25-6792317aa284", - "transition_timestamp": "2020-01-02T07:56:46Z" -} - """ - - # Act - frbc_actuator_status = FRBCActuatorStatus.from_json(json_str) - - # Assert - self.assertEqual( - frbc_actuator_status.active_operation_mode_id, - uuid.UUID("395dcbc5-5c7f-415e-8727-e48fc53761bc"), - ) - self.assertEqual( - frbc_actuator_status.actuator_id, - uuid.UUID("1cee425e-861b-417a-8208-bb6d53aafb00"), - ) - self.assertEqual( - frbc_actuator_status.message_id, - uuid.UUID("07f3d559-63c5-4369-a9e0-deed4195f651"), - ) - self.assertEqual(frbc_actuator_status.message_type, "FRBC.ActuatorStatus") - self.assertEqual(frbc_actuator_status.operation_mode_factor, 6919.960475850124) - self.assertEqual( - frbc_actuator_status.previous_operation_mode_id, - uuid.UUID("2ed8f7de-cbaa-4cab-9d25-6792317aa284"), - ) - self.assertEqual( - frbc_actuator_status.transition_timestamp, - datetime( - year=2020, - month=1, - day=2, - hour=7, - minute=56, - second=46, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_actuator_status = FRBCActuatorStatus( - active_operation_mode_id=uuid.UUID("395dcbc5-5c7f-415e-8727-e48fc53761bc"), - actuator_id=uuid.UUID("1cee425e-861b-417a-8208-bb6d53aafb00"), - message_id=uuid.UUID("07f3d559-63c5-4369-a9e0-deed4195f651"), - message_type="FRBC.ActuatorStatus", - operation_mode_factor=6919.960475850124, - previous_operation_mode_id=uuid.UUID( - "2ed8f7de-cbaa-4cab-9d25-6792317aa284" - ), - transition_timestamp=datetime( - year=2020, - month=1, - day=2, - hour=7, - minute=56, - second=46, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - # Act - json_str = frbc_actuator_status.to_json() - - # Assert - expected_json = { - "active_operation_mode_id": "395dcbc5-5c7f-415e-8727-e48fc53761bc", - "actuator_id": "1cee425e-861b-417a-8208-bb6d53aafb00", - "message_id": "07f3d559-63c5-4369-a9e0-deed4195f651", - "message_type": "FRBC.ActuatorStatus", - "operation_mode_factor": 6919.960475850124, - "previous_operation_mode_id": "2ed8f7de-cbaa-4cab-9d25-6792317aa284", - "transition_timestamp": "2020-01-02T07:56:46Z", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py deleted file mode 100644 index f3ea375..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_element_test.py +++ /dev/null @@ -1,61 +0,0 @@ -from datetime import timedelta -import json -from unittest import TestCase - -from s2python.common import * -from s2python.frbc import * -from s2python.s2_validation_error import S2ValidationError - - -class FRBCFillLevelTargetProfileElementTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "duration": 12950, - "fill_level_range": { - "end_of_range": 8176, - "start_of_range": 6207 - } -} - """ - - # Act - frbc_fill_level_target_profile_element = ( - FRBCFillLevelTargetProfileElement.from_json(json_str) - ) - - # Assert - self.assertEqual( - frbc_fill_level_target_profile_element.duration, - Duration.from_timedelta(timedelta(milliseconds=12950)), - ) - self.assertEqual( - frbc_fill_level_target_profile_element.fill_level_range, - NumberRange(end_of_range=8176.0, start_of_range=6207.0), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_fill_level_target_profile_element = FRBCFillLevelTargetProfileElement( - duration=Duration.from_timedelta(timedelta(milliseconds=12950)), - fill_level_range=NumberRange(end_of_range=8176, start_of_range=6207), - ) - - # Act - json_str = frbc_fill_level_target_profile_element.to_json() - - # Assert - expected_json = { - "duration": 12950, - "fill_level_range": {"end_of_range": 8176, "start_of_range": 6207}, - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__init__fill_level_range_end_is_smaller_than_start(self): - # Arrange / Act / Assert - with self.assertRaises(S2ValidationError): - FRBCFillLevelTargetProfileElement( - duration=Duration.from_timedelta(timedelta(milliseconds=12950)), - fill_level_range=NumberRange(end_of_range=6000, start_of_range=8176), - ) diff --git a/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py b/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py deleted file mode 100644 index 5f2f2b3..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_fill_level_target_profile_test.py +++ /dev/null @@ -1,109 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCFillLevelTargetProfileTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "elements": [ - { - "duration": 4704, - "fill_level_range": { - "end_of_range": 10800.98606857073545, - "start_of_range": 6891.19014440217 - } - } - ], - "message_id": "04a6c8af-ca8d-420c-9c11-e96a70fe82b1", - "message_type": "FRBC.FillLevelTargetProfile", - "start_time": "2021-04-17T00:19:20Z" -} - """ - - # Act - frbc_fill_level_target_profile = FRBCFillLevelTargetProfile.from_json(json_str) - - # Assert - self.assertEqual( - frbc_fill_level_target_profile.elements, - [ - FRBCFillLevelTargetProfileElement( - duration=Duration.from_timedelta(timedelta(milliseconds=4704)), - fill_level_range=NumberRange( - end_of_range=10800.98606857073545, - start_of_range=6891.19014440217, - ), - ) - ], - ) - self.assertEqual( - frbc_fill_level_target_profile.message_id, - uuid.UUID("04a6c8af-ca8d-420c-9c11-e96a70fe82b1"), - ) - self.assertEqual( - frbc_fill_level_target_profile.message_type, "FRBC.FillLevelTargetProfile" - ) - self.assertEqual( - frbc_fill_level_target_profile.start_time, - datetime( - year=2021, - month=4, - day=17, - hour=0, - minute=19, - second=20, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_fill_level_target_profile = FRBCFillLevelTargetProfile( - elements=[ - FRBCFillLevelTargetProfileElement( - duration=Duration.from_timedelta(timedelta(milliseconds=4704)), - fill_level_range=NumberRange( - end_of_range=10800.98606857073545, - start_of_range=6891.19014440217, - ), - ) - ], - message_id=uuid.UUID("04a6c8af-ca8d-420c-9c11-e96a70fe82b1"), - message_type="FRBC.FillLevelTargetProfile", - start_time=datetime( - year=2021, - month=4, - day=17, - hour=0, - minute=19, - second=20, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - # Act - json_str = frbc_fill_level_target_profile.to_json() - - # Assert - expected_json = { - "elements": [ - { - "duration": 4704, - "fill_level_range": { - "end_of_range": 10800.98606857073545, - "start_of_range": 6891.19014440217, - }, - } - ], - "message_id": "04a6c8af-ca8d-420c-9c11-e96a70fe82b1", - "message_type": "FRBC.FillLevelTargetProfile", - "start_time": "2021-04-17T00:19:20Z", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_instruction_test.py b/packages/s2-python/tests/unit/frbc/frbc_instruction_test.py deleted file mode 100644 index 901a711..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_instruction_test.py +++ /dev/null @@ -1,96 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCInstructionTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "abnormal_condition": true, - "actuator_id": "db7855dd-05c4-4ba8-81e2-d10001c5bc3f", - "execution_time": "2023-04-11T16:46:33+01:00", - "id": "9ffd68cd-b0e2-44a6-aded-4dce6c18247e", - "message_id": "bcb3e1da-e797-4951-86be-5e5d9136c63f", - "message_type": "FRBC.Instruction", - "operation_mode": "e7bf29a7-4ebc-49c1-a1fb-20725f450c91", - "operation_mode_factor": 2303.58902271682 -} - """ - - # Act - frbc_instruction = FRBCInstruction.from_json(json_str) - - # Assert - self.assertEqual(frbc_instruction.abnormal_condition, True) - self.assertEqual( - frbc_instruction.actuator_id, - uuid.UUID("db7855dd-05c4-4ba8-81e2-d10001c5bc3f"), - ) - self.assertEqual( - frbc_instruction.execution_time, - datetime( - year=2023, - month=4, - day=11, - hour=16, - minute=46, - second=33, - tzinfo=offset(offset=timedelta(seconds=3600.0)), - ), - ) - self.assertEqual( - frbc_instruction.id, uuid.UUID("9ffd68cd-b0e2-44a6-aded-4dce6c18247e") - ) - self.assertEqual( - frbc_instruction.message_id, - uuid.UUID("bcb3e1da-e797-4951-86be-5e5d9136c63f"), - ) - self.assertEqual(frbc_instruction.message_type, "FRBC.Instruction") - self.assertEqual( - frbc_instruction.operation_mode, - uuid.UUID("e7bf29a7-4ebc-49c1-a1fb-20725f450c91"), - ) - self.assertEqual(frbc_instruction.operation_mode_factor, 2303.58902271682) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_instruction = FRBCInstruction( - abnormal_condition=True, - actuator_id=uuid.UUID("db7855dd-05c4-4ba8-81e2-d10001c5bc3f"), - execution_time=datetime( - year=2023, - month=4, - day=11, - hour=16, - minute=46, - second=33, - tzinfo=offset(offset=timedelta(seconds=3600.0)), - ), - id=uuid.UUID("9ffd68cd-b0e2-44a6-aded-4dce6c18247e"), - message_id=uuid.UUID("bcb3e1da-e797-4951-86be-5e5d9136c63f"), - message_type="FRBC.Instruction", - operation_mode=uuid.UUID("e7bf29a7-4ebc-49c1-a1fb-20725f450c91"), - operation_mode_factor=2303.58902271682, - ) - - # Act - json_str = frbc_instruction.to_json() - - # Assert - expected_json = { - "abnormal_condition": True, - "actuator_id": "db7855dd-05c4-4ba8-81e2-d10001c5bc3f", - "execution_time": "2023-04-11T16:46:33+01:00", - "id": "9ffd68cd-b0e2-44a6-aded-4dce6c18247e", - "message_id": "bcb3e1da-e797-4951-86be-5e5d9136c63f", - "message_type": "FRBC.Instruction", - "operation_mode": "e7bf29a7-4ebc-49c1-a1fb-20725f450c91", - "operation_mode_factor": 2303.58902271682, - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py deleted file mode 100644 index 08a5364..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_element_test.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * -from s2python.s2_validation_error import S2ValidationError - - -class FRBCLeakageBehaviourElementTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "fill_level_range": { - "end_of_range": 40192.498918818455, - "start_of_range": 29234.82582981918 - }, - "leakage_rate": 1170.4041485129987 -} - """ - - # Act - frbc_leakage_behaviour_element = FRBCLeakageBehaviourElement.from_json(json_str) - - # Assert - self.assertEqual( - frbc_leakage_behaviour_element.fill_level_range, - NumberRange( - end_of_range=40192.498918818455, start_of_range=29234.82582981918 - ), - ) - self.assertEqual( - frbc_leakage_behaviour_element.leakage_rate, 1170.4041485129987 - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_leakage_behaviour_element = FRBCLeakageBehaviourElement( - fill_level_range=NumberRange( - end_of_range=40192.498918818455, start_of_range=29234.82582981918 - ), - leakage_rate=1170.4041485129987, - ) - - # Act - json_str = frbc_leakage_behaviour_element.to_json() - - # Assert - expected_json = { - "fill_level_range": { - "end_of_range": 40192.498918818455, - "start_of_range": 29234.82582981918, - }, - "leakage_rate": 1170.4041485129987, - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__init__fill_level_range_end_is_smaller_than_start(self): - # Arrange / Act / Assert - with self.assertRaises(S2ValidationError): - FRBCLeakageBehaviourElement( - fill_level_range=NumberRange( - end_of_range=29234.82582981918, start_of_range=40192.498918818455 - ), - leakage_rate=1170.4041485129987, - ) diff --git a/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py b/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py deleted file mode 100644 index ad290d1..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_leakage_behaviour_test.py +++ /dev/null @@ -1,107 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCLeakageBehaviourTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "elements": [ - { - "fill_level_range": { - "end_of_range": 31155.931914859895, - "start_of_range": 5727.722922773178 - }, - "leakage_rate": 1225.9695121338086 - } - ], - "message_id": "b3e9604a-1127-4ecc-9f9e-336047fde285", - "message_type": "FRBC.LeakageBehaviour", - "valid_from": "2022-05-26T15:02:32Z" -} - """ - - # Act - frbc_leakage_behaviour = FRBCLeakageBehaviour.from_json(json_str) - - # Assert - self.assertEqual( - frbc_leakage_behaviour.elements, - [ - FRBCLeakageBehaviourElement( - fill_level_range=NumberRange( - end_of_range=31155.931914859895, - start_of_range=5727.722922773178, - ), - leakage_rate=1225.9695121338086, - ) - ], - ) - self.assertEqual( - frbc_leakage_behaviour.message_id, - uuid.UUID("b3e9604a-1127-4ecc-9f9e-336047fde285"), - ) - self.assertEqual(frbc_leakage_behaviour.message_type, "FRBC.LeakageBehaviour") - self.assertEqual( - frbc_leakage_behaviour.valid_from, - datetime( - year=2022, - month=5, - day=26, - hour=15, - minute=2, - second=32, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_leakage_behaviour = FRBCLeakageBehaviour( - elements=[ - FRBCLeakageBehaviourElement( - fill_level_range=NumberRange( - end_of_range=31155.931914859895, - start_of_range=5727.722922773178, - ), - leakage_rate=1225.9695121338086, - ) - ], - message_id=uuid.UUID("b3e9604a-1127-4ecc-9f9e-336047fde285"), - message_type="FRBC.LeakageBehaviour", - valid_from=datetime( - year=2022, - month=5, - day=26, - hour=15, - minute=2, - second=32, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - # Act - json_str = frbc_leakage_behaviour.to_json() - - # Assert - expected_json = { - "elements": [ - { - "fill_level_range": { - "end_of_range": 31155.931914859895, - "start_of_range": 5727.722922773178, - }, - "leakage_rate": 1225.9695121338086, - } - ], - "message_id": "b3e9604a-1127-4ecc-9f9e-336047fde285", - "message_type": "FRBC.LeakageBehaviour", - "valid_from": "2022-05-26T15:02:32Z", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py deleted file mode 100644 index d2f3455..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_operation_mode_element_test.py +++ /dev/null @@ -1,117 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import NumberRange, PowerRange -from s2python.frbc.frbc_operation_mode_element import FRBCOperationModeElement -from s2python.generated.gen_s2 import CommodityQuantity - - -class FRBCOperationModeElementTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "fill_level_range": { - "end_of_range": 51798.05122344172, - "start_of_range": 12901.48976850875 - }, - "fill_rate": { - "end_of_range": 35734.54630113551, - "start_of_range": 10740.443924585083 - }, - "power_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "end_of_range": 69093.48993128976, - "start_of_range": 34859.59303603876 - } - ], - "running_costs": { - "end_of_range": 47869.03540464825, - "start_of_range": 19009.60894672492 - } -} - """ - - # Act - frbc_operation_mode_element = FRBCOperationModeElement.from_json(json_str) - - # Assert - self.assertEqual( - frbc_operation_mode_element.fill_level_range, - NumberRange( - end_of_range=51798.05122344172, start_of_range=12901.48976850875 - ), - ) - self.assertEqual( - frbc_operation_mode_element.fill_rate, - NumberRange( - end_of_range=35734.54630113551, start_of_range=10740.443924585083 - ), - ) - self.assertEqual( - frbc_operation_mode_element.power_ranges, - [ - PowerRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - end_of_range=69093.48993128976, - start_of_range=34859.59303603876, - ) - ], - ) - self.assertEqual( - frbc_operation_mode_element.running_costs, - NumberRange( - end_of_range=47869.03540464825, start_of_range=19009.60894672492 - ), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_operation_mode_element = FRBCOperationModeElement( - fill_level_range=NumberRange( - end_of_range=51798.05122344172, start_of_range=12901.48976850875 - ), - fill_rate=NumberRange( - end_of_range=35734.54630113551, start_of_range=10740.443924585083 - ), - power_ranges=[ - PowerRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - end_of_range=69093.48993128976, - start_of_range=34859.59303603876, - ) - ], - running_costs=NumberRange( - end_of_range=47869.03540464825, start_of_range=19009.60894672492 - ), - ) - - # Act - json_str = frbc_operation_mode_element.to_json() - - # Assert - expected_json = { - "fill_level_range": { - "end_of_range": 51798.05122344172, - "start_of_range": 12901.48976850875, - }, - "fill_rate": { - "end_of_range": 35734.54630113551, - "start_of_range": 10740.443924585083, - }, - "power_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "end_of_range": 69093.48993128976, - "start_of_range": 34859.59303603876, - } - ], - "running_costs": { - "end_of_range": 47869.03540464825, - "start_of_range": 19009.60894672492, - }, - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_operation_mode_test.py b/packages/s2-python/tests/unit/frbc/frbc_operation_mode_test.py deleted file mode 100644 index 0d97f27..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_operation_mode_test.py +++ /dev/null @@ -1,139 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCOperationModeTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "abnormal_condition_only": true, - "diagnostic_label": "some-test-string7557", - "elements": [ - { - "fill_level_range": { - "end_of_range": 34304.92092046668, - "start_of_range": 17579.18236077446 - }, - "fill_rate": { - "end_of_range": 41719.931165871916, - "start_of_range": 10542.600445486576 - }, - "power_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "end_of_range": 44983.5145552435, - "start_of_range": 29337.138579372047 - } - ], - "running_costs": { - "end_of_range": 62835.00070350196, - "start_of_range": 33318.34845926906 - } - } - ], - "id": "b1255236-475c-4dc7-a728-afb620a99ec8" -} - """ - - # Act - frbc_operation_mode = FRBCOperationMode.from_json(json_str) - - # Assert - self.assertEqual(frbc_operation_mode.abnormal_condition_only, True) - self.assertEqual(frbc_operation_mode.diagnostic_label, "some-test-string7557") - self.assertEqual( - frbc_operation_mode.elements, - [ - FRBCOperationModeElement( - fill_level_range=NumberRange( - end_of_range=34304.92092046668, start_of_range=17579.18236077446 - ), - fill_rate=NumberRange( - end_of_range=41719.931165871916, - start_of_range=10542.600445486576, - ), - power_ranges=[ - PowerRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - end_of_range=44983.5145552435, - start_of_range=29337.138579372047, - ) - ], - running_costs=NumberRange( - end_of_range=62835.00070350196, start_of_range=33318.34845926906 - ), - ) - ], - ) - self.assertEqual( - frbc_operation_mode.id, uuid.UUID("b1255236-475c-4dc7-a728-afb620a99ec8") - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_operation_mode = FRBCOperationMode( - abnormal_condition_only=True, - diagnostic_label="some-test-string7557", - elements=[ - FRBCOperationModeElement( - fill_level_range=NumberRange( - end_of_range=34304.92092046668, start_of_range=17579.18236077446 - ), - fill_rate=NumberRange( - end_of_range=41719.931165871916, - start_of_range=10542.600445486576, - ), - power_ranges=[ - PowerRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - end_of_range=44983.5145552435, - start_of_range=29337.138579372047, - ) - ], - running_costs=NumberRange( - end_of_range=62835.00070350196, start_of_range=33318.34845926906 - ), - ) - ], - id=uuid.UUID("b1255236-475c-4dc7-a728-afb620a99ec8"), - ) - - # Act - json_str = frbc_operation_mode.to_json() - - # Assert - expected_json = { - "abnormal_condition_only": True, - "diagnostic_label": "some-test-string7557", - "elements": [ - { - "fill_level_range": { - "end_of_range": 34304.92092046668, - "start_of_range": 17579.18236077446, - }, - "fill_rate": { - "end_of_range": 41719.931165871916, - "start_of_range": 10542.600445486576, - }, - "power_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "end_of_range": 44983.5145552435, - "start_of_range": 29337.138579372047, - } - ], - "running_costs": { - "end_of_range": 62835.00070350196, - "start_of_range": 33318.34845926906, - }, - } - ], - "id": "b1255236-475c-4dc7-a728-afb620a99ec8", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_storage_description_test.py b/packages/s2-python/tests/unit/frbc/frbc_storage_description_test.py deleted file mode 100644 index a1e8e2e..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_storage_description_test.py +++ /dev/null @@ -1,77 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCStorageDescriptionTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "diagnostic_label": "some-test-string3063", - "fill_level_label": "some-test-string2323", - "fill_level_range": { - "end_of_range": 14555.806367871957, - "start_of_range": 10409.397377840089 - }, - "provides_fill_level_target_profile": true, - "provides_leakage_behaviour": false, - "provides_usage_forecast": false -} - """ - - # Act - frbc_storage_description = FRBCStorageDescription.from_json(json_str) - - # Assert - self.assertEqual( - frbc_storage_description.diagnostic_label, "some-test-string3063" - ) - self.assertEqual( - frbc_storage_description.fill_level_label, "some-test-string2323" - ) - self.assertEqual( - frbc_storage_description.fill_level_range, - NumberRange( - end_of_range=14555.806367871957, start_of_range=10409.397377840089 - ), - ) - self.assertEqual( - frbc_storage_description.provides_fill_level_target_profile, True - ) - self.assertEqual(frbc_storage_description.provides_leakage_behaviour, False) - self.assertEqual(frbc_storage_description.provides_usage_forecast, False) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_storage_description = FRBCStorageDescription( - diagnostic_label="some-test-string3063", - fill_level_label="some-test-string2323", - fill_level_range=NumberRange( - end_of_range=14555.806367871957, start_of_range=10409.397377840089 - ), - provides_fill_level_target_profile=True, - provides_leakage_behaviour=False, - provides_usage_forecast=False, - ) - - # Act - json_str = frbc_storage_description.to_json() - - # Assert - expected_json = { - "diagnostic_label": "some-test-string3063", - "fill_level_label": "some-test-string2323", - "fill_level_range": { - "end_of_range": 14555.806367871957, - "start_of_range": 10409.397377840089, - }, - "provides_fill_level_target_profile": True, - "provides_leakage_behaviour": False, - "provides_usage_forecast": False, - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_storage_status_test.py b/packages/s2-python/tests/unit/frbc/frbc_storage_status_test.py deleted file mode 100644 index c2b99ab..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_storage_status_test.py +++ /dev/null @@ -1,49 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCStorageStatusTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "message_id": "6bad8186-9ebf-4647-ac45-1c6856511a2f", - "message_type": "FRBC.StorageStatus", - "present_fill_level": 2443.939298819414 -} - """ - - # Act - frbc_storage_status = FRBCStorageStatus.from_json(json_str) - - # Assert - self.assertEqual( - frbc_storage_status.message_id, - uuid.UUID("6bad8186-9ebf-4647-ac45-1c6856511a2f"), - ) - self.assertEqual(frbc_storage_status.message_type, "FRBC.StorageStatus") - self.assertEqual(frbc_storage_status.present_fill_level, 2443.939298819414) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_storage_status = FRBCStorageStatus( - message_id=uuid.UUID("6bad8186-9ebf-4647-ac45-1c6856511a2f"), - message_type="FRBC.StorageStatus", - present_fill_level=2443.939298819414, - ) - - # Act - json_str = frbc_storage_status.to_json() - - # Assert - expected_json = { - "message_id": "6bad8186-9ebf-4647-ac45-1c6856511a2f", - "message_type": "FRBC.StorageStatus", - "present_fill_level": 2443.939298819414, - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_system_description_test.py b/packages/s2-python/tests/unit/frbc/frbc_system_description_test.py deleted file mode 100644 index 0ad8bfd..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_system_description_test.py +++ /dev/null @@ -1,359 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCSystemDescriptionTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "actuators": [ - { - "diagnostic_label": "some-test-string2728", - "id": "a1061148-f19e-4b1b-8fe3-b506583ce61e", - "operation_modes": [ - { - "abnormal_condition_only": false, - "diagnostic_label": "some-test-string2930", - "elements": [ - { - "fill_level_range": { - "end_of_range": 36932.65171036228, - "start_of_range": 12649.272766336762 - }, - "fill_rate": { - "end_of_range": 34553.16163528188, - "start_of_range": 14377.963894945604 - }, - "power_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "end_of_range": 46924.65023353163, - "start_of_range": 11888.235871902496 - } - ], - "running_costs": { - "end_of_range": 42897.60731684277, - "start_of_range": 33997.56376994998 - } - } - ], - "id": "2795136c-eb30-4f8a-bdaa-61feba1e71b6" - } - ], - "supported_commodities": [ - "ELECTRICITY" - ], - "timers": [ - { - "diagnostic_label": "some-test-string4315", - "duration": 14099, - "id": "e1ff9e58-935b-4765-92e3-5e7679f73eb6" - } - ], - "transitions": [ - { - "abnormal_condition_only": true, - "blocking_timers": [ - "e1ff9e58-935b-4765-92e3-5e7679f73eb6" - ], - "from": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", - "id": "c32cc1d3-4722-41e3-a8de-55307c723611", - "start_timers": [ - "e1ff9e58-935b-4765-92e3-5e7679f73eb6" - ], - "to": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", - "transition_costs": 1018.4228054114793, - "transition_duration": 11988 - } - ] - } - ], - "message_id": "97256813-de70-4640-a992-9ae0b2d8e4d1", - "message_type": "FRBC.SystemDescription", - "storage": { - "diagnostic_label": "some-test-string8418", - "fill_level_label": "some-test-string9512", - "fill_level_range": { - "end_of_range": 20876.752745956997, - "start_of_range": 18324.0229135081 - }, - "provides_fill_level_target_profile": false, - "provides_leakage_behaviour": true, - "provides_usage_forecast": false - }, - "valid_from": "2020-10-07T06:30:55Z" -} - """ - - # Act - frbc_system_description = FRBCSystemDescription.from_json(json_str) - - # Assert - # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. - # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to - # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 - transition = Transition( - **{ - "id": uuid.UUID("c32cc1d3-4722-41e3-a8de-55307c723611"), - "from": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), - "to": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), - "start_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], - "blocking_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], - "transition_costs": 1018.4228054114793, - "transition_duration": Duration.from_milliseconds(11988), - "abnormal_condition_only": True, - } - ) - - self.assertEqual( - frbc_system_description.actuators, - [ - FRBCActuatorDescription( - diagnostic_label="some-test-string2728", - id=uuid.UUID("a1061148-f19e-4b1b-8fe3-b506583ce61e"), - operation_modes=[ - FRBCOperationMode( - abnormal_condition_only=False, - diagnostic_label="some-test-string2930", - elements=[ - FRBCOperationModeElement( - fill_level_range=NumberRange( - end_of_range=36932.65171036228, - start_of_range=12649.272766336762, - ), - fill_rate=NumberRange( - end_of_range=34553.16163528188, - start_of_range=14377.963894945604, - ), - power_ranges=[ - PowerRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - end_of_range=46924.65023353163, - start_of_range=11888.235871902496, - ) - ], - running_costs=NumberRange( - end_of_range=42897.60731684277, - start_of_range=33997.56376994998, - ), - ) - ], - id=uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), - ) - ], - supported_commodities=[Commodity.ELECTRICITY], - timers=[ - Timer( - diagnostic_label="some-test-string4315", - duration=Duration.from_timedelta( - timedelta(milliseconds=14099) - ), - id=uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6"), - ) - ], - transitions=[transition], - ) - ], - ) - self.assertEqual( - frbc_system_description.message_id, - uuid.UUID("97256813-de70-4640-a992-9ae0b2d8e4d1"), - ) - self.assertEqual(frbc_system_description.message_type, "FRBC.SystemDescription") - self.assertEqual( - frbc_system_description.storage, - FRBCStorageDescription( - diagnostic_label="some-test-string8418", - fill_level_label="some-test-string9512", - fill_level_range=NumberRange( - end_of_range=20876.752745956997, start_of_range=18324.0229135081 - ), - provides_fill_level_target_profile=False, - provides_leakage_behaviour=True, - provides_usage_forecast=False, - ), - ) - self.assertEqual( - frbc_system_description.valid_from, - datetime( - year=2020, - month=10, - day=7, - hour=6, - minute=30, - second=55, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - def test__to_json__happy_path_full(self): - # Arrange - # TODO We have to resort to using a dict as we HAVE to pass the 'from' key which is a Python reserved keyword. - # We will fix this by moving to pydantic v2 in which aliases have been fixed in which they may be used to - # assign values during init. See: https://github.com/flexiblepower/s2-ws-json-python/issues/10 - transition = Transition( - **{ - "id": uuid.UUID("c32cc1d3-4722-41e3-a8de-55307c723611"), - "from": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), - "to": uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), - "start_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], - "blocking_timers": [uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6")], - "transition_costs": 1018.4228054114793, - "transition_duration": Duration.from_milliseconds(11988), - "abnormal_condition_only": True, - } - ) - frbc_system_description = FRBCSystemDescription( - actuators=[ - FRBCActuatorDescription( - diagnostic_label="some-test-string2728", - id=uuid.UUID("a1061148-f19e-4b1b-8fe3-b506583ce61e"), - operation_modes=[ - FRBCOperationMode( - abnormal_condition_only=False, - diagnostic_label="some-test-string2930", - elements=[ - FRBCOperationModeElement( - fill_level_range=NumberRange( - end_of_range=36932.65171036228, - start_of_range=12649.272766336762, - ), - fill_rate=NumberRange( - end_of_range=34553.16163528188, - start_of_range=14377.963894945604, - ), - power_ranges=[ - PowerRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - end_of_range=46924.65023353163, - start_of_range=11888.235871902496, - ) - ], - running_costs=NumberRange( - end_of_range=42897.60731684277, - start_of_range=33997.56376994998, - ), - ) - ], - id=uuid.UUID("2795136c-eb30-4f8a-bdaa-61feba1e71b6"), - ) - ], - supported_commodities=[Commodity.ELECTRICITY], - timers=[ - Timer( - diagnostic_label="some-test-string4315", - duration=Duration.from_timedelta( - timedelta(milliseconds=14099) - ), - id=uuid.UUID("e1ff9e58-935b-4765-92e3-5e7679f73eb6"), - ) - ], - transitions=[transition], - ) - ], - message_id=uuid.UUID("97256813-de70-4640-a992-9ae0b2d8e4d1"), - message_type="FRBC.SystemDescription", - storage=FRBCStorageDescription( - diagnostic_label="some-test-string8418", - fill_level_label="some-test-string9512", - fill_level_range=NumberRange( - end_of_range=20876.752745956997, start_of_range=18324.0229135081 - ), - provides_fill_level_target_profile=False, - provides_leakage_behaviour=True, - provides_usage_forecast=False, - ), - valid_from=datetime( - year=2020, - month=10, - day=7, - hour=6, - minute=30, - second=55, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - # Act - json_str = frbc_system_description.to_json() - - # Assert - expected_json = { - "actuators": [ - { - "diagnostic_label": "some-test-string2728", - "id": "a1061148-f19e-4b1b-8fe3-b506583ce61e", - "operation_modes": [ - { - "abnormal_condition_only": False, - "diagnostic_label": "some-test-string2930", - "elements": [ - { - "fill_level_range": { - "end_of_range": 36932.65171036228, - "start_of_range": 12649.272766336762, - }, - "fill_rate": { - "end_of_range": 34553.16163528188, - "start_of_range": 14377.963894945604, - }, - "power_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "end_of_range": 46924.65023353163, - "start_of_range": 11888.235871902496, - } - ], - "running_costs": { - "end_of_range": 42897.60731684277, - "start_of_range": 33997.56376994998, - }, - } - ], - "id": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", - } - ], - "supported_commodities": ["ELECTRICITY"], - "timers": [ - { - "diagnostic_label": "some-test-string4315", - "duration": 14099, - "id": "e1ff9e58-935b-4765-92e3-5e7679f73eb6", - } - ], - "transitions": [ - { - "abnormal_condition_only": True, - "blocking_timers": ["e1ff9e58-935b-4765-92e3-5e7679f73eb6"], - "from": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", - "id": "c32cc1d3-4722-41e3-a8de-55307c723611", - "start_timers": ["e1ff9e58-935b-4765-92e3-5e7679f73eb6"], - "to": "2795136c-eb30-4f8a-bdaa-61feba1e71b6", - "transition_costs": 1018.4228054114793, - "transition_duration": 11988, - } - ], - } - ], - "message_id": "97256813-de70-4640-a992-9ae0b2d8e4d1", - "message_type": "FRBC.SystemDescription", - "storage": { - "diagnostic_label": "some-test-string8418", - "fill_level_label": "some-test-string9512", - "fill_level_range": { - "end_of_range": 20876.752745956997, - "start_of_range": 18324.0229135081, - }, - "provides_fill_level_target_profile": False, - "provides_leakage_behaviour": True, - "provides_usage_forecast": False, - }, - "valid_from": "2020-10-07T06:30:55Z", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_timer_status_test.py b/packages/s2-python/tests/unit/frbc/frbc_timer_status_test.py deleted file mode 100644 index 74e2924..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_timer_status_test.py +++ /dev/null @@ -1,82 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCTimerStatusTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "actuator_id": "f2e1f540-0235-429f-a45c-4d5cbe65d33f", - "finished_at": "2020-11-03T12:57:27+02:00", - "message_id": "57240f00-0b91-49bb-a4b0-2107d062faec", - "message_type": "FRBC.TimerStatus", - "timer_id": "bcb8e64f-ea4c-4b92-b4cb-20026a13d663" -} - """ - - # Act - frbc_timer_status = FRBCTimerStatus.from_json(json_str) - - # Assert - self.assertEqual( - frbc_timer_status.actuator_id, - uuid.UUID("f2e1f540-0235-429f-a45c-4d5cbe65d33f"), - ) - self.assertEqual( - frbc_timer_status.finished_at, - datetime( - year=2020, - month=11, - day=3, - hour=12, - minute=57, - second=27, - tzinfo=offset(offset=timedelta(seconds=7200.0)), - ), - ) - self.assertEqual( - frbc_timer_status.message_id, - uuid.UUID("57240f00-0b91-49bb-a4b0-2107d062faec"), - ) - self.assertEqual(frbc_timer_status.message_type, "FRBC.TimerStatus") - self.assertEqual( - frbc_timer_status.timer_id, - uuid.UUID("bcb8e64f-ea4c-4b92-b4cb-20026a13d663"), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_timer_status = FRBCTimerStatus( - actuator_id=uuid.UUID("f2e1f540-0235-429f-a45c-4d5cbe65d33f"), - finished_at=datetime( - year=2020, - month=11, - day=3, - hour=12, - minute=57, - second=27, - tzinfo=offset(offset=timedelta(seconds=7200.0)), - ), - message_id=uuid.UUID("57240f00-0b91-49bb-a4b0-2107d062faec"), - message_type="FRBC.TimerStatus", - timer_id=uuid.UUID("bcb8e64f-ea4c-4b92-b4cb-20026a13d663"), - ) - - # Act - json_str = frbc_timer_status.to_json() - - # Assert - expected_json = { - "actuator_id": "f2e1f540-0235-429f-a45c-4d5cbe65d33f", - "finished_at": "2020-11-03T12:57:27+02:00", - "message_id": "57240f00-0b91-49bb-a4b0-2107d062faec", - "message_type": "FRBC.TimerStatus", - "timer_id": "bcb8e64f-ea4c-4b92-b4cb-20026a13d663", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py b/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py deleted file mode 100644 index 3df7f63..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_element_test.py +++ /dev/null @@ -1,83 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCUsageForecastElementTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "duration": 9317, - "usage_rate_expected": 866.9362374046218, - "usage_rate_lower_68PPR": 3496.6233093198375, - "usage_rate_lower_95PPR": 4206.0536932975065, - "usage_rate_lower_limit": 7353.272756502293, - "usage_rate_upper_68PPR": 5124.8129813156465, - "usage_rate_upper_95PPR": 264.3386978845277, - "usage_rate_upper_limit": 4474.174577002476 -} - """ - - # Act - frbc_usage_forecast_element = FRBCUsageForecastElement.from_json(json_str) - - # Assert - self.assertEqual( - frbc_usage_forecast_element.duration, - Duration.from_timedelta(timedelta(milliseconds=9317)), - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_expected, 866.9362374046218 - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_lower_68PPR, 3496.6233093198375 - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_lower_95PPR, 4206.0536932975065 - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_lower_limit, 7353.272756502293 - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_upper_68PPR, 5124.8129813156465 - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_upper_95PPR, 264.3386978845277 - ) - self.assertEqual( - frbc_usage_forecast_element.usage_rate_upper_limit, 4474.174577002476 - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_usage_forecast_element = FRBCUsageForecastElement( - duration=Duration.from_timedelta(timedelta(milliseconds=9317)), - usage_rate_expected=866.9362374046218, - usage_rate_lower_68PPR=3496.6233093198375, - usage_rate_lower_95PPR=4206.0536932975065, - usage_rate_lower_limit=7353.272756502293, - usage_rate_upper_68PPR=5124.8129813156465, - usage_rate_upper_95PPR=264.3386978845277, - usage_rate_upper_limit=4474.174577002476, - ) - - # Act - json_str = frbc_usage_forecast_element.to_json() - - # Assert - expected_json = { - "duration": 9317, - "usage_rate_expected": 866.9362374046218, - "usage_rate_lower_68PPR": 3496.6233093198375, - "usage_rate_lower_95PPR": 4206.0536932975065, - "usage_rate_lower_limit": 7353.272756502293, - "usage_rate_upper_68PPR": 5124.8129813156465, - "usage_rate_upper_95PPR": 264.3386978845277, - "usage_rate_upper_limit": 4474.174577002476, - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py b/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py deleted file mode 100644 index a7f0da1..0000000 --- a/packages/s2-python/tests/unit/frbc/frbc_usage_forecast_test.py +++ /dev/null @@ -1,119 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.frbc import * - - -class FRBCUsageForecastTest(TestCase): - def test__from_json__happy_path_full(self): - # Arrange - json_str = """ -{ - "elements": [ - { - "duration": 14010, - "usage_rate_expected": 8032.572599815139, - "usage_rate_lower_68PPR": 3910.197692207213, - "usage_rate_lower_95PPR": 6541.633895752248, - "usage_rate_lower_limit": 3419.1709124422173, - "usage_rate_upper_68PPR": 7146.0702352976305, - "usage_rate_upper_95PPR": 627.7040858037238, - "usage_rate_upper_limit": 8477.800850190179 - } - ], - "message_id": "4a91b4ab-21fb-42ae-b97d-6170f8b922cc", - "message_type": "FRBC.UsageForecast", - "start_time": "2023-03-25T13:48:35+02:00" -} - """ - - # Act - frbc_usage_forecast = FRBCUsageForecast.from_json(json_str) - - # Assert - self.assertEqual( - frbc_usage_forecast.elements, - [ - FRBCUsageForecastElement( - duration=Duration.from_timedelta(timedelta(milliseconds=14010)), - usage_rate_expected=8032.572599815139, - usage_rate_lower_68PPR=3910.197692207213, - usage_rate_lower_95PPR=6541.633895752248, - usage_rate_lower_limit=3419.1709124422173, - usage_rate_upper_68PPR=7146.0702352976305, - usage_rate_upper_95PPR=627.7040858037238, - usage_rate_upper_limit=8477.800850190179, - ) - ], - ) - self.assertEqual( - frbc_usage_forecast.message_id, - uuid.UUID("4a91b4ab-21fb-42ae-b97d-6170f8b922cc"), - ) - self.assertEqual(frbc_usage_forecast.message_type, "FRBC.UsageForecast") - self.assertEqual( - frbc_usage_forecast.start_time, - datetime( - year=2023, - month=3, - day=25, - hour=13, - minute=48, - second=35, - tzinfo=offset(offset=timedelta(seconds=7200.0)), - ), - ) - - def test__to_json__happy_path_full(self): - # Arrange - frbc_usage_forecast = FRBCUsageForecast( - elements=[ - FRBCUsageForecastElement( - duration=Duration.from_timedelta(timedelta(milliseconds=14010)), - usage_rate_expected=8032.572599815139, - usage_rate_lower_68PPR=3910.197692207213, - usage_rate_lower_95PPR=6541.633895752248, - usage_rate_lower_limit=3419.1709124422173, - usage_rate_upper_68PPR=7146.0702352976305, - usage_rate_upper_95PPR=627.7040858037238, - usage_rate_upper_limit=8477.800850190179, - ) - ], - message_id=uuid.UUID("4a91b4ab-21fb-42ae-b97d-6170f8b922cc"), - message_type="FRBC.UsageForecast", - start_time=datetime( - year=2023, - month=3, - day=25, - hour=13, - minute=48, - second=35, - tzinfo=offset(offset=timedelta(seconds=7200.0)), - ), - ) - - # Act - json_str = frbc_usage_forecast.to_json() - - # Assert - expected_json = { - "elements": [ - { - "duration": 14010, - "usage_rate_expected": 8032.572599815139, - "usage_rate_lower_68PPR": 3910.197692207213, - "usage_rate_lower_95PPR": 6541.633895752248, - "usage_rate_lower_limit": 3419.1709124422173, - "usage_rate_upper_68PPR": 7146.0702352976305, - "usage_rate_upper_95PPR": 627.7040858037238, - "usage_rate_upper_limit": 8477.800850190179, - } - ], - "message_id": "4a91b4ab-21fb-42ae-b97d-6170f8b922cc", - "message_type": "FRBC.UsageForecast", - "start_time": "2023-03-25T13:48:35+02:00", - } - self.assertEqual(json.loads(json_str), expected_json) diff --git a/packages/s2-python/tests/unit/message_test.py b/packages/s2-python/tests/unit/message_test.py deleted file mode 100644 index f667306..0000000 --- a/packages/s2-python/tests/unit/message_test.py +++ /dev/null @@ -1,63 +0,0 @@ -import unittest - -import importlib -import inspect -import pkgutil -from typing import get_args - -from s2python import message -from s2python.validate_values_mixin import S2MessageComponent - - -class S2MessageTest(unittest.TestCase): - """Check importing S2Message classes from s2_python.message.""" - - def _test_import_s2_messages(self, module_name): - """Check each S2MessageComponent subclass in the given module is importable.""" - module = importlib.import_module(module_name) - - # Find all submodules - all_subclasses = [] - for _, name, _ in pkgutil.iter_modules(module.__path__, module.__name__ + "."): - submodule = importlib.import_module(name) - - # Find all classes in the submodule that subclass BaseClass - subclasses = [ - obj - for _, obj in inspect.getmembers(submodule, inspect.isclass) - if issubclass(obj, S2MessageComponent) and obj is not S2MessageComponent - ] - all_subclasses.extend(subclasses) - - # Ensure we found at least one subclass - self.assertGreater( - len(all_subclasses), 0, f"No subclasses found in {module_name}" - ) - - for _class in all_subclasses: - assert hasattr( - message, _class.__name__ - ), f"{_class} should be importable from s2_python.message" - if "message_id" in _class.model_fields or "subject_message_id" in _class.model_fields: - assert _class in get_args(message.S2Message), ( - f"{_class} should be typed as a s2_python.message.S2Message", - ) - else: - assert _class in get_args(message.S2MessageElement), ( - f"{_class} should be typed as a s2_python.message.S2MessageElement", - ) - - def test_import_s2_messages__common(self): - self._test_import_s2_messages("s2python.common") - - def test_import_s2_messages__ddbc(self): - self._test_import_s2_messages("s2python.ddbc") - - def test_import_s2_messages__frbc(self): - self._test_import_s2_messages("s2python.frbc") - - def test_import_s2_messages__pebc(self): - self._test_import_s2_messages("s2python.pebc") - - def test_import_s2_messages__ppbc(self): - self._test_import_s2_messages("s2python.ppbc") diff --git a/packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py b/packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py deleted file mode 100644 index c27964e..0000000 --- a/packages/s2-python/tests/unit/pebc/pebc_allowed_limit_range_test.py +++ /dev/null @@ -1,87 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.pebc import * -from s2python.s2_validation_error import S2ValidationError - - -class PEBCAllowedLimitRangeTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """ -{ - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": 1000.0 - }, - "abnormal_condition_only": false -} - """ - - # Act - allowed_limit_range = PEBCAllowedLimitRange.from_json(json_str) - - # Assert - self.assertEqual( - allowed_limit_range.commodity_quantity, - CommodityQuantity.ELECTRIC_POWER_L1, - ) - self.assertEqual( - allowed_limit_range.limit_type, - PEBCPowerEnvelopeLimitType.UPPER_LIMIT, - ) - self.assertEqual( - allowed_limit_range.range_boundary.start_of_range, 0.0 - ) - self.assertEqual( - allowed_limit_range.range_boundary.end_of_range, 1000.0 - ) - self.assertEqual( - allowed_limit_range.abnormal_condition_only, False - ) - - def test__to_json__happy_path(self): - # Arrange - allowed_limit_range = PEBCAllowedLimitRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - limit_type=PEBCPowerEnvelopeLimitType.UPPER_LIMIT, - range_boundary=NumberRange( - start_of_range=0.0, end_of_range=1000.0 - ), - abnormal_condition_only=False, - ) - - # Act - json_str = allowed_limit_range.to_json() - - # Assert - expected_json = { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": {"start_of_range": 0.0, "end_of_range": 1000.0}, - "abnormal_condition_only": False, - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__from_json__invalid_range_boundary(self): - # Arrange - json_str = """ -{ - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": { - "start_of_range": 1000.0, - "end_of_range": 0.0 - }, - "abnormal_condition_only": false -} - """ - - # Act & Assert - with self.assertRaises(S2ValidationError) as context: - PEBCAllowedLimitRange.from_json(json_str) diff --git a/packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py b/packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py deleted file mode 100644 index 20c739d..0000000 --- a/packages/s2-python/tests/unit/pebc/pebc_power_constraints_test.py +++ /dev/null @@ -1,248 +0,0 @@ -from datetime import timedelta, datetime, timezone as offset -import json -from unittest import TestCase -import uuid - -from s2python.common import * -from s2python.pebc import * -from s2python.s2_validation_error import S2ValidationError - - -class PEBCPowerConstraintsTest(TestCase): - def test__from_json__happy_path(self): - # Arrange - json_str = """ -{ - "message_type": "PEBC.PowerConstraints", - "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", - "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", - "valid_from": "2025-05-12T12:00:00.000000Z", - "valid_until": "2025-05-12T13:00:00.000000Z", - "consequence_type": "VANISH", - "allowed_limit_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": 0.0 - }, - "abnormal_condition_only": false - }, - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "LOWER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": -2000.0 - }, - "abnormal_condition_only": false - } - ] -} - """ - - # Act - pebc_power_constraints: PEBCPowerConstraints = PEBCPowerConstraints.from_json( - json_str - ) - - self.assertEqual( - pebc_power_constraints.id, - uuid.UUID("7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e"), - ) - self.assertEqual( - pebc_power_constraints.message_id, - uuid.UUID("5165cd7f-04bd-4c78-8fdd-b504cb0013a3"), - ) - self.assertEqual(pebc_power_constraints.message_type, "PEBC.PowerConstraints") - self.assertEqual( - pebc_power_constraints.consequence_type, - PEBCPowerEnvelopeConsequenceType.VANISH, - ) - - self.assertEqual( - pebc_power_constraints.valid_from, - datetime( - year=2025, - month=5, - day=12, - hour=12, - minute=0, - second=0, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - self.assertEqual( - pebc_power_constraints.valid_until, - datetime( - year=2025, - month=5, - day=12, - hour=13, - minute=0, - second=0, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - ) - - self.assertEqual(len(pebc_power_constraints.allowed_limit_ranges), 2) - - def test__to_json__happy_path(self): - # Arrange - pebc_power_constraints = PEBCPowerConstraints( - message_type="PEBC.PowerConstraints", - message_id=uuid.UUID("5165cd7f-04bd-4c78-8fdd-b504cb0013a3"), - id=uuid.UUID("7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e"), - valid_from=datetime( - year=2025, - month=5, - day=12, - hour=12, - minute=0, - second=0, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - valid_until=datetime( - year=2025, - month=5, - day=12, - hour=13, - minute=0, - second=0, - tzinfo=offset(offset=timedelta(seconds=0.0)), - ), - consequence_type=PEBCPowerEnvelopeConsequenceType.VANISH, - allowed_limit_ranges=[ - PEBCAllowedLimitRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - limit_type=PEBCPowerEnvelopeLimitType.UPPER_LIMIT, - range_boundary=NumberRange(start_of_range=0.0, end_of_range=0.0), - abnormal_condition_only=False, - ), - PEBCAllowedLimitRange( - commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, - limit_type=PEBCPowerEnvelopeLimitType.LOWER_LIMIT, - range_boundary=NumberRange( - start_of_range=0.0, end_of_range=-2000.0 - ), - abnormal_condition_only=False, - ), - ], - ) - - # Act - json_str = pebc_power_constraints.to_json() - - # Assert - expected_json = { - "message_type": "PEBC.PowerConstraints", - "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", - "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", - "valid_from": "2025-05-12T12:00:00Z", - "valid_until": "2025-05-12T13:00:00Z", - "consequence_type": "VANISH", - "allowed_limit_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": {"start_of_range": 0.0, "end_of_range": 0.0}, - "abnormal_condition_only": False, - }, - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "LOWER_LIMIT", - "range_boundary": {"start_of_range": 0.0, "end_of_range": -2000.0}, - "abnormal_condition_only": False, - }, - ], - } - self.assertEqual(json.loads(json_str), expected_json) - - def test__from_json__missing_upper_limit(self): - # Arrange - json_str = """ -{ - "message_type": "PEBC.PowerConstraints", - "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", - "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", - "valid_from": "2025-05-12T12:00:00.000000Z", - "valid_until": "2025-05-12T13:00:00.000000Z", - "consequence_type": "VANISH", - "allowed_limit_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "LOWER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": -2000.0 - }, - "abnormal_condition_only": false - } - ] -} - """ - with self.assertRaises(S2ValidationError) as context: - PEBCPowerConstraints.from_json(json_str) - - def test__from_json__missing_lower_limit(self): - # Arrange - json_str = """ -{ - "message_type": "PEBC.PowerConstraints", - "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", - "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", - "valid_from": "2025-05-12T12:00:00.000000Z", - "valid_until": "2025-05-12T13:00:00.000000Z", - "consequence_type": "VANISH", - "allowed_limit_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": 0.0 - }, - "abnormal_condition_only": false - } - ] -} - """ - with self.assertRaises(S2ValidationError) as context: - PEBCPowerConstraints.from_json(json_str) - - def test__from_json__valid_until_before_valid_from(self): - # Arrange - json_str = """ -{ - "message_type": "PEBC.PowerConstraints", - "message_id": "5165cd7f-04bd-4c78-8fdd-b504cb0013a3", - "id": "7fe73aa9-1ce0-41e1-9a5f-ea7795687e5e", - "valid_from": "2025-05-12T13:00:00.000000Z", - "valid_until": "2025-05-12T12:00:00.000000Z", - "consequence_type": "VANISH", - "allowed_limit_ranges": [ - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "UPPER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": 0.0 - }, - "abnormal_condition_only": false - }, - { - "commodity_quantity": "ELECTRIC.POWER.L1", - "limit_type": "LOWER_LIMIT", - "range_boundary": { - "start_of_range": 0.0, - "end_of_range": -2000.0 - }, - "abnormal_condition_only": false - } - ] -} - """ - with self.assertRaises(S2ValidationError) as context: - PEBCPowerConstraints.from_json(json_str) diff --git a/packages/s2-python/tests/unit/reception_status_awaiter_test.py b/packages/s2-python/tests/unit/reception_status_awaiter_test.py deleted file mode 100644 index fb06630..0000000 --- a/packages/s2-python/tests/unit/reception_status_awaiter_test.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Tests for ReceptionStatusAwaiter. - -Copied from -https://github.com/flexiblepower/s2-analyzer/blob/main/backend/test/s2_analyzer_backend/reception_status_awaiter_test.py -under Apache2 license on 31-08-2024. -""" - -import asyncio -import datetime -import uuid -from unittest import IsolatedAsyncioTestCase - -from s2python.common import ( - ReceptionStatus, - ReceptionStatusValues, - InstructionStatus, - InstructionStatusUpdate, -) -from s2python.reception_status_awaiter import ReceptionStatusAwaiter - - -class ReceptionStatusAwaiterTest(IsolatedAsyncioTestCase): - async def test__wait_for_reception_status__receive_while_waiting(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - message_id = uuid.uuid4() - s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - # Act - wait_task = asyncio.create_task( - awaiter.wait_for_reception_status(message_id, 1.0) - ) - should_be_waiting_still = not wait_task.done() - await awaiter.receive_reception_status(s2_reception_status) - await wait_task - received_s2_reception_status = wait_task.result() - - # Assert - expected_s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - self.assertTrue(should_be_waiting_still) - self.assertEqual(expected_s2_reception_status, received_s2_reception_status) - - async def test__wait_for_reception_status__already_received(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - message_id = uuid.uuid4() - s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - # Act - await awaiter.receive_reception_status(s2_reception_status) - received_s2_reception_status = await awaiter.wait_for_reception_status( - message_id, 1.0 - ) - - # Assert - expected_s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - self.assertEqual(expected_s2_reception_status, received_s2_reception_status) - - async def test__wait_for_reception_status__multiple_receive_while_waiting(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - message_id = uuid.uuid4() - s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - # Act - wait_task_1 = asyncio.create_task( - awaiter.wait_for_reception_status(message_id, 1.0) - ) - wait_task_2 = asyncio.create_task( - awaiter.wait_for_reception_status(message_id, 1.0) - ) - should_be_waiting_still_1 = not wait_task_1.done() - should_be_waiting_still_2 = not wait_task_2.done() - await awaiter.receive_reception_status(s2_reception_status) - await wait_task_1 - await wait_task_2 - received_s2_reception_status_1 = wait_task_1.result() - received_s2_reception_status_2 = wait_task_2.result() - - # Assert - expected_s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - self.assertTrue(should_be_waiting_still_1) - self.assertTrue(should_be_waiting_still_2) - self.assertEqual(expected_s2_reception_status, received_s2_reception_status_1) - self.assertEqual(expected_s2_reception_status, received_s2_reception_status_2) - - async def test__receive_reception_status__wrong_message(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - s2_msg = InstructionStatusUpdate( - message_id=uuid.uuid4(), - instruction_id=uuid.uuid4(), - status_type=InstructionStatus.NEW, - timestamp=datetime.datetime.now(datetime.timezone.utc), - ) - - # Act / Assert - with self.assertRaises(RuntimeError): - await awaiter.receive_reception_status(s2_msg) # type: ignore[arg-type] - - async def test__receive_reception_status__received_duplicate(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=uuid.uuid4(), status=ReceptionStatusValues.OK - ) - - # Act / Assert - await awaiter.receive_reception_status(s2_reception_status) - with self.assertRaises(RuntimeError): - await awaiter.receive_reception_status(s2_reception_status) - - async def test__receive_reception_status__receive_no_awaiting(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - message_id = uuid.uuid4() - s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - # Act - await awaiter.receive_reception_status(s2_reception_status) - - # Assert - expected_received = { - message_id: ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - } - self.assertEqual(awaiter.received, expected_received) - self.assertEqual(awaiter.awaiting, {}) - - async def test__receive_reception_status__receive_with_awaiting(self): - # Arrange - awaiter = ReceptionStatusAwaiter() - awaiting_event = asyncio.Event() - message_id = uuid.uuid4() - awaiter.awaiting = {message_id: awaiting_event} - s2_reception_status = ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - - # Act - should_not_be_set = not awaiting_event.is_set() - await awaiter.receive_reception_status(s2_reception_status) - should_be_set = awaiting_event.is_set() - - # Assert - expected_received = { - message_id: ReceptionStatus( # pyright: ignore[reportCallIssue] - subject_message_id=message_id, status=ReceptionStatusValues.OK - ) - } - - self.assertTrue(should_not_be_set) - self.assertTrue(should_be_set) - self.assertEqual(awaiter.received, expected_received) - self.assertEqual(awaiter.awaiting, {}) diff --git a/packages/s2-python/tests/unit/s2_connection_test.py b/packages/s2-python/tests/unit/s2_connection_test.py deleted file mode 100644 index fcb8b37..0000000 --- a/packages/s2-python/tests/unit/s2_connection_test.py +++ /dev/null @@ -1,65 +0,0 @@ -# import unittest -# -# -# class S2ConnectionTest(unittest.TestCase): -# async def test__send_and_await_reception_status__receive_while_waiting(self): -# # Arrange -# conn = Mock() -# awaiter = ReceptionStatusAwaiter() -# message_id = "1" -# s2_message = { -# "message_type": "Handshake", -# "message_id": message_id, -# "role": "RM", -# "supported_protocol_versions": ["1.0"], -# } -# s2_reception_status = { -# "message_type": "ReceptionStatus", -# "subject_message_id": message_id, -# "status": "OK", -# } -# -# # Act -# wait_task = asyncio.create_task( -# awaiter.send_and_await_reception_status(conn, s2_message, True) -# ) -# should_be_waiting_still = not wait_task.done() -# await awaiter.receive_reception_status(s2_reception_status) -# await wait_task -# received_s2_reception_status = wait_task.result() -# -# # Assert -# expected_s2_reception_status = { -# "message_type": "ReceptionStatus", -# "subject_message_id": "1", -# "status": "OK", -# } -# -# self.assertTrue(should_be_waiting_still) -# self.assertEqual(expected_s2_reception_status, received_s2_reception_status) -# -# async def test__send_and_await_reception_status__receive_while_waiting_not_okay(self): -# # Arrange -# conn = Mock() -# awaiter = ReceptionStatusAwaiter() -# message_id = "1" -# s2_message = { -# "message_type": "Handshake", -# "message_id": message_id, -# "role": "RM", -# "supported_protocol_versions": ["1.0"], -# } -# s2_reception_status = { -# "message_type": "ReceptionStatus", -# "subject_message_id": message_id, -# "status": "INVALID_MESSAGE", -# } -# -# # Act / Assert -# wait_task = asyncio.create_task( -# awaiter.send_and_await_reception_status(conn, s2_message, True) -# ) -# await awaiter.receive_reception_status(s2_reception_status) -# -# with self.assertRaises(RuntimeError): -# await wait_task diff --git a/packages/s2-python/tests/unit/s2_parser_test.py b/packages/s2-python/tests/unit/s2_parser_test.py deleted file mode 100644 index eac9f4a..0000000 --- a/packages/s2-python/tests/unit/s2_parser_test.py +++ /dev/null @@ -1,120 +0,0 @@ -from unittest import TestCase -from uuid import UUID - -from s2python.common import HandshakeResponse -from s2python.generated.gen_s2 import EnergyManagementRole -from s2python.s2_parser import S2Parser -from s2python.common.handshake import Handshake -from s2python.s2_validation_error import S2ValidationError - - -class S2ParserTest(TestCase): - def test_parse_as_any_message__str(self): - # Arrange - message_json = ( - '{"message_id": "ca093515-0bb3-4709-bd56-092c1808b791", "message_type": "Handshake", "role": ' - '"CEM", "supported_protocol_versions": ["3.0alpha"]}' - ) - - # Act - parsed_message = S2Parser.parse_as_any_message(message_json) - - # Assert - self.assertEqual( - parsed_message, - Handshake( - message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), - role=EnergyManagementRole.CEM, - supported_protocol_versions=["3.0alpha"], - ), - ) - - def test_parse_as_any_message__dict(self): - # Arrange - message_json = { - "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", - "message_type": "Handshake", - "role": "CEM", - "supported_protocol_versions": ["3.0alpha"], - } - - # Act - parsed_message = S2Parser.parse_as_any_message(message_json) - - # Assert - self.assertEqual( - parsed_message, - Handshake( - message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), - role=EnergyManagementRole.CEM, - supported_protocol_versions=["3.0alpha"], - ), - ) - - def test_parse_as_any_message__dict_validation_error(self): - # Arrange - message_json = { - # "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", - "message_type": "Handshake", - "role": "CEM", - "supported_protocol_versions": ["3.0alpha"], - } - - # Act / Assert - with self.assertRaises(S2ValidationError): - S2Parser.parse_as_any_message(message_json) - - def test_parse_as_message__str(self): - # Arrange - message_json = ( - '{"message_id": "ca093515-0bb3-4709-bd56-092c1808b791", "message_type": "Handshake", "role": ' - '"CEM", "supported_protocol_versions": ["3.0alpha"]}' - ) - - # Act - parsed_message = S2Parser.parse_as_message(message_json, Handshake) - - # Assert - self.assertEqual( - parsed_message, - Handshake( - message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), - role=EnergyManagementRole.CEM, - supported_protocol_versions=["3.0alpha"], - ), - ) - - def test_parse_as_message__dict(self): - # Arrange - message_json = { - "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", - "message_type": "Handshake", - "role": "CEM", - "supported_protocol_versions": ["3.0alpha"], - } - - # Act - parsed_message = S2Parser.parse_as_message(message_json, Handshake) - - # Assert - self.assertEqual( - parsed_message, - Handshake( - message_id=UUID("ca093515-0bb3-4709-bd56-092c1808b791"), - role=EnergyManagementRole.CEM, - supported_protocol_versions=["3.0alpha"], - ), - ) - - def test_parse_as_message__dict_wrong_class(self): - # Arrange - message_json = { - "message_id": "ca093515-0bb3-4709-bd56-092c1808b791", - "message_type": "Handshake", - "role": "CEM", - "supported_protocol_versions": ["3.0alpha"], - } - - # Act / Assert - with self.assertRaises(S2ValidationError): - S2Parser.parse_as_message(message_json, HandshakeResponse) diff --git a/packages/s2-python/tests/unit/utils_test.py b/packages/s2-python/tests/unit/utils_test.py deleted file mode 100644 index 1b96baa..0000000 --- a/packages/s2-python/tests/unit/utils_test.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import List -from unittest import TestCase - -from s2python.utils import pairwise - - -class PairwiseTest(TestCase): - def test_empty(self): - # Arrange - input_array: List[int] = [] - - # Act - pairs = list(pairwise(input_array)) - - # Assert - self.assertEqual(len(pairs), 0) - - def test_len_2(self): - # Arrange - input_array = [1, 2] - - # Act - pairs = list(pairwise(input_array)) - - # Assert - self.assertEqual(pairs, [(1, 2)]) - - def test_odd(self): - # Arrange - input_array = [1, 2, 3] - - # Act - pairs = list(pairwise(input_array)) - - # Assert - self.assertEqual(pairs, [(1, 2), (2, 3)]) - - def test_even(self): - # Arrange - input_array = [1, 2, 3, 4] - - # Act - pairs = list(pairwise(input_array)) - - # Assert - self.assertEqual(pairs, [(1, 2), (2, 3), (3, 4)]) diff --git a/packages/s2-python/tox.ini b/packages/s2-python/tox.ini deleted file mode 100644 index deeca3b..0000000 --- a/packages/s2-python/tox.ini +++ /dev/null @@ -1,72 +0,0 @@ -[tox] -minversion = 3.24 -envlist = default -isolated_build = True - - -[testenv] -description = Invoke pytest to run automated tests - -setenv = - TOXINIDIR = {toxinidir} -passenv = - HOME - SETUPTOOLS_* -extras = - testing -commands = - pytest {posargs} - - -[testenv:{build,clean}] -description = - build: Build the package in isolation according to PEP517, see https://github.com/pypa/build - clean: Remove old distribution files and temporary build artifacts (./build and ./dist) -skip_install = True -changedir = {toxinidir} -deps = - build: build[virtualenv] -passenv = - SETUPTOOLS_* -commands = - clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' - clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' - build: python -m build {posargs} - - -[testenv:lint] -description = Lint the source code using pylint. -skip_install = True -changedir = {toxinidir} -deps = - -r dev-requirements.txt -commands = - pylint src/ tests/unit/ - -[testenv:typecheck] -description = Typecheck the source code using mypy. -skip_install = True -changedir = {toxinidir} -deps = - -r dev-requirements.txt -commands = - mypy --config-file mypy.ini src/ ./tests/unit/ - - -[testenv:publish] -description = - Publish the package you have been developing to a package index server. - By default, it uses testpypi. If you really want to publish your package - to be publicly accessible in PyPI, use the `-- --repository pypi` option. -skip_install = True -changedir = {toxinidir} -passenv = - # See: https://twine.readthedocs.io/en/latest/ - TWINE_USERNAME - TWINE_PASSWORD - TWINE_REPOSITORY - TWINE_REPOSITORY_URL -deps = twine -commands = - python -m twine check dist/* - python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* \ No newline at end of file From b65ebfc7c924f875088402a21e3bb9e52e977850 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 17:04:39 +0200 Subject: [PATCH 28/75] Split up FRBC test case into multiple cases --- .../test_suite/frbc_test_cases/__init__.py | 0 .../base.py} | 39 +----------- .../frbc_test_cases/frbc_test_cases.py | 61 +++++++++++++++++++ 3 files changed, 62 insertions(+), 38 deletions(-) create mode 100644 packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py rename packages/test-suites/src/testsuites/test_suite/{frbc_test_cases.py => frbc_test_cases/base.py} (59%) create mode 100644 packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py similarity index 59% rename from packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py rename to packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py index 30f10c8..e75b404 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py @@ -58,41 +58,4 @@ async def wait_for_system_description(self): self.controller._system_description_received, ) await self.controller._system_description_received.wait() - logger.debug("System description is set.") - - @S2TestCase.test - async def test_receive_frbc_system_description(self): - await self.wait_for_system_description() - - finding = ComplianceFinding(test="Test receive FRBCSystemDescription") - - message = await self.check_receive_message_type(FRBCSystemDescription, finding) - - self.report.add_finding(finding) - - @S2TestCase.test - async def test_receive_actuator_status(self): - - finding = ComplianceFinding(test="Test receive FRBCActuatorStatus") - - message = await self.check_receive_message_type(FRBCActuatorStatus, finding) - - self.report.add_finding(finding) - - @S2TestCase.test - async def test_receive_storage_status(self): - - finding = ComplianceFinding(test="Test receive FRBCStorageStatus") - - message = await self.check_receive_message_type(FRBCStorageStatus, finding) - - self.report.add_finding(finding) - - @S2TestCase.test - async def test_receive_usage_forecast(self): - - finding = ComplianceFinding(test="Test receive FRBCUsageForecast") - - message = await self.check_receive_message_type(FRBCUsageForecast, finding) - - self.report.add_finding(finding) + logger.debug("System description is set.") \ No newline at end of file diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py new file mode 100644 index 0000000..16e1e89 --- /dev/null +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py @@ -0,0 +1,61 @@ +import datetime +import json +import logging +import uuid + +from .base import FRBCTestCase +from testsuites.certificate.certificate import ( + ComplianceFinding, + ComplianceParameter, + ComplianceReport, + ComplianceStatus, +) +from s2python.common import PowerMeasurement, ControlType as ProtocolControlType +from s2python.frbc import ( + FRBCActuatorStatus, + FRBCStorageDescription, + FRBCStorageStatus, + FRBCSystemDescription, + FRBCUsageForecast, +) +from testsuites.test_suite.test_suite import S2TestCase + +logger = logging.getLogger(__name__) + + +class TestReceiveFRBCSystemDescription(FRBCTestCase): + finding = ComplianceFinding(test="Test receive FRBCSystemDescription") + + @S2TestCase.test + async def test_receive_frbc_system_description(self): + await self.wait_for_system_description() + + message = await self.check_receive_message_type(FRBCSystemDescription) + + +class TEstReceiveActuatorStatus(FRBCTestCase): + @S2TestCase.test + async def test_receive_actuator_status(self): + await self.wait_for_system_description() + + message = await self.check_receive_message_type(FRBCActuatorStatus) + + +class TestReceiveStorageStatus(FRBCTestCase): + finding = ComplianceFinding(test="Test receive FRBCStorageStatus") + + @S2TestCase.test + async def test_receive_storage_status(self): + await self.wait_for_system_description() + + message = await self.check_receive_message_type(FRBCStorageStatus) + + +class TestReceiveUsageForecast(FRBCTestCase): + finding = ComplianceFinding(test="Test receive FRBCUsageForecast") + + @S2TestCase.test + async def test_receive_usage_forecast(self): + await self.wait_for_system_description() + + message = await self.check_receive_message_type(FRBCUsageForecast) From 59a4f9d53e39acfa7f4cf0fc601bf1980cf5bdf4 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Mon, 12 May 2025 17:05:36 +0200 Subject: [PATCH 29/75] Added S2-Python as a git submodule --- .gitmodules | 4 ++++ packages/s2-python | 1 + 2 files changed, 5 insertions(+) create mode 100644 .gitmodules create mode 160000 packages/s2-python diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..20bf314 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "packages/s2-python"] + path = packages/s2-python + url = git@github.com:AndrewRutherfoord/s2-python.git + branch = add_pebc_validation diff --git a/packages/s2-python b/packages/s2-python new file mode 160000 index 0000000..01be883 --- /dev/null +++ b/packages/s2-python @@ -0,0 +1 @@ +Subproject commit 01be883107530f0e5751c4c58ecfe4f3a7f5c445 From 77cde71064a034a74026b99253d45fe25786d47a Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Wed, 14 May 2025 10:04:41 +0200 Subject: [PATCH 30/75] Added test logger to get seperate logs from test suite --- README.md | 9 +- packages/s2-python | 2 +- .../src/testsuites/test_executor.py | 76 ++++++----- .../src/testsuites/test_suite/__init__.py | 3 +- .../testsuites/test_suite/base_test_case.py | 26 ++-- .../test_suite/frbc_test_cases/__init__.py | 6 + .../test_suite/frbc_test_cases/base.py | 5 +- .../frbc_test_cases/frbc_test_cases.py | 16 ++- .../test_suite/pebc_test_cases/base.py | 4 +- .../curtailment_instruction_test_case.py | 120 +++++++++++++----- .../src/testsuites/test_suite/test_suite.py | 80 ++++++++++-- packages/test-suites/uv.lock | 5 +- s2-self-cert/src/log.py | 15 +++ s2-self-cert/src/main.py | 4 +- s2-self-cert/uv.lock | 5 +- 15 files changed, 265 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 7b523ed..e8b841e 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,15 @@ ## Getting started -## Todo - ## Development +Currently this repository uses a custom version of the S2-Python library. It is in this repository as a Git Submodule. In order to clone the repo with the submodule please run: + +```bash +git submodule init +git submodule update --remote --merge +``` + ### Executing with Hot Reload ```bash diff --git a/packages/s2-python b/packages/s2-python index 01be883..83ab15b 160000 --- a/packages/s2-python +++ b/packages/s2-python @@ -1 +1 @@ -Subproject commit 01be883107530f0e5751c4c58ecfe4f3a7f5c445 +Subproject commit 83ab15bcd8ff78f9922a794dc48c67d08a24684c diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 907a3d0..7882f56 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -9,9 +9,8 @@ from testsuites.certificate.certificate import ComplianceReport -from testsuites.test_suite.test_suite import TestSuite, TestSuiteBuilder +from testsuites.test_suite.test_suite import TestLogger, TestSuite, TestSuiteBuilder from testsuites.test_suite import ( - FRBCTestCase, ReceivePowerMeasurementTestCase, ReceivePowerForecastTestCase, ) @@ -25,6 +24,12 @@ PEBCController, FRBCController, ) +from testsuites.test_suite.frbc_test_cases import ( + FRBCActuatorStatusTestCase, + FRBCSystemDescriptionTestCase, + FRBCStorageStatusTestCase, + FRBCUsageForecastTestCase, +) from connectivity.async_task_manager import AsyncTaskManager @@ -64,17 +69,17 @@ class IntegrationTestExecutor(AbstractExecutor): report: ComplianceReport + test_logger: TestLogger + _stop_event: asyncio.Event _handshake_complete: asyncio.Event - logger: logging.Logger - def __init__( self, available_control_types: Dict[ProtocolControlType, Controller], test_suite: TestSuite, report: ComplianceReport, - logger: logging.Logger = logging.getLogger(__name__), + test_logger: TestLogger, ) -> None: self.controllers = available_control_types @@ -88,7 +93,7 @@ def __init__( self.report = report - self.logger = logger + self.test_logger = test_logger self._stop_event = asyncio.Event() self._handshake_complete = asyncio.Event() @@ -123,11 +128,11 @@ async def process_received_messages(self): # logger.info(message) await self.process_message(message) except asyncio.CancelledError: - self.logger.info("Message Channel cancelled.") + logger.info("Message Channel cancelled.") except ConnectionClosed: pass except Exception as e: - self.logger.exception("Message processor encountered an error: %s", e) + logger.exception("Message processor encountered an error: %s", e) await self.stop() async def execute_test_suite(self): @@ -141,41 +146,39 @@ async def execute_test_suite(self): await self.test_suite.execute(self.channel, self.controller) async def main_loop(self): - self.logger.info("Starting Main Loop.") + logger.info("Starting Main Loop.") if self.channel is None: raise ValueError("Channel not set.") - + self.test_logger.info("Test suite starting. ", ident=0) try: await self.controller.perform_handshake(self.channel) await self.controller.wait_until_rm_details_received() - self.logger.info("Handshake Complete!") + self.test_logger.success("Handshake Complete", ident=0) await self.send_select_control_type() - self.logger.info("Starting tests!") - await self.execute_test_suite() - self.logger.info("Sending graceful disconnect.") await self.controller.perform_disconnect(self.channel) + self.test_logger.success("Sent Graceful Disconnect..", ident=0) - self.logger.info("Exiting Main Loop.") + logger.info("Exiting Test Executor Main Loop.") except asyncio.CancelledError: - self.logger.warning("Main loop was cancelled.") + logger.warning("Main loop was cancelled.") raise # Propagate for TaskGroup except Exception as e: - self.logger.exception("Exception in main_loop: %s", e) + logger.exception("Exception in main_loop: %s", e) await self.stop() raise finally: - self.logger.info("Main loop finished. Signaling stop.") + self.test_logger.info("Main loop finished. Signaling stop.", ident=0) await self.stop() async def send_select_control_type(self): # TODO: Select the control type in a better way. - self.logger.info("Selecting Control Type.") + logger.info("Selecting Control Type.") if ( self.controller.resource_manager_details is None or self.controller.resource_manager_details.available_control_types is None @@ -202,20 +205,24 @@ async def send_select_control_type(self): # ) if control_type is None: - self.logger.warning("No suitable control types available. Exiting...") + self.test_logger.error( + "Select Control Type Failed. No suitable control type available.", + ident=0, + ) await self.stop() return - self.logger.info("Selecting control type %s", control_type) self.set_control_type(control_type) await self.controller.select_control_type(self.channel) + self.test_logger.success(f"Control Type Selection. Selected: {control_type}") + def is_running(self): return self.running async def stop(self): - self.logger.debug("Stop Called in class %s", self.__class__.__name__) + logger.debug("Stop Called in class %s", self.__class__.__name__) self._stop_event.set() if self.channel is not None: @@ -253,7 +260,7 @@ async def run(self, *args, **kwargs): async with asyncio.TaskGroup() as tg: if self.channel is None: - self.logger.error( + logger.error( "Channel not initialized before run, cannot start channel.run task." ) await self.stop() @@ -265,16 +272,14 @@ async def run(self, *args, **kwargs): tg.create_task(self.process_received_messages(), name="MessageProcess") tg.create_task(self.main_loop(), name="MainLoop") - self.logger.info( - "IntegrationTestExecutor TaskGroup completed successfully." - ) + logger.info("IntegrationTestExecutor TaskGroup completed successfully.") except* Exception as eg: # Catches one or more exceptions from tasks - self.logger.error( + logger.error( f"ExceptionGroup caught in IntegrationTestExecutor run: {len(eg.exceptions)} exceptions" ) for i, exc in enumerate(eg.exceptions): - self.logger.error( + logger.error( f" Exception {i+1}/{len(eg.exceptions)} in TaskGroup:", exc_info=exc, ) @@ -283,9 +288,9 @@ async def run(self, *args, **kwargs): # logger.warning("IntegrationTestExecutor run method was cancelled externally.") # self.stop() finally: - self.logger.info("IntegrationTestExecutor run method finishing.") + logger.info("IntegrationTestExecutor run method finishing.") await self.cleanup() # Perform final cleanup (e.g., channel.stop()) - self.logger.info("Cleanup finished.") + logger.info("Cleanup finished.") self.running = False @@ -306,19 +311,22 @@ def create_controllers_dict_with_config( def create_test_executor( - config: Config, logger: logging.Logger + config: Config, test_logger: TestLogger ) -> IntegrationTestExecutor: report = ComplianceReport(timestamp=datetime.now(), device=config.device_details) controllers = create_controllers_dict_with_config(config) test_suite = ( - TestSuiteBuilder(config.control_types, report) + TestSuiteBuilder(config.control_types, report, test_logger) .with_test_case(ReceivePowerForecastTestCase) .with_test_case(ReceivePowerMeasurementTestCase) .with_test_case(PEBCPowerConstraintsTestCase) .with_test_case(PEBCCurtailmentInstructionTestCase) - .with_test_case(FRBCTestCase) + .with_test_case(FRBCUsageForecastTestCase) + .with_test_case(FRBCActuatorStatusTestCase) + .with_test_case(FRBCSystemDescriptionTestCase) + .with_test_case(FRBCStorageStatusTestCase) .build() ) @@ -326,7 +334,7 @@ def create_test_executor( available_control_types=controllers, test_suite=test_suite, report=report, - logger=logger, + test_logger=test_logger, ) return executor diff --git a/packages/test-suites/src/testsuites/test_suite/__init__.py b/packages/test-suites/src/testsuites/test_suite/__init__.py index d03cbb4..7de4094 100644 --- a/packages/test-suites/src/testsuites/test_suite/__init__.py +++ b/packages/test-suites/src/testsuites/test_suite/__init__.py @@ -1,3 +1,2 @@ -from .frbc_test_cases import FRBCTestCase from .base_test_case import ReceivePowerMeasurementTestCase, ReceivePowerForecastTestCase -from .test_suite import S2TestCase, TestSuite, TestSuiteBuilder +from .test_suite import TestLogger, S2TestCase, TestSuite, TestSuiteBuilder diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index 72a7dc0..3c03398 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -4,13 +4,12 @@ ComplianceStatus, ) from ..controllers.controller import BaseController -from ..test_suite.test_suite import S2TestCase +from ..test_suite.test_suite import S2TestCase, TestLogger from s2python.common import ( PowerForecast, PowerMeasurement, ControlType as ProtocolControlType, - ResourceManagerDetails, ) from connectivity.config import BaseTestConfig from connectivity.s2_channel import S2Channel @@ -21,45 +20,46 @@ class NoSelectionTestCase(S2TestCase): - control_type = ProtocolControlType.NO_SELECTION + finding = ComplianceFinding(test="Test Receive RM Details") + TIMEOUT = 5 controller: BaseController config: BaseTestConfig - def __init__( self, config: BaseTestConfig, channel: S2Channel, controller: BaseController, report: ComplianceReport, + logger: TestLogger, ): - super().__init__(config, channel, controller, report) - - finding = ComplianceFinding(test="Test Receive RM Details") + super().__init__(config, channel, controller, report, logger) async def test_validate_rm_details_received(self): - finding = ComplianceFinding(test="Test Receive RM Details") - if self.controller.resource_manager_details is not None: - finding.add_parameter( + self.finding.add_parameter( "ResourceManagerDetails Received.", ComplianceStatus.PASS ) - finding.add_parameter( + self.finding.add_parameter( "ResourceManagerDetails Valid.", ComplianceStatus.PASS ) - self.report.add_finding(finding) + self.test_logger.success("Resource manager details received.") + class ReceivePowerForecastTestCase(NoSelectionTestCase): finding = ComplianceFinding(test="Test Receive Power Forecast") + @S2TestCase.test async def test_receive_power_forecast(self): message = await self.check_receive_message_type(PowerForecast) + self.test_logger.success("Test Received Power Forecast") + class ReceivePowerMeasurementTestCase(NoSelectionTestCase): finding = ComplianceFinding(test="Test Receive Power Measurement") @@ -67,4 +67,4 @@ class ReceivePowerMeasurementTestCase(NoSelectionTestCase): @S2TestCase.test async def test_receive_power_measurement(self): message = await self.check_receive_message_type(PowerMeasurement) - + self.test_logger.success("Test Received Power Measurement") diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py index e69de29..f5f02ec 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/__init__.py @@ -0,0 +1,6 @@ +from .frbc_test_cases import ( + FRBCActuatorStatusTestCase, + FRBCSystemDescriptionTestCase, + FRBCStorageStatusTestCase, + FRBCUsageForecastTestCase, +) diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py index e75b404..61e035d 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py @@ -40,8 +40,9 @@ def __init__( channel: S2Channel, controller: FRBCController, report: ComplianceReport, + logger: logging.Logger = logging.getLogger(__name__), ): - super().__init__(config, channel, controller, report) + super().__init__(config, channel, controller, report, logger) async def setup(self): await self.controller._system_description_received.wait() @@ -58,4 +59,4 @@ async def wait_for_system_description(self): self.controller._system_description_received, ) await self.controller._system_description_received.wait() - logger.debug("System description is set.") \ No newline at end of file + logger.debug("System description is set.") diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py index 16e1e89..179de4f 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) -class TestReceiveFRBCSystemDescription(FRBCTestCase): +class FRBCSystemDescriptionTestCase(FRBCTestCase): finding = ComplianceFinding(test="Test receive FRBCSystemDescription") @S2TestCase.test @@ -32,16 +32,20 @@ async def test_receive_frbc_system_description(self): message = await self.check_receive_message_type(FRBCSystemDescription) + self.test_logger.info("[SUCCESS] Test Receive FRBC System Description") -class TEstReceiveActuatorStatus(FRBCTestCase): + +class FRBCActuatorStatusTestCase(FRBCTestCase): @S2TestCase.test async def test_receive_actuator_status(self): await self.wait_for_system_description() message = await self.check_receive_message_type(FRBCActuatorStatus) + self.test_logger.info("[SUCCESS] Test Receive FRBC Actuator Status") + -class TestReceiveStorageStatus(FRBCTestCase): +class FRBCStorageStatusTestCase(FRBCTestCase): finding = ComplianceFinding(test="Test receive FRBCStorageStatus") @S2TestCase.test @@ -50,8 +54,10 @@ async def test_receive_storage_status(self): message = await self.check_receive_message_type(FRBCStorageStatus) + self.test_logger.info("[SUCCESS] Test Receive FRBC Storage Status") -class TestReceiveUsageForecast(FRBCTestCase): + +class FRBCUsageForecastTestCase(FRBCTestCase): finding = ComplianceFinding(test="Test receive FRBCUsageForecast") @S2TestCase.test @@ -59,3 +65,5 @@ async def test_receive_usage_forecast(self): await self.wait_for_system_description() message = await self.check_receive_message_type(FRBCUsageForecast) + + self.test_logger.info("[SUCCESS] Test Receive FRBC Usage Forecast") diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py index 9dd6146..a4336eb 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py @@ -21,15 +21,15 @@ class PEBCTestCase(NoSelectionTestCase): controller: PEBCController config: PEBCTestConfig - def __init__( self, config: PEBCTestConfig, channel: S2Channel, controller: PEBCController, report: ComplianceReport, + logger: logging.Logger = logging.getLogger(__name__), ): - super().__init__(config, channel, controller, report) + super().__init__(config, channel, controller, report, logger) async def setup(self): await self.controller._power_constraints_received.wait() diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py index f5460c6..6b88429 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py @@ -59,8 +59,10 @@ def create_power_envelope( ) async def send_power_envelope( - self, power_envelope, expected_instruction_status: InstructionStatus - ): + self, + power_envelope: PEBCPowerEnvelope, + expected_instruction_status: InstructionStatus, + ) -> ComplianceStatus: # Prepare coroutines first so the events are waiting in the awaiter. # This avoids the case where the message somehow arrives between sending the message and starting to await. # This is highly unlikely but might as well make sure! @@ -80,36 +82,93 @@ async def send_power_envelope( status_update = await status_update_coroutine if type(status_update) != InstructionStatusUpdate: - self.add_finding_param( - name="InstructionStatusUpdate received.", status=ComplianceStatus.FAIL + self.test_logger.error( + f"Curtailment Test {power_envelope.commodity_quantity}: No Instruction status received after sending curtailment for {status_update}." ) - return - self.add_finding_param( - name="InstructionStatusUpdate received.", status=ComplianceStatus.PASS - ) + # self.add_finding_param( + # name="InstructionStatusUpdate received.", status=ComplianceStatus.FAIL + # ) + return ComplianceStatus.FAIL + # self.add_finding_param( + # name="InstructionStatusUpdate received.", status=ComplianceStatus.PASS + # ) logger.info("Status Update: %s", status_update) - if status_update.instruction_id == instruction.id: + # TODO: THis could break if multiple status updates are incoming... + if status_update.instruction_id != instruction.id: + self.add_finding_param( name="InstructionStatusUpdate instruction_id matches instruction's ID.", - status=ComplianceStatus.PASS, + status=ComplianceStatus.SOFT_FAIL, ) - else: - self.add_finding_param( - name="InstructionStatusUpdate instruction_id matches instruction's ID.", - status=ComplianceStatus.FAIL, + self.test_logger.soft_error( + "Curtailment Test {power_envelope.commodity_quantity}: InstructionStatusUpdate instruction_id does not matches instruction's ID." ) + return ComplianceStatus.SOFT_FAIL + + self.add_finding_param( + name="InstructionStatusUpdate instruction_id matches instruction's ID.", + status=ComplianceStatus.PASS, + ) if status_update.status_type != expected_instruction_status: + self.test_logger.soft_error( + f"Curtailment Test {power_envelope.commodity_quantity}: Expected Instruction Status of {expected_instruction_status} but received {status_update.status_type}." + ) self.add_finding_param( name="InstructionStatusUpdate status_type does not matches expected.", detail=f"Received status {status_update.status_type} but expected {expected_instruction_status} for instruction.", status=ComplianceStatus.SOFT_FAIL, ) + return ComplianceStatus.SOFT_FAIL + return ComplianceStatus.PASS + + async def curtail_with_limits( + self, + commodity_quantity: CommodityQuantity, + lower_limit: NumberRange, + upper_limit: NumberRange, + duration: int, + ) -> list[ComplianceStatus]: + limits = [ + (lower_limit.start_of_range, upper_limit.start_of_range), + (lower_limit.start_of_range, upper_limit.end_of_range), + (lower_limit.end_of_range, upper_limit.start_of_range), + (lower_limit.end_of_range, upper_limit.end_of_range), + ] + + statuses = [] + for upper, lower in limits: + power_envelope = self.create_power_envelope( + commodity_quantity=commodity_quantity, + lower_limit=lower, + upper_limit=upper, + duration=duration, + ) + logger.debug("Curtailing with power envelope: %s", power_envelope) - # message = await power_measurement_coroutine + self.test_logger.info( + f"Curtailing {power_envelope.commodity_quantity} with upper_limit={upper}, lower_limit={lower}" + ) - # logger.info("Received Power Measurement: %s", message) + status = await self.send_power_envelope( + power_envelope, InstructionStatus.SUCCEEDED + ) + statuses.append(status) + + power_envelope = self.create_power_envelope( + commodity_quantity=commodity_quantity, + lower_limit=lower_limit.end_of_range - 1, + upper_limit=upper_limit.start_of_range + 1, + duration=duration, + ) + + status = await self.send_power_envelope( + power_envelope, InstructionStatus.REJECTED + ) + statuses.append(status) + + return statuses async def curtail_commodity_quantity( self, @@ -117,7 +176,7 @@ async def curtail_commodity_quantity( commodity_quantity: CommodityQuantity, limits: List[PEBCAllowedLimitRange], duration=3600, - ): + ) -> list[ComplianceStatus]: logger.info(power_constraints.model_dump_json()) logger.info("Curtailing %s", commodity_quantity) @@ -135,26 +194,13 @@ async def curtail_commodity_quantity( ) logger.info(limit_range_pairs) + statuses = [] for lower_limit, upper_limit in limit_range_pairs: - - power_envelope = self.create_power_envelope( - commodity_quantity=commodity_quantity, - lower_limit=lower_limit.end_of_range, - upper_limit=upper_limit.start_of_range, - duration=duration, + statuses += await self.curtail_with_limits( + commodity_quantity, lower_limit, upper_limit, duration ) - logger.debug("Curtailing with power envelope: %s", power_envelope) - await self.send_power_envelope(power_envelope, InstructionStatus.SUCCEEDED) - - power_envelope = self.create_power_envelope( - commodity_quantity=commodity_quantity, - lower_limit=lower_limit.end_of_range - 1, - upper_limit=upper_limit.start_of_range + 1, - duration=duration, - ) - - await self.send_power_envelope(power_envelope, InstructionStatus.REJECTED) + return statuses @S2TestCase.test async def test_set_limit_ranges_instruction(self): @@ -175,9 +221,13 @@ async def test_set_limit_ranges_instruction(self): else: limit_ranges[limit_range.commodity_quantity] = [limit_range] + statuses: List[ComplianceStatus] = [] for commodity_quantity, ranges in limit_ranges.items(): - await self.curtail_commodity_quantity( + statuses += await self.curtail_commodity_quantity( power_constraints=power_constraints, commodity_quantity=commodity_quantity, limits=ranges, ) + self.test_logger.log_status_list( + "Curtailment Instruction Test Complete.", statuses, ident=0 + ) diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index d5cc6db..34daec7 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -15,18 +15,58 @@ from testsuites.controllers.controller import Controller from s2python.common import ControlType as ProtocolControlType from s2python.message import S2Message +from s2python.s2_validation_error import S2ValidationError from connectivity.s2_channel import S2Channel logger = logging.getLogger(__name__) +class TestLogger: + + logger: logging.Logger + + def __init__(self, logger: logging.Logger): + self.logger = logger + + def info(self, message, ident=2): + self.logger.info("%s[INFO] %s", " " * ident, message) + + def success(self, message, ident=2): + self.logger.info("%s[SUCCESS] %s", " " * ident, message) + + def soft_error(self, message, ident=2): + self.logger.warning("%s[SOFT-FAIL] %s", " " * ident, message) + + def error(self, message, ident=2): + self.logger.warning("%s[FAIL] %s", " " * ident, message) + + def log(self, message, status: ComplianceStatus = ComplianceStatus.PASS, ident=2): + match status: + case ComplianceStatus.PASS: + self.success(message, ident=ident) + case ComplianceStatus.SOFT_FAIL: + self.soft_error(message, ident=ident) + case ComplianceStatus.FAIL: + self.error(message, ident=ident) + + def log_status_list(self, message, statuses: list[ComplianceStatus], ident=2): + if ComplianceStatus.FAIL in statuses: + self.error(message, ident=ident) + elif ComplianceStatus.SOFT_FAIL in statuses: + self.soft_error(message, ident=ident) + elif ComplianceStatus.PASS in statuses: + self.success(message, ident=ident) + + class S2TestCase(abc.ABC): control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION config: BaseTestConfig finding: ComplianceFinding + test_logger: TestLogger + TIMEOUT = 5 def __init__( @@ -35,6 +75,7 @@ def __init__( channel: S2Channel, controller: Controller, report: ComplianceReport, + logger: TestLogger, ): self.channel = channel self.controller = controller @@ -53,6 +94,10 @@ def __init__( "Finding must be declared as a constant for a test case class." ) + self.test_logger = logger + + self.test_logger.info(self.finding.test, ident=0) + def add_finding_param( self, name: str, @@ -99,6 +144,10 @@ async def check_receive_message_type( return message + def handle_validation_error(self, err: S2ValidationError): + self.test_logger.error(f"Received validation error: {err}") + pass + @classmethod def test(cls, func): func._is_test_case = True @@ -126,13 +175,14 @@ async def execute(self): len(self.get_test_cases()), ) for name, method in self.get_test_cases(): - logger.info(f"Running test case: {name}") + # self.logger.info(f"Running test case: {name}") await self.setup() try: await method() finally: await self.teardown() - logger.info("Test case %s complete.", self.__class__.__name__) + # self.logger.info("Test case %s complete.", self.__class__.__name__) + # self.logger.info("-" * 20) class TestSuite: @@ -140,11 +190,20 @@ class TestSuite: test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]] report: ComplianceReport - def __init__(self, config: ControlTypeTestConfig, report: ComplianceReport): + test_logger: TestLogger + + def __init__( + self, + config: ControlTypeTestConfig, + report: ComplianceReport, + test_logger: TestLogger, + ): self.test_cases = {} self.config = config self.report = report + self.test_logger = test_logger + def add_test_case(self, test_case: Type[S2TestCase]): if test_case.control_type in self.test_cases: self.test_cases[test_case.control_type].append(test_case) @@ -153,12 +212,9 @@ def add_test_case(self, test_case: Type[S2TestCase]): async def execute(self, channel: S2Channel, controller: Controller): control_type = controller.control_type - test_cases = self.test_cases.get( - ProtocolControlType.NO_SELECTION, [] - ) + test_cases = self.test_cases.get(ProtocolControlType.NO_SELECTION, []) test_cases += self.test_cases.get(control_type, []) - logger.info(self.test_cases) logger.info( "Executing test suite for %s control type. %s test cases to execute.", control_type, @@ -171,6 +227,7 @@ async def execute(self, channel: S2Channel, controller: Controller): channel, controller, self.report, + self.test_logger, ) await test_case.execute() @@ -178,8 +235,13 @@ async def execute(self, channel: S2Channel, controller: Controller): class TestSuiteBuilder: - def __init__(self, config: ControlTypeTestConfig, report: ComplianceReport): - self.test_suite = TestSuite(config, report) + def __init__( + self, + config: ControlTypeTestConfig, + report: ComplianceReport, + test_logger: TestLogger, + ): + self.test_suite = TestSuite(config, report, test_logger) def with_test_case(self, test_case): self.test_suite.add_test_case(test_case) diff --git a/packages/test-suites/uv.lock b/packages/test-suites/uv.lock index 0a27ea3..e9adc7c 100644 --- a/packages/test-suites/uv.lock +++ b/packages/test-suites/uv.lock @@ -214,7 +214,6 @@ dependencies = [ { name = "click" }, { name = "pydantic" }, { name = "pytz" }, - { name = "websockets" }, ] [package.metadata] @@ -239,9 +238,9 @@ requires-dist = [ { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, { name = "tox", marker = "extra == 'development'" }, { name = "types-pytz", marker = "extra == 'testing'" }, - { name = "websockets", specifier = "~=13.1" }, + { name = "websockets", marker = "extra == 'ws'", specifier = "~=13.1" }, ] -provides-extras = ["testing", "development", "docs"] +provides-extras = ["ws", "testing", "development", "docs"] [[package]] name = "test-suites" diff --git a/s2-self-cert/src/log.py b/s2-self-cert/src/log.py index 3760d04..dff559a 100644 --- a/s2-self-cert/src/log.py +++ b/s2-self-cert/src/log.py @@ -10,12 +10,22 @@ "()": "logging.Formatter", "fmt": "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", }, + "log-file": { + "()": "logging.Formatter", + "fmt": "%(message)s", + }, "short": { "()": "logging.Formatter", "fmt": "%(name)s:%(lineno)d - %(levelname)s - %(message)s", }, }, "handlers": { + "test-suite-log-handler": { + "class": "logging.FileHandler", + "formatter": "log-file", + "filename": "test_suite.log", + "mode": "w", + }, "console": { "class": "logging.StreamHandler", "formatter": "short", @@ -28,5 +38,10 @@ "ws_adapter": {"handlers": ["console"], "level": "INFO", "propagate": False}, "websockets": {"handlers": ["console"], "level": "WARNING", "propagate": True}, "asyncio": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "test-suite-logger": { + "handlers": ["test-suite-log-handler", "console"], + "level": "DEBUG", + "propagate": False, + }, }, } diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index 51e8893..8bb8007 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -13,6 +13,7 @@ from server_side_certification_orchestrator import CertificationTestExecutor from testsuites.certification_executor import AbstractCertificationExecutor from testsuites.test_executor import create_test_executor +from testsuites.test_suite import TestLogger logging.config.dictConfig(LOGGING_CONFIG) @@ -40,7 +41,8 @@ async def main(): if config.mode == "certification": test_executor = create_server_certification_executor(config) elif config.mode == "testing": - test_executor = create_test_executor(config, logger) + test_logger = TestLogger(logger=logging.getLogger("test-suite-logger")) + test_executor = create_test_executor(config, test_logger) else: raise ValueError("Invalid mode.") diff --git a/s2-self-cert/uv.lock b/s2-self-cert/uv.lock index da8aabf..b2db08b 100644 --- a/s2-self-cert/uv.lock +++ b/s2-self-cert/uv.lock @@ -214,7 +214,6 @@ dependencies = [ { name = "click" }, { name = "pydantic" }, { name = "pytz" }, - { name = "websockets" }, ] [package.metadata] @@ -239,9 +238,9 @@ requires-dist = [ { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, { name = "tox", marker = "extra == 'development'" }, { name = "types-pytz", marker = "extra == 'testing'" }, - { name = "websockets", specifier = "~=13.1" }, + { name = "websockets", marker = "extra == 'ws'", specifier = "~=13.1" }, ] -provides-extras = ["testing", "development", "docs"] +provides-extras = ["ws", "testing", "development", "docs"] [[package]] name = "s2-self-cert" From 9f74d1cb1aab4d56daf4cd15bbab38403fb44beb Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Wed, 14 May 2025 12:09:47 +0200 Subject: [PATCH 31/75] Merge Docker configurations --- Dockerfile.client | 38 +++++++++++++ Dockerfile.server | 38 +++++++++++++ docker-compose.server.yaml | 17 ++++++ docker-compose.yaml | 33 +++++++++++ .../connectivity/src/connectivity/config.py | 7 ++- s2-self-cert-server/src/main.py | 13 +++-- s2-self-cert-server/uv.lock | 5 +- s2-self-cert/.dockerignore | 1 + s2-self-cert/cert.yaml | 55 ++++++++++++++++++- s2-self-cert/config.yaml | 6 +- .../server_side_certification_orchestrator.py | 7 +-- 11 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 Dockerfile.client create mode 100644 Dockerfile.server create mode 100644 docker-compose.server.yaml create mode 100644 docker-compose.yaml create mode 100644 s2-self-cert/.dockerignore diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 0000000..5869c02 --- /dev/null +++ b/Dockerfile.client @@ -0,0 +1,38 @@ +# Use a Python image with uv pre-installed +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim + +# Install the project into `/app` +WORKDIR /app + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a mounted volume +ENV UV_LINK_MODE=copy + +COPY s2-self-cert/pyproject.toml . +COPY s2-self-cert/uv.lock . + +# Install the project's dependencies using the lockfile and settings +COPY packages /packages +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project + + +COPY s2-self-cert /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" +RUN whoami + +# Change ownership and permissions of config.yaml +RUN chown -R root:root /app/ +RUN chmod -R 755 /app/ + +# Reset the entrypoint, don't invoke `uv` +ENTRYPOINT [] + +CMD ["fastapi", "dev", "src/main.py"] \ No newline at end of file diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..ca4de11 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,38 @@ +# Use a Python image with uv pre-installed +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim + +# Install the project into `/app` +WORKDIR /app + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a mounted volume +ENV UV_LINK_MODE=copy + +COPY s2-self-cert-server/pyproject.toml . +COPY s2-self-cert-server/uv.lock . + +# Install the project's dependencies using the lockfile and settings +COPY packages /packages +RUN --mount=type=cache,target=/root/.cache/uv \ +uv sync --locked --no-install-project + + +COPY s2-self-cert-server /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" +RUN whoami + +# Change ownership and permissions of config.yaml +RUN chown -R root:root /app/ +RUN chmod -R 755 /app/ + +# Reset the entrypoint, don't invoke `uv` +ENTRYPOINT [] + +CMD ["fastapi", "dev", "/app/src/main.py", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/docker-compose.server.yaml b/docker-compose.server.yaml new file mode 100644 index 0000000..14028b6 --- /dev/null +++ b/docker-compose.server.yaml @@ -0,0 +1,17 @@ +--- + +services: + s2-self-cert-server: + build: + dockerfile: ./docker/Dockerfile.server + ports: + - 8001:8001 + volumes: + - ./s2-self-cert-server/src:/app/src + - ./packages:/packages + healthcheck: + test: curl http://localhost:8001/healthcheck || exit 1 + interval: 2s + timeout: 5s + retries: 3 + start_period: 5s diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..1ebef9d --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,33 @@ +--- + +services: + s2-self-cert: + build: + dockerfile: ./docker/Dockerfile.client + command: python3 src/main.py /app/config.yaml -o /app/cert.yaml + ports: + - 8000:8000 + volumes: + - ./s2-self-cert/src:/app/src + - ./s2-self-cert/config.yaml:/app/config.yaml + - ./s2-self-cert/cert.yaml:/app/cert.yaml + - ./packages:/packages + healthcheck: + test: [ "CMD-SHELL", "true" ] + interval: 0s + timeout: 0s + retries: 1 + start_period: 1s + pv-installation: + image: docker.io/library/s2-example-implementations-pv-installation:latest + environment: + # Provide the URL to your CEM here; this should be a WebSocket endpoint + - CEM_URL=ws://s2-self-cert:8000 + # - CEM_URL=ws://host.docker.internal:8001/backend/rm/pv1/cem/cem1/ws + # Supported values: + # - PEBC: PV installation that can curtail + # - NOT_CONTROLABLE: PV installation without the option to curtail + - CONTROL_TYPE=PEBC + depends_on: + s2-self-cert: + condition: service_healthy diff --git a/packages/connectivity/src/connectivity/config.py b/packages/connectivity/src/connectivity/config.py index b5521c5..02ab7ab 100644 --- a/packages/connectivity/src/connectivity/config.py +++ b/packages/connectivity/src/connectivity/config.py @@ -63,10 +63,15 @@ def check_mode_fields(cls, values): return values +class CertificationConfig(BaseModel): + uri: str + + class Config(BaseModel): mode: Literal["testing", "certification"] connection: ConnectionConfig - device_details: Optional[DeviceDetails] + certification: Optional[CertificationConfig] = None + device_details: Optional[DeviceDetails] = None control_types: ControlTypeTestConfig diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index c3383de..5acaa38 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -1,6 +1,6 @@ from importlib.metadata import version from typing import Optional -from fastapi import FastAPI +from fastapi import FastAPI, Response import asyncio from enum import Enum import json @@ -35,8 +35,8 @@ from connectivity.channel import Channel -from log import LOGGING_CONFIG -from ws_adapter import FastAPIWebSocketAdapter +from .log import LOGGING_CONFIG +from .ws_adapter import FastAPIWebSocketAdapter logging.config.dictConfig(LOGGING_CONFIG) @@ -54,6 +54,11 @@ def verify_certificate(file: UploadFile): return {"valid": False} +@app.get("/healthcheck") +def healthcheck() -> dict[str, str]: + return {"status": "OK"} + + class MockConnectionAdapter(ConnectionAdapter): incoming_queue: asyncio.Queue @@ -192,7 +197,7 @@ async def run( return await super().run(s2_channel_mock, server_channel, *args, **kwargs) -@app.websocket("/ws") +@app.websocket("/") async def connect_tester(websocket: WebSocket): await websocket.accept() # The wrapper around the FastAPI websocket for consistency and reusability diff --git a/s2-self-cert-server/uv.lock b/s2-self-cert-server/uv.lock index 85d40a1..9c8a481 100644 --- a/s2-self-cert-server/uv.lock +++ b/s2-self-cert-server/uv.lock @@ -540,7 +540,6 @@ dependencies = [ { name = "click" }, { name = "pydantic" }, { name = "pytz" }, - { name = "websockets" }, ] [package.metadata] @@ -565,9 +564,9 @@ requires-dist = [ { name = "sphinxcontrib-httpdomain", marker = "extra == 'docs'" }, { name = "tox", marker = "extra == 'development'" }, { name = "types-pytz", marker = "extra == 'testing'" }, - { name = "websockets", specifier = "~=13.1" }, + { name = "websockets", marker = "extra == 'ws'", specifier = "~=13.1" }, ] -provides-extras = ["testing", "development", "docs"] +provides-extras = ["ws", "testing", "development", "docs"] [[package]] name = "s2-server" diff --git a/s2-self-cert/.dockerignore b/s2-self-cert/.dockerignore new file mode 100644 index 0000000..b694934 --- /dev/null +++ b/s2-self-cert/.dockerignore @@ -0,0 +1 @@ +.venv \ No newline at end of file diff --git a/s2-self-cert/cert.yaml b/s2-self-cert/cert.yaml index 60936ef..77e65ff 100644 --- a/s2-self-cert/cert.yaml +++ b/s2-self-cert/cert.yaml @@ -1,6 +1,55 @@ device: manufacturer: ABCD name: Some Device -findings: [] -signature: null -timestamp: 2025-05-08 16:38:59.518045 +findings: +- parameters: + - name: PowerForecast Provided. + status: PASS + status: PASS + test: Test Receive Power Forecast +- parameters: + - name: PowerMeasurement Provided. + status: PASS + status: PASS + test: Test Receive Power Measurement +- parameters: + - name: PEBCPowerConstraints Received. + status: PASS + status: PASS + test: Test receive PEBCPowerConstraints +- parameters: + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - detail: Received status InstructionStatus.SUCCEEDED but expected InstructionStatus.REJECTED + for instruction. + name: InstructionStatusUpdate status_type does not matches expected. + status: SOFT_FAIL + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - detail: Received status InstructionStatus.SUCCEEDED but expected InstructionStatus.REJECTED + for instruction. + name: InstructionStatusUpdate status_type does not matches expected. + status: SOFT_FAIL + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - detail: Received status InstructionStatus.SUCCEEDED but expected InstructionStatus.REJECTED + for instruction. + name: InstructionStatusUpdate status_type does not matches expected. + status: SOFT_FAIL + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - name: InstructionStatusUpdate instruction_id matches instruction's ID. + status: PASS + - detail: Received status InstructionStatus.SUCCEEDED but expected InstructionStatus.REJECTED + for instruction. + name: InstructionStatusUpdate status_type does not matches expected. + status: SOFT_FAIL + status: PASS + test: Test sending curtailment instruction. +timestamp: 2025-05-14 09:25:22.458170 diff --git a/s2-self-cert/config.yaml b/s2-self-cert/config.yaml index fdec529..449c58e 100644 --- a/s2-self-cert/config.yaml +++ b/s2-self-cert/config.yaml @@ -2,10 +2,12 @@ device_details: name: Some Device manufacturer: ABCD -mode: testing +mode: certification +certification: + uri: ws://s2-self-cert-server:8000/ connection: mode: server - host: 127.0.0.1 + host: 0.0.0.0 port: 8000 uri: ws://localhost:8001/backend/cem/cem1/rm/pv1/ws control_types: diff --git a/s2-self-cert/src/server_side_certification_orchestrator.py b/s2-self-cert/src/server_side_certification_orchestrator.py index 7ef1375..181e2e7 100644 --- a/s2-self-cert/src/server_side_certification_orchestrator.py +++ b/s2-self-cert/src/server_side_certification_orchestrator.py @@ -49,11 +49,6 @@ logger = logging.getLogger(__name__) -SERVER_PROTOCOL = os.environ.get("CERTIFICATION_SERVER_PROTOCOL", "ws") -SERVER_HOST = os.environ.get("CERTIFICATION_SERVER_HOST", "localhost") -SERVER_PORT = os.environ.get("CERTIFICATION_SERVER_PORT", "8001") -SERVER_PATH = os.environ.get("CERTIFICATION_SERVER_PORT", "/ws") - class CertificationTestExecutor(AbstractCertificationExecutor): config: Config @@ -76,7 +71,7 @@ async def handle_report_control_message(self, message: ReportControlMessage): self.report = report async def connect_to_server(self) -> Channel[ServerMessageEnvelope, str]: - uri = f"{SERVER_PROTOCOL}://{SERVER_HOST}:{SERVER_PORT}{SERVER_PATH}" + uri = self.config.certification.uri logger.info(f"Connecting to server ({uri})...") ws = await connect(uri) From 372e558711b6142dd1bd80521c06545d8cd192df Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Wed, 14 May 2025 14:03:50 +0200 Subject: [PATCH 32/75] Finalised test logging setup --- .gitignore | 1 + docker-compose.yaml | 3 +- .../curtailment_instruction_test_case.py | 28 ++--- s2-self-cert/src/log.py | 110 +++++++++++------- s2-self-cert/src/main.py | 13 ++- 5 files changed, 89 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index 1953182..b9fd1db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ .vscode +**.log __pycache__ **/*.egg-info \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 1ebef9d..27a9f08 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,13 +4,14 @@ services: s2-self-cert: build: dockerfile: ./docker/Dockerfile.client - command: python3 src/main.py /app/config.yaml -o /app/cert.yaml + command: python3 src/main.py /app/config.yaml -o /app/cert.yaml -l log.log ports: - 8000:8000 volumes: - ./s2-self-cert/src:/app/src - ./s2-self-cert/config.yaml:/app/config.yaml - ./s2-self-cert/cert.yaml:/app/cert.yaml + - ./s2-self-cert/log.log:/app/log.log - ./packages:/packages healthcheck: test: [ "CMD-SHELL", "true" ] diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py index 6b88429..8f6fb76 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py @@ -85,41 +85,21 @@ async def send_power_envelope( self.test_logger.error( f"Curtailment Test {power_envelope.commodity_quantity}: No Instruction status received after sending curtailment for {status_update}." ) - # self.add_finding_param( - # name="InstructionStatusUpdate received.", status=ComplianceStatus.FAIL - # ) return ComplianceStatus.FAIL - # self.add_finding_param( - # name="InstructionStatusUpdate received.", status=ComplianceStatus.PASS - # ) logger.info("Status Update: %s", status_update) # TODO: THis could break if multiple status updates are incoming... if status_update.instruction_id != instruction.id: - self.add_finding_param( - name="InstructionStatusUpdate instruction_id matches instruction's ID.", - status=ComplianceStatus.SOFT_FAIL, - ) self.test_logger.soft_error( "Curtailment Test {power_envelope.commodity_quantity}: InstructionStatusUpdate instruction_id does not matches instruction's ID." ) return ComplianceStatus.SOFT_FAIL - self.add_finding_param( - name="InstructionStatusUpdate instruction_id matches instruction's ID.", - status=ComplianceStatus.PASS, - ) - if status_update.status_type != expected_instruction_status: self.test_logger.soft_error( f"Curtailment Test {power_envelope.commodity_quantity}: Expected Instruction Status of {expected_instruction_status} but received {status_update.status_type}." ) - self.add_finding_param( - name="InstructionStatusUpdate status_type does not matches expected.", - detail=f"Received status {status_update.status_type} but expected {expected_instruction_status} for instruction.", - status=ComplianceStatus.SOFT_FAIL, - ) return ComplianceStatus.SOFT_FAIL return ComplianceStatus.PASS @@ -177,7 +157,6 @@ async def curtail_commodity_quantity( limits: List[PEBCAllowedLimitRange], duration=3600, ) -> list[ComplianceStatus]: - logger.info(power_constraints.model_dump_json()) logger.info("Curtailing %s", commodity_quantity) lower_limits: List[NumberRange] = [] @@ -231,3 +210,10 @@ async def test_set_limit_ranges_instruction(self): self.test_logger.log_status_list( "Curtailment Instruction Test Complete.", statuses, ident=0 ) + + if ComplianceStatus.FAIL in statuses: + self.finding.status = ComplianceStatus.FAIL + elif ComplianceStatus.SOFT_FAIL in statuses: + self.finding.status = ComplianceStatus.SOFT_FAIL + elif ComplianceStatus.PASS in statuses: + self.finding.status = ComplianceStatus.PASS diff --git a/s2-self-cert/src/log.py b/s2-self-cert/src/log.py index dff559a..311c2d8 100644 --- a/s2-self-cert/src/log.py +++ b/s2-self-cert/src/log.py @@ -2,46 +2,76 @@ import logging.config from typing import Dict -LOGGING_CONFIG: Dict = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "()": "logging.Formatter", - "fmt": "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", - }, - "log-file": { - "()": "logging.Formatter", - "fmt": "%(message)s", - }, - "short": { - "()": "logging.Formatter", - "fmt": "%(name)s:%(lineno)d - %(levelname)s - %(message)s", - }, - }, - "handlers": { - "test-suite-log-handler": { - "class": "logging.FileHandler", - "formatter": "log-file", - "filename": "test_suite.log", - "mode": "w", + +def get_log_config(test_log_file_name=None) -> Dict: + config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "logging.Formatter", + "fmt": "%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s", + }, + "log-file": { + "()": "logging.Formatter", + "fmt": "%(message)s", + }, + "short": { + "()": "logging.Formatter", + "fmt": "%(name)s:%(lineno)d - %(levelname)s - %(message)s", + }, }, - "console": { - "class": "logging.StreamHandler", - "formatter": "short", - "stream": "ext://sys.stdout", + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "short", + "stream": "ext://sys.stdout", + }, }, - }, - "loggers": { - "": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, - "connectivity": {"handlers": ["console"], "level": "INFO", "propagate": False}, - "ws_adapter": {"handlers": ["console"], "level": "INFO", "propagate": False}, - "websockets": {"handlers": ["console"], "level": "WARNING", "propagate": True}, - "asyncio": {"handlers": ["console"], "level": "WARNING", "propagate": True}, - "test-suite-logger": { - "handlers": ["test-suite-log-handler", "console"], - "level": "DEBUG", - "propagate": False, + "loggers": { + "": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, + "connectivity": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "ws_adapter": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "websockets": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "asyncio": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "test-suite-logger": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, }, - }, -} + } + + if test_log_file_name is not None: + config["handlers"] = { + **config["handlers"], + "test-suite-log-handler": { + "class": "logging.FileHandler", + "formatter": "log-file", + "filename": test_log_file_name, + # "filename": "test_suite.log", + "mode": "w", + }, + } + config["loggers"] = { + **config["loggers"], + "test-suite-logger": { + "handlers": ["test-suite-log-handler", "console"], + "level": "DEBUG", + "propagate": False, + }, + } + + return config diff --git a/s2-self-cert/src/main.py b/s2-self-cert/src/main.py index 8bb8007..b22f3e6 100644 --- a/s2-self-cert/src/main.py +++ b/s2-self-cert/src/main.py @@ -8,22 +8,26 @@ import logging.config from connectivity.config import Config, load_config -from log import LOGGING_CONFIG +from log import get_log_config from server import S2WebSocketClient, S2WebSocketServer from server_side_certification_orchestrator import CertificationTestExecutor from testsuites.certification_executor import AbstractCertificationExecutor from testsuites.test_executor import create_test_executor from testsuites.test_suite import TestLogger -logging.config.dictConfig(LOGGING_CONFIG) - -logger = logging.getLogger(__name__) parser = argparse.ArgumentParser(prog="S2 Self Cert") parser.add_argument("config") parser.add_argument( "-o", "--output", default="cert.yaml", help="Output file for the certificate." ) +parser.add_argument( + "-l", "--log_file", default=None, help="Output file for the test suite logs." +) + + + +logger = logging.getLogger(__name__) def create_server_certification_executor(config: Config) -> CertificationTestExecutor: @@ -35,6 +39,7 @@ def create_server_certification_executor(config: Config) -> CertificationTestExe async def main(): args = parser.parse_args() + logging.config.dictConfig(get_log_config(args.log_file)) config: Config = load_config(args.config) From cc41b923201ff4a532a6c61bedfc3ca7ef2e54be Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Wed, 14 May 2025 14:08:50 +0200 Subject: [PATCH 33/75] Removed config file from tracking so it doesn't change each commit --- .gitignore | 3 +++ s2-self-cert/{config.yaml => config.example.yaml} | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) rename s2-self-cert/{config.yaml => config.example.yaml} (94%) diff --git a/.gitignore b/.gitignore index b9fd1db..b9e3b62 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ .vscode **.log __pycache__ +config*.yaml +!config.example.yaml +cert*.yaml **/*.egg-info \ No newline at end of file diff --git a/s2-self-cert/config.yaml b/s2-self-cert/config.example.yaml similarity index 94% rename from s2-self-cert/config.yaml rename to s2-self-cert/config.example.yaml index 449c58e..d89a369 100644 --- a/s2-self-cert/config.yaml +++ b/s2-self-cert/config.example.yaml @@ -2,7 +2,7 @@ device_details: name: Some Device manufacturer: ABCD -mode: certification +mode: testing certification: uri: ws://s2-self-cert-server:8000/ connection: From 16aab9b902b9600e67caedb6bbe0d5a0f99f6359 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Thu, 15 May 2025 12:01:30 +0200 Subject: [PATCH 34/75] Refactored to allow testing of CEMs and RMs Now the system waits for a Handshake and depending on the role it receives it chooses the corresponding executor --- .../src/connectivity/config/__init__.py | 4 + .../src/connectivity/config/base.py | 10 + .../src/connectivity/config/cem.py | 34 ++ .../src/connectivity/{ => config}/config.py | 63 ++-- .../src/connectivity/config/rm.py | 35 ++ packages/s2-python | 2 +- .../src/testsuites/controllers/__init__.py | 9 +- .../testsuites/controllers/cem/__init__.py | 1 + .../src/testsuites/controllers/cem/base.py | 56 ++++ .../src/testsuites/controllers/controller.py | 132 +------- .../src/testsuites/controllers/rm/__init__.py | 1 + .../src/testsuites/controllers/rm/base.py | 130 ++++++++ .../controllers/{ => rm}/frbc_controller.py | 4 +- .../controllers/{ => rm}/pebc_controller.py | 11 +- .../src/testsuites/message_handlers.py | 6 + .../src/testsuites/test_executor.py | 299 ++++++++++++++---- .../testsuites/test_suite/base_test_case.py | 8 +- .../test_suite/frbc_test_cases/base.py | 16 +- .../test_suite/pebc_test_cases/base.py | 15 +- .../curtailment_instruction_test_case.py | 4 +- .../power_constraints_test_case.py | 23 -- .../src/testsuites/test_suite/test_suite.py | 16 +- s2-self-cert-server/src/main.py | 5 +- s2-self-cert/README.md | 4 + 24 files changed, 590 insertions(+), 298 deletions(-) create mode 100644 packages/connectivity/src/connectivity/config/__init__.py create mode 100644 packages/connectivity/src/connectivity/config/base.py create mode 100644 packages/connectivity/src/connectivity/config/cem.py rename packages/connectivity/src/connectivity/{ => config}/config.py (59%) create mode 100644 packages/connectivity/src/connectivity/config/rm.py create mode 100644 packages/test-suites/src/testsuites/controllers/cem/__init__.py create mode 100644 packages/test-suites/src/testsuites/controllers/cem/base.py create mode 100644 packages/test-suites/src/testsuites/controllers/rm/__init__.py create mode 100644 packages/test-suites/src/testsuites/controllers/rm/base.py rename packages/test-suites/src/testsuites/controllers/{ => rm}/frbc_controller.py (93%) rename packages/test-suites/src/testsuites/controllers/{ => rm}/pebc_controller.py (88%) diff --git a/packages/connectivity/src/connectivity/config/__init__.py b/packages/connectivity/src/connectivity/config/__init__.py new file mode 100644 index 0000000..8b4faa2 --- /dev/null +++ b/packages/connectivity/src/connectivity/config/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseTestConfig +from .cem import * +from .rm import * +from .config import * \ No newline at end of file diff --git a/packages/connectivity/src/connectivity/config/base.py b/packages/connectivity/src/connectivity/config/base.py new file mode 100644 index 0000000..62277e5 --- /dev/null +++ b/packages/connectivity/src/connectivity/config/base.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +from s2python.common import EnergyManagementRole + + +class BaseTestConfig(BaseModel): + enabled: bool = True + + +class ControlTypeTestConfig(BaseModel): + role: EnergyManagementRole diff --git a/packages/connectivity/src/connectivity/config/cem.py b/packages/connectivity/src/connectivity/config/cem.py new file mode 100644 index 0000000..e043c6a --- /dev/null +++ b/packages/connectivity/src/connectivity/config/cem.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel +from .base import BaseTestConfig +from typing import Dict, Optional +from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole + + +class NoSelectionCEMTestConfig(BaseTestConfig): + pass + + +class PEBCCEMTestConfig(BaseTestConfig): + pass + + +class ControlTypeCEMTestConfig(BaseModel): + enabled: bool = True + role: EnergyManagementRole = EnergyManagementRole.CEM + + pebc: Optional[PEBCCEMTestConfig] + + def get_controller_configs_dics( + self, + ) -> Dict[ProtocolControlType, BaseTestConfig | None]: + return {ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: self.pebc} + + def get_control_type_config(self, control_type: ProtocolControlType): + return self.get_controller_configs_dics()[control_type] + + def get_enabled_control_types(self): + control_types = [] + for control_type, config in self.get_controller_configs_dics().items(): + if config is not None and config.enabled: + control_types.append(control_type) + return control_types diff --git a/packages/connectivity/src/connectivity/config.py b/packages/connectivity/src/connectivity/config/config.py similarity index 59% rename from packages/connectivity/src/connectivity/config.py rename to packages/connectivity/src/connectivity/config/config.py index 02ab7ab..7c47747 100644 --- a/packages/connectivity/src/connectivity/config.py +++ b/packages/connectivity/src/connectivity/config/config.py @@ -1,46 +1,16 @@ import logging -from typing import Optional, Literal +from typing import Dict, Optional, Literal import yaml from pydantic import BaseModel, model_validator -from s2python.common import ControlType as ProtocolControlType +from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole +from .base import BaseTestConfig +from .cem import ControlTypeCEMTestConfig +from .rm import ControlTypeRMTestConfig logger = logging.getLogger(__name__) -class BaseTestConfig(BaseModel): - enabled: bool = True - pass - - -class NoSelectionTestConfig(BaseTestConfig): - pass - - -class PEBCTestConfig(BaseTestConfig): - status_update_frequency: int - status_update_frequency_buffer: int = 5 - - -class FRBCTestConfig(BaseTestConfig): - pass - - -class ControlTypeTestConfig(BaseModel): - no_selection: Optional[NoSelectionTestConfig] - pebc: Optional[PEBCTestConfig] - frbc: Optional[FRBCTestConfig] - - def get_control_type_config( - self, control_type: ProtocolControlType - ) -> NoSelectionTestConfig | PEBCTestConfig | FRBCTestConfig: - return { - ProtocolControlType.NO_SELECTION: self.no_selection, - ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: self.pebc, - ProtocolControlType.FILL_RATE_BASED_CONTROL: self.frbc, - }[control_type] - - class DeviceDetails(BaseModel): name: str manufacturer: str @@ -67,12 +37,33 @@ class CertificationConfig(BaseModel): uri: str +class RoleTestConfig(BaseModel): + rm: ControlTypeRMTestConfig + cem: ControlTypeCEMTestConfig + + def get_test_config(self, role: EnergyManagementRole): + match role: + case EnergyManagementRole.RM: + return self.rm + case EnergyManagementRole.CEM: + return self.cem + + def get_control_type_config( + self, role: EnergyManagementRole, control_type: ProtocolControlType + ): + match role: + case EnergyManagementRole.RM: + return self.rm.get_control_type_config(control_type) + case EnergyManagementRole.CEM: + return self.cem.get_control_type_config(control_type) + + class Config(BaseModel): mode: Literal["testing", "certification"] connection: ConnectionConfig certification: Optional[CertificationConfig] = None device_details: Optional[DeviceDetails] = None - control_types: ControlTypeTestConfig + roles: RoleTestConfig def load_config(config_path) -> Config: diff --git a/packages/connectivity/src/connectivity/config/rm.py b/packages/connectivity/src/connectivity/config/rm.py new file mode 100644 index 0000000..6537e83 --- /dev/null +++ b/packages/connectivity/src/connectivity/config/rm.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from .base import BaseTestConfig +from typing import Optional +from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole + + +class NoSelectionRMTestConfig(BaseTestConfig): + pass + + +class PEBCRMTestConfig(BaseTestConfig): + status_update_frequency: int + status_update_frequency_buffer: int = 5 + + +class FRBCRMTestConfig(BaseTestConfig): + pass + + +class ControlTypeRMTestConfig(BaseModel): + enabled: bool = True + role: EnergyManagementRole = EnergyManagementRole.RM + + no_selection: Optional[NoSelectionRMTestConfig] + pebc: Optional[PEBCRMTestConfig] + frbc: Optional[FRBCRMTestConfig] + + def get_control_type_config( + self, control_type: ProtocolControlType + ) -> NoSelectionRMTestConfig | PEBCRMTestConfig | FRBCRMTestConfig: + return { + ProtocolControlType.NO_SELECTION: self.no_selection, + ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: self.pebc, + ProtocolControlType.FILL_RATE_BASED_CONTROL: self.frbc, + }[control_type] diff --git a/packages/s2-python b/packages/s2-python index 83ab15b..0c209e7 160000 --- a/packages/s2-python +++ b/packages/s2-python @@ -1 +1 @@ -Subproject commit 83ab15bcd8ff78f9922a794dc48c67d08a24684c +Subproject commit 0c209e7a502e495e997c8e74d73831fa6e62af82 diff --git a/packages/test-suites/src/testsuites/controllers/__init__.py b/packages/test-suites/src/testsuites/controllers/__init__.py index 0442072..b1459b6 100644 --- a/packages/test-suites/src/testsuites/controllers/__init__.py +++ b/packages/test-suites/src/testsuites/controllers/__init__.py @@ -1,4 +1,7 @@ from .builder import ControlTypeBuilder -from .controller import Controller, BaseController -from .frbc_controller import FRBCController -from .pebc_controller import PEBCController +from .controller import Controller +from .rm import BaseRMController +from .rm.frbc_controller import FRBCRMController +from .rm.pebc_controller import PEBCRMController + +from .cem import BaseCEMController diff --git a/packages/test-suites/src/testsuites/controllers/cem/__init__.py b/packages/test-suites/src/testsuites/controllers/cem/__init__.py new file mode 100644 index 0000000..3268965 --- /dev/null +++ b/packages/test-suites/src/testsuites/controllers/cem/__init__.py @@ -0,0 +1 @@ +from .base import BaseCEMController \ No newline at end of file diff --git a/packages/test-suites/src/testsuites/controllers/cem/base.py b/packages/test-suites/src/testsuites/controllers/cem/base.py new file mode 100644 index 0000000..744a27d --- /dev/null +++ b/packages/test-suites/src/testsuites/controllers/cem/base.py @@ -0,0 +1,56 @@ +import uuid +from typing import Awaitable + + +from s2python.version import S2_VERSION +from s2python.common import ( + ControlType as ProtocolControlType, + EnergyManagementRole, + PowerForecast, + PowerMeasurement, + ResourceManagerDetails, + Handshake, +) +from connectivity.s2_channel import S2Channel + +from ..controller import Controller + +import logging + +logger = logging.getLogger(__name__) + + +class BaseCEMController(Controller): + role = EnergyManagementRole.RM + control_type = ProtocolControlType.NO_SELECTION + + def __init__(self, resource_manager_details: ResourceManagerDetails): + super().__init__() + + self.resource_manager_details = resource_manager_details + + self.add_handler(Handshake, self.handle_handshake) + + async def handle_handshake( + self, message: Handshake, channel: S2Channel, send_okay: Awaitable[None] + ): + logger.info("Received Handshake message: %s", message) + + await send_okay + + async def perform_handshake(self, channel: S2Channel): + if channel is None: + raise ValueError("Channel not set.") + + await channel.send_msg_and_await_reception_status( + Handshake( + message_id=uuid.uuid4(), # type: ignore + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + + async def send_resource_manager_details(self, channel: S2Channel): + if self.resource_manager_details is None: + raise ValueError("Resource Manager Details must be set.") + await channel.send_msg_and_await_reception_status(self.resource_manager_details) diff --git a/packages/test-suites/src/testsuites/controllers/controller.py b/packages/test-suites/src/testsuites/controllers/controller.py index 406b0b4..c998bb1 100644 --- a/packages/test-suites/src/testsuites/controllers/controller.py +++ b/packages/test-suites/src/testsuites/controllers/controller.py @@ -1,6 +1,7 @@ +import logging import asyncio -from typing import Awaitable, Callable, Optional, Type import uuid +from typing import Awaitable, Callable, Optional, Type from testsuites.message_handlers import MessageHandler from s2python.common import ( ControlType as ProtocolControlType, @@ -18,11 +19,7 @@ SessionRequestType, ) from s2python.s2_validation_error import S2ValidationError -from connectivity.s2_channel import S2Channel, SendOkay - -from testsuites.util import wait_for_event_or_stop -from s2python.version import S2_VERSION -import logging +from connectivity.s2_channel import S2Channel logger = logging.getLogger(__name__) @@ -30,22 +27,15 @@ class Controller(MessageHandler): control_type: ProtocolControlType - role: EnergyManagementRole = EnergyManagementRole.CEM + role: EnergyManagementRole resource_manager_details: Optional[ResourceManagerDetails] = None - _resource_manager_details_received: asyncio.Event - messages_received = [] def __init__(self): super().__init__() - self._resource_manager_details_received = asyncio.Event() - - self.add_handler(Handshake, self.handle_handshake) - self.add_handler(ResourceManagerDetails, self.handle_rm_details) - def handle_message(self, message: S2Message, channel: Optional[S2Channel]): if channel is None: raise ValueError("Channel must be provided.") @@ -54,8 +44,9 @@ def handle_message(self, message: S2Message, channel: Optional[S2Channel]): except: raise finally: + # Add all messages to the list so tests can find them. self.messages_received.append(message) - return result or None + return result def handle_s2_validation_exception(self, e: S2ValidationError): logger.error("Failed to validate S2 Message.") @@ -68,114 +59,3 @@ def filter_messages(m: S2Message): result = list(filter(filter_messages, self.messages_received)) return result - - def handshake_acknowledged(self): - # The CEM sends a handshake message. Once the RM sends HandshakeResponse this method should be called. - pass - - def handshake_received(self): - # After the handshake message is sent by the RM and the CEM (this program) responds with a HandshakeResponse - # and receives a valid status response, then this method is called. - pass - - async def perform_handshake(self, channel: S2Channel): - if channel is None: - raise ValueError("Channel not set.") - - await channel.send_msg_and_await_reception_status( - Handshake( - message_id=uuid.uuid4(), # type: ignore - role=self.role, - supported_protocol_versions=[S2_VERSION], - ) - ) - - # if not await wait_for_event_or_stop(self._handshake_complete, stop_event): - # return - - async def handle_handshake( - self, - message: Handshake, - channel: "S2Channel", - send_okay: Awaitable[None], - ) -> None: - - if channel is None: - raise ValueError("Channel not set.") - - logger.debug( - "%s supports S2 protocol versions: %s", - message.role, - message.supported_protocol_versions, - ) - if message.supported_protocol_versions is None: - raise ValueError( - "Missing supported protocol versions in handshake message." - ) - - await send_okay - - await channel.send_msg_and_await_reception_status( - HandshakeResponse( - message_id=uuid.uuid4(), - selected_protocol_version=message.supported_protocol_versions[0], - ) - ) - - async def select_control_type(self, channel: "S2Channel"): - await channel.send_msg_and_await_reception_status( - # The controller is updated in executor before sending the selection. So we just use the current instance's type. - SelectControlType(message_id=uuid.uuid4(), control_type=self.control_type) - ) - - async def handle_rm_details( - self, - message: ResourceManagerDetails, - channel: "S2Channel", - send_okay: Awaitable, - ): - self.resource_manager_details = message - - await send_okay - - self._resource_manager_details_received.set() - - async def wait_until_rm_details_received(self): - await self._resource_manager_details_received.wait() - - async def perform_disconnect(self, channel: "S2Channel"): - await channel.send_msg_and_await_reception_status( - SessionRequest( - message_id=uuid.uuid4(), - request=SessionRequestType.TERMINATE, - diagnostic_label="Testing complete.", - ) - ) - - -class BaseController(Controller): - control_type = ProtocolControlType.NOT_CONTROLABLE - - def __init__(self): - super().__init__() - - self.add_handler(PowerMeasurement, self.handle_power_measurement_message) - self.add_handler(PowerForecast, self.handle_power_forecast_message) - - async def handle_power_measurement_message( - self, - message: PowerMeasurement, - channel: "S2Channel", - send_okay: Awaitable, - ): - - await send_okay - - async def handle_power_forecast_message( - self, - message: PowerForecast, - channel: "S2Channel", - send_okay: Awaitable, - ): - - await send_okay diff --git a/packages/test-suites/src/testsuites/controllers/rm/__init__.py b/packages/test-suites/src/testsuites/controllers/rm/__init__.py new file mode 100644 index 0000000..f15f222 --- /dev/null +++ b/packages/test-suites/src/testsuites/controllers/rm/__init__.py @@ -0,0 +1 @@ +from .base import BaseRMController \ No newline at end of file diff --git a/packages/test-suites/src/testsuites/controllers/rm/base.py b/packages/test-suites/src/testsuites/controllers/rm/base.py new file mode 100644 index 0000000..03b6d83 --- /dev/null +++ b/packages/test-suites/src/testsuites/controllers/rm/base.py @@ -0,0 +1,130 @@ +import uuid +import asyncio +from typing import Awaitable +from s2python.common import ( + ControlType as ProtocolControlType, + EnergyManagementRole, + PowerForecast, + PowerMeasurement, + Handshake, + ResourceManagerDetails, + HandshakeResponse, + SessionRequest, + SessionRequestType, + SelectControlType, +) +from connectivity.s2_channel import S2Channel + +from ..controller import Controller + +from s2python.version import S2_VERSION + +import logging + +logger = logging.getLogger(__name__) + + +class BaseRMController(Controller): + role = EnergyManagementRole.CEM + control_type = ProtocolControlType.NO_SELECTION + + _resource_manager_details_received: asyncio.Event + + def __init__(self): + super().__init__() + + self._resource_manager_details_received = asyncio.Event() + + self.add_handler(Handshake, self.handle_handshake) + self.add_handler(ResourceManagerDetails, self.handle_rm_details) + + self.add_handler(PowerMeasurement, self.handle_power_measurement_message) + self.add_handler(PowerForecast, self.handle_power_forecast_message) + + async def handle_handshake( + self, + message: Handshake, + channel: "S2Channel", + send_okay: Awaitable[None], + ) -> None: + + if channel is None: + raise ValueError("Channel not set.") + + logger.debug( + "%s supports S2 protocol versions: %s", + message.role, + message.supported_protocol_versions, + ) + if message.supported_protocol_versions is None: + raise ValueError( + "Missing supported protocol versions in handshake message." + ) + + await channel.send_msg_and_await_reception_status( + HandshakeResponse( + message_id=uuid.uuid4(), + selected_protocol_version=message.supported_protocol_versions[0], + ) + ) + await send_okay + + async def select_control_type(self, channel: "S2Channel"): + await channel.send_msg_and_await_reception_status( + # The controller is updated in executor before sending the selection. So we just use the current instance's type. + SelectControlType(message_id=uuid.uuid4(), control_type=self.control_type) + ) + + async def handle_rm_details( + self, + message: ResourceManagerDetails, + channel: "S2Channel", + send_okay: Awaitable, + ): + self.resource_manager_details = message + + self._resource_manager_details_received.set() + + await send_okay + + async def wait_until_rm_details_received(self): + await self._resource_manager_details_received.wait() + + async def perform_handshake(self, channel: S2Channel): + if channel is None: + raise ValueError("Channel not set.") + + await channel.send_msg_and_await_reception_status( + Handshake( + message_id=uuid.uuid4(), # type: ignore + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + + async def perform_disconnect(self, channel: "S2Channel"): + await channel.send_msg_and_await_reception_status( + SessionRequest( + message_id=uuid.uuid4(), + request=SessionRequestType.TERMINATE, + diagnostic_label="Testing complete.", + ) + ) + + async def handle_power_measurement_message( + self, + message: PowerMeasurement, + channel: "S2Channel", + send_okay: Awaitable, + ): + + await send_okay + + async def handle_power_forecast_message( + self, + message: PowerForecast, + channel: "S2Channel", + send_okay: Awaitable, + ): + + await send_okay diff --git a/packages/test-suites/src/testsuites/controllers/frbc_controller.py b/packages/test-suites/src/testsuites/controllers/rm/frbc_controller.py similarity index 93% rename from packages/test-suites/src/testsuites/controllers/frbc_controller.py rename to packages/test-suites/src/testsuites/controllers/rm/frbc_controller.py index 37af03f..a0eaf25 100644 --- a/packages/test-suites/src/testsuites/controllers/frbc_controller.py +++ b/packages/test-suites/src/testsuites/controllers/rm/frbc_controller.py @@ -3,7 +3,7 @@ from typing import Optional from s2python.common import ControlType as ProtocolControlType from s2python.frbc import FRBCSystemDescription -from .controller import BaseController +from .base import BaseRMController from connectivity.s2_channel import S2Channel @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -class FRBCController(BaseController): +class FRBCRMController(BaseRMController): control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL system_description: Optional[FRBCSystemDescription] = None diff --git a/packages/test-suites/src/testsuites/controllers/pebc_controller.py b/packages/test-suites/src/testsuites/controllers/rm/pebc_controller.py similarity index 88% rename from packages/test-suites/src/testsuites/controllers/pebc_controller.py rename to packages/test-suites/src/testsuites/controllers/rm/pebc_controller.py index 5bc7170..17a5e59 100644 --- a/packages/test-suites/src/testsuites/controllers/pebc_controller.py +++ b/packages/test-suites/src/testsuites/controllers/rm/pebc_controller.py @@ -1,7 +1,6 @@ import asyncio import datetime -import json -from typing import TYPE_CHECKING, Awaitable, List, Optional +from typing import Awaitable, List, Optional import uuid from s2python.common import ControlType as ProtocolControlType, InstructionStatusUpdate @@ -11,7 +10,7 @@ PEBCInstruction, PEBCPowerEnvelope, ) -from .controller import BaseController +from .base import BaseRMController from connectivity.s2_channel import S2Channel import logging @@ -19,7 +18,7 @@ logger = logging.getLogger(__name__) -class PEBCController(BaseController): +class PEBCRMController(BaseRMController): control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL power_constraints: Optional[PEBCPowerConstraints] = None @@ -41,10 +40,6 @@ async def handle_power_constraints_message( channel: "S2Channel", send_okay: Awaitable, ): - # logger.info("Received power constraints.") - # logger.info("----------------------------------------------") - # logger.info(json.dumps(message.model_dump(), default=str, indent=2)) - # logger.info("----------------------------------------------") self.power_constraints = message self._power_constraints_received.set() diff --git a/packages/test-suites/src/testsuites/message_handlers.py b/packages/test-suites/src/testsuites/message_handlers.py index 77cfbb7..5000ed7 100644 --- a/packages/test-suites/src/testsuites/message_handlers.py +++ b/packages/test-suites/src/testsuites/message_handlers.py @@ -60,6 +60,12 @@ def receive_message(self, message: S2Message): # ) +async def send_okay_message(channel: S2Channel, message: S2Message): + send_okay = SendOkay(channel, message.message_id) # type: ignore[attr-defined, union-attr] + await send_okay.run_async() + await send_okay.ensure_send_async(type(message)) + + class MessageHandler: handlers: Dict[Type[S2Message], Callable] diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 7882f56..9739bb2 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -2,12 +2,27 @@ import asyncio from datetime import datetime import logging -from typing import Dict, Optional - -from s2python.common import ControlType as ProtocolControlType +from typing import Callable, Dict, Optional +import uuid + +from s2python.common import ( + ControlType as ProtocolControlType, + EnergyManagementRole, + Handshake, + ResourceManagerDetails, + CommodityQuantity, + Currency, + Duration, + Role, + RoleType, + Commodity, + SelectControlType, +) from s2python.message import S2Message +from testsuites.message_handlers import send_okay_message +from testsuites.util import wait_for_event_or_stop from testsuites.certificate.certificate import ComplianceReport from testsuites.test_suite.test_suite import TestLogger, TestSuite, TestSuiteBuilder from testsuites.test_suite import ( @@ -20,9 +35,10 @@ ) from testsuites.controllers import ( Controller, - BaseController, - PEBCController, - FRBCController, + BaseRMController, + BaseCEMController, + PEBCRMController, + FRBCRMController, ) from testsuites.test_suite.frbc_test_cases import ( FRBCActuatorStatusTestCase, @@ -32,9 +48,12 @@ ) -from connectivity.async_task_manager import AsyncTaskManager from connectivity.s2_channel import S2Channel -from connectivity.config import Config +from connectivity.config import ( + Config, + ControlTypeRMTestConfig, + ControlTypeCEMTestConfig, +) from connectivity.connection_adapter import ConnectionClosed, ConnectionError @@ -57,10 +76,10 @@ async def run(self, *args, **kwargs): pass -class IntegrationTestExecutor(AbstractExecutor): - - # The channel which connects to the S2 RM. +class AbstractRoleExecutor(abc.ABC): + # In the role executor the channel is only used for sending messages. Receiving messages handled by IntegrationTestExecutor. channel: Optional["S2Channel"] = None + role: EnergyManagementRole controller: Controller controllers: Dict[ProtocolControlType, Controller] @@ -68,11 +87,9 @@ class IntegrationTestExecutor(AbstractExecutor): test_suite: TestSuite report: ComplianceReport - test_logger: TestLogger - _stop_event: asyncio.Event - _handshake_complete: asyncio.Event + _main_loop_started_event: asyncio.Event def __init__( self, @@ -95,10 +112,13 @@ def __init__( self.test_logger = test_logger - self._stop_event = asyncio.Event() self._handshake_complete = asyncio.Event() + self._main_loop_started_event = asyncio.Event() - self.running = False + async def run(self, channel: S2Channel, *args, **kwargs): + self.channel = channel + + await self.main_loop() def set_control_type(self, control_type: ProtocolControlType): controller = self.controllers[control_type] @@ -107,33 +127,19 @@ def set_control_type(self, control_type: ProtocolControlType): self.controller = controller async def process_message(self, message: S2Message): + # This is just to make sure that the channel is set before any messages are processed + await self._main_loop_started_event.wait() await self.controller.handle_message(message, self.channel) - async def process_received_messages(self): - """AsyncIO task which pops messages off the queue and processes them using the control type.""" + @abc.abstractmethod + async def main_loop(self): + self._main_loop_started_event.set() + pass - if self.channel is None: - raise ValueError("Channel not set.") - try: - while not self._stop_event.is_set(): - try: - # Use a timeout to periodically check for cancellation - message = await asyncio.wait_for( - self.channel.get_next_message(), timeout=1.0 - ) - except asyncio.TimeoutError: - continue # Check stop event and loop again - - # logger.info(message) - await self.process_message(message) - except asyncio.CancelledError: - logger.info("Message Channel cancelled.") - except ConnectionClosed: - pass - except Exception as e: - logger.exception("Message processor encountered an error: %s", e) - await self.stop() +class RMTestExecutor(AbstractRoleExecutor): + role = EnergyManagementRole.RM + controller: BaseRMController async def execute_test_suite(self): # Wait until the handshake is complete before starting the testing. @@ -143,12 +149,14 @@ async def execute_test_suite(self): raise ValueError("Channel not set.") if self.controller: - await self.test_suite.execute(self.channel, self.controller) + await self.test_suite.execute(self.channel, self.controller, self.role) async def main_loop(self): - logger.info("Starting Main Loop.") + await super().main_loop() + logger.info("Starting Main Loop for RM Test Executor.") if self.channel is None: raise ValueError("Channel not set.") + self.test_logger.info("Test suite starting. ", ident=0) try: await self.controller.perform_handshake(self.channel) @@ -170,11 +178,9 @@ async def main_loop(self): raise # Propagate for TaskGroup except Exception as e: logger.exception("Exception in main_loop: %s", e) - await self.stop() raise finally: self.test_logger.info("Main loop finished. Signaling stop.", ident=0) - await self.stop() async def send_select_control_type(self): # TODO: Select the control type in a better way. @@ -209,8 +215,7 @@ async def send_select_control_type(self): "Select Control Type Failed. No suitable control type available.", ident=0, ) - await self.stop() - return + raise Exception("No suitable control types available.") self.set_control_type(control_type) @@ -218,6 +223,112 @@ async def send_select_control_type(self): self.test_logger.success(f"Control Type Selection. Selected: {control_type}") + +class CEMTestExecutor(AbstractRoleExecutor): + role = EnergyManagementRole.CEM + controller: BaseCEMController + + async def handle_select_control_type(self, message: SelectControlType): + control_type = message.control_type + + try: + self.set_control_type(control_type) + except KeyError: + raise ValueError("Invalid control type selection...") + + def process_message(self, message: S2Message): + if type(message) == SelectControlType: + await self.handle_select_control_type(message) + return super().process_message(message) + + async def main_loop(self): + logger.info("Starting Main Loop for CEM Test Executor.") + + if self.channel is None: + raise ValueError("Channel not set.") + + self.test_logger.info("Test suite starting. ", ident=0) + + await self.controller.perform_handshake(self.channel) + + +class IntegrationTestExecutor(AbstractExecutor): + """ + Waits for a handshake message to be received and based on the Role value chooses the correct + RoleExecutor. From that point onwards all messages are passed straight to the role executor. + The main method of the role executor is executed. + """ + + # The channel which connects to the S2 RM. + channel: Optional["S2Channel"] = None + + executor: AbstractRoleExecutor | None = None + role_executors: Dict[EnergyManagementRole, AbstractRoleExecutor] + + _stop_event: asyncio.Event + + _select_role_executor = asyncio.Event() + + def __init__( + self, + role_executors: Dict[EnergyManagementRole, AbstractRoleExecutor], + ) -> None: + + self.role_executors = role_executors + + self._stop_event = asyncio.Event() + self._select_role_executor = asyncio.Event() + + self.running = False + + async def handle_handshake(self, message: Handshake): + # On handshake we select the executor based on the role of the connected device. + role = message.role + + self.executor = self.role_executors[role] + + # Setting this will allow the main_loop to proceed + self._select_role_executor.set() + + async def process_message(self, message: S2Message): + + if type(message) == Handshake: + await self.handle_handshake(message) + + # Handle the incoming message with the selected executor + if self.executor is not None: + await self.executor.process_message(message) + else: + raise ValueError( + f"Message of type {message.message_type} received before Handshake." + ) + + async def process_received_messages(self): + """AsyncIO task which pops messages off the queue and processes them using the control type.""" + + if self.channel is None: + raise ValueError("Channel not set.") + + try: + while not self._stop_event.is_set(): + try: + # Use a timeout to periodically check for cancellation + message = await asyncio.wait_for( + self.channel.get_next_message(), timeout=1.0 + ) + except asyncio.TimeoutError: + continue # Check stop event and loop again + + # logger.info(message) + await self.process_message(message) + except asyncio.CancelledError: + logger.info("Message Channel cancelled.") + except ConnectionClosed: + pass + except Exception as e: + logger.exception("Message processor encountered an error: %s", e) + await self.stop() + def is_running(self): return self.running @@ -232,12 +343,7 @@ async def setup(self, channel: S2Channel, *args, **kwargs): self.channel = channel self._stop_event.clear() - self._handshake_complete.clear() - - # self.create_task(self.main_loop(), True) - - # self.create_task(self.channel.run(), True) - # self.create_task(self.process_received_messages(), True) + self._select_role_executor.clear() async def cleanup(self, *args, **kwargs): pass @@ -252,6 +358,15 @@ async def run_channel(self): except ConnectionError: await self.stop() + async def main_loop(self): + # Wait until the initial handshake message has been received to set role executor. + await wait_for_event_or_stop(self._select_role_executor, self._stop_event) + + if self.executor is not None and self.channel is not None: + await self.executor.run(self.channel) + else: + raise ValueError("Unable to run role executor main loop.") + async def run(self, *args, **kwargs): self.running = True await self.setup(*args, **kwargs) @@ -267,8 +382,7 @@ async def run(self, *args, **kwargs): self.running = False return - # TODO: Should this use `run_channel()`? - tg.create_task(self.channel.run(), name="ChannelRun") + tg.create_task(self.run_channel(), name="ChannelRun") tg.create_task(self.process_received_messages(), name="MessageProcess") tg.create_task(self.main_loop(), name="MainLoop") @@ -294,18 +408,52 @@ async def run(self, *args, **kwargs): self.running = False -def create_controllers_dict_with_config( - config: Config, +def create_rm_controllers_dict_with_config( + config: ControlTypeRMTestConfig, ) -> Dict[ProtocolControlType, Controller]: controllers: Dict[ProtocolControlType, Controller] = {} - controllers[ProtocolControlType.NO_SELECTION] = BaseController() + controllers[ProtocolControlType.NO_SELECTION] = BaseRMController() + + if config.frbc and config.frbc.enabled: + controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCRMController() + + if config.pebc and config.pebc.enabled: + controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = ( + PEBCRMController() + ) + + return controllers + - if config.control_types.frbc and config.control_types.frbc.enabled: - controllers[ProtocolControlType.FILL_RATE_BASED_CONTROL] = FRBCController() +def create_cem_controllers_dict_with_config( + config: ControlTypeCEMTestConfig, +) -> Dict[ProtocolControlType, Controller]: + controllers: Dict[ProtocolControlType, Controller] = {} - if config.control_types.pebc and config.control_types.pebc.enabled: - controllers[ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL] = PEBCController() + controllers[ProtocolControlType.NO_SELECTION] = BaseCEMController( + # TODO: More details should come from config. + ResourceManagerDetails( + available_control_types=config.get_enabled_control_types(), + roles=[ + Role( + role=RoleType.ENERGY_PRODUCER, + commodity=Commodity.ELECTRICITY, + ) + ], + name="TEST RM", + manufacturer="TEST", + model="TEST", + firmware_version="0", + currency=Currency.EUR, + message_id=uuid.uuid4(), + provides_forecast=True, + provides_power_measurement_types=[CommodityQuantity.ELECTRIC_POWER_L1], + resource_id=uuid.uuid4(), + serial_number="00000", + instruction_processing_delay=Duration(0), + ) + ) return controllers @@ -315,26 +463,43 @@ def create_test_executor( ) -> IntegrationTestExecutor: report = ComplianceReport(timestamp=datetime.now(), device=config.device_details) - controllers = create_controllers_dict_with_config(config) - - test_suite = ( - TestSuiteBuilder(config.control_types, report, test_logger) + rm_controllers = create_rm_controllers_dict_with_config(config.roles.rm) + rm_test_suite = ( + TestSuiteBuilder(config.roles, report, test_logger) + # Not Controllable Test Cases .with_test_case(ReceivePowerForecastTestCase) .with_test_case(ReceivePowerMeasurementTestCase) + # PEBC Test Cases .with_test_case(PEBCPowerConstraintsTestCase) .with_test_case(PEBCCurtailmentInstructionTestCase) + # FRBC Test Cases .with_test_case(FRBCUsageForecastTestCase) .with_test_case(FRBCActuatorStatusTestCase) .with_test_case(FRBCSystemDescriptionTestCase) .with_test_case(FRBCStorageStatusTestCase) .build() ) + rm_role_executor = RMTestExecutor( + available_control_types=rm_controllers, + test_suite=rm_test_suite, + report=report, + test_logger=test_logger, + ) - executor = IntegrationTestExecutor( - available_control_types=controllers, - test_suite=test_suite, + cem_controllers = create_cem_controllers_dict_with_config(config.roles.cem) + cem_test_suite = TestSuiteBuilder(config.roles, report, test_logger).build() + cem_role_executor = CEMTestExecutor( + available_control_types=cem_controllers, + test_suite=cem_test_suite, report=report, test_logger=test_logger, ) + role_executors: Dict[EnergyManagementRole, AbstractRoleExecutor] = { + EnergyManagementRole.RM: rm_role_executor, + EnergyManagementRole.CEM: cem_role_executor, + } + + executor = IntegrationTestExecutor(role_executors=role_executors) + return executor diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index 3c03398..7c076ac 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -3,8 +3,8 @@ ComplianceReport, ComplianceStatus, ) -from ..controllers.controller import BaseController -from ..test_suite.test_suite import S2TestCase, TestLogger +from testsuites.controllers import BaseRMController +from testsuites.test_suite.test_suite import S2TestCase, TestLogger from s2python.common import ( PowerForecast, @@ -26,14 +26,14 @@ class NoSelectionTestCase(S2TestCase): TIMEOUT = 5 - controller: BaseController + controller: BaseRMController config: BaseTestConfig def __init__( self, config: BaseTestConfig, channel: S2Channel, - controller: BaseController, + controller: BaseRMController, report: ComplianceReport, logger: TestLogger, ): diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py index 61e035d..a44ae12 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py @@ -9,8 +9,8 @@ ComplianceReport, ComplianceStatus, ) -from connectivity.config import FRBCTestConfig, PEBCTestConfig -from testsuites.controllers.frbc_controller import FRBCController +from connectivity.config import FRBCRMTestConfig, PEBCRMTestConfig +from testsuites.controllers import FRBCRMController from s2python.common import PowerMeasurement, ControlType as ProtocolControlType from s2python.frbc import ( FRBCActuatorStatus, @@ -20,7 +20,7 @@ FRBCUsageForecast, ) from testsuites.test_suite.base_test_case import NoSelectionTestCase -from testsuites.test_suite.test_suite import S2TestCase +from testsuites.test_suite.test_suite import S2TestCase, TestLogger from connectivity.s2_channel import S2Channel logger = logging.getLogger(__name__) @@ -31,16 +31,16 @@ class FRBCTestCase(NoSelectionTestCase): TIMEOUT = 5 - controller: FRBCController - config: FRBCTestConfig + controller: FRBCRMController + config: FRBCRMTestConfig def __init__( self, - config: FRBCTestConfig, + config: FRBCRMTestConfig, channel: S2Channel, - controller: FRBCController, + controller: FRBCRMController, report: ComplianceReport, - logger: logging.Logger = logging.getLogger(__name__), + logger: TestLogger ): super().__init__(config, channel, controller, report, logger) diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py index a4336eb..70e7873 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py @@ -1,14 +1,15 @@ import logging from typing import Optional +from testsuites.test_suite.test_suite import TestLogger from testsuites.certificate.certificate import ( ComplianceFinding, ComplianceParameter, ComplianceReport, ComplianceStatus, ) -from connectivity.config import PEBCTestConfig -from testsuites.controllers.pebc_controller import PEBCController +from connectivity.config import PEBCRMTestConfig +from testsuites.controllers import PEBCRMController from s2python.common import ControlType as ProtocolControlType from testsuites.test_suite.base_test_case import NoSelectionTestCase from connectivity.s2_channel import S2Channel @@ -18,16 +19,16 @@ class PEBCTestCase(NoSelectionTestCase): control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL - controller: PEBCController - config: PEBCTestConfig + controller: PEBCRMController + config: PEBCRMTestConfig def __init__( self, - config: PEBCTestConfig, + config: PEBCRMTestConfig, channel: S2Channel, - controller: PEBCController, + controller: PEBCRMController, report: ComplianceReport, - logger: logging.Logger = logging.getLogger(__name__), + logger: TestLogger, ): super().__init__(config, channel, controller, report, logger) diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py index 8f6fb76..99fbf93 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py @@ -11,9 +11,9 @@ ComplianceReport, ComplianceStatus, ) -from connectivity.config import BaseTestConfig, PEBCTestConfig +from connectivity.config import BaseTestConfig, PEBCRMTestConfig from testsuites.controllers.controller import Controller -from testsuites.controllers.pebc_controller import PEBCController +from testsuites.controllers import PEBCRMController from s2python.common import ( ControlType as ProtocolControlType, PowerMeasurement, diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py index 7339262..0adf5b1 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py @@ -1,33 +1,10 @@ -import datetime import logging -from typing import Dict, List -import uuid from testsuites.certificate.certificate import ( ComplianceFinding, - ComplianceParameter, - ComplianceReport, ComplianceStatus, ) -from connectivity.config import BaseTestConfig, PEBCTestConfig -from testsuites.controllers.controller import Controller -from testsuites.controllers.pebc_controller import PEBCController -from s2python.common import ( - ControlType as ProtocolControlType, - PowerMeasurement, - InstructionStatusUpdate, - CommodityQuantity, -) -from s2python.pebc import ( - PEBCAllowedLimitRange, - PEBCInstruction, - PEBCPowerConstraints, - PEBCPowerEnvelope, - PEBCPowerEnvelopeElement, -) -from testsuites.test_suite.base_test_case import NoSelectionTestCase from testsuites.test_suite.test_suite import S2TestCase -from connectivity.s2_channel import S2Channel from .base import PEBCTestCase logger = logging.getLogger(__name__) diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index 34daec7..e8a33bc 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -11,9 +11,9 @@ ComplianceReport, ComplianceStatus, ) -from connectivity.config import BaseTestConfig, ControlTypeTestConfig +from connectivity.config import BaseTestConfig, ControlTypeRMTestConfig, RoleTestConfig from testsuites.controllers.controller import Controller -from s2python.common import ControlType as ProtocolControlType +from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole from s2python.message import S2Message from s2python.s2_validation_error import S2ValidationError @@ -186,7 +186,7 @@ async def execute(self): class TestSuite: - config: ControlTypeTestConfig + config: RoleTestConfig test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]] report: ComplianceReport @@ -194,7 +194,7 @@ class TestSuite: def __init__( self, - config: ControlTypeTestConfig, + config: RoleTestConfig, report: ComplianceReport, test_logger: TestLogger, ): @@ -210,7 +210,9 @@ def add_test_case(self, test_case: Type[S2TestCase]): else: self.test_cases[test_case.control_type] = [test_case] - async def execute(self, channel: S2Channel, controller: Controller): + async def execute( + self, channel: S2Channel, controller: Controller, role: EnergyManagementRole + ): control_type = controller.control_type test_cases = self.test_cases.get(ProtocolControlType.NO_SELECTION, []) test_cases += self.test_cases.get(control_type, []) @@ -223,7 +225,7 @@ async def execute(self, channel: S2Channel, controller: Controller): for TestCase in test_cases: control_type = TestCase.control_type test_case = TestCase( - self.config.get_control_type_config(control_type), + self.config.get_control_type_config(role, control_type), channel, controller, self.report, @@ -237,7 +239,7 @@ async def execute(self, channel: S2Channel, controller: Controller): class TestSuiteBuilder: def __init__( self, - config: ControlTypeTestConfig, + config: RoleTestConfig, report: ComplianceReport, test_logger: TestLogger, ): diff --git a/s2-self-cert-server/src/main.py b/s2-self-cert-server/src/main.py index 5acaa38..0c0bb4c 100644 --- a/s2-self-cert-server/src/main.py +++ b/s2-self-cert-server/src/main.py @@ -8,7 +8,6 @@ import logging.config from connectivity.connection_adapter import ConnectionAdapter from fastapi import UploadFile, WebSocket -from fastapi import WebSocketDisconnect from testsuites.certification_executor import AbstractCertificationExecutor from connectivity.config import Config from testsuites.server_websocket_envelope_channel import ( @@ -16,6 +15,7 @@ ) from connectivity.s2_channel import S2Channel from testsuites.test_executor import IntegrationTestExecutor, create_test_executor +from testsuites.certificate.certificate import ComplianceReport from testsuites.envelope_models import ( @@ -99,9 +99,6 @@ async def receive(self) -> str: return await self.connection.get_next_outgoing() -from testsuites.certificate.certificate import ComplianceReport - - class ServerSideCertificationExecutor(AbstractCertificationExecutor): s2_connection_adapter: ConnectionAdapter config: Config diff --git a/s2-self-cert/README.md b/s2-self-cert/README.md index e69de29..eca56b2 100644 --- a/s2-self-cert/README.md +++ b/s2-self-cert/README.md @@ -0,0 +1,4 @@ + +```bash +uv run ./src/main.py ./config.yaml -o cert.yaml +``` \ No newline at end of file From e7c1c18040af5a7c939fe46099a687c34b5a02b4 Mon Sep 17 00:00:00 2001 From: Andrew Rutherfoord Date: Fri, 16 May 2025 16:35:37 +0200 Subject: [PATCH 35/75] Using unittest again + JUnit XML format output for gitlab CI/CD --- .gitignore | 4 +- Dockerfile.client | 2 +- docker-compose.yaml | 3 +- .../src/connectivity/config/__init__.py | 10 +- .../src/connectivity/config/config.py | 7 + .../src/testsuites/certificate/certificate.py | 207 +++++++++++++--- .../src/testsuites/certification_executor.py | 7 +- .../src/testsuites/test_executor.py | 9 +- .../testsuites/test_suite/base_test_case.py | 38 ++- .../test_suite/frbc_test_cases/base.py | 6 +- .../frbc_test_cases/frbc_test_cases.py | 24 +- .../test_suite/pebc_test_cases/base.py | 10 +- .../curtailment_instruction_test_case.py | 229 ++++++++++-------- .../power_constraints_test_case.py | 12 +- .../src/testsuites/test_suite/test_suite.py | 178 +++++++++----- renderer.html | 98 ++++++-- s2-self-cert/src/main.py | 5 +- s2-self-cert/src/server.py | 23 +- 18 files changed, 592 insertions(+), 280 deletions(-) diff --git a/.gitignore b/.gitignore index b9e3b62..97bdc85 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ __pycache__ config*.yaml !config.example.yaml cert*.yaml -**/*.egg-info \ No newline at end of file +**/*.egg-info +report.* +!report.example.* \ No newline at end of file diff --git a/Dockerfile.client b/Dockerfile.client index 5869c02..1e6a72a 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -35,4 +35,4 @@ RUN chmod -R 755 /app/ # Reset the entrypoint, don't invoke `uv` ENTRYPOINT [] -CMD ["fastapi", "dev", "src/main.py"] \ No newline at end of file +CMD ["python3", "src/main.py"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 27a9f08..f29989a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,8 +2,9 @@ services: s2-self-cert: + image: ci.tno.nl:4567/s2/kiflin/s2-self-certification:latest build: - dockerfile: ./docker/Dockerfile.client + dockerfile: ./Dockerfile.client command: python3 src/main.py /app/config.yaml -o /app/cert.yaml -l log.log ports: - 8000:8000 diff --git a/packages/connectivity/src/connectivity/config/__init__.py b/packages/connectivity/src/connectivity/config/__init__.py index 8b4faa2..55d76b5 100644 --- a/packages/connectivity/src/connectivity/config/__init__.py +++ b/packages/connectivity/src/connectivity/config/__init__.py @@ -1,4 +1,12 @@ from .base import BaseTestConfig from .cem import * from .rm import * -from .config import * \ No newline at end of file +from .config import ( + Config, + ConnectionConfig, + ReportConfig, + CertificationConfig, + RoleTestConfig, + DeviceDetails, + load_config, +) diff --git a/packages/connectivity/src/connectivity/config/config.py b/packages/connectivity/src/connectivity/config/config.py index 7c47747..2d7f786 100644 --- a/packages/connectivity/src/connectivity/config/config.py +++ b/packages/connectivity/src/connectivity/config/config.py @@ -37,6 +37,11 @@ class CertificationConfig(BaseModel): uri: str +class ReportConfig(BaseModel): + yaml: Optional[str] = None + xml: Optional[str] = None + + class RoleTestConfig(BaseModel): rm: ControlTypeRMTestConfig cem: ControlTypeCEMTestConfig @@ -64,12 +69,14 @@ class Config(BaseModel): certification: Optional[CertificationConfig] = None device_details: Optional[DeviceDetails] = None roles: RoleTestConfig + report: Optional[ReportConfig] = None def load_config(config_path) -> Config: with open(config_path) as stream: try: config = yaml.safe_load(stream) + logger.info(config) except yaml.YAMLError as exc: logger.error("Failed to load yaml config file.") raise diff --git a/packages/test-suites/src/testsuites/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py index 7a7b662..904a1bb 100644 --- a/packages/test-suites/src/testsuites/certificate/certificate.py +++ b/packages/test-suites/src/testsuites/certificate/certificate.py @@ -1,6 +1,6 @@ import logging from datetime import datetime -from typing import List, Optional, Type +from typing import Dict, List, Optional, Type from enum import Enum from pydantic import BaseModel, field_serializer @@ -8,69 +8,214 @@ from connectivity.config import DeviceDetails from s2python.message import S2Message +import xml.etree.ElementTree as ET +from connectivity.config import ReportConfig logger = logging.getLogger(__name__) -class ComplianceStatus(Enum): +class TestResultStatus(Enum): PASS = "PASS" FAIL = "FAIL" SOFT_FAIL = "SOFT_FAIL" N_A = "N/A" -class ComplianceParameter(BaseModel): +class TestResult(BaseModel): name: str - detail: Optional[str] = None - status: ComplianceStatus + status: TestResultStatus + message: Optional[str] = None + duration: Optional[float] = None + parameters: Optional[Dict] = None @field_serializer("status") - def serializer_status(self, status: ComplianceStatus): + def serializer_status(self, status: TestResultStatus): return status.name -class ComplianceFinding(BaseModel): - test: str - status: ComplianceStatus = ComplianceStatus.PASS - parameters: List[ComplianceParameter] = [] +class TestSuiteResults(BaseModel): + name: str + duration: Optional[float] = None + status: TestResultStatus = TestResultStatus.PASS + tests: List[TestResult] = [] - def add_parameter( - self, - name: Optional[str] = None, - status: Optional[ComplianceStatus] = None, - param: Optional[ComplianceParameter] = None, - ): - if param is None and name is not None and status is not None: - param = ComplianceParameter(name=name, status=status) - elif param is None: - raise ValueError("Either the param must be set or name and status.") + def add_test_result(self, result: TestResult): - self.parameters.append(param) + self.tests.append(result) - if param.status == ComplianceStatus.FAIL: - self.status = ComplianceStatus.FAIL + if result.status in [TestResultStatus.N_A, TestResultStatus.PASS]: + """No change to the test status.""" + elif result.status == TestResultStatus.FAIL: + self.status = TestResultStatus.FAIL + elif result.status == TestResultStatus.SOFT_FAIL and self.status not in [ + TestResultStatus.SOFT_FAIL, + TestResultStatus.FAIL, + ]: + self.status = TestResultStatus.SOFT_FAIL @field_serializer("status") - def serializer_status(self, status: ComplianceStatus): + def serializer_status(self, status: TestResultStatus): return status.name + @property + def count_passed(self, include_soft_fail=False): + count = 0 + for test in self.tests: + if test.status == TestResultStatus.PASS: + count += 1 + elif include_soft_fail and test.status == TestResultStatus.SOFT_FAIL: + count += 1 + return count + + @property + def count_failed(self, include_soft_fail=True): + count = 0 + for test in self.tests: + if test.status == TestResultStatus.FAIL: + count += 1 + elif include_soft_fail and test.status == TestResultStatus.SOFT_FAIL: + count += 1 + return count + + @property + def count_skipped(self): + count = 0 + for test in self.tests: + if test.status == TestResultStatus.N_A: + count += 1 + return count + class ComplianceReport(BaseModel): timestamp: datetime = datetime.now() - findings: List[ComplianceFinding] = [] + test_suites: List[TestSuiteResults] = [] device: Optional[DeviceDetails] signature: Optional[str] = None - def add_finding(self, finding: ComplianceFinding): - self.findings.append(finding) + def add_test_suite_result(self, result: TestSuiteResults): + self.test_suites.append(result) def generate_certificate_dict(self) -> dict: return self.model_dump(exclude_none=True) - def export(self, filename="cert.yaml"): - if filename is None: - filename = "cert.yaml" + def export(self, config: ReportConfig): + if config.yaml is not None: + self.export_to_yaml(config.yaml) + if config.xml is not None: + self.export_to_junit_xml(config.xml) + + def export_to_yaml(self, filename): with open(filename, "w") as output: - logger.info("Exporting report to `%s`.", filename) cert_data = self.generate_certificate_dict() yaml.dump(cert_data, output, default_flow_style=False) + + @staticmethod + def _format_duration_xml(seconds: Optional[float]) -> str: + """Formats duration for XML, handles None.""" + if seconds is None: + return "0.000" + return f"{seconds:.3f}" + + def export_to_junit_xml(self, filename) -> Optional[str]: + """ + Exports the compliance report to JUnit XML format. + If filename is provided, writes to the file. + Otherwise, returns the XML string. + """ + overall_total_tests = 0 + overall_total_failures = 0 + overall_total_skipped = 0 + overall_total_errors = 0 + overall_total_duration = 0.0 + + report_name = "Compliance Test Run" + if self.device and self.device.name: + report_name = f"Compliance Test for {self.device.name}" + + report_timestamp_str = self.timestamp.isoformat() + + root_testsuites_element = ET.Element("testsuites") + + for suite in self.test_suites: + suite_name = suite.name + suite_duration = suite.duration if suite.duration else 0 + suite_num_tests = len(suite.tests) + suite_num_failures = suite.count_failed + suite_num_skipped = suite.count_skipped + suite_num_errors = 0 + + testsuite_element = ET.SubElement( + root_testsuites_element, + "testsuite", + name=suite_name, + timestamp=report_timestamp_str, # Use main report timestamp for all suites + tests=str(suite_num_tests), + failures=str(suite_num_failures), + errors=str(suite_num_errors), + skipped=str(suite_num_skipped), + time=self._format_duration_xml(suite_duration), + ) + + for test_case in suite.tests: + testcase_element = ET.SubElement( + testsuite_element, + "testcase", + name=test_case.name, + classname=suite_name, + time=self._format_duration_xml(test_case.duration), + ) + + if test_case.status == TestResultStatus.FAIL: + failure_element = ET.SubElement( + testcase_element, + "failure", + message=test_case.message or "Test failed", + type="Failure", + ) + if test_case.parameters: + failure_element.text = f"Parameters: {test_case.parameters}" + elif test_case.status == TestResultStatus.SOFT_FAIL: + failure_element = ET.SubElement( + testcase_element, + "failure", # Still a failure for JUnit + message=f"[SOFT FAIL] {test_case.message or 'Test soft failed'}", + type="SoftFailure", + ) + if test_case.parameters: + failure_element.text = f"Parameters: {test_case.parameters}" + elif test_case.status == TestResultStatus.N_A: + ET.SubElement(testcase_element, "skipped") + + overall_total_tests += suite_num_tests + overall_total_failures += suite_num_failures + overall_total_skipped += suite_num_skipped + overall_total_errors += suite_num_errors + overall_total_duration += suite_duration + + root_testsuites_element.set("name", report_name) + root_testsuites_element.set("tests", str(overall_total_tests)) + root_testsuites_element.set("failures", str(overall_total_failures)) + root_testsuites_element.set("errors", str(overall_total_errors)) + root_testsuites_element.set("skipped", str(overall_total_skipped)) + root_testsuites_element.set( + "time", self._format_duration_xml(overall_total_duration) + ) + + # Create XML tree and get string representation + xml_tree = ET.ElementTree(root_testsuites_element) + try: + ET.indent(xml_tree, space=" ") # Python 3.9+ for pretty printing + except AttributeError: + pass # No pretty printing for older Python + + if filename: + xml_tree.write(filename, encoding="utf-8", xml_declaration=True) + logger.info("Exporting JUnit XML report to `%s`.", filename) + return None + else: + # To return as string + from io import BytesIO + + xml_bytes_io = BytesIO() + xml_tree.write(xml_bytes_io, encoding="utf-8", xml_declaration=True) + return xml_bytes_io.getvalue().decode("utf-8") diff --git a/packages/test-suites/src/testsuites/certification_executor.py b/packages/test-suites/src/testsuites/certification_executor.py index 2ccbd63..d651e4a 100644 --- a/packages/test-suites/src/testsuites/certification_executor.py +++ b/packages/test-suites/src/testsuites/certification_executor.py @@ -1,3 +1,4 @@ +import abc import asyncio from typing import ( Callable, @@ -82,8 +83,6 @@ class AbstractCertificationExecutor(AbstractExecutor, MessageHandler[ControlMess config: Optional[Config] - report: ComplianceReport - _stop_event: asyncio.Event def __init__(self): @@ -270,3 +269,7 @@ async def run( await self.cleanup() # Perform final cleanup (e.g., channel.stop()) logger.info("Cleanup finished.") self.running = False + + @abc.abstractmethod + def get_compliance_report(self): + pass diff --git a/packages/test-suites/src/testsuites/test_executor.py b/packages/test-suites/src/testsuites/test_executor.py index 9739bb2..2ee5465 100644 --- a/packages/test-suites/src/testsuites/test_executor.py +++ b/packages/test-suites/src/testsuites/test_executor.py @@ -236,7 +236,7 @@ async def handle_select_control_type(self, message: SelectControlType): except KeyError: raise ValueError("Invalid control type selection...") - def process_message(self, message: S2Message): + async def process_message(self, message: S2Message): if type(message) == SelectControlType: await self.handle_select_control_type(message) return super().process_message(message) @@ -367,6 +367,8 @@ async def main_loop(self): else: raise ValueError("Unable to run role executor main loop.") + await self.stop() + async def run(self, *args, **kwargs): self.running = True await self.setup(*args, **kwargs) @@ -407,6 +409,11 @@ async def run(self, *args, **kwargs): logger.info("Cleanup finished.") self.running = False + def get_compliance_report(self) -> ComplianceReport: + if self.executor is not None: + return self.executor.report + raise ValueError("No testing has been done.") + def create_rm_controllers_dict_with_config( config: ControlTypeRMTestConfig, diff --git a/packages/test-suites/src/testsuites/test_suite/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/base_test_case.py index 7c076ac..e4358b1 100644 --- a/packages/test-suites/src/testsuites/test_suite/base_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/base_test_case.py @@ -1,7 +1,7 @@ from ..certificate.certificate import ( - ComplianceFinding, + TestSuiteResults, ComplianceReport, - ComplianceStatus, + TestResultStatus, ) from testsuites.controllers import BaseRMController from testsuites.test_suite.test_suite import S2TestCase, TestLogger @@ -22,39 +22,29 @@ class NoSelectionTestCase(S2TestCase): control_type = ProtocolControlType.NO_SELECTION - finding = ComplianceFinding(test="Test Receive RM Details") + name = "Test Receive RM Details" TIMEOUT = 5 controller: BaseRMController config: BaseTestConfig - def __init__( - self, - config: BaseTestConfig, - channel: S2Channel, - controller: BaseRMController, - report: ComplianceReport, - logger: TestLogger, - ): - super().__init__(config, channel, controller, report, logger) - async def test_validate_rm_details_received(self): - if self.controller.resource_manager_details is not None: - self.finding.add_parameter( - "ResourceManagerDetails Received.", ComplianceStatus.PASS - ) - self.finding.add_parameter( - "ResourceManagerDetails Valid.", ComplianceStatus.PASS - ) + # if self.controller.resource_manager_details is not None: + # self.name.add_parameter( + # "ResourceManagerDetails Received.", TestResultStatus.PASS + # ) + # self.name.add_parameter( + # "ResourceManagerDetails Valid.", TestResultStatus.PASS + # ) self.test_logger.success("Resource manager details received.") class ReceivePowerForecastTestCase(NoSelectionTestCase): - finding = ComplianceFinding(test="Test Receive Power Forecast") + name = "Test Receive Power Forecast" - @S2TestCase.test + @S2TestCase.test("Test Receive Power Forecast") async def test_receive_power_forecast(self): message = await self.check_receive_message_type(PowerForecast) @@ -62,9 +52,9 @@ async def test_receive_power_forecast(self): class ReceivePowerMeasurementTestCase(NoSelectionTestCase): - finding = ComplianceFinding(test="Test Receive Power Measurement") + name = "Test Receive Power Measurement" - @S2TestCase.test + @S2TestCase.test("Test Receive Power Measurement") async def test_receive_power_measurement(self): message = await self.check_receive_message_type(PowerMeasurement) self.test_logger.success("Test Received Power Measurement") diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py index a44ae12..ce64e49 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/base.py @@ -4,10 +4,10 @@ import uuid from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceParameter, + TestSuiteResults, + TestResult, ComplianceReport, - ComplianceStatus, + TestResultStatus, ) from connectivity.config import FRBCRMTestConfig, PEBCRMTestConfig from testsuites.controllers import FRBCRMController diff --git a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py index 179de4f..6d9477d 100644 --- a/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py +++ b/packages/test-suites/src/testsuites/test_suite/frbc_test_cases/frbc_test_cases.py @@ -5,10 +5,10 @@ from .base import FRBCTestCase from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceParameter, + TestSuiteResults, + TestResult, ComplianceReport, - ComplianceStatus, + TestResultStatus, ) from s2python.common import PowerMeasurement, ControlType as ProtocolControlType from s2python.frbc import ( @@ -24,7 +24,7 @@ class FRBCSystemDescriptionTestCase(FRBCTestCase): - finding = ComplianceFinding(test="Test receive FRBCSystemDescription") + name = "Test receive FRBCSystemDescription" @S2TestCase.test async def test_receive_frbc_system_description(self): @@ -32,33 +32,35 @@ async def test_receive_frbc_system_description(self): message = await self.check_receive_message_type(FRBCSystemDescription) - self.test_logger.info("[SUCCESS] Test Receive FRBC System Description") + self.test_logger.success("Test Receive FRBC System Description") class FRBCActuatorStatusTestCase(FRBCTestCase): + name = "Test Receive FRBC Actuator Status" + @S2TestCase.test async def test_receive_actuator_status(self): await self.wait_for_system_description() message = await self.check_receive_message_type(FRBCActuatorStatus) - self.test_logger.info("[SUCCESS] Test Receive FRBC Actuator Status") + self.test_logger.success("Test Receive FRBC Actuator Status") class FRBCStorageStatusTestCase(FRBCTestCase): - finding = ComplianceFinding(test="Test receive FRBCStorageStatus") + name = "Test receive FRBCStorageStatus" - @S2TestCase.test + @S2TestCase.test("Test Receive FRBC Storage Status") async def test_receive_storage_status(self): await self.wait_for_system_description() message = await self.check_receive_message_type(FRBCStorageStatus) - self.test_logger.info("[SUCCESS] Test Receive FRBC Storage Status") + self.test_logger.success("Test Receive FRBC Storage Status") class FRBCUsageForecastTestCase(FRBCTestCase): - finding = ComplianceFinding(test="Test receive FRBCUsageForecast") + name = "Test receive FRBCUsageForecast" @S2TestCase.test async def test_receive_usage_forecast(self): @@ -66,4 +68,4 @@ async def test_receive_usage_forecast(self): message = await self.check_receive_message_type(FRBCUsageForecast) - self.test_logger.info("[SUCCESS] Test Receive FRBC Usage Forecast") + self.test_logger.success("Test Receive FRBC Usage Forecast") diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py index 70e7873..8f23796 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/base.py @@ -1,12 +1,12 @@ import logging from typing import Optional -from testsuites.test_suite.test_suite import TestLogger +from testsuites.test_suite.test_suite import S2TestCase, TestLogger from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceParameter, + TestSuiteResults, + TestResult, ComplianceReport, - ComplianceStatus, + TestResultStatus, ) from connectivity.config import PEBCRMTestConfig from testsuites.controllers import PEBCRMController @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -class PEBCTestCase(NoSelectionTestCase): +class PEBCTestCase(S2TestCase): control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL controller: PEBCRMController config: PEBCRMTestConfig diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py index 99fbf93..e29c1bd 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/curtailment_instruction_test_case.py @@ -5,11 +5,12 @@ from typing import Dict, List, Tuple import uuid +from testsuites.test_suite.test_suite import TestLogger from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceParameter, + TestSuiteResults, + TestResult, ComplianceReport, - ComplianceStatus, + TestResultStatus, ) from connectivity.config import BaseTestConfig, PEBCRMTestConfig from testsuites.controllers.controller import Controller @@ -41,7 +42,11 @@ class PEBCCurtailmentInstructionTestCase(PEBCTestCase): - finding = ComplianceFinding(test="Test sending curtailment instruction.") + name = "Test sending curtailment instruction." + + async def generate_tests(self): + await super().generate_tests() + await self.generate_set_limit_range_instruction_tests() def create_power_envelope( self, commodity_quantity, lower_limit, upper_limit, duration=3600 @@ -60,9 +65,19 @@ def create_power_envelope( async def send_power_envelope( self, - power_envelope: PEBCPowerEnvelope, + commodity_quantity: CommodityQuantity, + lower_limit: NumberRange, + upper_limit: NumberRange, + duration: int, expected_instruction_status: InstructionStatus, - ) -> ComplianceStatus: + ): + + power_envelope = self.create_power_envelope( + commodity_quantity=commodity_quantity, + lower_limit=lower_limit, + upper_limit=upper_limit, + duration=duration, + ) # Prepare coroutines first so the events are waiting in the awaiter. # This avoids the case where the message somehow arrives between sending the message and starting to await. # This is highly unlikely but might as well make sure! @@ -81,88 +96,87 @@ async def send_power_envelope( status_update = await status_update_coroutine + self.assertEqual(type(status_update), InstructionStatusUpdate) if type(status_update) != InstructionStatusUpdate: - self.test_logger.error( - f"Curtailment Test {power_envelope.commodity_quantity}: No Instruction status received after sending curtailment for {status_update}." - ) - return ComplianceStatus.FAIL - - logger.info("Status Update: %s", status_update) - # TODO: THis could break if multiple status updates are incoming... - if status_update.instruction_id != instruction.id: - - self.test_logger.soft_error( - "Curtailment Test {power_envelope.commodity_quantity}: InstructionStatusUpdate instruction_id does not matches instruction's ID." - ) - return ComplianceStatus.SOFT_FAIL - - if status_update.status_type != expected_instruction_status: - self.test_logger.soft_error( - f"Curtailment Test {power_envelope.commodity_quantity}: Expected Instruction Status of {expected_instruction_status} but received {status_update.status_type}." - ) - return ComplianceStatus.SOFT_FAIL - return ComplianceStatus.PASS - - async def curtail_with_limits( - self, - commodity_quantity: CommodityQuantity, - lower_limit: NumberRange, - upper_limit: NumberRange, - duration: int, - ) -> list[ComplianceStatus]: - limits = [ - (lower_limit.start_of_range, upper_limit.start_of_range), - (lower_limit.start_of_range, upper_limit.end_of_range), - (lower_limit.end_of_range, upper_limit.start_of_range), - (lower_limit.end_of_range, upper_limit.end_of_range), - ] - - statuses = [] - for upper, lower in limits: - power_envelope = self.create_power_envelope( - commodity_quantity=commodity_quantity, - lower_limit=lower, - upper_limit=upper, - duration=duration, - ) - logger.debug("Curtailing with power envelope: %s", power_envelope) - - self.test_logger.info( - f"Curtailing {power_envelope.commodity_quantity} with upper_limit={upper}, lower_limit={lower}" - ) - - status = await self.send_power_envelope( - power_envelope, InstructionStatus.SUCCEEDED - ) - statuses.append(status) - - power_envelope = self.create_power_envelope( - commodity_quantity=commodity_quantity, - lower_limit=lower_limit.end_of_range - 1, - upper_limit=upper_limit.start_of_range + 1, - duration=duration, - ) - - status = await self.send_power_envelope( - power_envelope, InstructionStatus.REJECTED - ) - statuses.append(status) - - return statuses - - async def curtail_commodity_quantity( + return + + self.assertEqual(status_update.instruction_id, instruction.id) + # logger.info("Status Update: %s", status_update) + # # TODO: THis could break if multiple status updates are incoming... + # if status_update.instruction_id != instruction.id: + + # self.test_logger.soft_error( + # "Curtailment Test {power_envelope.commodity_quantity}: InstructionStatusUpdate instruction_id does not matches instruction's ID." + # ) + # return TestResultStatus.SOFT_FAIL + + self.assertEqual(status_update.status_type, expected_instruction_status) + # if status_update.status_type != expected_instruction_status: + # self.test_logger.soft_error( + # f"Curtailment Test {power_envelope.commodity_quantity}: Expected Instruction Status of {expected_instruction_status} but received {status_update.status_type}." + # ) + # return TestResultStatus.SOFT_FAIL + # return TestResultStatus.PASS + + # async def curtail_with_limits( + # self, + # commodity_quantity: CommodityQuantity, + # lower_limit: NumberRange, + # upper_limit: NumberRange, + # duration: int, + # ) -> list[TestResultStatus]: + # limits = [ + # (lower_limit.start_of_range, upper_limit.start_of_range), + # (lower_limit.start_of_range, upper_limit.end_of_range), + # (lower_limit.end_of_range, upper_limit.start_of_range), + # (lower_limit.end_of_range, upper_limit.end_of_range), + # ] + + # for upper, lower in limits: + # power_envelope = self.create_power_envelope( + # commodity_quantity=commodity_quantity, + # lower_limit=lower, + # upper_limit=upper, + # duration=duration, + # ) + # logger.debug("Curtailing with power envelope: %s", power_envelope) + + # self.test_logger.info( + # f"Curtailing {power_envelope.commodity_quantity} with upper_limit={upper}, lower_limit={lower}" + # ) + + # status = await self.send_power_envelope( + # power_envelope, InstructionStatus.SUCCEEDED + # ) + # statuses.append(status) + + # power_envelope = self.create_power_envelope( + # commodity_quantity=commodity_quantity, + # lower_limit=lower_limit.end_of_range - 1, + # upper_limit=upper_limit.start_of_range + 1, + # duration=duration, + # ) + + # status = await self.send_power_envelope( + # power_envelope, InstructionStatus.REJECTED + # ) + # statuses.append(status) + + # return statuses + + def generate_curtail_commodity_quantity_tests( self, power_constraints: PEBCPowerConstraints, commodity_quantity: CommodityQuantity, - limits: List[PEBCAllowedLimitRange], + limit_ranges: List[PEBCAllowedLimitRange], duration=3600, - ) -> list[ComplianceStatus]: + ): logger.info("Curtailing %s", commodity_quantity) lower_limits: List[NumberRange] = [] upper_limits: List[NumberRange] = [] - for limit in limits: + for limit in limit_ranges: if limit.limit_type == PEBCPowerEnvelopeLimitType.LOWER_LIMIT: lower_limits.append(limit.range_boundary) else: @@ -171,19 +185,39 @@ async def curtail_commodity_quantity( limit_range_pairs: List[Tuple[NumberRange, NumberRange]] = list( product(lower_limits, upper_limits) ) - logger.info(limit_range_pairs) - statuses = [] + logger.info("---------- Generating ----------") + logger.info(limit_range_pairs) for lower_limit, upper_limit in limit_range_pairs: - statuses += await self.curtail_with_limits( - commodity_quantity, lower_limit, upper_limit, duration - ) + limits = [ + (lower_limit.start_of_range, upper_limit.start_of_range), + (lower_limit.start_of_range, upper_limit.end_of_range), + (lower_limit.end_of_range, upper_limit.start_of_range), + (lower_limit.end_of_range, upper_limit.end_of_range), + ] + for upper, lower in limits: + + self.add_test_method( + f"Succeed Curtail {commodity_quantity}", + self.send_power_envelope, + commodity_quantity=commodity_quantity, + lower_limit=lower, + upper_limit=upper, + duration=duration, + expected_instruction_status=InstructionStatus.SUCCEEDED, + ) - return statuses + self.add_test_method( + f"Reject Curtail {commodity_quantity}", + self.send_power_envelope, + commodity_quantity=commodity_quantity, + lower_limit=lower_limit.end_of_range - 1, + upper_limit=upper_limit.start_of_range + 1, + duration=duration, + expected_instruction_status=InstructionStatus.REJECTED, + ) - @S2TestCase.test - async def test_set_limit_ranges_instruction(self): - logger.info("Testing set limit range") + async def generate_set_limit_range_instruction_tests(self): await self.wait_until_power_constraints_set() power_constraints = self.controller.power_constraints @@ -200,20 +234,23 @@ async def test_set_limit_ranges_instruction(self): else: limit_ranges[limit_range.commodity_quantity] = [limit_range] - statuses: List[ComplianceStatus] = [] + statuses: List[TestResultStatus] = [] for commodity_quantity, ranges in limit_ranges.items(): - statuses += await self.curtail_commodity_quantity( + self.generate_curtail_commodity_quantity_tests( power_constraints=power_constraints, commodity_quantity=commodity_quantity, - limits=ranges, + limit_ranges=ranges, ) + self.test_logger.log_status_list( "Curtailment Instruction Test Complete.", statuses, ident=0 ) - if ComplianceStatus.FAIL in statuses: - self.finding.status = ComplianceStatus.FAIL - elif ComplianceStatus.SOFT_FAIL in statuses: - self.finding.status = ComplianceStatus.SOFT_FAIL - elif ComplianceStatus.PASS in statuses: - self.finding.status = ComplianceStatus.PASS + # if TestResultStatus.FAIL in statuses: + # self.name.status = TestResultStatus.FAIL + # elif TestResultStatus.SOFT_FAIL in statuses: + # self.name.status = TestResultStatus.SOFT_FAIL + # elif TestResultStatus.PASS in statuses: + # self.name.status = TestResultStatus.PASS + + # return TestResultStatus.PASS, None diff --git a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py index 0adf5b1..560a25b 100644 --- a/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py +++ b/packages/test-suites/src/testsuites/test_suite/pebc_test_cases/power_constraints_test_case.py @@ -1,8 +1,8 @@ import logging from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceStatus, + TestSuiteResults, + TestResultStatus, ) from testsuites.test_suite.test_suite import S2TestCase from .base import PEBCTestCase @@ -11,12 +11,10 @@ class PEBCPowerConstraintsTestCase(PEBCTestCase): - finding = ComplianceFinding(test="Test receive PEBCPowerConstraints") + name = "Test receive PEBCPowerConstraints" - @S2TestCase.test + @S2TestCase.test("Receive PEBC Power Constraints") async def validate_power_constraints_set(self): await self.wait_until_power_constraints_set() - self.add_finding_param( - name="PEBCPowerConstraints Received.", status=ComplianceStatus.PASS - ) + self.test_logger.success("Test Receive PEBC Power Constraints") diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py index e8a33bc..995b23f 100644 --- a/packages/test-suites/src/testsuites/test_suite/test_suite.py +++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py @@ -3,13 +3,15 @@ import functools import inspect import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Type +import time +from typing import TYPE_CHECKING, Callable, Coroutine, Dict, List, Optional, Tuple, Type +import unittest from testsuites.certificate.certificate import ( - ComplianceFinding, - ComplianceParameter, + TestSuiteResults, + TestResult, ComplianceReport, - ComplianceStatus, + TestResultStatus, ) from connectivity.config import BaseTestConfig, ControlTypeRMTestConfig, RoleTestConfig from testsuites.controllers.controller import Controller @@ -41,72 +43,83 @@ def soft_error(self, message, ident=2): def error(self, message, ident=2): self.logger.warning("%s[FAIL] %s", " " * ident, message) - def log(self, message, status: ComplianceStatus = ComplianceStatus.PASS, ident=2): + def log(self, message, status: TestResultStatus = TestResultStatus.PASS, ident=2): match status: - case ComplianceStatus.PASS: + case TestResultStatus.PASS: self.success(message, ident=ident) - case ComplianceStatus.SOFT_FAIL: + case TestResultStatus.SOFT_FAIL: self.soft_error(message, ident=ident) - case ComplianceStatus.FAIL: + case TestResultStatus.FAIL: self.error(message, ident=ident) - def log_status_list(self, message, statuses: list[ComplianceStatus], ident=2): - if ComplianceStatus.FAIL in statuses: + def log_status_list(self, message, statuses: list[TestResultStatus], ident=2): + if TestResultStatus.FAIL in statuses: self.error(message, ident=ident) - elif ComplianceStatus.SOFT_FAIL in statuses: + elif TestResultStatus.SOFT_FAIL in statuses: self.soft_error(message, ident=ident) - elif ComplianceStatus.PASS in statuses: + elif TestResultStatus.PASS in statuses: self.success(message, ident=ident) -class S2TestCase(abc.ABC): +class S2TestCase(unittest.TestCase): control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION config: BaseTestConfig - finding: ComplianceFinding + name: str test_logger: TestLogger TIMEOUT = 5 + tests: List[Tuple[str, Callable, Tuple, Dict]] + def __init__( self, config: BaseTestConfig, channel: S2Channel, controller: Controller, report: ComplianceReport, - logger: TestLogger, + logger1: TestLogger, ): + super().__init__() self.channel = channel self.controller = controller self.config = config self.report = report - finding_set = True + name_set = True try: - if self.finding is None: - finding_set = False + if self.name is None: + name_set = False except: - finding_set = False + name_set = False - if not finding_set: + if not name_set: raise ValueError( - "Finding must be declared as a constant for a test case class." + f"Test case name must be declared as a constant for a test case class ({self.__class__})" ) - self.test_logger = logger + self.tests: List[Tuple[str, Callable, Tuple, Dict, TestResultStatus]] = [] + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + if getattr(method, "_is_test_method", False): + self.add_test_method(method.test_name, method) # type: ignore + + self.test_logger = logger1 - self.test_logger.info(self.finding.test, ident=0) + self.test_logger.info(self.name, ident=0) - def add_finding_param( + def add_test_method( self, - name: str, - detail: Optional[str] = None, - status: ComplianceStatus = ComplianceStatus.PASS, + name, + method: Callable, + *args, + fail_result_status=TestResultStatus.FAIL, + **kwargs, ): - param = ComplianceParameter(name=name, detail=detail, status=status) + self.tests.append((name, method, args, kwargs, fail_result_status)) - self.finding.add_parameter(param=param) + async def generate_tests(self): + pass async def check_receive_message_type( self, @@ -131,17 +144,6 @@ async def check_receive_message_type( if len(messages) > 0: message = messages[0] - if message is not None: - self.add_finding_param( - name=f"{message_type.__name__} Provided.", - status=ComplianceStatus.PASS, - ) - else: - self.add_finding_param( - name=f"{message_type.__name__} Not Provided.", - status=ComplianceStatus.FAIL, - ) - return message def handle_validation_error(self, err: S2ValidationError): @@ -149,9 +151,16 @@ def handle_validation_error(self, err: S2ValidationError): pass @classmethod - def test(cls, func): - func._is_test_case = True - return func + def test(cls, name=None): + def decorator(func): + func._is_test_method = True + if name is not None: + func.test_name = name + else: + func.test_name = func.__name__ + return func + + return decorator async def setup(self): """Override in subclass for per-test setup.""" @@ -161,29 +170,69 @@ async def teardown(self): """Override in subclass for per-test teardown.""" pass - def get_test_cases(self): - test_cases = [] - for name, method in inspect.getmembers(self, predicate=inspect.ismethod): - if getattr(method, "_is_test_case", False): - test_cases.append((name, method)) - return test_cases - - async def execute(self): - logger.info( - "Executing test case %s. Has %s tests.", - self.__class__.__name__, - len(self.get_test_cases()), - ) - for name, method in self.get_test_cases(): - # self.logger.info(f"Running test case: {name}") - await self.setup() + async def execute(self) -> TestSuiteResults: + # logger.info( + # "Executing test case %s. Has %s tests.", + # self.__class__.__name__, + # len(self.tests), + # ) + test_suite_result = TestSuiteResults(name=self.name) + start_time = time.time() + + # Generates the parametarised test cases and adds them to the tests list. + # This method is overridden in subclasses for generating the test + await self.generate_tests() + + logger.info("%s: %s", self.name, len(self.tests)) + + for name, method, args, kwargs, fail_result_status in self.tests: + case_start_time = time.time() + status = fail_result_status + message: Optional[str] = None try: - await method() - finally: - await self.teardown() + await self.setup() + try: + await method(*args, **kwargs) + finally: + await self.teardown() + + self.test_logger.success(f"{name}") + status = TestResultStatus.PASS + except AssertionError as e: + message = str(e) + self.test_logger.error(f"Assertion error: {e}") + # self.report.add_test_suite_result(f"{name}: FAILED ({e})") + except Exception as e: + message = str(e) + self.test_logger.error(f"Error error: {e}") + + case_end_time = time.time() + + result = TestResult( + name=name, + status=status, + duration=round(case_end_time - case_start_time, 2), + message=message, + parameters={ + **{f"arg_{index}": str(value) for index, value in enumerate(args)}, + **{key: str(value) for key, value in kwargs.items()}, + }, + ) + + test_suite_result.add_test_result(result) + + if len(self.tests) < 1: + test_suite_result.status = TestResultStatus.N_A + + end_time = time.time() + duration = round(end_time - start_time, 2) + + test_suite_result.duration = duration # self.logger.info("Test case %s complete.", self.__class__.__name__) # self.logger.info("-" * 20) + return test_suite_result + class TestSuite: config: RoleTestConfig @@ -231,9 +280,10 @@ async def execute( self.report, self.test_logger, ) - await test_case.execute() - self.report.add_finding(test_case.finding) + result = await test_case.execute() + + self.report.add_test_suite_result(result) class TestSuiteBuilder: diff --git a/renderer.html b/renderer.html index 1e3fe73..f482501 100644 --- a/renderer.html +++ b/renderer.html @@ -9,6 +9,11 @@ + @@ -18,33 +23,91 @@

S2 Self Certifier Certificate

-

Overview

-

Timestamp: {{ timestamp }}

-

Result: TODO

+

Overview

+

Timestamp: {{ timestamp }}

+

Result: TODO

-

Device Information

-

Manufacturer: {{ device.manufacturer }}

-

Name: {{ device.name }}

+

Device Information

+

Manufacturer: {{ device.manufacturer }}

+

Name: {{ device.name }}

+
-

Test Findings

-
-
-
{{ finding.test }}
-
    -
  • - {{ parameter.name }}: {{ parameter.status }} -
  • -
-

Status: {{ finding.status }}

+

Test Suite Results

+
+
+

+ +

+
+
+
+
+
+

+ +

+
+
+
+ Message: +
{{ test.message }}
+
+
+ Parameters: +
    +
  • + {{ key }}: {{ value }} +
  • +
+
+
No parameters.
+
+
+
+
+
+
No test methods found in this suite.
+
+
+
+ +