diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..84ee7fe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+.venv/
+.vscode
+**.log
+__pycache__
+config*.yaml
+!**config.example.yaml
+cert*.yaml
+**/*.egg-info
+report.*
+!report.example.*
+**.pem
+**.pytest_cache**
+**.tox**
+
+
+s2-self-cert-server/data
\ No newline at end of file
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/.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/.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/Dockerfile.client b/Dockerfile.client
new file mode 100644
index 0000000..d8613cf
--- /dev/null
+++ b/Dockerfile.client
@@ -0,0 +1,39 @@
+# 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
+ENV PYTHONPATH="/app/src"
+
+# 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 ["python3", "/app/src/s2selfcert/main.py"]
\ No newline at end of file
diff --git a/Dockerfile.server b/Dockerfile.server
new file mode 100644
index 0000000..26f3fad
--- /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 AS server
+
+# 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", "run", "/app/src/main.py", "--host", "0.0.0.0", "--port", "8001"]
\ No newline at end of file
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
new file mode 100644
index 0000000..b49ff61
--- /dev/null
+++ b/README.md
@@ -0,0 +1,466 @@
+# S2 Testing and Certification Tool
+
+
+

+
+
+
+## Overview
+
+The **S2 Testing and Certification Tool** is an open-source Python application designed to test and certify implementations of S2 CEMs and RMs that use the S2 WebSocket JSON implementation. The tool enables S2 developers to verify that their CEM or RM is compliant with the S2 specification and S2 WebSocket JSON.
+
+The tool supports two modes of operation:
+
+1. **Test Mode**: Run integration tests locally to validate S2 device implementations.
+2. **Certify Mode**: Perform tests remotely on a secure server and generate cryptographically signed compliance certificates.
+
+This project is developed with modularity and extensibility in mind, making it easy to add new test cases, control types, and communication protocols.
+
+---
+
+## Features
+
+- **Integration Testing**: Test S2 devices locally or remotely to verify compliance with the S2 Specification and S2 WebSocket JSON.
+- **Test both CEM and RM Implementations**: Test both Resource Manager (RM) and Customer Energy Manager (CEM) implementations.
+- **Control Type Coverage**: Includes test scenarios for the following control types:
+ - Not Controllable
+ - Power Envelope Based Control (PEBC)
+ - Fill Rate Based Control (FRBC)
+- **Result Reporting**: Generate detailed test reports, including pass/fail status, logs, and compliance summaries.
+- **Certification**: Generate cryptographically signed certificates for compliant implementations.
+- **Extensibility**: Easily add new test cases, control types.
+
+### Certification
+
+The certificate that is produced when running in Certification mode is signed by both the client side with the Organisation's key and as well as on the server side with the Certification server's key. This overlapping signature ensures that the certificate cannot be tampered with.
+
+The signed certificate can be validated by a 3rd party by sending an HTTP POST request to the certification server at the endpoint `/certification/verify/`. If the certificate is valid you will get a response `{"valid": true}`.
+
+> Disclaimer:
+> While the tool implements cryptographic signing to ensure the integrity of the certificates, I cannot guarantee the reliability or security of the signing process at this time. The implementation is provided as-is and should not be considered a reliable cryptographic solution until it has been verified by someone with more experience in cryptographic signing. Users are advised to review the signing process and adapt it to meet their specific security requirements. Use this tool at your own discretion and risk.
+
+See below for more details about how the signing process works.
+
+---
+
+## Installation
+
+### Prerequisites
+
+- Python 3.11 or higher
+- Docker (optional, for containerized deployment)
+- [UV Package Manager](https://docs.astral.sh/uv/) (for running the tool)
+
+### Clone the Repository
+
+```bash
+git clone https://github.com/flexiblepower/s2-certification-tool.git
+cd s2-certification-tool
+```
+
+### Clone with Submodules
+
+This repository uses a custom version of the S2-Python library, included as a Git submodule. To clone the repository with the submodule, run:
+
+```bash
+git submodule init
+git submodule update --remote --merge
+```
+
+> TODO: Remove this once the S2Python package is updated
+
+---
+
+## Usage Local
+
+### Running the Tool
+
+The tool is executed using the `uv` package manager. To run the tool you must provide a configuration file path as an argument. Optionally supply the `-l` flag which specifies a log file where all S2 messages will be saved.
+
+#### Example Command
+
+```bash
+cd s2-self-cert
+uv run src/s2selfcert/main.py config.yaml -l messages.log
+```
+
+When running in certification mode you need to connect to an instance of the certification server and also provide a private key in the PEM format which can be used for signing the certificate. The certificate is double signed by both the client (you) and the server. You can generate a new key with:
+
+```bash
+openssl genpkey -algorithm RSA -out org_key.pem -pkeyopt rsa_keygen_bits:2048
+```
+
+This will create a key called `org_key.pem`. Put this into your configuration file using a relative path from where you run the program or a direct path.
+
+### Configuration
+
+The tool uses a YAML configuration file to define the parameters for testing and certification. The config is loaded on startup and used to configure the tool. The control type specific configurations are passed to the test cases for that control type. These configurations are used to configure the tests. Below is an example configuration file and an explanation of its fields.
+
+#### Example Configuration File
+
+```yaml
+device_details:
+ name: Some Device
+ manufacturer: ABCD
+
+mode: testing # Options: 'testing' for local testing, 'certification' for Certify Mode
+
+certification:
+ client_id: "Your Org Name" # The name of the organisation requesting the certificate. Included in the cert.
+ uri: ws://localhost:8001/ # WebSocket URI for the Certification Server
+ key_path: ./org_key.pem # The key used for double signing of the certificate
+
+connection:
+ mode: server # Options: 'server' or 'client'
+
+ # Host and port must be supplied when in server mode
+ host: 0.0.0.0 # Host address for the WebSocket server
+ port: 8000 # Port for the WebSocket server
+
+ # URI must be supplied when in client mode
+ uri: wss://localhost:8000/websocket # WebSocket URI for the S2 device
+
+report:
+ yaml: report.yaml # Path to save the YAML report
+
+ # If provided then a JUnit XML output is produce. Mainly for usage in GitLab CI.
+ xml: report.xml # Path to save the XML report
+ xml_soft_fail_is_fail: true # Treat soft failures as failures in the XML report
+
+ include_test_parameters: false # Whether to include test parameters in the report
+
+roles:
+ # Here you can enable or disable the RM or CEM testing or specific control types.
+ # Any that are left blank will default to `enabled = true`
+ rm: # Resource Manager (RM) role configuration
+ enabled: false
+ not_controllable:
+ enabled: true
+ pebc:
+ enabled: true
+ frbc:
+ enabled: true
+ cem: # Customer Energy Manager (CEM) role configuration
+ enabled: true
+ not_controllable:
+ enabled: true
+ pebc:
+ enabled: true
+ instruction_wait_timeout: 0
+ frbc:
+ enabled: true
+ instruction_wait_timeout: 0
+```
+
+---
+
+## Usage Server
+
+### Running the tool
+
+The server component of the tool is a FastAPI server that is executed using the `uv` package manager. Once the tool is running a client instance can connect in order to be tested and certified.
+
+#### Example Command - Server
+
+```bash
+cd s2-self-cert-server
+uv run fastapi dev ./src/main.py --port 8000 --host 0.0.0.0
+```
+
+## Project Structure
+
+The repository is organized into the following directories and files:
+
+```text
+.
+├── packages # Shared Python packages
+│ ├── connectivity # WebSocket communication logic
+│ ├── s2-python # Custom version of the S2-Python library. TODO: Remove!
+│ └── test-suites # Test suite logic
+├── s2-self-cert # Client application
+└── s2-self-cert-server # Server application
+```
+
+The project is split into 4 python packages which are all managed by UV
+
+---
+
+## Architecture
+
+The tool is built using a modular, object-oriented design. Key components include:
+
+- **Connection Adapter**: Abstracts WebSocket communication, supporting both FastAPI and standard Python WebSocket implementations.
+- **Test Suite**: Encapsulates test cases and orchestrates their execution.
+- **Controllers**: Manage state and behavior for specific control types.
+- **Client and Server Applications**: Facilitate local testing and remote certification.
+
+---
+
+## About the tool
+
+### How the testing works
+
+Since this tool is designed to be able to test any and all S2 devices, it creates the test cases at runtime based on the device details provided to it. For example, based on the Resource Manager Details that an RM provides, the test case will check that a device sends a forecast if the `provides_forecast` toggle is set.
+
+Furthermore, the tests in this tool are time and event bound. Essentially the test case will do some kind of task, such as send a message, and then wait to see how the device will response. For example, sending a PEBC Instruction and waiting for power readings to validate that the PEBC is following the instruction.
+
+In order to achieve these behaviours, the test cases are based around triggers. The test case has a queue of triggers which are executed one by one and after executing the trigger, it waits for either:
+
+- An asyncio.Event to be set
+- A wait time to be reached
+
+Once the event has been triggered or the wait time complete, the next trigger in the queue is executed. Once the triggers queue is empty and all validations are complete, the test case is complete.
+
+The triggers queue allows additional triggers to be added during test execution based on the messages sent by the device under test.
+
+#### Triggers Example
+
+We will use the RM PEBC Test Case as an example of how the triggers work. This test case can be found in `testsuites/rm/pebc_test_cases.py`.
+
+When the test cases is started the `generate_tests` method is run which adds the initial triggers to the queue. Before anything else can happen we need to receive the PEBC Power Constraints from the device. This is done by adding a trigger with only an event which will be waited for. The `_power_constraints_received_event` is set by the power PEBCPowerConstraints message handler once it's received.
+
+When the PEBCPowerConstraints, the main set of tests are created. Based on the power constraints we queue up a number of instruction triggers which will test all permutations of the curtailment limits. Each one of these triggers has a wait time to allow the S2 device to respond. This wait time is based on the config since different S2 devices might send power readings at different periods.
+
+Once the new triggers have been added, the `_power_constraints_received_event` is set, causing the next trigger to be executed.
+
+#### Making assertions
+
+The testing in this tool is centered around Python's `unittest` assertions. The S2 Test Case inherits from the `unittest.TestCase` so assertions in the test case can be called by `self.assertEqual(True, True)` for example.
+
+VERY IMPORTANT: Never run assertions inside of a message handler! Always create a validate method and pass the execution to the testing task (asyncio). Here's an example:
+
+```python
+await self.add_test_method(
+ "Validate Instruction Status Update", # Name used in test report.
+ self.validate_instruction_status_update,
+ message, # Pass any args or kwargs that the validate method needs here
+)
+```
+
+Inside of a validate method you can raise assertions. Once the validate method is complete it will add the test to the compliance report as:
+
+- PASS - if no assertions raised
+- FAIL - if an assertion is raised
+- SOFT_FAIL - if an assertion is raised and the `fail_result_status=TestResultStatus.FAIL` is set in the `add_test_method` call
+
+The diagram below shows the asyncio tasks which are involved in the testing. Validation can only happen on the Test Case Thread.
+
+```mermaid
+sequenceDiagram
+ participant Channel as Message Channel
Thread
+ participant Handlers as Message Handlers
Thread
+ participant Triggers as Triggers Thread
Managed by Test Case
+ participant Tests as Tests Case Thread
+
+ Tests ->> Tests : Generate initial triggers
+ Channel ->> Handlers : Receives Message
(via queue)
+
+ Handlers ->> Tests : Pass message to be validated
(added to tests queue)
+ Handlers ->> Triggers : Generate new Triggers if needed
(added to triggers queue)
+
+ Triggers ->> Channel : Send messages
+```
+
+---
+
+### How the Certificate Signing Works
+
+#### Key Challenge
+
+This diagram shows the flow of how the certificate challenge on initial connection to the certification server works. If the challenge fails then the tool disconnects. If it passes the challenge then the testing is executed and the test report is double signed at the end.
+
+```mermaid
+---
+config:
+ layout: dagre
+ look: neo
+ theme: neo
+---
+
+sequenceDiagram
+ participant ClientOrg
+ participant Server
+
+ Note over Server, ClientOrg: Certificate Challenge
+ ClientOrg->>Server: Submit public key and Client Org ID
+
+ alt If ClientID in storage and public key doesn't match
+ Server->>ClientOrg : Send Challenge Failed Response
+ Server-->>ClientOrg : Disconnect
+ else
+
+ Server->>Server: Generate random challenge string
+ Server->>ClientOrg: Send challenge
+ ClientOrg->>ClientOrg: Sign challenge with private key
+ ClientOrg->>Server: Send signature
+ Server->>Server: Verify signature with submitted public key
+
+ alt If signature valid
+ Server->>Server: Store public key if verification succeeds
+ Note over Server, ClientOrg: Start Testing
+ else
+ Server->>ClientOrg : Send Invalid Signature Response
+ Server-->>ClientOrg : Disconnect
+ end
+end
+```
+
+#### Certificate Signing
+
+This diagram shows the flow of how the certificate is double signed after the testing is complete. The client signs the certificate first so that the server can validate that it was signed with the same key that was challenged at the start. Only if this is valid does the server sign the certificate and send it back.
+
+```mermaid
+sequenceDiagram
+ participant ClientOrg
+ participant Server
+
+%% rect rgb(83, 0, 83)
+ Note over Server, ClientOrg: Testing Complete
+ Server->>Server : Generate Unsigned Certificate
+ Server->> ClientOrg : Send Unsigned Certificate to Client
+ ClientOrg->>ClientOrg : Sign certificate using Org Private Key
+ ClientOrg ->>Server : Send single singed certificate back to server
+ Server->>Server : Verify that Certificate data matches and
verify signature with Org Public Key (From Challenge)
+
+ alt if certificate data and signature valid
+ Server->>Server : Sign Certificate with Private Key
+ Server->>ClientOrg : Send Double Signed Certificate
+ else
+ Server->>ClientOrg: Send Invalid Signature Response
+ end
+ Server-->>ClientOrg: Disconnect
+```
+
+## Adding to the Tool
+
+This section outlines how to add new test cases to this tool.
+
+### Adding Additional Configurations
+
+All of the configuration data is loaded from the YAML file using Pydanic models to simplify data validation. All of the models can be found in the `connectivity` package under `config` (The location of the config is not ideal but was necessary to allow connectivity package to access the config.) The root model that all the configuration is loaded into is the `Config` class.
+
+### Adding New Controllers
+
+To add a new controller:
+
+1. Create a new class that extends from the `Controller` class in `testsuite.controllers`. Be sure to set the `role` and `control_type`.
+
+2. Add all the message handler methods to the class. All message handlers must have the following signature:
+
+```python
+async def handle_system_description_message(
+ self, message: FRBCSystemDescription, channel: "S2Channel", send_okay
+):
+ # Handle the message
+```
+
+3. Use the add handler method in the constructor to register the handler method for the message type it's supposed to handle:
+
+```python
+self.add_handler(FRBCSystemDescription, self.handle_system_description_message)
+```
+
+4. Register the new controller with the `IntegrationTestExecutor` setup methods in `testsuites.setup.setup`. Just add the class to the `controller_classes` list.
+
+5. Proceed to the next section to add a test case which uses this controller.
+
+### Adding New Test Cases
+
+To add a new test case:
+
+1. If necessary, implement a new controller. See above... (Only necessary if implementing a totally new control type)
+2. Implement a new `TestCase` class in the `testsuites` package under "./src/testsuites/test_suite"
+ - Optionally, inherit from one of the existing base classes. It's a good idea to inherit from the NotControllable test case for the given role.
+3. Register the new `TestCase` using the builder in the `setup` folder of the `testsuites` module.
+
+Here is an example from the FRBC Test Case:
+
+```python
+class FRBCTestCase(NotControllableRMTestCase):
+ name = "FRBC Test Case"
+ control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL
+
+ controller: FRBCRMController
+ config: FRBCRMTestConfig
+
+ _system_description_received_event: asyncio.Event
+ _initial_storage_status: asyncio.Event
+
+ transitions_traversed: set
+
+ def __init__(
+ self,
+ config: FRBCRMTestConfig, # The config class for this control type
+ channel: S2Channel, # The S2 Channel used to send messages to and from the S2 Device.
+ controller: FRBCRMController, # The controller that stores the state and is used for device interaction. Use it's state to perform validations.
+ report: ComplianceReport, # The test report where test results are written to.
+ logger: TestLogger, # The logger where test logs are written to.
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ # Initialise the events used for synchronisation.
+ self._system_description_received_event = asyncio.Event()
+ self._initial_storage_status = asyncio.Event()
+
+ # The S2TestCase is a message handler so that we can receive messages directly.
+ # Add all the message handlers to the test case
+ # If a handler is defined here then it will be used instead of the controller's handler method.
+ # You can run the controllers handler method (kindof like running super methods, ish...) with the
+ self.message_handlers[FRBCSystemDescription] = (
+ self.handle_frbc_system_description
+ )
+ self.message_handlers[FRBCUsageForecast] = self.handle_usage_forecast
+ self.message_handlers[FRBCLeakageBehaviour] = self.handle_leakage_behaviour
+ self.message_handlers[FRBCActuatorStatus] = self.handle_actuator_status
+ self.message_handlers[FRBCStorageStatus] = self.handle_storage_status
+
+ self.transitions_traversed = set()
+
+ async def generate_tests(self):
+ """Add all initial triggers here"""
+ await super().generate_tests()
+
+ await self.add_trigger_method(
+ None, wait_time=5, event=self._system_description_received_event
+ )
+
+ async def handle_leakage_behaviour(
+ self, message: FRBCLeakageBehaviour, channel: "S2Channel", send_okay
+ ):
+ # Use the original handler from the controller
+ await self.handle_with_original_handler(message, channel, send_okay)
+ # Add a validation to be performed. NEVER PERFORM TEST VALIDATION INSIDE A HANDLER!
+ await self.add_test_method(
+ "9.6.3. Update Leakage Behaviour", self.validate_leakage_behaviour, message
+ )
+ # Wait for the reception status to finish being sent.
+ await send_okay
+
+ async def validate_leakage_behaviour(self, message: FRBCLeakageBehaviour):
+ # The validation method for the leakage behaviour message.
+ self.update_system_description_precondition("9.6.3.2")
+
+ # Sanity checks
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), FRBCLeakageBehaviour)
+
+ if self.controller.system_description is None:
+ raise AssertionError("System Description not set on controller.")
+
+ self.assertTrue(
+ self.controller.system_description.storage.provides_leakage_behaviour,
+ "Received unexpected leakage behaviour.",
+ )
+```
+
+## CI Testing
+
+In the `.ci-testing` folder are a few files that can allow you to run this integration testing suite in your CI. Currently only a GitLab CI pipeline has been created. The tool also produces a JUnit XML style report which GitLab CI can parse and include in the UI.
+
+All that needs to happen to allow you to test your application in CI is add the docker compose config to the `./ci-testing/docker-compose.yaml` file. And include the GitLab CI file in the root of your repository.
+
+---
+
+## Future Plans
+
+- Add support for additional control types (PPBC, DDBC, OMBC).
diff --git a/ci-testiing/.gitlab-ci.yml b/ci-testiing/.gitlab-ci.yml
new file mode 100644
index 0000000..9841e71
--- /dev/null
+++ b/ci-testiing/.gitlab-ci.yml
@@ -0,0 +1,32 @@
+stages:
+ - integration_test
+
+variables:
+ APP_IMAGE_SUBPATH: "pv-installation"
+
+# Add a build job here...
+
+integration_test:
+ image: docker:24.0
+ stage: integration_test
+ services:
+ - name: docker:24.0-dind
+ alias: docker
+ variables:
+ DOCKER_HOST: tcp://docker:2375
+ DOCKER_TLS_CERTDIR: ""
+ DOCKER_BUILDKIT: "1"
+ script:
+ - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
+ - docker compose -f ./testing/compose.yaml up
+ - cat ./testing/test.log
+ - cat ./testing/report.yaml
+ artifacts:
+ # Write all of the test results to the GitLab Artifacts.
+ paths:
+ - ./testing/test.log
+ - ./testing/messages.log
+ - ./testing/report.xml
+ - ./testing/report.yaml
+ reports:
+ junit: ./testing/report.xml
\ No newline at end of file
diff --git a/ci-testiing/config.example.yaml b/ci-testiing/config.example.yaml
new file mode 100644
index 0000000..47f319a
--- /dev/null
+++ b/ci-testiing/config.example.yaml
@@ -0,0 +1,41 @@
+device_details:
+ name: Some Device
+ manufacturer: ABCD
+ model: basic
+ firmware_version: "0"
+mode: or testing
+certification:
+ client_id: "example"
+ uri: ws://localhost:8001/certification
+ key_path: ./org_key.pem
+connection:
+ mode: server
+ host: 0.0.0.0
+ port: 8000
+ uri: wss://some-cem.com/connection-path
+report:
+ yaml: /app/tests/report.yaml
+ xml: /app/tests/report.xml
+ log_path: /app/tests/tests.log
+ message_log: /app/tests/messages.log
+ xml_soft_fail_is_fail: true
+ include_test_parameters: false
+
+roles:
+ rm:
+ enabled: false
+ not_controllable: null
+ pebc:
+ enabled : true
+ frbc:
+ enabled: true
+ cem:
+ enabled: true
+ not_controllable:
+ enabled: true
+ pebc:
+ enabled: false
+ instruction_wait_timeout: 0
+ frbc:
+ enabled: false
+ instruction_wait_timeout: 0
\ No newline at end of file
diff --git a/ci-testiing/docker-compose.yaml b/ci-testiing/docker-compose.yaml
new file mode 100644
index 0000000..2d14112
--- /dev/null
+++ b/ci-testiing/docker-compose.yaml
@@ -0,0 +1,39 @@
+---
+
+services:
+ s2-self-cert:
+ image: busybox # TODO! Add the certifier image here!
+ networks:
+ - test_network
+ command: python3 /app/src/s2selfcert/main.py
+ environment:
+ - CONFIG_PATH=${CONFIG_PATH:-/app/ci-testing/config.yaml}
+ volumes:
+ - ./:/app/test/
+ healthcheck:
+ test: [ "CMD-SHELL", "true" ]
+ interval: 0s
+ timeout: 0s
+ retries: 1
+ start_period: 1s
+
+ # Add you application to be tested here! (This is an example)
+ # pv-installation:
+ # image: pv-installation:latest # Image from https://github.com/flexiblepower/s2-example-implementations
+ # build: ./pv-installations
+ # 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
+ # networks:
+ # - test_network
+
+networks:
+ test_network:
diff --git a/docker-compose.server.yaml b/docker-compose.server.yaml
new file mode 100644
index 0000000..f1e8138
--- /dev/null
+++ b/docker-compose.server.yaml
@@ -0,0 +1,22 @@
+---
+
+services:
+ s2-self-cert-server:
+ build:
+ dockerfile: ./Dockerfile.server
+ command: fastapi run /app/src/main.py --host 0.0.0.0 --port 8001
+ ports:
+ - ${PORT:-8001}:8001
+ volumes:
+ - ./s2-self-cert-server/data:/app/data
+ - ./s2-self-cert-server/src:/app/src
+ - ./packages:/packages
+ environment:
+ SERVER_KEY_PATH: "/app/data/server_key.pem"
+ KEYS_STORAGE_PATH: "/app/data/keys.json"
+ 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..3c29d2e
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,22 @@
+---
+
+services:
+ s2-self-cert:
+ image: ci.tno.nl:4567/s2/kiflin/s2-self-certification:latest
+ build:
+ dockerfile: ./Dockerfile.client
+ command: python3 /app/src/s2selfcert/main.py
+ ports:
+ - 8000:8000
+ volumes:
+ - ./s2-self-cert/src:/app/src
+ - ./s2-self-cert/data:/app/data
+ - ./packages:/packages
+ environment:
+ - CONFIG_PATH=${CONFIG_PATH:-/app/data/config-cem-certify.yaml}
+ healthcheck:
+ test: [ "CMD-SHELL", "true" ]
+ interval: 0s
+ timeout: 0s
+ retries: 1
+ start_period: 1s
\ No newline at end of file
diff --git a/docs/modes.png b/docs/modes.png
new file mode 100644
index 0000000..3552dfb
Binary files /dev/null and b/docs/modes.png differ
diff --git a/docs/testing_threads.png b/docs/testing_threads.png
new file mode 100644
index 0000000..4fbcbfd
Binary files /dev/null and b/docs/testing_threads.png differ
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/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/packages/connectivity/README.md b/packages/connectivity/README.md
new file mode 100644
index 0000000..e69de29
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/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/__init__.py b/packages/connectivity/src/connectivity/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/connectivity/src/connectivity/async_task_manager.py b/packages/connectivity/src/connectivity/async_task_manager.py
new file mode 100644
index 0000000..2c10033
--- /dev/null
+++ b/packages/connectivity/src/connectivity/async_task_manager.py
@@ -0,0 +1,72 @@
+import asyncio
+
+import logging
+from typing import Coroutine, Set
+
+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[asyncio.Task] = 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...
+ """
+ task_name = getattr(task, "__name__", str(task))
+ try:
+ await task
+ if stop_on_complete:
+ 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 %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, *args, **kwargs):
+ self._stop_event = asyncio.Event()
+
+ async def cleanup(self, *args, **kwargs):
+ logger.info("Cleanup of %s", self.__class__.__name__)
+ for task in self._tasks:
+ task.cancel()
+
+ await asyncio.gather(*self._tasks)
+
+ 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):
+ return self.running
diff --git a/packages/connectivity/src/connectivity/channel.py b/packages/connectivity/src/connectivity/channel.py
new file mode 100644
index 0000000..bbe6c55
--- /dev/null
+++ b/packages/connectivity/src/connectivity/channel.py
@@ -0,0 +1,88 @@
+import abc
+import asyncio
+
+import json
+import logging
+from typing import Generic, TypeVar
+
+from .connection_adapter import (
+ ConnectionAdapter,
+ ConnectionClosed,
+ ConnectionError,
+ ConnectionProtocolError,
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+class ChannelSendError:
+ pass
+
+
+T = TypeVar("T")
+RawT = TypeVar("RawT")
+
+
+class Channel(Generic[T, RawT], abc.ABC):
+ connection: ConnectionAdapter[RawT]
+ message_queue: asyncio.Queue[T | None]
+ _stop_event: asyncio.Event
+
+ 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) -> T:
+ 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
+
+ async def receive(self) -> RawT:
+ return await self.connection.receive()
+
+ 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.")
+
+ 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()
+
+ async def run(self):
+ await self.receive_messages()
+
+ await self.connection.close()
+
+ logger.info("Channel Run complete.")
+
+ async def stop(self):
+ logger.info("Stopping Channel.")
+ if not self._stop_event.is_set():
+ self.message_queue.put_nowait(None)
+ self._stop_event.set()
+ await self.connection.close()
+
+
+class BaseChannel(Channel[str, str]):
+ """A str, str channel."""
diff --git a/packages/connectivity/src/connectivity/config/__init__.py b/packages/connectivity/src/connectivity/config/__init__.py
new file mode 100644
index 0000000..eac9920
--- /dev/null
+++ b/packages/connectivity/src/connectivity/config/__init__.py
@@ -0,0 +1,13 @@
+from .base import BaseTestConfig
+from .cem import *
+from .rm import *
+from .config import (
+ Config,
+ ConnectionConfig,
+ ReportConfig,
+ CertificationConfig,
+ RoleTestConfig,
+ DeviceDetails,
+ load_config,
+ ConfigError
+)
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..7ab2264
--- /dev/null
+++ b/packages/connectivity/src/connectivity/config/cem.py
@@ -0,0 +1,45 @@
+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):
+ instruction_wait_timeout: int = 0
+
+
+class FRBCCEMTestConfig(BaseTestConfig):
+ instruction_wait_timeout: int = 0
+
+
+class ControlTypeCEMTestConfig(BaseModel):
+ enabled: bool = True
+ role: EnergyManagementRole = EnergyManagementRole.CEM
+
+ not_controllable: Optional[BaseTestConfig] = None
+ pebc: Optional[PEBCCEMTestConfig] = None
+ frbc: Optional[FRBCCEMTestConfig] = None
+
+ def get_controller_configs_dics(
+ self,
+ ) -> Dict[ProtocolControlType, BaseTestConfig | None]:
+ return {
+ ProtocolControlType.NO_SELECTION: self.not_controllable,
+ ProtocolControlType.NOT_CONTROLABLE: self.not_controllable,
+ ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: self.pebc,
+ ProtocolControlType.FILL_RATE_BASED_CONTROL: self.frbc,
+ }
+
+ 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/config.py b/packages/connectivity/src/connectivity/config/config.py
new file mode 100644
index 0000000..5d1cd81
--- /dev/null
+++ b/packages/connectivity/src/connectivity/config/config.py
@@ -0,0 +1,97 @@
+import logging
+from typing import Dict, Optional, Literal
+
+import yaml
+from pydantic import BaseModel, field_serializer, model_validator
+from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole
+from .base import BaseTestConfig
+from .cem import ControlTypeCEMTestConfig
+from .rm import ControlTypeRMTestConfig
+from s2python.generated.gen_s2 import Currency
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigError(Exception):
+ """Raised when there is a configuration missing or a misconfiguration for the attempted state."""
+
+
+class DeviceDetails(BaseModel):
+ name: str
+ manufacturer: str
+ model: str
+ firmware_version: str
+ currency: Currency = Currency.EUR
+ serial_number: str = "0000"
+
+
+ @field_serializer("currency")
+ def serializer_currency(self, currency: Currency):
+ return currency.name
+
+
+class ConnectionConfig(BaseModel):
+ mode: Literal["server", "client"] = "client"
+ 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 CertificationConfig(BaseModel):
+ uri: str
+ key_path: str
+ client_id: str
+
+
+class ReportConfig(BaseModel):
+ yaml: Optional[str] = None
+ xml: Optional[str] = None
+ log_path: Optional[str] = None # The file that test logs are written to
+ xml_soft_fail_is_fail: bool = True
+ include_test_parameters: bool = True
+
+
+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
+ ) -> BaseTestConfig | None:
+ 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: DeviceDetails
+ roles: RoleTestConfig
+ report: Optional[ReportConfig] = ReportConfig()
+
+
+def load_config(config_path) -> Config:
+ with open(config_path) as stream:
+ config = yaml.safe_load(stream)
+ return Config.model_validate(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..fde5299
--- /dev/null
+++ b/packages/connectivity/src/connectivity/config/rm.py
@@ -0,0 +1,51 @@
+from pydantic import BaseModel
+from .base import BaseTestConfig
+from typing import Optional
+from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class NoSelectionRMTestConfig(BaseTestConfig):
+ pass
+
+
+class PEBCRMTestConfig(BaseTestConfig):
+ # Indicates whether of not this device sends energy constraints.
+ sends_energy_constraints: Optional[bool] = True
+
+ # The time in seconds to wait for the energy constraints before timing out.
+ energy_constraints_wait_timeout: Optional[int] = 0
+
+ # The amount of time to wait after sending an instruction before sending the next instruction
+ # (in addition to the instruction processing time specified in RM Details)
+ instruction_trigger_wait_time: int = 5
+
+ # Indicates whether to wait for the specified instruction processing time (specified in RM details)
+ # after sending an instruction
+ wait_instruction_processing_time: bool = True
+
+
+class FRBCRMTestConfig(BaseTestConfig):
+ pass
+
+
+class ControlTypeRMTestConfig(BaseModel):
+ enabled: bool = True
+ role: EnergyManagementRole = EnergyManagementRole.RM
+
+ not_controllable: Optional[NoSelectionRMTestConfig]
+ pebc: Optional[PEBCRMTestConfig]
+ frbc: Optional[FRBCRMTestConfig]
+
+ def get_control_type_config(
+ self, control_type: ProtocolControlType
+ ) -> NoSelectionRMTestConfig | PEBCRMTestConfig | FRBCRMTestConfig:
+ config = {
+ ProtocolControlType.NO_SELECTION: self.not_controllable,
+ ProtocolControlType.NOT_CONTROLABLE: self.not_controllable,
+ ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL: self.pebc,
+ ProtocolControlType.FILL_RATE_BASED_CONTROL: self.frbc,
+ }[control_type]
+ return config
diff --git a/packages/connectivity/src/connectivity/connection_adapter.py b/packages/connectivity/src/connectivity/connection_adapter.py
new file mode 100644
index 0000000..9cbb88f
--- /dev/null
+++ b/packages/connectivity/src/connectivity/connection_adapter.py
@@ -0,0 +1,69 @@
+import abc
+import asyncio
+
+import logging
+from typing import Generic, TypeVar
+
+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."""
+
+
+# The generic type that the messages sent and received should have.
+T = TypeVar("T")
+
+
+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 adapter allows wrapping of the different websocket implementations.
+ """
+
+ @abc.abstractmethod
+ async def receive(self) -> T:
+ """
+ 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: T):
+ """
+ Send a message over the websocket.
+ Raises:
+ ConnectionClosed: if the connection is closed.
+ ConnectionError: for other errors.
+ """
+ pass
+
+ @property
+ @abc.abstractmethod
+ 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..e870285
--- /dev/null
+++ b/packages/connectivity/src/connectivity/s2_channel.py
@@ -0,0 +1,241 @@
+import asyncio
+import json
+from typing import Awaitable, Callable, Literal, Optional, Type
+import uuid
+from s2python.common import ReceptionStatus, ReceptionStatusValues, EnergyManagementRole
+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
+
+
+def reverse_role(role: EnergyManagementRole):
+ roles = {
+ EnergyManagementRole.RM: EnergyManagementRole.CEM,
+ EnergyManagementRole.CEM: EnergyManagementRole.RM,
+ }
+ return roles[role]
+
+
+logger = logging.getLogger(__name__)
+
+message_logger = logging.getLogger("messages")
+
+
+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[S2Message, str]):
+ """
+ Manages the connection and message processing from an S2 Device.
+ Received messages off the ConnectionAdapter in a loop and puts them onto a queue to be processed elsewhere.
+ Manages the conversion from/to JSON string to/from S2 Message. Uses S2 Parser for conversion
+
+ This class is heavily based on the S2Connection class from the S2Python Library.
+ """
+
+ s2_parser: S2Parser
+
+ reception_status_awaiter: ReceptionStatusAwaiter
+
+ role: Optional[EnergyManagementRole] = None
+
+ validation_error_handler: (
+ Callable[[json.JSONDecodeError | S2ValidationError], Awaitable[None]] | None
+ ) = None
+
+ def __init__(self, connection: ConnectionAdapter) -> None:
+ super().__init__(connection)
+
+ self.reception_status_awaiter = ReceptionStatusAwaiter()
+ self.s2_parser = S2Parser()
+
+ self.message_queue = asyncio.Queue()
+
+ def set_validation_error_handler(
+ self,
+ handler: Callable[[json.JSONDecodeError | S2ValidationError], Awaitable[None]],
+ ):
+ self.validation_error_handler = handler
+
+ def log_messages(
+ self, message: S2Message, direction: Literal["INCOMING", "OUTGOING"]
+ ):
+ msg_dict = message.to_dict()
+ sender = None
+ receiver = None
+ if self.role is not None:
+ sender = (
+ reverse_role(self.role) if direction == "INCOMING" else self.role
+ ).name
+ receiver = (
+ reverse_role(self.role) if direction == "OUTGOING" else self.role
+ ).name
+
+ message_logger.log(
+ level=(
+ logging.DEBUG
+ if msg_dict["message_type"] == "ReceptionStatus"
+ else logging.INFO
+ ),
+ msg=direction,
+ extra={"s2_message": msg_dict, "sender": sender, "receiver": receiver},
+ )
+
+ async def send(self, message: S2Message):
+ if message.message_type == "Handshake" and self.role is None:
+ self.role = (
+ EnergyManagementRole.CEM
+ if message.role == EnergyManagementRole.RM
+ else EnergyManagementRole.RM
+ )
+
+ self.log_messages(message, "OUTGOING")
+ str_msg = message.to_json()
+
+ return await self.connection.send(str_msg)
+
+ 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(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(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)
+
+ if s2_msg.message_type == "Handshake" and self.role is None:
+ self.role = (
+ EnergyManagementRole.CEM
+ if s2_msg.role == EnergyManagementRole.RM
+ else EnergyManagementRole.RM
+ )
+
+ self.log_messages(s2_msg, "INCOMING")
+ except json.JSONDecodeError as e:
+ await self.send(
+ ReceptionStatus(
+ subject_message_id=uuid.UUID(
+ "00000000-0000-0000-0000-000000000000"
+ ),
+ status=ReceptionStatusValues.INVALID_DATA,
+ diagnostic_label="Not valid json.",
+ )
+ )
+ if self.validation_error_handler is None:
+ raise
+ else:
+ await self.validation_error_handler(e)
+
+ 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
+ if self.validation_error_handler is None:
+ raise
+ else:
+ await self.validation_error_handler(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)
diff --git a/packages/connectivity/uv.lock b/packages/connectivity/uv.lock
new file mode 100644
index 0000000..76bb6b3
--- /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 = "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"
+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 = "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/packages/s2-python b/packages/s2-python
new file mode 160000
index 0000000..63db330
--- /dev/null
+++ b/packages/s2-python
@@ -0,0 +1 @@
+Subproject commit 63db330cb7d7843769aca2359a792f423d4ec814
diff --git a/packages/test-suites/README.md b/packages/test-suites/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/packages/test-suites/pyproject.toml b/packages/test-suites/pyproject.toml
new file mode 100644
index 0000000..8e3aade
--- /dev/null
+++ b/packages/test-suites/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "test-suites"
+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",
+ "websockets>=13.1",
+ "s2-python",
+ "connectivity",
+ "cryptography>=45.0.4",
+]
+
+[tool.uv.sources]
+s2-python = { path = "../s2-python", editable = true }
+
+connectivity = { path = "../connectivity", editable = true }
+
+[dependency-groups]
+dev = [
+ "pytest>=8.4.0",
+]
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/packages/test-suites/src/testsuites/certificate/certificate.py b/packages/test-suites/src/testsuites/certificate/certificate.py
new file mode 100644
index 0000000..ae203da
--- /dev/null
+++ b/packages/test-suites/src/testsuites/certificate/certificate.py
@@ -0,0 +1,334 @@
+import logging
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Type
+from enum import Enum
+
+from pydantic import (
+ BaseModel,
+ field_serializer,
+ field_validator,
+ model_validator,
+ validator,
+)
+import yaml
+from connectivity.config import DeviceDetails
+
+from s2python.message import S2Message
+from s2python.common import ControlType as ProtocolControlType
+import xml.etree.ElementTree as ET
+from connectivity.config import ReportConfig
+
+logger = logging.getLogger(__name__)
+
+
+class TestResultStatus(Enum):
+ PASS = "PASS"
+ FAIL = "FAIL"
+ SOFT_FAIL = "SOFT_FAIL"
+ N_A = "N/A"
+
+
+class TestResult(BaseModel):
+ name: str
+ status: TestResultStatus
+ message: Optional[str] = None
+ duration: Optional[float] = None
+ parameters: Optional[Dict] = None
+
+ @field_serializer("status")
+ def serializer_status(self, status: TestResultStatus):
+ return status.name
+
+ @field_validator("status", mode="before")
+ @classmethod
+ def deserialize_status(cls, status):
+ if isinstance(status, TestResultStatus):
+ return status
+ return TestResultStatus[status]
+
+
+class TestSuiteResults(BaseModel):
+ name: str
+ control_type: Optional[ProtocolControlType] = None
+ duration: Optional[float] = None
+ status: TestResultStatus = TestResultStatus.PASS
+ tests: Dict[str, TestResult] = {}
+
+ def add_test_result(self, result: TestResult):
+
+ self.tests[result.name] = result
+ # self.tests.append(result)
+
+ 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,
+ ]:
+ # Soft fail doesn't cause whole failure
+ self.status = TestResultStatus.PASS
+
+ @field_serializer("status")
+ def serializer_status(self, status: TestResultStatus):
+ return status.name
+
+ @field_validator("status", mode="before")
+ @classmethod
+ def deserialize_status(cls, status):
+ if isinstance(status, TestResultStatus):
+ return status
+ return TestResultStatus[status]
+
+ @field_serializer("control_type")
+ def serializer_control_type(self, control_type: Optional[ProtocolControlType]):
+ if control_type is None:
+ return None
+ return control_type.name
+
+ @field_validator("control_type", mode="before")
+ @classmethod
+ def deserialize_control_type(
+ cls, control_type: Optional[str | ProtocolControlType]
+ ):
+ if control_type is None:
+ return None
+
+ if isinstance(control_type, ProtocolControlType):
+ return control_type
+ return ProtocolControlType[control_type]
+
+ @property
+ def count_passed(self, include_soft_fail=False):
+ count = 0
+ for test in self.tests.values():
+ 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.values():
+ 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.values():
+ if test.status == TestResultStatus.N_A:
+ count += 1
+ return count
+
+ def model_dump(self, *args, include_test_parameters=False, **kwargs):
+ dump = super().model_dump(*args, exclude={"tests"}, **kwargs)
+
+ ex = {}
+ if not include_test_parameters:
+ ex = {"parameters"}
+
+ dump["tests"] = [
+ suite.model_dump(exclude=ex, exclude_none=True)
+ for suite in self.tests.values()
+ ]
+ # dump["tests"] = [
+ # suite.model_dump(exclude=ex, exclude_none=True) for suite in self.tests
+ # ]
+
+ return dump
+
+ # @model_validator(mode='before')
+ # @classmethod
+ # def validate_tests(cls, data : Any):
+
+ # if "test" not in data:
+ # return data
+
+ # if isinstance(data["tests"], dict):
+ # return data
+
+ # tests = data["tests"]
+ # data["tests"] = {}
+
+ # for test in tests:
+ # data["tests"][test["name"]] = test
+
+ # return data
+ @model_validator(mode="before")
+ @classmethod
+ def _coerce_tests_list(cls, data: Any) -> Any:
+ # only rewrite if the raw input has a list under "tests"
+ if "tests" not in data or isinstance(data["tests"], dict):
+ return data
+
+ # build a dict[name -> test_payload]
+ tests_list = data["tests"]
+ data["tests"] = {t["name"]: t for t in tests_list}
+ return data
+
+
+class Signature(BaseModel):
+ server_signature: Optional[str] = None
+ server_signature_timestamp: Optional[datetime] = None
+
+ client_id: Optional[str] = None
+ client_signature: Optional[str] = None
+ client_signature_timestamp: Optional[datetime] = None
+
+
+class ComplianceReport(BaseModel):
+ timestamp: datetime = datetime.now()
+ test_suites: List[TestSuiteResults] = []
+ device: Optional[DeviceDetails]
+ signature: Signature = Signature()
+
+ def add_test_suite_result(self, result: TestSuiteResults):
+ self.test_suites.append(result)
+
+ def generate_certificate_dict(self, include_test_parameters=False) -> dict:
+
+ dump = self.model_dump(exclude_none=True, exclude={"test_suites"})
+
+ dump["test_suites"] = [
+ suite.model_dump(
+ include_test_parameters=include_test_parameters, exclude_none=True
+ )
+ for suite in self.test_suites
+ ]
+
+ return dump
+
+ def export(self, config: ReportConfig):
+ if config.yaml is not None:
+ self.export_to_yaml(config.yaml, config.include_test_parameters)
+ if config.xml is not None:
+ self.export_to_junit_xml(config.xml, config.xml_soft_fail_is_fail)
+
+ def export_to_yaml(self, filename, include_test_parameters):
+ logger.info(f"Writing YAML report to `{filename}`")
+ with open(filename, "w") as output:
+ cert_data = self.generate_certificate_dict(
+ include_test_parameters=include_test_parameters
+ )
+ 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, soft_fail_is_fail=True) -> 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 = f"{suite.name} ({str(suite.control_type)})"
+ 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.values():
+ 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 and soft_fail_is_fail
+ ):
+ failure_element = ET.SubElement(
+ testcase_element,
+ "failure",
+ 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(f"Writing JUnit XML report to `{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/certificate/signature.py b/packages/test-suites/src/testsuites/certificate/signature.py
new file mode 100644
index 0000000..adf1583
--- /dev/null
+++ b/packages/test-suites/src/testsuites/certificate/signature.py
@@ -0,0 +1,218 @@
+import base64
+from datetime import datetime
+import os
+from typing import Optional
+from testsuites.certificate.certificate import ComplianceReport
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.exceptions import InvalidSignature
+import yaml
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class CertificationEncoder:
+ # Centralize encoding and decoding from bytes to allow for it to be changed later
+ # Currently uses Base 64
+ # ALL ENCODING AND DECODING WITH BYTES SHOULD USE THIS - Facilitates easily swapping it out.
+
+ @classmethod
+ def encode(cls, data: bytes) -> str:
+ return base64.b64encode(data).decode("ascii")
+
+ @classmethod
+ def decode(cls, data: str) -> bytes:
+ return base64.b64decode(data)
+
+
+class SimpleCertifier:
+ """This class is responsible for signing with a given PEM private key."""
+
+ PADDING = padding.PSS(
+ mgf=padding.MGF1(hashes.SHA256()),
+ salt_length=padding.PSS.MAX_LENGTH,
+ )
+ ALGORITHM = hashes.SHA256()
+
+ SERIALIZATION_ENCODING = serialization.Encoding.PEM
+ SERIALIZATION_FORMAT = serialization.PublicFormat.SubjectPublicKeyInfo
+
+ def __init__(self, key_path: str, key_pass: Optional[bytes] = None) -> None:
+ self.load_key(key_path, key_pass)
+
+ def get_public_key(self):
+ """Gets the public key from the private key that was loaded."""
+
+ if self.private_key is None:
+ raise ValueError("Private Key must be provided.")
+ return self.private_key.public_key()
+
+ def get_serialized_public_key(self) -> bytes:
+ public_key = self.get_public_key()
+ pem_bytes = public_key.public_bytes(
+ encoding=self.SERIALIZATION_ENCODING,
+ format=self.SERIALIZATION_FORMAT,
+ )
+ return pem_bytes
+
+ def load_key(self, key_path: str, key_pass: Optional[bytes] = None):
+ if not os.path.exists(key_path):
+ raise ValueError("Invalid Key Path.")
+
+ with open(key_path, "rb") as key_file:
+ self.private_key = serialization.load_pem_private_key(
+ key_file.read(), password=key_pass
+ )
+
+ def sign_bytes(self, data: bytes) -> bytes:
+ """Performs the signing of some byte data using the loaded private key."""
+ signature = self.private_key.sign( # type: ignore
+ data,
+ self.PADDING, # type: ignore
+ self.ALGORITHM, # type: ignore
+ )
+ return signature
+
+ def verify_bytes(self, signature: bytes, data: bytes, public_key=None) -> bool:
+ # Use provided public key or default to our private key's public key
+ if public_key is None:
+ public_key = self.get_public_key()
+
+ try:
+ public_key.verify( # type: ignore
+ signature,
+ data,
+ self.PADDING, # type: ignore
+ self.ALGORITHM, # type: ignore
+ )
+ return True
+ except InvalidSignature:
+ return False
+
+ @staticmethod
+ def load_public_key_from_pem(pem_string: str):
+ """Load a public key from PEM string format"""
+ return serialization.load_pem_public_key(pem_string.encode("utf-8"))
+
+
+class ReportSigner(SimpleCertifier):
+
+ def get_bytes_to_sign(self, report: ComplianceReport) -> bytes:
+ report_dict = report.generate_certificate_dict()
+ yaml_bytes = yaml.dump(report_dict, sort_keys=True).encode("utf-8")
+
+ return yaml_bytes
+
+ def generate_report_signature(self, report: ComplianceReport) -> str:
+ yaml_bytes = self.get_bytes_to_sign(report)
+
+ signature = self.sign_bytes(yaml_bytes)
+
+ return CertificationEncoder.encode(signature)
+
+ def verify(self, report: ComplianceReport, signature: str, public_key=None) -> bool:
+ yaml_bytes = self.get_bytes_to_sign(report)
+ signature_bytes = CertificationEncoder.decode(signature)
+
+ return self.verify_bytes(signature_bytes, yaml_bytes, public_key=public_key)
+
+ def verify_client_signed(
+ self, report: ComplianceReport, client_id: str, client_public_key=None
+ ) -> bool:
+ report_copy = report.model_copy(deep=True)
+ if report_copy.signature.client_signature is None:
+ return False
+
+ if report_copy.signature.client_id != client_id:
+ return False
+
+ client_signature = report_copy.signature.client_signature
+
+ # Clear it so that the certificate will be correct
+ report_copy.signature.client_signature = None
+ report_copy.signature.server_signature = None
+ report_copy.signature.server_signature_timestamp = None
+
+ result = self.verify(report_copy, client_signature, client_public_key)
+ return result
+
+ def verify_double_signed(
+ self, report: ComplianceReport, client_id: str, client_public_key=None
+ ) -> bool:
+ """Verifies that the report is double signed by both the server and the client certificate."""
+ report_copy = report.model_copy(deep=True)
+ if (
+ report_copy.signature.client_signature is None
+ or report_copy.signature.server_signature is None
+ ):
+ logger.warning("Both signatures must be present to verify double signed.")
+ return False
+
+ if report_copy.signature.client_id != client_id:
+ logger.warning("Client IDs don't match.")
+ return False
+
+ server_signature = report_copy.signature.server_signature
+ report_copy.signature.server_signature = None
+
+ logger.info("Verifying server signature...")
+
+ server_signature_valid = self.verify(
+ report_copy, server_signature, self.get_public_key()
+ )
+
+ if not server_signature_valid:
+ logger.warning("Server signature is not valid.")
+ return False
+ logger.warning("Server signature is valid. Verifying client signature...")
+
+ client_signature_valid = self.verify_client_signed(
+ report_copy, client_id, client_public_key
+ )
+
+ if not client_signature_valid:
+ logger.warning("Client signature is not valid.")
+ return client_signature_valid
+
+
+class ClientReportSigner(ReportSigner):
+
+ def sign_report(self, report: ComplianceReport):
+ """Single Signs the report with the client key"""
+ report.signature.server_signature = None
+ report.signature.server_signature_timestamp = None
+ report.signature.client_signature = None
+
+ if report.signature.client_id is None:
+ raise ValueError("Client ID must be included in the signature.")
+
+ # Include the datatime in the content to be signed
+ report.signature.client_signature_timestamp = datetime.now()
+ report.signature.client_signature = self.generate_report_signature(report)
+
+ return report
+
+
+class ServerReportSigner(ReportSigner):
+
+ def sign_report(self, report: ComplianceReport, client_id: str):
+ """Double signs a report, provided that it's already been signed by the client."""
+
+ if (
+ report.signature.client_signature is None
+ or report.signature.client_signature_timestamp is None
+ or report.signature.client_id is None
+ or report.signature.client_id != client_id
+ ):
+ raise ValueError(
+ "Report must first be signed by the client and the client IDs must match.."
+ )
+
+ report.signature.server_signature = None
+ report.signature.server_signature_timestamp = datetime.now()
+
+ report.signature.server_signature = self.generate_report_signature(report)
+
+ return report
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..3fbbc05
--- /dev/null
+++ b/packages/test-suites/src/testsuites/certification_executor.py
@@ -0,0 +1,258 @@
+import abc
+import asyncio
+from typing import (
+ Callable,
+ Dict,
+ Generic,
+ Literal,
+ Optional,
+ Type,
+ TypeVar,
+ Union,
+)
+from connectivity.async_task_manager import AsyncTaskManager
+
+from connectivity.config import Config
+from connectivity.channel import Channel
+from testsuites.message_handlers import ControlMessageHandler
+from s2python.message import S2Message
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.test_logger import AbstractTestLogger
+from testsuites.envelope_models import (
+ ServerMessageEnvelope,
+ S2MessageEnvelope,
+ LogMessage,
+ LogMessageEnvelope,
+ ControlMessage,
+ ControlMessageEnvelope,
+ CertificationEnvelope,
+ CertificationMessage,
+)
+from testsuites.message_handlers import CertificationMessageHandler
+
+from connectivity.connection_adapter import (
+ ConnectionClosed,
+)
+
+import logging
+
+from testsuites.test_executor import AbstractExecutor
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T") # Message type
+H = TypeVar("H") # Channel type
+
+
+class MessageHandlerNotFoundError(Exception):
+ pass
+
+
+class AbstractCertificationExecutor(AbstractExecutor, ControlMessageHandler):
+
+ s2_channel: Channel[str, str]
+ server_channel: Channel[ServerMessageEnvelope, str]
+
+ config: Optional[Config]
+
+ _stop_event: asyncio.Event
+
+ test_logger: AbstractTestLogger
+
+ certification_handler: CertificationMessageHandler
+
+ def __init__(self, certification_handler: CertificationMessageHandler):
+ super().__init__()
+
+ # ! Control message handlers
+ self.handlers: Dict[Type[ControlMessage], Callable] = {}
+
+ self.certification_handler = certification_handler
+
+ self._stop_event = asyncio.Event()
+
+ self.running = False
+
+ async def main_loop(self):
+ """This is the main execution task of the certification executor. This should coordinate all the other tasks.
+ Once this function exits it will cause all other tasks to exist."""
+ 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):
+ await self.handle_message(message)
+
+ @abc.abstractmethod
+ async def handle_log_message(self, message: LogMessage):
+ # logger.info(message.message)
+ pass
+
+ async def handle_certification_message(self, message: CertificationMessage):
+ await self.certification_handler.handle_message(message, self.server_channel)
+
+ async def process_server_message(self, message: ServerMessageEnvelope):
+ if type(message) == S2MessageEnvelope:
+ await self.s2_channel.send(message.message)
+ elif type(message) == LogMessageEnvelope:
+ await self.handle_log_message(message.message)
+ elif type(message) == ControlMessageEnvelope:
+ await self.handle_control_message(message.message)
+ elif (type(message)) == CertificationEnvelope:
+ await self.handle_certification_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 ConnectionClosed:
+ # Exit when channel has been closed.
+ pass
+ 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 get_next_s2_channel_message(self):
+ # Done like this so the server side can override this.
+ return await self.s2_channel.get_next_message()
+
+ async def setup(
+ self,
+ s2_channel: Channel[str, str],
+ server_channel: Channel[ServerMessageEnvelope, str],
+ *args,
+ **kwargs,
+ ):
+ self._stop_event.clear()
+
+ self.s2_channel = s2_channel
+ self.server_channel = server_channel
+
+ def create_tasks(self, tg: asyncio.TaskGroup):
+ # This is the main execution of this class. It does the setup and then the test executor will run on this thread
+ tg.create_task(self.main_loop(), name="MainLoop")
+
+ tg.create_task(self.s2_channel.run(), name="S2ChannelRun")
+
+ # This is the channel that receives messages from the local certifier on the dev's device
+ tg.create_task(self.server_channel.run(), name="ServerChannelRun")
+
+ # This task processes messages popped off the outgoing message queue form the integration test executor
+ tg.create_task(
+ self.process_received_message(
+ self.get_next_s2_channel_message, self.process_rm_message
+ ),
+ name="ProcessRMMessages",
+ )
+
+ # This task processes messages popped off the incoming message queue form the local certifier on the dev's machine
+ tg.create_task(
+ self.process_received_message(
+ self.server_channel.get_next_message, self.process_server_message
+ ),
+ name="ProcessServerMessages",
+ )
+
+ 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
+
+ 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.setup(s2_channel, server_channel, *args, **kwargs)
+
+ try:
+ async with asyncio.TaskGroup() as tg:
+
+ 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
+
+ @abc.abstractmethod
+ async def get_compliance_report(self) -> ComplianceReport:
+ pass
diff --git a/packages/test-suites/src/testsuites/controllers/__init__.py b/packages/test-suites/src/testsuites/controllers/__init__.py
new file mode 100644
index 0000000..009da53
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/__init__.py
@@ -0,0 +1,11 @@
+from .controller import Controller
+from .rm import BaseRMController
+from .rm.frbc_controller import FRBCRMController
+from .rm.pebc_controller import PEBCRMController
+
+from .cem import (
+ BaseCEMController,
+ NotControllableCEMController,
+ FRBCCEMController,
+ PEBCCEMController,
+)
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..1c8edee
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/cem/__init__.py
@@ -0,0 +1,4 @@
+from .base import BaseCEMController
+from .not_controllable_controller import NotControllableCEMController
+from .frbc_controller import FRBCCEMController
+from .pebc_controller import PEBCCEMController
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..60b3d71
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/cem/base.py
@@ -0,0 +1,41 @@
+import asyncio
+import uuid
+from typing import Awaitable, Optional
+
+
+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.CEM
+ control_type = ProtocolControlType.NO_SELECTION
+
+ resource_manager_details : ResourceManagerDetails
+
+ 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 send_resource_manager_details(self, channel: S2Channel):
+ self.resource_manager_details.message_id = uuid.uuid4()
+ 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/cem/frbc_controller.py b/packages/test-suites/src/testsuites/controllers/cem/frbc_controller.py
new file mode 100644
index 0000000..278d10a
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/cem/frbc_controller.py
@@ -0,0 +1,340 @@
+import asyncio
+from bisect import bisect_right, insort
+from dataclasses import dataclass, field
+import datetime
+import logging
+from typing import Awaitable, Dict, List, Optional
+import uuid
+from s2python.common import ControlType as ProtocolControlType
+from s2python.frbc import (
+ FRBCSystemDescription,
+ FRBCLeakageBehaviour,
+ FRBCStorageDescription,
+ FRBCActuatorDescription,
+ FRBCOperationMode,
+ FRBCOperationModeElement,
+ FRBCInstruction,
+ FRBCActuatorStatus,
+ FRBCUsageForecast,
+ FRBCStorageStatus,
+)
+
+from testsuites.controllers.cem.not_controllable_controller import (
+ NotControllableCEMController,
+)
+from testsuites.util import current_timezone_time
+from .base import BaseCEMController
+from connectivity.s2_channel import S2Channel
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ InstructionStatusUpdate,
+ InstructionStatus,
+ RevokeObject,
+ RevokableObjects,
+ Timer,
+ ReceptionStatus,
+)
+import logging
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+
+class InstructionsStore:
+ def __init__(self):
+ self.instructions: Dict[uuid.UUID, List[FRBCInstruction]] = {}
+ # self.instructions: List[FRBCInstruction] = []
+
+ def add_instruction(self, obj: FRBCInstruction):
+ # Keep instructions sorted
+ if obj.actuator_id in self.instructions:
+ insort(
+ self.instructions[obj.actuator_id], obj, key=lambda i: i.execution_time
+ )
+ else:
+ self.instructions[obj.actuator_id] = [obj]
+
+ def get_active_instruction(
+ self, actuator_id: uuid.UUID, timestamp: datetime
+ ) -> Optional[FRBCInstruction]:
+ if actuator_id not in self.instructions:
+ return None
+
+ timestamps = [
+ instruction.execution_time for instruction in self.instructions[actuator_id]
+ ]
+ logger.info(
+ "Getting instruction for timestamp %s. First in list = %s",
+ timestamp,
+ timestamps[0] if len(timestamps) > 0 else None,
+ )
+ idx = bisect_right(timestamps, timestamp) - 1
+ if idx >= 0:
+ return self.instructions[actuator_id][idx]
+ return None
+
+
+@dataclass
+class ActuatorInformation:
+ id = uuid.uuid4()
+ diagnostic_label: str = ""
+ supported_commodities: List[Commodity] = field(default_factory=list)
+
+ # Map of the UUID to the operation mode
+ operation_modes: Dict[uuid.UUID, FRBCOperationMode] = field(default_factory=dict)
+
+ named_operation_modes: Dict[str, uuid.UUID] = field(default_factory=dict)
+
+ # Map of the transition UUID to the transition
+ transitions: Dict[uuid.UUID, Transition] = field(default_factory=dict)
+
+ # Map from an operation mode to the transitions uuids which can be made
+ transitions_from_to: Dict[uuid.UUID, List[uuid.UUID]] = field(default_factory=dict)
+
+ # TODO: Not really sure what these are for
+ timers: List[Timer] = field(default_factory=list)
+
+ # actuator_status : FRBCActuatorStatus = None
+
+ def add_operation_mode(
+ self, mode: FRBCOperationMode, name: Optional[str] = None
+ ) -> FRBCOperationMode:
+ self.operation_modes[mode.id] = mode
+
+ if name is not None:
+ self.named_operation_modes[name] = mode.id
+
+ return mode
+
+ def get_operation_mode(self, id: uuid.UUID) -> FRBCOperationMode:
+ return self.operation_modes[id]
+
+ def get_named_operation_mode(self, name: str) -> FRBCOperationMode:
+ id = self.named_operation_modes[name]
+ return self.get_operation_mode(id)
+
+ def add_transition(self, transition: Transition):
+ self.transitions[transition.id] = transition
+
+ if transition.from_ in self.transitions_from_to:
+ self.transitions_from_to[transition.from_].append(transition.id)
+ else:
+ self.transitions_from_to[transition.from_] = [transition.id]
+
+ def add_timer(self, timer):
+ self.timers.append(timer)
+
+ # def set_actuator_status(self, actuator_status : FRBCActuatorStatus):
+ # self.actuator_status = actuator_status
+
+ def to_actuator_description(self):
+ return FRBCActuatorDescription(
+ id=self.id,
+ diagnostic_label=self.diagnostic_label,
+ timers=self.timers,
+ operation_modes=[o for o in self.operation_modes.values()],
+ supported_commodities=self.supported_commodities,
+ transitions=[t for t in self.transitions.values()],
+ )
+
+
+class FRBCCEMController(NotControllableCEMController):
+ control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL
+
+ leakage_behaviour: Optional[FRBCLeakageBehaviour] = None
+
+ usage_forecast: Optional[FRBCUsageForecast] = None
+
+ actuators: Dict[uuid.UUID, ActuatorInformation] = {}
+ actuator_status: dict[uuid.UUID, FRBCActuatorStatus] = {}
+
+ storage_status: Optional[FRBCStorageStatus] = None
+
+ instructions: InstructionsStore
+
+ def __init__(self, resource_manager_details: ResourceManagerDetails):
+ super().__init__(resource_manager_details)
+
+ self.reset_system_description()
+
+ self.add_handler(FRBCInstruction, self.handle_instruction)
+
+ def reset_system_description(self):
+ self.leakage_behaviour = None
+ self.usage_forecast = None
+
+ self.actuators = {}
+ self.actuator_status = {}
+
+ self.storage_status = None
+
+ self.instructions = InstructionsStore()
+
+ def add_actuator(self, actuator: ActuatorInformation):
+ self.actuators[actuator.id] = actuator
+
+ def set_storage_description(self, storage_description: FRBCStorageDescription):
+ self.storage_description = storage_description
+
+ def set_leakage_behaviour(self, leakage_behaviour: FRBCLeakageBehaviour):
+ self.leakage_behaviour = leakage_behaviour
+
+ def generate_system_description(self):
+ if self.storage_description is None:
+ raise ValueError("Storage description must be set.")
+
+ self.system_description = FRBCSystemDescription(
+ message_id=uuid.uuid4(),
+ valid_from=current_timezone_time(),
+ actuators=[a.to_actuator_description() for a in self.actuators.values()],
+ storage=self.storage_description,
+ )
+ return self.system_description
+
+ def get_active_operation_mode(self, actuator_id: uuid.UUID):
+ actuator_status = self.actuator_status[actuator_id]
+
+ operation_mode = self.actuators[actuator_id].operation_modes[
+ actuator_status.active_operation_mode_id
+ ]
+
+ return operation_mode
+
+ async def send_frbc_system_description(
+ self, channel: Optional[S2Channel]
+ ) -> tuple[FRBCSystemDescription, ReceptionStatus]:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ logger.info("Sending FRBC system description")
+ system_description = self.generate_system_description()
+ reception_status = await channel.send_msg_and_await_reception_status(
+ system_description, raise_on_error=False
+ )
+ return system_description, reception_status
+
+ async def revoke_frbc_system_description(self, channel: Optional[S2Channel]):
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ if self.system_description_id is None:
+ raise ValueError("No System Description is set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ RevokeObject(
+ message_id=uuid.uuid4(),
+ object_id=self.system_description_id,
+ object_type=RevokableObjects.FRBC_SystemDescription,
+ ),
+ raise_on_error=False,
+ )
+
+ self.system_description_id = None
+ return reception_status
+
+ async def update_frbc_leakage_behavior(
+ self, channel: Optional[S2Channel]
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ if self.leakage_behaviour is None:
+ raise ValueError("Leakage behavior not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ self.leakage_behaviour, raise_on_error=False
+ )
+
+ return reception_status
+
+ async def revoke_leakage_behaviour(self, channel: Optional[S2Channel]):
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ raise NotImplementedError(
+ "Revoke FRBC Leakage Behaviour Message not implemented in S2 Python."
+ )
+
+ # self.leakage_behaviour = None
+ # return reception_status
+
+ async def update_frbc_usage_forecast(
+ self, channel: Optional[S2Channel], forecast: FRBCUsageForecast
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ forecast, raise_on_error=False
+ )
+
+ return reception_status
+
+ async def update_actuator_status(
+ self, channel: Optional[S2Channel], actuator_status: FRBCActuatorStatus
+ ):
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ actuator_status, raise_on_error=False
+ )
+
+ self.actuator_status[actuator_status.actuator_id] = actuator_status
+
+ return reception_status
+
+ async def update_storage_status(
+ self, channel: Optional[S2Channel], storage_status: FRBCStorageStatus
+ ):
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ storage_status, raise_on_error=False
+ )
+
+ self.storage_status = storage_status
+
+ return reception_status
+
+ async def send_instruction_status_update(
+ self, channel: "S2Channel", instruction_status_update: InstructionStatusUpdate
+ ):
+ reception_status = await channel.send_msg_and_await_reception_status(
+ instruction_status_update, raise_on_error=False
+ )
+ return reception_status
+
+ async def handle_instruction(
+ self, instruction: FRBCInstruction, channel: S2Channel, send_okay: Awaitable
+ ):
+ logger.info("Instruction: %s", instruction)
+
+ self.instructions.add_instruction(instruction)
+
+ await send_okay
+ update = InstructionStatusUpdate(
+ message_id=uuid.uuid4(),
+ instruction_id=instruction.id,
+ status_type=InstructionStatus.SUCCEEDED,
+ timestamp=current_timezone_time(),
+ )
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ update, raise_on_error=False
+ )
+
+ # return update, reception_status
diff --git a/packages/test-suites/src/testsuites/controllers/cem/not_controllable_controller.py b/packages/test-suites/src/testsuites/controllers/cem/not_controllable_controller.py
new file mode 100644
index 0000000..cd7c196
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/cem/not_controllable_controller.py
@@ -0,0 +1,62 @@
+from typing import Optional
+import uuid
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ ResourceManagerDetails,
+ Handshake,
+ RevokeObject,
+ RevokableObjects,
+ ReceptionStatus,
+)
+from connectivity.s2_channel import S2Channel
+
+from .base import BaseCEMController
+
+
+class NotControllableCEMController(BaseCEMController):
+ control_type = ProtocolControlType.NOT_CONTROLABLE
+
+ power_measurements: list[PowerMeasurement] = []
+ power_forecasts: list[PowerForecast] = []
+
+ _save_power_forecasts = False
+ _save_power_measurements = False
+
+ async def send_power_measurement(
+ self, channel: Optional[S2Channel], power_measurement: PowerMeasurement
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ power_measurement, raise_on_error=False
+ )
+
+ if self._save_power_measurements:
+ self.power_measurements.append(power_measurement)
+ else:
+ self.power_measurements = [power_measurement]
+
+ return reception_status
+
+ async def send_power_forecast(
+ self, channel: Optional[S2Channel], power_forecast: PowerForecast
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ power_forecast, raise_on_error=False
+ )
+
+ if self._save_power_forecasts:
+ self.power_forecasts.append(power_forecast)
+ else:
+ self.power_forecasts = [power_forecast]
+
+ return reception_status
+
+ # Power Forecast not revokable in S2-Python
diff --git a/packages/test-suites/src/testsuites/controllers/cem/pebc_controller.py b/packages/test-suites/src/testsuites/controllers/cem/pebc_controller.py
new file mode 100644
index 0000000..b5acf84
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/cem/pebc_controller.py
@@ -0,0 +1,126 @@
+import asyncio
+import datetime
+import logging
+from typing import Awaitable, List, Optional
+import uuid
+from s2python.common import ControlType as ProtocolControlType
+from s2python.pebc import PEBCEnergyConstraint, PEBCPowerConstraints, PEBCInstruction
+
+from testsuites.controllers.cem.not_controllable_controller import (
+ NotControllableCEMController,
+)
+from testsuites.util import current_timezone_time
+from .base import BaseCEMController
+from connectivity.s2_channel import S2Channel
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ InstructionStatusUpdate,
+ InstructionStatus,
+ RevokeObject,
+ RevokableObjects,
+ ReceptionStatus,
+)
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PEBCCEMController(NotControllableCEMController):
+ control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL
+
+ energy_constraint: Optional[PEBCEnergyConstraint] = None
+ power_constraint: Optional[PEBCPowerConstraints] = None
+
+ instructions: List[PEBCInstruction] = []
+
+ def __init__(self, resource_manager_details: ResourceManagerDetails):
+ super().__init__(resource_manager_details)
+
+ async def send_pebc_power_constraint(
+ self, channel: Optional[S2Channel], power_constraints: PEBCPowerConstraints
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ power_constraints, raise_on_error=False
+ )
+
+ self.power_constraint = power_constraints
+
+ return reception_status
+
+ async def revoke_pebc_power_constraint(
+ self, channel: Optional[S2Channel]
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ if self.power_constraint is None:
+ raise ValueError("No Power Constraint to Revoke.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ RevokeObject(
+ message_id=uuid.uuid4(),
+ object_id=self.power_constraint.message_id,
+ object_type=RevokableObjects.PEBC_PowerConstraints,
+ ),
+ raise_on_error=False,
+ )
+ self.power_constraint = None
+ return reception_status
+
+ async def send_pebc_energy_constraint(
+ self, channel: Optional[S2Channel], energy_constraints: PEBCEnergyConstraint
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ energy_constraints, raise_on_error=False
+ )
+
+ self.energy_constraint = energy_constraints
+ return reception_status
+
+ async def revoke_pebc_energy_constraint(
+ self, channel: Optional[S2Channel]
+ ) -> ReceptionStatus:
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ if self.energy_constraint is None:
+ raise ValueError("No Energy Constraint to Revoke.")
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ RevokeObject(
+ message_id=uuid.uuid4(),
+ object_id=self.energy_constraint.message_id,
+ object_type=RevokableObjects.PEBC_EnergyConstraint,
+ ),
+ raise_on_error=False,
+ )
+ self.energy_constraint = None
+ return reception_status
+
+ async def handle_revoke(
+ self, revoke_message: RevokeObject, channel: S2Channel, send_okay: Awaitable
+ ):
+ if revoke_message.object_type == RevokableObjects.PEBC_Instruction:
+ for i, instruction in enumerate(self.instructions):
+ if instruction.id == revoke_message.object_id:
+ self.instructions.pop(i)
+ break
+ await send_okay
diff --git a/packages/test-suites/src/testsuites/controllers/controller.py b/packages/test-suites/src/testsuites/controllers/controller.py
new file mode 100644
index 0000000..4bdd96a
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/controller.py
@@ -0,0 +1,101 @@
+import abc
+import logging
+import asyncio
+import uuid
+from typing import Awaitable, Callable, Optional, Type
+from testsuites.message_handlers import S2MessageHandler
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ PowerForecast,
+ PowerMeasurement,
+ ResourceManagerDetails,
+)
+from s2python.version import S2_VERSION
+from s2python.message import S2Message
+from s2python.common import (
+ Handshake,
+ HandshakeResponse,
+ SelectControlType,
+ EnergyManagementRole,
+ SessionRequest,
+ SessionRequestType,
+)
+from s2python.s2_validation_error import S2ValidationError
+from connectivity.s2_channel import S2Channel
+
+logger = logging.getLogger(__name__)
+
+
+class Controller(S2MessageHandler):
+ control_type: ProtocolControlType
+
+ role: EnergyManagementRole
+
+ resource_manager_details: Optional[ResourceManagerDetails] = None
+
+ messages_received = []
+
+ _handshake_received_event: asyncio.Event
+
+ def __init__(self):
+ super().__init__()
+
+ self._handshake_received_event = asyncio.Event()
+
+ async def handle_message(self, message: S2Message, channel: Optional[S2Channel]):
+ if channel is None:
+ raise ValueError("Channel must be provided.")
+ try:
+ result = await super().handle_message(message, channel)
+ except:
+ raise
+ finally:
+ # Add all messages to the list so tests can find them.
+ self.messages_received.append(message)
+ return result
+
+ 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):
+ return type(m) == message_type
+
+ result = list(filter(filter_messages, self.messages_received))
+
+ return result
+
+ async def send_handshake(self, channel: S2Channel, message: Handshake):
+ if channel is None:
+ raise ValueError("Channel not set.")
+
+ await channel.send_msg_and_await_reception_status(message)
+
+ async def handle_handshake(
+ self,
+ message: Handshake,
+ channel: "S2Channel",
+ send_okay: Awaitable[None],
+ ) -> None:
+
+ if channel is None:
+ raise ValueError("Channel not set.")
+ self._handshake_received_event.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
+
+ async def after_chosen(self, channel: Optional[S2Channel]):
+ """
+ This method should be run after the controller is set
+ and should send any init messages for that control type."""
+ pass
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..140e4d6
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/rm/base.py
@@ -0,0 +1,84 @@
+import uuid
+import asyncio
+from typing import Awaitable, Optional
+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: Optional[ResourceManagerDetails]
+ _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)
+
+ async def handle_handshake(
+ self,
+ message: Handshake,
+ channel: "S2Channel",
+ send_okay: Awaitable[None],
+ ) -> None:
+
+ await super().handle_handshake(message, channel, send_okay)
+
+ await channel.send_msg_and_await_reception_status(
+ HandshakeResponse(
+ message_id=uuid.uuid4(),
+ selected_protocol_version=message.supported_protocol_versions[0], # type: ignore
+ )
+ )
+
+ async def send_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,
+ ):
+ # Set the variable before before the event so that waiting events are satisfied
+ self.resource_manager_details = message
+
+ self._resource_manager_details_received.set()
+
+ await send_okay
+
+ async def send_session_request_disconnect(self, channel: "S2Channel"):
+ await channel.send_msg_and_await_reception_status(
+ SessionRequest(
+ message_id=uuid.uuid4(),
+ request=SessionRequestType.TERMINATE,
+ diagnostic_label="Testing complete.",
+ )
+ )
diff --git a/packages/test-suites/src/testsuites/controllers/rm/frbc_controller.py b/packages/test-suites/src/testsuites/controllers/rm/frbc_controller.py
new file mode 100644
index 0000000..6d406e7
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/rm/frbc_controller.py
@@ -0,0 +1,160 @@
+import asyncio
+from dataclasses import dataclass
+import logging
+from typing import Dict, List, Optional, Tuple
+import uuid
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ Transition,
+ InstructionStatusUpdate,
+ InstructionStatus,
+)
+from s2python.frbc import (
+ FRBCSystemDescription,
+ FRBCInstruction,
+ FRBCOperationMode,
+ FRBCStorageStatus,
+ FRBCActuatorDescription,
+ FRBCActuatorStatus,
+)
+from .base import BaseRMController
+
+
+from connectivity.s2_channel import S2Channel
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ActuatorInfo:
+ id: uuid.UUID
+ actuator: FRBCActuatorDescription
+ operation_modes: Dict[uuid.UUID, FRBCOperationMode]
+ from_transitions_map: Dict[uuid.UUID, List[Transition]]
+
+
+class FRBCRMController(BaseRMController):
+ control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL
+
+ system_description: Optional[FRBCSystemDescription] = None
+
+ # operation_mode: Optional[FRBCOperationMode] = None
+ # # operation_modes : Dict[uuid.UUID, FRBCOperationMode]= {}
+ actuators: Dict[uuid.UUID, ActuatorInfo] = {}
+ actuator_status: Dict[uuid.UUID, FRBCActuatorStatus] = {}
+ storage_status: Optional[FRBCStorageStatus] = None
+
+ # Map of instruction ID to a tuple containing the instruction and the status update
+ instructions: Dict[
+ uuid.UUID, Tuple[FRBCInstruction, InstructionStatusUpdate | None]
+ ] = {}
+ # A list of instructions to know the order in which the instructions were executed.
+ instruction_ordering: List[FRBCInstruction] = []
+
+ _system_description_received: asyncio.Event
+
+ def __init__(self):
+ super().__init__()
+ self.system_description = None
+ self._system_description_received = asyncio.Event()
+
+ self.add_handler(FRBCSystemDescription, self.handle_system_description_message)
+ self.add_handler(FRBCStorageStatus, self.handle_storage_status)
+ self.add_handler(FRBCActuatorStatus, self.handle_actuator_status)
+
+ async def handle_system_description_message(
+ self, message: FRBCSystemDescription, channel: "S2Channel", send_okay
+ ):
+ logger.info("Received FRBC System Description.")
+ self.system_description = message
+ self._system_description_received.set()
+
+ for actuator in message.actuators:
+ op_modes = {}
+ transitions: Dict[uuid.UUID, List[Transition]] = {}
+
+ for operation_mode in actuator.operation_modes:
+ op_modes[operation_mode.id] = operation_mode
+
+ for transition in actuator.transitions:
+ if transition.from_ in transitions:
+ transitions[transition.from_].append(transition)
+ else:
+ transitions[transition.from_] = [transition]
+
+ self.actuators[actuator.id] = ActuatorInfo(
+ id=actuator.id,
+ actuator=actuator,
+ operation_modes=op_modes,
+ from_transitions_map=transitions,
+ )
+
+ await send_okay
+
+ async def handle_storage_status(
+ self, message: FRBCStorageStatus, channel: "S2Channel", send_okay
+ ):
+ self.storage_status = message
+ await send_okay
+
+ async def handle_actuator_status(
+ self, message: FRBCActuatorStatus, channel: "S2Channel", send_okay
+ ):
+ self.actuator_status[message.actuator_id] = message
+ await send_okay
+
+ async def send_instruction_message(
+ self, message: FRBCInstruction, channel: "S2Channel", raise_on_error=True
+ ):
+
+ actuator_info = self.actuators[message.actuator_id]
+ from_op_mode = None
+ if self.actuator_status.get(actuator_info.id, None) is not None:
+ from_op_mode = actuator_info.operation_modes[
+ self.actuator_status[actuator_info.id].active_operation_mode_id
+ ].diagnostic_label
+ to_op_mode = actuator_info.operation_modes[
+ message.operation_mode
+ ].diagnostic_label
+
+ logger.info(
+ "Sending Transition Instruction for actuator %s. From %s to %s",
+ actuator_info.actuator.diagnostic_label,
+ from_op_mode,
+ to_op_mode,
+ )
+
+ self.instructions[message.id] = (message, None)
+ self.instruction_ordering.append(message)
+
+ return await channel.send_msg_and_await_reception_status(
+ message, 5, raise_on_error=raise_on_error
+ )
+
+ async def handle_instruction_status_update(
+ self, message: InstructionStatusUpdate, channel: "S2Channel", send_okay
+ ):
+
+ self.instructions[message.instruction_id] = (
+ self.instructions[message.instruction_id][0],
+ message,
+ )
+
+ await send_okay
+
+ def get_latest_actuator_instruction(
+ self, actuator_id: uuid.UUID, succeeded=False
+ ) -> tuple[FRBCInstruction | None, InstructionStatusUpdate | None]:
+ """Retrieves the most recent instruction for a given actuator
+ If succeeded param is True then it will skip rejected instructions.
+ """
+
+ for instruction in reversed(self.instruction_ordering):
+ if instruction.actuator_id == actuator_id:
+ _, status = self.instructions[instruction.id]
+ if (
+ succeeded and status == InstructionStatus.SUCCEEDED
+ ) or not succeeded:
+ return instruction, status
+
+ return (None, None)
diff --git a/packages/test-suites/src/testsuites/controllers/rm/not_controllable_controller.py b/packages/test-suites/src/testsuites/controllers/rm/not_controllable_controller.py
new file mode 100644
index 0000000..13eb376
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/rm/not_controllable_controller.py
@@ -0,0 +1,41 @@
+from typing import Awaitable, Optional
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ ResourceManagerDetails,
+ Handshake,
+ RevokeObject,
+ RevokableObjects,
+)
+from connectivity.s2_channel import S2Channel
+from .base import BaseRMController
+
+
+class NotControllableRMController(BaseRMController):
+ 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/pebc_controller.py b/packages/test-suites/src/testsuites/controllers/rm/pebc_controller.py
new file mode 100644
index 0000000..122f6d8
--- /dev/null
+++ b/packages/test-suites/src/testsuites/controllers/rm/pebc_controller.py
@@ -0,0 +1,111 @@
+import asyncio
+import datetime
+from typing import Awaitable, List, Optional
+import uuid
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ InstructionStatusUpdate,
+ ReceptionStatus,
+)
+from s2python.pebc import (
+ PEBCEnergyConstraint,
+ PEBCPowerConstraints,
+ PEBCInstruction,
+ PEBCPowerEnvelope,
+)
+
+from testsuites.util import current_timezone_time
+from .base import BaseRMController
+from connectivity.s2_channel import S2Channel
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PEBCRMController(BaseRMController):
+ control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL
+ power_constraints: List[PEBCPowerConstraints] = []
+ energy_constraints: List[PEBCEnergyConstraint] = []
+
+ def __init__(self):
+ super().__init__()
+
+ self.add_handler(PEBCPowerConstraints, self.handle_power_constraints_message)
+ 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,
+ channel: "S2Channel",
+ send_okay: Awaitable,
+ ):
+ self.power_constraints.append(message)
+
+ await send_okay
+
+ async def handle_energy_constraints_message(
+ self,
+ message: PEBCEnergyConstraint,
+ connection: "S2Channel",
+ send_okay: Awaitable,
+ ):
+ self.energy_constraints.append(message)
+ await send_okay
+
+ async def handle_instruction_status_update(
+ self,
+ message: InstructionStatusUpdate,
+ connection: "S2Channel",
+ send_okay: Awaitable,
+ ):
+ await send_okay
+
+ def get_power_constraint(
+ self,
+ valid_from: datetime.datetime,
+ valid_until: datetime.datetime | None = None,
+ ) -> PEBCPowerConstraints | None:
+ """Gets power constraint which the provided range fits into."""
+
+ # current_time = current_timezone_time()
+ for power_constraint in reversed(self.power_constraints):
+
+ if power_constraint.valid_from < valid_from:
+ if valid_until is None or power_constraint.valid_until is None:
+ return power_constraint
+ elif power_constraint.valid_until > valid_until:
+ return power_constraint
+ return None
+
+ async def send_power_envelope_instruction(
+ self,
+ channel: "S2Channel",
+ power_envelopes: List[PEBCPowerEnvelope],
+ execution_time: datetime.datetime = datetime.datetime.now(
+ datetime.timezone.utc
+ ),
+ ) -> tuple[PEBCInstruction, ReceptionStatus]:
+
+ power_constraint = self.get_power_constraint(current_timezone_time())
+
+ if power_constraint 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=power_constraint.id,
+ power_envelopes=power_envelopes,
+ # Make it timezone-aware (UTC)
+ execution_time=execution_time,
+ abnormal_condition=False,
+ )
+
+ reception_status = await channel.send_msg_and_await_reception_status(
+ instruction, 5, raise_on_error=False
+ )
+
+ return instruction, reception_status
diff --git a/packages/test-suites/src/testsuites/envelope_models/__init__.py b/packages/test-suites/src/testsuites/envelope_models/__init__.py
new file mode 100644
index 0000000..d1ec6e3
--- /dev/null
+++ b/packages/test-suites/src/testsuites/envelope_models/__init__.py
@@ -0,0 +1,34 @@
+from .envelope_messages import (
+ ServerMessageValidationException,
+ MessageEnvelopeTypeEnum,
+ LogMessage,
+ BaseEnvelope,
+ ControlMessageEnvelope,
+ LogMessageEnvelope,
+ S2MessageEnvelope,
+ CertificationEnvelope,
+ ServerMessageEnvelope,
+ parse_envelope,
+)
+from .certification_message import (
+ CertificationMessageType,
+ KeyRegistrationRequestMessage,
+ ChallengeMessage,
+ ChallengeProofMessage,
+ RawCertificateMessage,
+ ClientSignedCertificateMessage,
+ DoubleSignedCertificateMessage,
+ SignatureStatusResponseCertificateMessage,
+ StatusResponseEnum,
+ SignatureException,
+ CertificationMessage,
+ parse_certification_message,
+)
+from .control_message import (
+ ControlMessageType,
+ ClientInfo,
+ ClientInfoControlMessage,
+ ConfigControlMessage,
+ ControlMessage,
+ parse_control_message,
+)
diff --git a/packages/test-suites/src/testsuites/envelope_models/certification_message.py b/packages/test-suites/src/testsuites/envelope_models/certification_message.py
new file mode 100644
index 0000000..2aab6bb
--- /dev/null
+++ b/packages/test-suites/src/testsuites/envelope_models/certification_message.py
@@ -0,0 +1,118 @@
+import logging
+from enum import Enum
+from typing import Union, Dict, Type
+from pydantic import BaseModel
+
+from testsuites.certificate.certificate import ComplianceReport
+
+logger = logging.getLogger(__name__)
+
+
+class CertificationMessageType(str, Enum):
+ # Client sends public key for registration/enrollment
+ KEY_REGISTRATION_REQUEST = "KEY_REGISTRATION_REQUEST"
+ # Server sends random data for the client to sign (proof of possession)
+ CHALLENGE = "CHALLENGE"
+ # Client sends the signed challenge back to the server
+ CHALLENGE_PROOF = "CHALLENGE_PROOF"
+ # Server sends the result of the challenge verification
+ CHALLENGE_STATUS = "CHALLENGE_STATUS"
+ # Server sends certificate to client so they can sign it with their key
+ RAW_CERTIFICATE = "RAW_CERTIFICATE"
+ # Client signs the report and sends the signature
+ CLIENT_SIGNED_CERTIFICATE = "CLIENT_SIGNED_CERTIFICATE"
+ # Server verifies that the certificate sent is with the same key that was challenged
+ # If valid then the server signs the certificate as well.
+ DOUBLE_SIGNED_CERTIFICATE = "DOUBLE_SIGNED_CERTIFICATE"
+
+ STATUS_RESPONSE = "STATUS_RESPONSE"
+
+
+class KeyRegistrationRequestMessage(BaseModel):
+ message_type: CertificationMessageType = (
+ CertificationMessageType.KEY_REGISTRATION_REQUEST
+ )
+ public_key: str # PEM-encoded public key
+ client_id: str # Identifier for the client
+
+
+class ChallengeMessage(BaseModel):
+ message_type: CertificationMessageType = CertificationMessageType.CHALLENGE
+ challenge: str # Base64-encoded random challenge data
+
+
+class ChallengeProofMessage(BaseModel):
+ message_type: CertificationMessageType = CertificationMessageType.CHALLENGE_PROOF
+ signature: str # Base64-encoded signature of the challenge
+
+
+class RawCertificateMessage(BaseModel):
+ message_type: CertificationMessageType = CertificationMessageType.RAW_CERTIFICATE
+ certificate: ComplianceReport
+
+
+class ClientSignedCertificateMessage(BaseModel):
+ message_type: CertificationMessageType = (
+ CertificationMessageType.CLIENT_SIGNED_CERTIFICATE
+ )
+ certificate: ComplianceReport
+
+
+class DoubleSignedCertificateMessage(BaseModel):
+ message_type: CertificationMessageType = (
+ CertificationMessageType.DOUBLE_SIGNED_CERTIFICATE
+ )
+ certificate: ComplianceReport
+
+
+class StatusResponseEnum(str, Enum):
+ SUCCESS = "SUCCESS"
+ ERROR = "ERROR"
+
+
+class SignatureStatusResponseCertificateMessage(BaseModel):
+ message_type: CertificationMessageType = (
+ CertificationMessageType.STATUS_RESPONSE
+ )
+ status: StatusResponseEnum
+ message: str
+ # The type of message which this is a response for
+ response_message_type: CertificationMessageType
+
+
+CertificationMessage = Union[
+ KeyRegistrationRequestMessage,
+ ChallengeMessage,
+ ChallengeProofMessage,
+ RawCertificateMessage,
+ ClientSignedCertificateMessage,
+ DoubleSignedCertificateMessage,
+ SignatureStatusResponseCertificateMessage,
+]
+
+certification_types_dict: Dict[CertificationMessageType, Type[CertificationMessage]] = { # type: ignore
+ CertificationMessageType.KEY_REGISTRATION_REQUEST: KeyRegistrationRequestMessage,
+ CertificationMessageType.CHALLENGE: ChallengeMessage,
+ CertificationMessageType.CHALLENGE_PROOF: ChallengeProofMessage,
+ CertificationMessageType.RAW_CERTIFICATE: RawCertificateMessage,
+ CertificationMessageType.CLIENT_SIGNED_CERTIFICATE: ClientSignedCertificateMessage,
+ CertificationMessageType.DOUBLE_SIGNED_CERTIFICATE: DoubleSignedCertificateMessage,
+ CertificationMessageType.STATUS_RESPONSE: SignatureStatusResponseCertificateMessage,
+}
+
+class SignatureException(Exception):
+ pass
+
+
+def parse_certification_message(message: dict) -> CertificationMessage: # type: ignore
+ msg_type: CertificationMessageType = CertificationMessageType[
+ message["message_type"]
+ ]
+
+ try:
+ message_class = certification_types_dict[msg_type]
+ except KeyError:
+ logger.error("Invalid message type %s", msg_type)
+ raise
+
+ return message_class.model_validate(message)
diff --git a/packages/test-suites/src/testsuites/envelope_models/control_message.py b/packages/test-suites/src/testsuites/envelope_models/control_message.py
new file mode 100644
index 0000000..e453fc6
--- /dev/null
+++ b/packages/test-suites/src/testsuites/envelope_models/control_message.py
@@ -0,0 +1,56 @@
+from enum import Enum
+import json
+from typing import Dict, Literal, Optional, 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 ControlMessageType(str, Enum):
+ CLIENT_INFO = "CLIENT_INFO"
+ CONFIG = "CONFIG"
+ REPORT = "REPORT"
+
+
+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):
+ message_type: ControlMessageType = ControlMessageType.CONFIG
+ config: Config
+
+
+ControlMessage = Union[
+ ConfigControlMessage, ClientInfoControlMessage
+]
+
+control_types_dict: Dict[ControlMessageType, Type[ControlMessage]] = { # type: ignore
+ ControlMessageType.CONFIG: ConfigControlMessage,
+ ControlMessageType.CLIENT_INFO: ClientInfoControlMessage,
+}
+
+
+def parse_control_message(message: dict) -> ControlMessage: # type: ignore
+ msg_type: ControlMessageType = ControlMessageType[message["message_type"]]
+
+ try:
+ message_class = control_types_dict[msg_type]
+ except KeyError as e:
+ logger.error("Invalid message type %s", msg_type)
+ raise
+
+ return message_class.model_validate(message)
diff --git a/packages/test-suites/src/testsuites/envelope_models/envelope_messages.py b/packages/test-suites/src/testsuites/envelope_models/envelope_messages.py
new file mode 100644
index 0000000..f38620b
--- /dev/null
+++ b/packages/test-suites/src/testsuites/envelope_models/envelope_messages.py
@@ -0,0 +1,104 @@
+from enum import Enum
+import json
+from typing import Dict, Literal, Optional, Type, Union
+from pydantic import BaseModel, ValidationError
+
+from connectivity.config import Config
+
+from testsuites.certificate.certificate import ComplianceReport
+
+from .control_message import ControlMessage, parse_control_message
+from .certification_message import CertificationMessage, parse_certification_message
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ServerMessageValidationException(Exception):
+ pass
+
+
+class MessageEnvelopeTypeEnum(str, Enum):
+ CONTROL = "CONTROL"
+ S2 = "S2"
+ LOG = "LOG"
+ CERTIFICATION = "CERTIFICATION"
+
+
+class LogMessage(BaseModel):
+ level: Literal["SUCCESS", "SOFT_ERROR", "ERROR", "INFO", "DEBUG"]
+ message: str
+ details: Optional[str] = None
+ ident: int = 2
+ logger: Literal["test", "stdout"] = "stdout"
+
+
+# ----- Envelopes -----
+
+
+class BaseEnvelope(BaseModel):
+ message_type: MessageEnvelopeTypeEnum
+
+
+class ControlMessageEnvelope(BaseEnvelope):
+ message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CONTROL
+ message: ControlMessage # type: ignore
+
+
+class LogMessageEnvelope(BaseEnvelope):
+ message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.LOG
+ message: LogMessage
+
+
+class S2MessageEnvelope(BaseEnvelope):
+ 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
+
+
+class CertificationEnvelope(BaseEnvelope):
+ message_type: MessageEnvelopeTypeEnum = MessageEnvelopeTypeEnum.CERTIFICATION
+ message : CertificationMessage
+
+
+ServerMessageEnvelope = Union[
+ ControlMessageEnvelope, LogMessageEnvelope, S2MessageEnvelope, CertificationEnvelope
+]
+
+
+envelope_types_dict: Dict[MessageEnvelopeTypeEnum, Type[ServerMessageEnvelope]] = {
+ MessageEnvelopeTypeEnum.CONTROL: ControlMessageEnvelope,
+ MessageEnvelopeTypeEnum.S2: S2MessageEnvelope,
+ MessageEnvelopeTypeEnum.LOG: LogMessageEnvelope,
+ MessageEnvelopeTypeEnum.CERTIFICATION: CertificationEnvelope,
+}
+
+
+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:
+ envelope = ControlMessageEnvelope(
+ message=parse_control_message(raw_envelope["message"])
+ )
+ return envelope
+ elif msg_type == MessageEnvelopeTypeEnum.CERTIFICATION:
+ envelope = CertificationEnvelope(
+ message=parse_certification_message(raw_envelope["message"])
+ )
+ 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/message_handlers.py b/packages/test-suites/src/testsuites/message_handlers.py
new file mode 100644
index 0000000..577abab
--- /dev/null
+++ b/packages/test-suites/src/testsuites/message_handlers.py
@@ -0,0 +1,204 @@
+import abc
+import asyncio
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ Generic,
+ Optional,
+ Tuple,
+ Type,
+ TypeVar,
+)
+
+from s2python.common import EnergyManagementRole
+from s2python.message import S2Message
+from connectivity.s2_channel import SendOkay, S2Channel
+import logging
+
+from testsuites.envelope_models.certification_message import CertificationMessage
+from testsuites.envelope_models.control_message import ControlMessage
+
+logger = logging.getLogger(__name__)
+
+
+class MessageHandlerNotFoundError(Exception):
+ pass
+
+
+T = TypeVar("T") # Generic type for handler identifier
+M = TypeVar("M") # Generic type for message
+
+
+class MessageHandler(abc.ABC, Generic[T, M]):
+ """Abstract base class for message handlers with configurable identifier types.
+
+ Provides a framework for dispatching messages to registered handlers based on
+ message identifiers. Supports graceful handling of unregistered message types.
+ """
+
+ handlers: Dict[T, Callable[..., Awaitable[None]]]
+ """Dictionary mapping message identifiers to their handler functions."""
+
+ _accept_unhandled_messages: bool = True
+ """If True, unhandled messages won't raise errors. Defaults to True."""
+
+ def __init__(self):
+ """Initialize the message handler with an empty handlers dictionary."""
+ self.handlers = {}
+
+ @abc.abstractmethod
+ def get_message_identifier(self, message: M) -> T:
+ """Extract the identifier used to select the appropriate handler.
+
+ Args:
+ message: The message to extract identifier from.
+
+ Returns:
+ The identifier used for handler lookup.
+ """
+ pass
+
+ def add_handler(self, identifier: T, handler: Callable[..., Awaitable[None]]):
+ """Register a handler for the given identifier.
+
+ Args:
+ identifier: The message identifier this handler processes.
+ handler: Async function to handle messages with this identifier.
+ """
+ self.handlers[identifier] = handler
+
+ async def handle_message(self, message: M, *args, **kwargs) -> Any:
+ """Dispatch message to appropriate handler based on its identifier.
+
+ Args:
+ message: The message to handle.
+ *args, **kwargs: Additional arguments passed to the handler.
+
+ Raises:
+ MessageHandlerNotFoundError: If no handler found and unhandled messages not accepted.
+
+ Returns:
+ Result from the handler if any.
+ """
+ identifier = self.get_message_identifier(message)
+
+ try:
+ handler = self.handlers[identifier]
+ return await handler(message, *args, **kwargs)
+ except KeyError:
+ if not self._accept_unhandled_messages:
+ raise MessageHandlerNotFoundError(
+ f"No handler found for identifier: {identifier}"
+ )
+
+
+class S2MessageHandler(MessageHandler[type, "S2Message"]):
+ """Message handler for S2Message objects using message type as identifier.
+
+ Extends the base MessageHandler to work specifically with S2Message objects,
+ automatically handling okay responses and message validation.
+ """
+
+ def get_message_identifier(self, message: "S2Message") -> type:
+ """Get the message type as identifier.
+
+ Args:
+ message: The S2Message to get the type from.
+
+ Returns:
+ The type of the message used for handler lookup.
+ """
+ return type(message)
+
+ def is_correct_message_type(
+ self, message: "S2Message", message_type: type, raise_exception=True
+ ) -> bool:
+ """Check if message matches expected type.
+
+ Args:
+ message: The message to validate.
+ message_type: The expected message type.
+ raise_exception: If True, raises ValueError on type mismatch.
+
+ Raises:
+ ValueError: If message type doesn't match and raise_exception is True.
+
+ Returns:
+ True if message type matches, False otherwise.
+ """
+ if not isinstance(message, message_type):
+ logger.error(
+ "Handler received wrong message type: %s, expected: %s",
+ type(message),
+ message_type,
+ )
+ if raise_exception:
+ raise ValueError(
+ f"Expected {message_type} but received {type(message)}"
+ )
+ return False
+ return True
+
+ async def handle_message(self, message: "S2Message", channel: "S2Channel") -> Any:
+ """Handle S2Message with automatic okay response.
+
+ Processes the message through the appropriate handler and automatically
+ sends an okay response back through the channel.
+
+ Args:
+ message: The S2Message to handle.
+ channel: The channel to send responses through.
+
+ Raises:
+ MessageHandlerNotFoundError: If no handler found and unhandled messages not accepted.
+
+ Returns:
+ Result from the message handler if any.
+ """
+ identifier = self.get_message_identifier(message)
+
+ try:
+ handler = self.handlers[identifier]
+ send_okay = SendOkay(channel, message.message_id) # type: ignore
+
+ result = await handler(message, channel, send_okay.run_async())
+ await send_okay.ensure_send_async(identifier)
+
+ return result
+ except KeyError:
+ if self._accept_unhandled_messages:
+ send_okay = SendOkay(channel, message.message_id) # type: ignore
+ await send_okay.run_async()
+ else:
+ raise MessageHandlerNotFoundError(
+ f"No handler for message type: {getattr(message, 'message_type', identifier)}"
+ )
+
+
+class CertificationMessageHandler(MessageHandler[type, "CertificationMessage"]):
+
+ def get_message_identifier(self, message: "S2Message") -> type:
+ """Get the message type as identifier.
+
+ Args:
+ message: The CertificationMessage to get the type from.
+
+ Returns:
+ The type of the message used for handler lookup.
+ """
+ return type(message)
+
+class ControlMessageHandler(MessageHandler[type, "ControlMessage"]):
+
+ def get_message_identifier(self, message: "S2Message") -> type:
+ """Get the message type as identifier.
+
+ Args:
+ message: The CertificationMessage to get the type from.
+
+ Returns:
+ The type of the message used for handler lookup.
+ """
+ return type(message)
\ No newline at end of file
diff --git a/packages/test-suites/src/testsuites/role_executors/__init__.py b/packages/test-suites/src/testsuites/role_executors/__init__.py
new file mode 100644
index 0000000..2fb5156
--- /dev/null
+++ b/packages/test-suites/src/testsuites/role_executors/__init__.py
@@ -0,0 +1,3 @@
+from .base_role_executor import RoleExecutor, TestRoleExecutor
+from .cem_role_executor import CEMTestRoleExecutor
+from .rm_role_executor import RMTestExecutor
diff --git a/packages/test-suites/src/testsuites/role_executors/base_role_executor.py b/packages/test-suites/src/testsuites/role_executors/base_role_executor.py
new file mode 100644
index 0000000..9881640
--- /dev/null
+++ b/packages/test-suites/src/testsuites/role_executors/base_role_executor.py
@@ -0,0 +1,302 @@
+import abc
+import asyncio
+import functools
+import json
+import logging
+import time
+import uuid
+from typing import Any, Callable, Coroutine, Dict, Optional, ParamSpec, TypeVar
+
+from s2python.version import S2_VERSION
+from s2python.s2_validation_error import S2ValidationError
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ Handshake,
+)
+from s2python.message import S2Message
+
+
+from testsuites.certificate.certificate import (
+ ComplianceReport,
+ TestResult,
+ TestResultStatus,
+ TestSuiteResults,
+)
+from testsuites.test_suite.test_suite import TestSuite, TestSuiteBuilder
+from testsuites.test_logger import (
+ AbstractTestLogger,
+)
+from testsuites.controllers import (
+ Controller,
+)
+from connectivity.s2_channel import S2Channel
+from testsuites.util import wait_for_event_or_stop
+
+
+logger = logging.getLogger(__name__)
+
+
+P = ParamSpec("P")
+R = TypeVar("R")
+
+
+def execute_as_test(
+ test_name: str,
+ error_message_prefix: str,
+ success_log_ident: int = 0,
+ error_log_ident: int = 0,
+):
+ """
+ Decorator to handle common test step logic including timing,
+ result status, error handling, and reporting.
+ """
+
+ def decorator(
+ func: Callable[P, Coroutine[Any, Any, R]],
+ ) -> Callable[P, Coroutine[Any, Any, R | None]]:
+ @functools.wraps(func)
+ async def wrapper(
+ self_obj: "TestRoleExecutor", *args: P.args, **kwargs: P.kwargs
+ ) -> R | None:
+ start_time = time.time()
+ status = TestResultStatus.FAIL
+ message = None
+ return_value = None
+
+ try:
+ # Execute the specific test logic
+ return_value = await func(self_obj, *args, **kwargs) # type: ignore
+
+ # Pass if no errors while running test
+ status = TestResultStatus.PASS
+ except asyncio.CancelledError:
+ # Propagate cancellation
+ raise
+ except ExitMainLoopException as e:
+ # If the test logic itself raises ExitMainLoopException,
+ # log it and ensure it's the message.
+ self_obj.test_logger.error(
+ f"{error_message_prefix}: {e}", ident=error_log_ident
+ )
+ message = str(e)
+ raise # Re-raise to be caught by outer loops if necessary
+ except Exception as e:
+ self_obj.test_logger.error(
+ f"{error_message_prefix}: {e}", ident=error_log_ident
+ )
+ message = str(e)
+ # Consistently raise ExitMainLoopException for other errors
+ # to ensure the main loop exits as in the original code.
+ raise ExitMainLoopException(f"{error_message_prefix}: {e}") from e
+ finally:
+ end_time = time.time()
+ result = TestResult(
+ name=test_name,
+ message=message,
+ status=status,
+ duration=round(end_time - start_time, 2),
+ )
+ self_obj.generic_tasks_test_suite_result.add_test_result(result)
+
+ return return_value # Return the result of the original function if any
+
+ return wrapper # type: ignore
+
+ return decorator
+
+
+class ExitMainLoopException(Exception):
+ pass
+
+
+class RoleExecutor(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
+
+ incoming_handshake_message: Optional[Handshake] = None
+ outgoing_handshake_message: Optional[Handshake] = None
+
+ _handshake_received_event: asyncio.Event
+ _main_loop_started_event: asyncio.Event
+
+ _stop_event: asyncio.Event
+
+ async def run(self, channel: S2Channel, stop_event: asyncio.Event, *args, **kwargs):
+ self.channel = channel
+ self._stop_event = stop_event
+
+ await self.main_loop()
+
+ 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()
+
+ if self.controller is None:
+ raise ValueError("Controller must be set.")
+
+ if type(message) == Handshake:
+ await self.handle_handshake(message)
+
+ await self.controller.handle_message(message, self.channel)
+
+ async def send_handshake(self):
+ try:
+ if self.channel is None:
+ raise ValueError("Channel is not set.")
+
+ self.outgoing_handshake_message = Handshake(
+ message_id=uuid.uuid4(), # type: ignore
+ role=self.role,
+ supported_protocol_versions=[S2_VERSION],
+ )
+
+ await self.controller.send_handshake(
+ self.channel, self.outgoing_handshake_message
+ )
+
+ except asyncio.CancelledError:
+ logger.warning("Main loop was cancelled.")
+ raise # Propagate for TaskGroup
+ except Exception as e:
+ raise ExitMainLoopException("Handshake failed.")
+
+ async def handle_handshake(self, message: Handshake):
+ self.incoming_handshake_message = message
+ self._handshake_received_event.set()
+
+ async def wait_for_handshake(self):
+ try:
+ await wait_for_event_or_stop(
+ self._handshake_received_event,
+ self._stop_event,
+ description="Handshake received event.",
+ )
+ except asyncio.CancelledError:
+ logger.warning("Main loop was cancelled.")
+ raise # Propagate for TaskGroup
+
+ @abc.abstractmethod
+ async def main_loop(self):
+ self._main_loop_started_event.set()
+
+
+class TestRoleExecutor(RoleExecutor):
+ # 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]
+
+ test_suite: TestSuite
+
+ report: ComplianceReport
+ test_logger: AbstractTestLogger
+
+ # This is the test results which keeps track of the handshake process etc...
+ # Tests for this are done using the `execute_as_test` method decorator
+ generic_tasks_test_suite_result = TestSuiteResults(name="9.2. Generic Tasks")
+
+ _handshake_received_event: asyncio.Event
+ _main_loop_started_event: asyncio.Event
+
+ _stop_event: asyncio.Event
+
+ def __init__(
+ self,
+ available_control_types: Dict[ProtocolControlType, Controller],
+ test_suite: TestSuite,
+ report: ComplianceReport,
+ test_logger: AbstractTestLogger,
+ ) -> None:
+
+ 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
+
+ self.test_logger = test_logger
+
+ self._handshake_received_event = asyncio.Event()
+ self._handshake_complete = asyncio.Event()
+ self._main_loop_started_event = asyncio.Event()
+
+ async def run(self, channel: S2Channel, stop_event: asyncio.Event, *args, **kwargs):
+ self.channel = channel
+ self._stop_event = stop_event
+
+ await self.main_loop()
+
+ 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 = self.controller.resource_manager_details
+ self.controller = controller
+
+ async def handle_s2_validation_error(
+ self, exception: S2ValidationError | json.JSONDecodeError
+ ):
+ await self.test_suite.handle_s2_validation_error(exception)
+
+ async def process_message(self, message: S2Message):
+ # This ensures that the channel is set before any messages are processed
+ await self._main_loop_started_event.wait()
+ if type(message) == Handshake:
+ self.handle_handshake(message)
+ await self.controller.handle_message(message, self.channel)
+
+ def handle_handshake(self, message: Handshake):
+ self.incoming_handshake_message = message
+ self._handshake_received_event.set()
+
+ @execute_as_test(
+ test_name="Send Handshake",
+ error_message_prefix="Error whilst sending handshake.",
+ )
+ def send_handshake(self):
+ return super().send_handshake()
+
+ @execute_as_test(
+ test_name="Handshake Received",
+ error_message_prefix="Error whilst processing handshake.",
+ )
+ async def wait_for_handshake(self):
+ await super().wait_for_handshake()
+
+ if (
+ self.incoming_handshake_message
+ and self.incoming_handshake_message.role == self.role
+ ):
+ raise AssertionError("Invalid role on handshake.")
+
+ @abc.abstractmethod
+ async def main_loop(self):
+ self._main_loop_started_event.set()
+ self.report.add_test_suite_result(self.generic_tasks_test_suite_result)
+
+ async def execute_test_suite(self):
+ # Wait until the handshake is complete before starting the testing.
+
+ if self.channel is None:
+ raise ValueError("Channel not set.")
+
+ if self.controller:
+ await self.test_suite.execute(
+ self.channel,
+ self.controller,
+ (
+ EnergyManagementRole.RM
+ if self.role == EnergyManagementRole.CEM
+ else EnergyManagementRole.CEM
+ ),
+ )
diff --git a/packages/test-suites/src/testsuites/role_executors/cem_role_executor.py b/packages/test-suites/src/testsuites/role_executors/cem_role_executor.py
new file mode 100644
index 0000000..1c8c666
--- /dev/null
+++ b/packages/test-suites/src/testsuites/role_executors/cem_role_executor.py
@@ -0,0 +1,185 @@
+import logging
+import asyncio
+import time
+from typing import Dict
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ Handshake,
+ SelectControlType,
+ HandshakeResponse,
+)
+from s2python.message import S2Message
+
+
+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_logger import (
+ AbstractTestLogger,
+)
+from testsuites.controllers import (
+ Controller,
+ BaseCEMController,
+)
+from .base_role_executor import (
+ TestRoleExecutor,
+ ExitMainLoopException,
+ execute_as_test,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class CEMTestRoleExecutor(TestRoleExecutor):
+ """Tests a CEM. This assumed the role of Resource Manager and behaves like one for the duration of the tests.
+ For all activated control types this role executor will send an RM Details message with only one available control type which forces the CEM to choose it.
+ Then all of the tests for that control type are executed, after which a new RM details is sent with the next control type to be tested.
+ """
+
+ role = EnergyManagementRole.RM
+ controller: BaseCEMController
+
+ def __init__(
+ self,
+ controllers: Dict[ProtocolControlType, Controller],
+ test_suite: TestSuite,
+ report: ComplianceReport,
+ test_logger: AbstractTestLogger,
+ ) -> None:
+ super().__init__(controllers, test_suite, report, test_logger)
+
+ self._control_type_selected_event = asyncio.Event()
+ self._handshake_response_received_event = asyncio.Event()
+
+ async def handle_select_control_type(self, message: SelectControlType):
+ logger.info("Control Type Selected: %s", message.control_type)
+ self.set_control_type(message.control_type)
+ self._control_type_selected_event.set()
+
+ async def handle_handshake_response(self, message: HandshakeResponse):
+ self._handshake_response_received_event.set()
+ self.handshake_response_message = message
+
+ async def process_message(self, message: S2Message):
+ # Grab messages here so we can process them inside this class without complicated callbacks.
+ if type(message) == SelectControlType:
+ await self.handle_select_control_type(message)
+ elif type(message) == HandshakeResponse:
+ await self.handle_handshake_response(message)
+ return await super().process_message(message)
+
+ @execute_as_test(
+ test_name="9.2.1. Update Resource Manager Details",
+ error_message_prefix="Error whilst sending RM Details:",
+ )
+ async def send_resource_manager_details(self, control_type: ProtocolControlType):
+ try:
+ if self.channel is None:
+ raise ValueError("Channel not set.")
+
+ if self.controller.resource_manager_details is None:
+ raise ValueError("RM Details not set.")
+
+ # logger.info(self.controller.resource_manager_details.available_control_types)
+ self.controller.resource_manager_details.available_control_types = [
+ control_type
+ ]
+ await self.controller.send_resource_manager_details(self.channel)
+ self.test_logger.success("Resource Manager Details Sent.", ident=0)
+ except Exception as e:
+ self.test_logger.error(
+ f"Failed to send ResourceManagerDetails: {e}", ident=0
+ )
+ raise ExitMainLoopException()
+
+ @execute_as_test(
+ test_name="9.2.2. Activate Control Type",
+ error_message_prefix="Failed to activate control type",
+ )
+ async def wait_for_select_control_type(self):
+ try:
+ await wait_for_event_or_stop(
+ self._control_type_selected_event,
+ self._stop_event,
+ 5,
+ description="Control Type Selected Event",
+ )
+ self.test_logger.success(
+ f"Control type set to {self.controller.control_type.name}", ident=0
+ )
+ except asyncio.CancelledError:
+ logger.warning("Main loop was cancelled.")
+ raise # Propagate for TaskGroup
+ except Exception as e:
+ self.test_logger.error(
+ f"Failed to receive control type selection: {e}", ident=0
+ )
+ raise ExitMainLoopException()
+
+ @execute_as_test(
+ test_name="Handshake Response",
+ error_message_prefix="Error whilst processing handshake response from CEM.",
+ )
+ async def wait_for_handshake_response(self):
+ await wait_for_event_or_stop(
+ self._handshake_response_received_event,
+ self._stop_event,
+ description="No handshake response received.",
+ )
+
+ if self.incoming_handshake_message is None:
+ raise ValueError("Handshake message was not saved for validation.")
+
+ # Checking that the protocol version selected by the CEM is included in the list of supported versions send in the handshake.
+ if (
+ self.incoming_handshake_message.supported_protocol_versions is not None
+ and self.handshake_response_message.selected_protocol_version
+ not in self.incoming_handshake_message.supported_protocol_versions
+ ):
+ raise AssertionError(
+ "Invalid protocol version selected by CEM. Version selected not included in supported protocol versions."
+ )
+
+ async def main_loop(self):
+ # This sets the main loop started event so that the message processing can start.
+ await super().main_loop()
+ 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)
+ try:
+ await self.send_handshake()
+ await self.wait_for_handshake()
+
+ await self.wait_for_handshake_response()
+
+ logger.info("Received Handshake from CEM. Sending RM Details.")
+ if self.controller.resource_manager_details is None:
+ raise ValueError("RM Details not set!")
+
+ control_types = (
+ self.controller.resource_manager_details.available_control_types
+ )
+ logger.info("Control Types: %s", control_types)
+ for control_type in control_types:
+
+ logger.info("Sending RM Details with %s control type.", control_type)
+ # Reset control type selected event so everything waits.
+ self._control_type_selected_event.clear()
+
+ await self.send_resource_manager_details(control_type)
+
+ await self.wait_for_select_control_type()
+
+ await self.controller.after_chosen(self.channel)
+
+ await self.execute_test_suite()
+
+ await asyncio.sleep(5)
+
+ except ExitMainLoopException:
+ return
diff --git a/packages/test-suites/src/testsuites/role_executors/rm_role_executor.py b/packages/test-suites/src/testsuites/role_executors/rm_role_executor.py
new file mode 100644
index 0000000..5562d22
--- /dev/null
+++ b/packages/test-suites/src/testsuites/role_executors/rm_role_executor.py
@@ -0,0 +1,173 @@
+import logging
+import abc
+import asyncio
+from datetime import datetime
+import logging
+import time
+from typing import Dict, Optional
+import uuid
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ SelectControlType,
+ ReceptionStatusValues,
+)
+from s2python.message import S2Message
+
+
+from testsuites.controllers import (
+ BaseRMController,
+)
+from testsuites.util import wait_for_event_or_stop
+from .base_role_executor import (
+ ExitMainLoopException,
+ TestRoleExecutor,
+ execute_as_test,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class RMTestExecutor(TestRoleExecutor):
+ role = EnergyManagementRole.CEM
+ controller: BaseRMController
+
+ available_control_types: set[ProtocolControlType] = set()
+ tested_control_types: set[ProtocolControlType] = set()
+
+ async def main_loop(self):
+ # This sets the main loop started event so that the message processing can start.
+ 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.send_handshake()
+ await self.wait_for_handshake()
+
+ await self.wait_for_rm_details()
+
+ for control_type in self.available_control_types:
+
+ await self.send_select_control_type(control_type)
+
+ await self.execute_test_suite()
+
+ await self.test_select_not_supported_control_type()
+
+ await self.controller.send_session_request_disconnect(self.channel)
+ self.test_logger.success("Sent Graceful Disconnect.", ident=0)
+
+ logger.info("Exiting Test Executor Main Loop.")
+ except asyncio.CancelledError:
+ logger.warning("Main loop was cancelled.")
+ raise # Propagate for TaskGroup
+ except ExitMainLoopException:
+ pass
+ except Exception as e:
+ logger.exception("Exception in main_loop: %s", e)
+ raise
+ finally:
+ self.test_logger.info("Main loop finished. Signaling stop.", ident=0)
+
+ @execute_as_test(
+ test_name="9.2.2. Activate Control Type - Not Available",
+ error_message_prefix="Resource Manager accepted a control type that it doesn't support: ",
+ )
+ async def test_select_not_supported_control_type(self):
+ if self.channel is None:
+ raise ValueError("Channel not provided")
+
+ # Get all the control types that the RM does not implement
+ control_types = set([member for member in ProtocolControlType])
+ not_available_control_types = control_types.difference(
+ self.available_control_types
+ )
+ # Remove the no selection type since all RMs support it and it isn't explicitly part of available control types
+ not_available_control_types.remove(ProtocolControlType.NO_SELECTION)
+ not_available_control_types.remove(ProtocolControlType.NOT_CONTROLABLE)
+
+ for control_type in not_available_control_types:
+ reception_status = await self.channel.send_msg_and_await_reception_status(
+ SelectControlType(message_id=uuid.uuid4(), control_type=control_type),
+ raise_on_error=False,
+ )
+
+ # RM should reply with non-Ok reception status since it should reject the selected control type
+ if reception_status.status == ReceptionStatusValues.OK:
+ raise AssertionError(
+ f"RM Accepted selection of control type `{control_type}` which it doesn't support."
+ )
+
+ def set_control_type(self, control_type: ProtocolControlType):
+ handshake_received_event = self.controller._handshake_received_event
+ resource_manager_details_received = (
+ self.controller._resource_manager_details_received
+ )
+
+ super().set_control_type(control_type)
+
+ # Copy the events over.
+ self.controller._handshake_received_event = handshake_received_event
+ self.controller._resource_manager_details_received = (
+ resource_manager_details_received
+ )
+
+ @execute_as_test(
+ test_name="9.2.1. Update Resource Manager Details",
+ error_message_prefix="Error whilst waiting for RM Details:",
+ )
+ async def wait_for_rm_details(self):
+ await wait_for_event_or_stop(
+ self.controller._resource_manager_details_received,
+ self._stop_event,
+ timeout=10,
+ description="Resource Manger Details Received Event.",
+ )
+ resource_manager_details = self.controller.resource_manager_details
+ if resource_manager_details is None:
+ raise ValueError(
+ "RM Details Received event is set but no resource manager details are present."
+ )
+
+ for control_type in resource_manager_details.available_control_types:
+ self.available_control_types.add(control_type)
+
+ self.test_logger.success("Resource Manager Details Received.", ident=0)
+
+ async def send_select_control_type(self, control_type: ProtocolControlType):
+ if self.controller.control_type != ProtocolControlType.NO_SELECTION:
+ await self.update_active_control_type(control_type)
+ else:
+ await self.activate_control_type(control_type)
+
+ @execute_as_test(
+ test_name="9.2.2. Activate Control Type",
+ error_message_prefix="Failed to activate control type",
+ )
+ async def activate_control_type(self, control_type: ProtocolControlType):
+ # Test case for setting the initial control type
+ if self.channel is None:
+ raise ValueError("Channel not set.")
+
+ self.set_control_type(control_type)
+ await self.controller.send_select_control_type(self.channel)
+ self.test_logger.success(f"Control Type Set to {control_type.name}", ident=0)
+
+ @execute_as_test(
+ test_name="9.2.3. Update Active Control Type",
+ error_message_prefix="Failed to activate control type",
+ )
+ async def update_active_control_type(self, control_type: ProtocolControlType):
+ # Test case for changing the control type after one has already been set
+ if self.channel is None:
+ raise ValueError("Channel not set.")
+
+ self.set_control_type(control_type)
+ await self.controller.send_select_control_type(self.channel)
+ self.test_logger.success(f"Control Type Set to {control_type.name}", ident=0)
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/__init__.py b/packages/test-suites/src/testsuites/setup/__init__.py
new file mode 100644
index 0000000..999fa30
--- /dev/null
+++ b/packages/test-suites/src/testsuites/setup/__init__.py
@@ -0,0 +1 @@
+from .setup import create_test_executor
\ No newline at end of file
diff --git a/packages/test-suites/src/testsuites/setup/cem_tests.py b/packages/test-suites/src/testsuites/setup/cem_tests.py
new file mode 100644
index 0000000..1e1b588
--- /dev/null
+++ b/packages/test-suites/src/testsuites/setup/cem_tests.py
@@ -0,0 +1,31 @@
+from connectivity.config import Config
+from testsuites.test_logger import AbstractTestLogger
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.test_suite.test_suite import TestSuiteBuilder
+
+from testsuites.test_suite.cem.frbc_test_cases import (
+ FRBCElectricVehicleScenarioTestCase,
+ FRBCBatteryScenarioTestCase,
+)
+from testsuites.test_suite.cem.pebc_test_cases import (
+ PEBCPVPanelScenarioTestCase,
+ PEBCElectricVehicleCurtailScenarioTestCase,
+)
+from testsuites.test_suite.cem.not_controllable import NotControllableCEMTestCase
+
+
+def build_cem_test_suite(
+ config: Config, report: ComplianceReport, test_logger: AbstractTestLogger
+) -> TestSuiteBuilder:
+ # Returns a builder so that it can be extended if needed elsewhere.
+ return (
+ TestSuiteBuilder(config.roles, report, test_logger)
+ # ! Not Controllable Test Cases
+ .with_test_case(NotControllableCEMTestCase)
+ # ! FRBC Test Cases
+ .with_test_case(FRBCElectricVehicleScenarioTestCase)
+ .with_test_case(FRBCBatteryScenarioTestCase)
+ # ! PEBC Test Case
+ .with_test_case(PEBCPVPanelScenarioTestCase)
+ .with_test_case(PEBCElectricVehicleCurtailScenarioTestCase)
+ )
diff --git a/packages/test-suites/src/testsuites/setup/rm_tests.py b/packages/test-suites/src/testsuites/setup/rm_tests.py
new file mode 100644
index 0000000..b28e365
--- /dev/null
+++ b/packages/test-suites/src/testsuites/setup/rm_tests.py
@@ -0,0 +1,25 @@
+from connectivity.config import (
+ Config,
+)
+
+from testsuites.test_suite.rm.base_test_case import NotControllableRMTestCase
+from testsuites.test_suite.rm.frbc_test_cases import FRBCTestCase
+from testsuites.test_suite.rm.pebc_test_cases import PEBCTestCase
+from testsuites.test_suite.test_suite import TestSuite, TestSuiteBuilder
+from testsuites.test_logger import AbstractTestLogger, TestLogger
+from testsuites.certificate.certificate import ComplianceReport
+
+
+def build_rm_test_suite(
+ config: Config, report: ComplianceReport, test_logger: AbstractTestLogger
+) -> TestSuiteBuilder:
+ # Returns a builder so that it can be extended if needed elsewhere.
+ return (
+ TestSuiteBuilder(config.roles, report, test_logger)
+ # RM Not Controllable Test Cases
+ .with_test_case(NotControllableRMTestCase)
+ # RM PEBC Test Cases
+ .with_test_case(PEBCTestCase)
+ # RM FRBC Test Cases
+ .with_test_case(FRBCTestCase)
+ )
diff --git a/packages/test-suites/src/testsuites/setup/setup.py b/packages/test-suites/src/testsuites/setup/setup.py
new file mode 100644
index 0000000..ae87bf6
--- /dev/null
+++ b/packages/test-suites/src/testsuites/setup/setup.py
@@ -0,0 +1,201 @@
+import logging
+import abc
+import asyncio
+from datetime import datetime
+import logging
+from typing import Dict, Optional
+import uuid
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ Handshake,
+ ResourceManagerDetails,
+ CommodityQuantity,
+ Currency,
+ Duration,
+ Role,
+ RoleType,
+ Commodity,
+ SelectControlType,
+)
+
+from testsuites.test_executor import IntegrationTestExecutor
+from testsuites.certificate.certificate import ComplianceReport
+from .cem_tests import build_cem_test_suite
+from .rm_tests import build_rm_test_suite
+from testsuites.test_logger import (
+ AbstractTestLogger,
+)
+from testsuites.controllers import (
+ Controller,
+ BaseRMController,
+ # NotControllableRMController,
+ PEBCRMController,
+ FRBCRMController,
+ BaseCEMController,
+ NotControllableCEMController,
+ FRBCCEMController,
+ PEBCCEMController,
+)
+
+from testsuites.role_executors import (
+ TestRoleExecutor,
+ CEMTestRoleExecutor,
+ RMTestExecutor,
+)
+from connectivity.config import Config, DeviceDetails
+
+logger = logging.getLogger(__name__)
+
+
+"""
+This file contains the functions which assembles the IntegrationTestExecutor which is used
+by both the client side tester and the server side certifier.
+
+The test case builder methods are located in this module as well.
+"""
+
+# Add all controllers to this list!
+# As long as the role and control type value are set correctly they will be sorted
+# The controllers will be loaded based on the config enabled field
+controller_classes: list[type[Controller]] = [
+ # Controllers used when testing an RM
+ BaseRMController,
+ FRBCRMController,
+ PEBCRMController,
+ # Controllers used when testing a CEM
+ BaseCEMController,
+ NotControllableCEMController,
+ FRBCCEMController,
+ PEBCCEMController,
+]
+
+
+def get_controller_classes_dict(role: EnergyManagementRole):
+ classes = {}
+ for controller_class in controller_classes:
+ if controller_class.role == role:
+ classes[controller_class.control_type] = controller_class
+ return classes
+
+
+def create_rm_controllers_dict_with_config(
+ config: Config,
+) -> Dict[ProtocolControlType, Controller]:
+ """This method assembles the controllers dictionary for RM testing which the test executor uses to pick specific controllers."""
+ controllers: Dict[ProtocolControlType, Controller] = {}
+
+ controllers[ProtocolControlType.NO_SELECTION] = BaseRMController()
+
+ # Load all of the enabled controllers based on config
+ for control_type, controller_class in get_controller_classes_dict(
+ EnergyManagementRole.RM
+ ).items():
+ try:
+ control_type_config = config.roles.get_control_type_config(
+ EnergyManagementRole.CEM, control_type
+ )
+ if control_type_config is not None and control_type_config.enabled:
+ controllers[control_type] = controller_class()
+ except KeyError:
+ pass
+ # 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
+
+
+def create_rm_details(details: DeviceDetails):
+ return ResourceManagerDetails(
+ available_control_types=[
+ ProtocolControlType.NOT_CONTROLABLE
+ ], # I'll set this based on the controllers dict
+ roles=[
+ Role(
+ role=RoleType.ENERGY_PRODUCER,
+ commodity=Commodity.ELECTRICITY,
+ )
+ ],
+ name=details.name,
+ manufacturer=details.manufacturer,
+ model=details.model,
+ firmware_version=details.firmware_version,
+ serial_number=details.serial_number,
+ currency=details.currency,
+ message_id=uuid.uuid4(),
+ resource_id=uuid.uuid4(),
+ # TODO: These should probably be determined by the test case somehow...
+ provides_forecast=True,
+ provides_power_measurement_types=[CommodityQuantity.ELECTRIC_POWER_L1],
+ instruction_processing_delay=Duration(0),
+ )
+
+
+def create_cem_controllers_dict_with_config(
+ config: Config,
+) -> Dict[ProtocolControlType, Controller]:
+ """This method assembles the controllers dictionary for CEM testing which the test executor uses to pick specific controllers."""
+
+ rm_details = create_rm_details(config.device_details)
+
+ controllers: Dict[ProtocolControlType, Controller] = {}
+ controllers[ProtocolControlType.NO_SELECTION] = BaseCEMController(rm_details)
+
+ # Load all of the enabled controllers based on config
+ for control_type, controller_class in get_controller_classes_dict(
+ EnergyManagementRole.CEM
+ ).items():
+ try:
+ control_type_config = config.roles.get_control_type_config(
+ EnergyManagementRole.CEM, control_type
+ )
+ if control_type_config is not None and control_type_config.enabled:
+ controllers[control_type] = controller_class(rm_details)
+ except KeyError:
+ pass
+
+ # Set the Available Control Types based on the config and available controllers
+ control_type_set = set(controllers.keys())
+ control_type_set.discard(ProtocolControlType.NO_SELECTION)
+ rm_details.available_control_types = list(control_type_set)
+
+ return controllers
+
+
+def create_test_executor(
+ config: Config, test_logger: AbstractTestLogger
+) -> IntegrationTestExecutor:
+ report = ComplianceReport(timestamp=datetime.now(), device=config.device_details)
+
+ rm_controllers = create_rm_controllers_dict_with_config(config)
+
+ rm_test_suite_builder = build_rm_test_suite(config, report, test_logger)
+ rm_role_executor = RMTestExecutor(
+ available_control_types=rm_controllers,
+ test_suite=rm_test_suite_builder.build(),
+ report=report,
+ test_logger=test_logger,
+ )
+
+ cem_controllers = create_cem_controllers_dict_with_config(config)
+ cem_test_suite_builder = build_cem_test_suite(config, report, test_logger)
+ cem_role_executor = CEMTestRoleExecutor(
+ controllers=cem_controllers,
+ test_suite=cem_test_suite_builder.build(),
+ report=report,
+ test_logger=test_logger,
+ )
+
+ role_executors: Dict[EnergyManagementRole, TestRoleExecutor] = {
+ EnergyManagementRole.RM: rm_role_executor,
+ EnergyManagementRole.CEM: cem_role_executor,
+ }
+
+ # The main integration test class. Everything to do with testing is encapsulated in it!
+ return IntegrationTestExecutor(role_executors=role_executors, test_logger=test_logger)
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..b11b641
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_executor.py
@@ -0,0 +1,219 @@
+import abc
+import asyncio
+import json
+import logging
+from typing import Dict, Optional
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ Handshake,
+)
+from s2python.message import S2Message
+from s2python.s2_validation_error import S2ValidationError
+from testsuites.test_logger import AbstractTestLogger
+from testsuites.util import wait_for_event_or_stop
+from testsuites.certificate.certificate import ComplianceReport
+
+from connectivity.s2_channel import S2Channel
+from connectivity.connection_adapter import ConnectionClosed, ConnectionError
+from .role_executors import TestRoleExecutor
+
+
+logger = logging.getLogger(__name__)
+
+
+class AbstractExecutor(abc.ABC):
+
+ _stop_event: asyncio.Event
+
+ running = False
+
+ def is_running(self):
+ return self.running
+
+ @abc.abstractmethod
+ async def run(self, *args, **kwargs):
+ pass
+
+ async def stop(self):
+ logger.debug("Stop Called in class %s", self.__class__.__name__)
+ self._stop_event.set()
+
+
+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.
+ # Has it's own task which needs to be run which received messages from the S2 device and puts them into a queue so they can be processed here.
+ # Sending a message to the device is done by calling `send_msg_and_await_reception_status`
+ channel: Optional["S2Channel"] = None
+
+ executor: TestRoleExecutor | None = None
+ role_executors: Dict[EnergyManagementRole, TestRoleExecutor]
+
+ test_logger: AbstractTestLogger
+
+ _stop_event: asyncio.Event
+
+ _select_role_executor = asyncio.Event()
+
+ def __init__(
+ self,
+ role_executors: Dict[EnergyManagementRole, TestRoleExecutor],
+ test_logger: AbstractTestLogger,
+ ) -> None:
+
+ self.role_executors = role_executors
+ self.test_logger = test_logger
+
+ 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]
+ self.executor.incoming_handshake_message = message
+
+ # Setting this will allow the main_loop to proceed
+ self._select_role_executor.set()
+
+ async def handle_s2_validation_error(
+ self, exception: S2ValidationError | json.JSONDecodeError
+ ):
+ if self.executor is None:
+ self.test_logger.error("Validation of S2 Message Failed...", ident=0)
+ else:
+ await self.executor.handle_s2_validation_error(exception)
+
+ 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:
+ # logger.info("Passing message to executor: %s", message)
+ 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. Otherwise this will block the program exiting.
+ message = await asyncio.wait_for(
+ self.channel.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 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
+
+ async def stop(self):
+ await super().stop()
+
+ if self.channel is not None:
+ await self.channel.stop()
+
+ async def setup(self, channel: S2Channel, *args, **kwargs):
+ self.channel = channel
+ self.channel.set_validation_error_handler(self.handle_s2_validation_error)
+ self._stop_event.clear()
+ self._select_role_executor.clear()
+
+ async def cleanup(self, *args, **kwargs):
+ pass
+
+ async def run_channel(self):
+ """Wrapper method for the channel.run task for catching errors in it."""
+ if self.channel is None:
+ raise ValueError("S2 Channel is not provided")
+ try:
+ await self.channel.run()
+ except ConnectionClosed:
+ await self.stop()
+ except ConnectionError:
+ await self.stop()
+
+ async def main_loop(self):
+ # Wait until the initial handshake message has been received and role executor set before running the role executor's main loop
+ await wait_for_event_or_stop(self._select_role_executor, self._stop_event)
+
+ if self.executor is None or self.channel is None:
+ raise ValueError("Unable to run role executor main loop.")
+
+ logger.info("Choosing executor as role %s", self.executor.role)
+ # await wait_for_event_or_stop(self._select_role_executor, self._stop_event)
+ await self.executor.run(self.channel, self._stop_event)
+
+ await self.stop()
+
+ async def run(self, *args, **kwargs):
+ """Main control method of the executor. It creates all of the tasks and manages them."""
+ self.running = True
+ await self.setup(*args, **kwargs)
+
+ try:
+ async with asyncio.TaskGroup() as tg:
+
+ 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.run_channel(), 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
+ finally:
+ logger.info("IntegrationTestExecutor run method finishing.")
+ await self.cleanup() # Perform final cleanup (e.g., channel.stop())
+ logger.info("Cleanup finished.")
+ self.running = False
+
+ async def get_compliance_report(self) -> ComplianceReport:
+ if self.executor is not None:
+ return self.executor.report
+ raise ValueError("No testing has been done.")
diff --git a/packages/test-suites/src/testsuites/test_logger.py b/packages/test-suites/src/testsuites/test_logger.py
new file mode 100644
index 0000000..8f3b97d
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_logger.py
@@ -0,0 +1,134 @@
+import logging
+import abc
+import asyncio
+from enum import Enum
+import logging
+from typing import Callable, Literal, Optional
+
+from pydantic import BaseModel
+
+from testsuites.envelope_models import LogMessage, LogMessageEnvelope
+from testsuites.certificate.certificate import (
+ TestResultStatus,
+)
+from connectivity.channel import Channel
+
+
+logger = logging.getLogger(__name__)
+
+
+class TestLoggerLevel(str, Enum):
+ SUCCESS = "SUCCESS"
+ SOFT_ERROR = "SOFT_ERROR"
+ ERROR = "ERROR"
+ INFO = "INFO"
+ DEBUG = "DEBUG"
+
+
+RESULT_STATUS_TO_LOG_LEVEL = {
+ TestResultStatus.PASS: TestLoggerLevel.SUCCESS,
+ TestResultStatus.FAIL: TestLoggerLevel.ERROR,
+ TestResultStatus.SOFT_FAIL: TestLoggerLevel.SOFT_ERROR,
+ TestResultStatus.N_A: TestLoggerLevel.INFO,
+}
+
+
+class AbstractTestLogger(abc.ABC):
+
+ def info(self, message, ident=2):
+ logger.info("%s[INFO] %s", message)
+
+ def success(self, message, ident=2):
+ logger.info("%s[SUCCESS] %s", message)
+
+ def soft_error(self, message, ident=2):
+ logger.warning("%s[SOFT-FAIL] %s", message)
+
+ def error(self, message, ident=2):
+ logger.warning("%s[FAIL] %s", message)
+
+ @abc.abstractmethod
+ def log(self, message, level: TestLoggerLevel = TestLoggerLevel.INFO, ident=2):
+ pass
+
+ def log_status_list(self, message, statuses: list[TestResultStatus], ident=2):
+ if TestResultStatus.FAIL in statuses:
+ self.error(message, ident=ident)
+ elif TestResultStatus.SOFT_FAIL in statuses:
+ self.soft_error(message, ident=ident)
+ elif TestResultStatus.PASS in statuses:
+ self.success(message, ident=ident)
+
+
+class TestLogger(AbstractTestLogger):
+
+ 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)
+ # self.logger.exception("%s[FAIL] %s", " " * ident, message)
+
+ def level_to_function(self, level) -> Callable[[str, int], None]:
+ return {
+ TestLoggerLevel.SUCCESS: self.success,
+ TestLoggerLevel.ERROR: self.error,
+ TestLoggerLevel.SOFT_ERROR: self.soft_error,
+ TestLoggerLevel.INFO: self.info,
+ }[level]
+
+ def log(self, message, level: TestLoggerLevel = TestLoggerLevel.INFO, ident=2):
+ self.level_to_function(level)(message, ident)
+
+ def log_result_status(
+ self, message, status: TestResultStatus = TestResultStatus.PASS, ident=2
+ ):
+ level = RESULT_STATUS_TO_LOG_LEVEL[status]
+ self.log(message, level, ident)
+
+ def log_status_list(self, message, statuses: list[TestResultStatus], ident=2):
+ if TestResultStatus.FAIL in statuses:
+ self.error(message, ident=ident)
+ elif TestResultStatus.SOFT_FAIL in statuses:
+ self.soft_error(message, ident=ident)
+ elif TestResultStatus.PASS in statuses:
+ self.success(message, ident=ident)
+
+
+class ServerTestLogger(AbstractTestLogger):
+
+ def __init__(self, channel: Channel) -> None:
+ super().__init__()
+
+ self.channel = channel
+
+ def info(self, message, ident=2):
+ self.log(message, TestLoggerLevel.INFO, ident=ident)
+
+ def success(self, message, ident=2):
+ self.log(message, TestLoggerLevel.SUCCESS, ident=ident)
+
+ def soft_error(self, message, ident=2):
+ self.log(message, TestLoggerLevel.SOFT_ERROR, ident=ident)
+
+ def error(self, message, ident=2):
+ self.log(message, TestLoggerLevel.ERROR, ident=ident)
+
+ def log(self, message, level: TestLoggerLevel = TestLoggerLevel.INFO, ident=2):
+ log_message = LogMessage(
+ level=level.name, message=message, ident=ident, logger="test"
+ )
+ envelope = LogMessageEnvelope(message=log_message)
+ # Done as a task to allow logging to by synchronous
+ asyncio.create_task(self.channel.send(envelope))
diff --git a/packages/test-suites/src/testsuites/test_suite/__init__.py b/packages/test-suites/src/testsuites/test_suite/__init__.py
new file mode 100644
index 0000000..4543747
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/__init__.py
@@ -0,0 +1 @@
+from .test_suite import TestLogger, S2TestCase, TestSuite, TestSuiteBuilder, NotApplicableTestException
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/__init__.py b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/__init__.py
new file mode 100644
index 0000000..d32a239
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/__init__.py
@@ -0,0 +1,2 @@
+from .ev_scenario import FRBCElectricVehicleScenarioTestCase
+from .battery_scenario import FRBCBatteryScenarioTestCase
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/base.py
new file mode 100644
index 0000000..f4f6540
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/base.py
@@ -0,0 +1,259 @@
+import abc
+import asyncio
+import logging
+from typing import Awaitable, Optional
+import uuid
+
+from s2python.frbc import (
+ FRBCSystemDescription,
+ FRBCStorageDescription,
+ FRBCActuatorDescription,
+ FRBCOperationMode,
+ FRBCOperationModeElement,
+ FRBCLeakageBehaviour,
+ FRBCLeakageBehaviourElement,
+ FRBCUsageForecast,
+ FRBCUsageForecastElement,
+ FRBCActuatorStatus,
+ FRBCStorageStatus,
+ FRBCInstruction,
+)
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+ RevokableObjects,
+ InstructionStatusUpdate,
+ ReceptionStatus,
+)
+
+from testsuites.controllers.cem.not_controllable_controller import (
+ NotControllableCEMController,
+)
+from testsuites.test_suite.cem.not_controllable import NotControllableCEMTestCase
+from testsuites.util import current_timezone_time
+from testsuites.certificate.certificate import (
+ ComplianceReport,
+)
+from connectivity.config import FRBCCEMTestConfig
+from testsuites.controllers import FRBCCEMController
+from testsuites.test_suite.test_suite import (
+ NotApplicableTestException,
+ S2TestCase,
+ TestLogger,
+)
+from connectivity.s2_channel import S2Channel
+
+logger = logging.getLogger(__name__)
+
+
+class FRBCCEMTestCase(NotControllableCEMTestCase):
+ control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL
+
+ TIMEOUT = 5
+
+ controller: FRBCCEMController
+ config: FRBCCEMTestConfig
+
+ _received_instruction_event: asyncio.Event
+
+ def __init__(
+ self,
+ config: FRBCCEMTestConfig,
+ channel: S2Channel,
+ controller: FRBCCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self._frbc_system_description_sent_event = asyncio.Event()
+ self._frbc_leakage_behavior_sent_event = asyncio.Event()
+ self._received_instruction_event = asyncio.Event()
+
+ self.message_handlers[RevokeObject] = self.handle_revoke
+ self.message_handlers[FRBCInstruction] = self.handle_instruction
+
+ async def setup(self):
+ await super().setup()
+
+ self.assertEqual(
+ self.controller.control_type,
+ ProtocolControlType.FILL_RATE_BASED_CONTROL,
+ "Precondition Activate Control Type not met",
+ )
+ self.test_logger.success("Precondition Activate Control Type Met")
+
+ def update_system_description_precondition(
+ self, precondition_id: str | None = None
+ ):
+ """Tests the system description precondition.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ # try:
+ self.assertTrue(
+ self._frbc_system_description_sent_event.is_set(),
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update System Description' not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update System Description' is complete."
+ )
+
+ async def send_frbc_system_description(self):
+ # Only send the FRBC System Description once.
+ system_description, reception_status = await self.controller.send_frbc_system_description(
+ self.channel
+ )
+ test_name = "9.6.1 Update System Description"
+ if not self._frbc_system_description_sent_event.is_set():
+ test_name += " (initial)"
+ self._frbc_system_description_sent_event.set()
+
+ await self.add_test_method(
+ test_name,
+ self.base_message_send_validate,
+ system_description,
+ reception_status,
+ )
+
+ async def send_revoke_system_description(self):
+ self.update_system_description_precondition("9.6.2.2.")
+
+ reception_status = await self.controller.revoke_frbc_system_description(
+ self.channel
+ )
+
+ # Reset event since it's been revoked.
+ self._frbc_system_description_sent_event.clear()
+
+ # await self.add_test_method(
+ # "9.6.2. Revoke System Description",
+ # self.base_message_send_validate,
+
+ # reception_status,
+ # )
+
+ async def send_leakage_behaviour(self):
+ self.update_system_description_precondition("9.6.3.2.")
+
+ reception_status = await self.controller.update_frbc_leakage_behavior(
+ self.channel
+ )
+ test_name = "9.6.3. Update Leakage Behaviour"
+ if not self._frbc_leakage_behavior_sent_event.is_set():
+ test_name += " (initial)"
+ self._frbc_leakage_behavior_sent_event.set()
+
+ await self.add_test_method(
+ test_name,
+ self.base_message_send_validate,
+ self.controller.leakage_behaviour,
+ reception_status,
+ )
+
+ async def send_revoke_leakage_behaviour(self):
+ self.assertTrue(
+ self._frbc_system_description_sent_event.is_set(),
+ "9.6.4.2. Task Precondition 'Update Leakage Behaviour' not complete.",
+ )
+ self.test_logger.success(
+ "9.6.4.2. Task Precondition 'Update Leakage Behaviour' is complete.",
+ )
+
+ reception_status = await self.controller.revoke_leakage_behaviour(self.channel)
+
+ self._frbc_leakage_behavior_sent_event.clear()
+
+ async def send_usage_forecast(self, forecast: FRBCUsageForecast):
+ self.update_system_description_precondition("9.6.5.2.")
+
+ reception_status = await self.controller.update_frbc_usage_forecast(
+ self.channel, forecast
+ )
+ await self.add_test_method(
+ "Update usage forecast",
+ self.base_message_send_validate,
+ forecast,
+ reception_status,
+ )
+
+ async def send_actuator_status(self, actuator_status: FRBCActuatorStatus):
+ self.update_system_description_precondition()
+
+ reception_status = await self.controller.update_actuator_status(
+ self.channel, actuator_status
+ )
+ await self.add_test_method(
+ "Update actuator status",
+ self.base_message_send_validate,
+ actuator_status,
+ reception_status,
+ )
+
+ async def send_storage_status(self, storage_status: FRBCStorageStatus):
+ self.update_system_description_precondition()
+
+ reception_status = await self.controller.update_storage_status(
+ self.channel, storage_status
+ )
+
+ await self.add_test_method(
+ "Update storage status",
+ self.base_message_send_validate,
+ storage_status,
+ reception_status,
+ )
+
+ async def send_power_measurement(self, power_measurement: PowerMeasurement):
+ self.update_system_description_precondition()
+ return await super().send_power_measurement(power_measurement)
+
+ @abc.abstractmethod
+ async def validate_instruction(
+ self,
+ instruction: FRBCInstruction,
+ status_update: InstructionStatusUpdate,
+ status_update_reception_status: ReceptionStatus,
+ ):
+ pass
+
+ async def handle_instruction(
+ self, instruction: FRBCInstruction, channel: S2Channel, send_okay: Awaitable
+ ):
+ await self.handle_with_original_handler(instruction, channel, send_okay)
+
+ await self.add_test_method(
+ "Receive Instruction", self.validate_instruction, instruction
+ )
+
+ self._received_instruction_event.set()
+
+ async def handle_revoke(
+ self, revoke_message: RevokeObject, channel: S2Channel, send_okay: Awaitable
+ ):
+ await self.handle_with_original_handler(revoke_message, channel, send_okay)
+
+ await self.add_test_method(
+ "Revoke PEBC Instruction.", self.validate_receive_revoke_message
+ )
+
+ async def validate_receive_revoke_message(self, revoke_message):
+ # The only thing that the CEM can revoke in an instruction.
+ self.assertIn(
+ revoke_message,
+ [RevokableObjects.FRBC_Instruction],
+ "Invalid revoke message received.",
+ )
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/battery_scenario.py b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/battery_scenario.py
new file mode 100644
index 0000000..b4ba79d
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/battery_scenario.py
@@ -0,0 +1,430 @@
+import asyncio
+from dataclasses import dataclass
+import datetime
+from typing import Awaitable, Dict
+import uuid
+from connectivity.config import FRBCCEMTestConfig
+from connectivity.s2_channel import S2Channel
+from s2python.frbc import (
+ FRBCSystemDescription,
+ FRBCStorageDescription,
+ FRBCActuatorDescription,
+ FRBCOperationMode,
+ FRBCOperationModeElement,
+ FRBCLeakageBehaviour,
+ FRBCLeakageBehaviourElement,
+ FRBCUsageForecast,
+ FRBCUsageForecastElement,
+ FRBCActuatorStatus,
+ FRBCStorageStatus,
+ FRBCInstruction,
+)
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+ ReceptionStatus,
+ InstructionStatusUpdate,
+ InstructionStatus,
+)
+
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.controllers.cem import FRBCCEMController
+from testsuites.controllers.cem.frbc_controller import ActuatorInformation
+from testsuites.test_logger import TestLogger
+from testsuites.test_suite.test_suite import NotApplicableTestException, S2TestCase
+from testsuites.util import current_timezone_time
+from .base import FRBCCEMTestCase, FRBCCEMTestCase
+
+CHARGE_EFFICIENCY: float = 1.0
+DISCHARGE_EFFICIENCY: float = 1.0
+CAPACITY_WH: float = 20_000.0
+LEAKAGE_W: float = 0.5
+INITIAL_FILL_LEVEL: float = 0.5
+
+SIMULATION_DURATION = 20
+
+
+class FRBCBatteryScenarioTestCase(FRBCCEMTestCase):
+ name = "Battery Scenario Test Case"
+
+ actuator_initial_status: Dict[uuid.UUID, FRBCActuatorStatus] = {}
+
+ def __init__(
+ self,
+ config: FRBCCEMTestConfig,
+ channel: S2Channel,
+ controller: FRBCCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self.create_ev_example_frbc_system_description()
+
+ self.controller.set_leakage_behaviour(
+ FRBCLeakageBehaviour(
+ message_id=uuid.uuid4(),
+ valid_from=current_timezone_time(),
+ elements=[
+ FRBCLeakageBehaviourElement(
+ fill_level_range=NumberRange(start_of_range=0, end_of_range=1),
+ leakage_rate=(LEAKAGE_W / CAPACITY_WH) / 3600,
+ )
+ ],
+ )
+ )
+
+ self.fill_level = INITIAL_FILL_LEVEL
+
+ self._simulation_started = asyncio.Event()
+
+ def create_ev_example_frbc_system_description(self):
+ self.commodity = Commodity.ELECTRICITY
+ self.commodity_quantity = CommodityQuantity.ELECTRIC_POWER_L1
+
+ self.controller.set_storage_description(
+ FRBCStorageDescription(
+ diagnostic_label="Battery",
+ fill_level_label="Fraction, 0.0 to 1.0",
+ provides_leakage_behaviour=True,
+ provides_fill_level_target_profile=False,
+ provides_usage_forecast=True,
+ fill_level_range=NumberRange(start_of_range=0, end_of_range=1),
+ )
+ )
+
+ actuator = ActuatorInformation(
+ supported_commodities=[self.commodity],
+ )
+ self.actuator = actuator
+
+ idle_operation_mode = actuator.add_operation_mode(
+ FRBCOperationMode(
+ id=uuid.uuid4(),
+ elements=[
+ FRBCOperationModeElement(
+ fill_rate=NumberRange(start_of_range=0.0, end_of_range=0.0),
+ fill_level_range=NumberRange(
+ start_of_range=0.0, end_of_range=1.0
+ ),
+ power_ranges=[
+ PowerRange(
+ start_of_range=0.0,
+ end_of_range=0.0,
+ commodity_quantity=self.commodity_quantity,
+ )
+ ],
+ )
+ ],
+ diagnostic_label="Idle",
+ abnormal_condition_only=False,
+ ),
+ name="idle",
+ )
+ discharge_operation_mode = actuator.add_operation_mode(
+ FRBCOperationMode(
+ id=uuid.uuid4(),
+ elements=[
+ FRBCOperationModeElement(
+ fill_rate=NumberRange(
+ start_of_range=0.5
+ * CHARGE_EFFICIENCY
+ * ((5000.0 / CAPACITY_WH) / 3600.0),
+ end_of_range=CHARGE_EFFICIENCY
+ * (5000.0 / CAPACITY_WH / 3600.0),
+ ),
+ fill_level_range=NumberRange(
+ start_of_range=0.0, end_of_range=1.0
+ ),
+ power_ranges=[
+ PowerRange(
+ start_of_range=0.5 * 5_000,
+ end_of_range=5_000,
+ commodity_quantity=self.commodity_quantity,
+ )
+ ],
+ )
+ ],
+ diagnostic_label="Idle",
+ abnormal_condition_only=False,
+ ),
+ name="discharge",
+ )
+
+ charge_operation_mode = actuator.add_operation_mode(
+ FRBCOperationMode(
+ id=uuid.uuid4(),
+ elements=[
+ FRBCOperationModeElement(
+ fill_rate=NumberRange(
+ start_of_range=DISCHARGE_EFFICIENCY
+ * ((5000.0 / CAPACITY_WH) / 3600.0),
+ end_of_range=0.5
+ * DISCHARGE_EFFICIENCY
+ * (5000.0 / CAPACITY_WH / 3600.0),
+ ),
+ fill_level_range=NumberRange(
+ start_of_range=0.0, end_of_range=1.0
+ ),
+ power_ranges=[
+ PowerRange(
+ start_of_range=-5_000,
+ end_of_range=0.5 * -5_000,
+ commodity_quantity=self.commodity_quantity,
+ )
+ ],
+ )
+ ],
+ diagnostic_label="Idle",
+ abnormal_condition_only=False,
+ ),
+ name="charge",
+ )
+
+ self.last_updated = current_timezone_time()
+ self.active_operation_mode = "idle"
+ self.operation_mode_factor = 0.5
+
+ # Idle <--> charging
+ actuator.add_transition(
+ Transition(
+ **{
+ "id": uuid.uuid4(),
+ "from": idle_operation_mode.id,
+ "to": charge_operation_mode.id,
+ "start_timers": [],
+ "blocking_timers": [],
+ "transition_duration": None,
+ "abnormal_condition_only": False,
+ }
+ ),
+ )
+ actuator.add_transition(
+ Transition(
+ **{
+ "id": uuid.uuid4(),
+ "from": charge_operation_mode.id,
+ "to": idle_operation_mode.id,
+ "start_timers": [],
+ "blocking_timers": [],
+ "transition_duration": None,
+ "abnormal_condition_only": False,
+ }
+ ),
+ )
+ # Idle <--> discharging
+ actuator.add_transition(
+ Transition(
+ **{
+ "id": uuid.uuid4(),
+ "from": idle_operation_mode.id,
+ "to": discharge_operation_mode.id,
+ "start_timers": [],
+ "blocking_timers": [],
+ "transition_duration": None,
+ "abnormal_condition_only": False,
+ }
+ ),
+ )
+ actuator.add_transition(
+ Transition(
+ **{
+ "id": uuid.uuid4(),
+ "from": discharge_operation_mode.id,
+ "to": idle_operation_mode.id,
+ "start_timers": [],
+ "blocking_timers": [],
+ "transition_duration": None,
+ "abnormal_condition_only": False,
+ }
+ ),
+ )
+
+ self.controller.add_actuator(actuator)
+ self.controller.generate_system_description()
+
+ self.actuator_initial_status[actuator.id] = FRBCActuatorStatus(
+ message_id=uuid.uuid4(),
+ actuator_id=actuator.id,
+ active_operation_mode_id=idle_operation_mode.id,
+ operation_mode_factor=0.5,
+ previous_operation_mode_id=None,
+ transition_timestamp=None,
+ )
+
+ async def update(self):
+ delta_time = current_timezone_time() - self.last_updated
+ self.last_updated = current_timezone_time()
+
+ # TODO: CHECK INSTRUCTION
+ self.test_logger.info("Running Step")
+ for actuator in self.controller.actuators.values():
+ current_operation_mode = self.controller.get_active_operation_mode(
+ actuator.id
+ )
+
+ instruction = self.controller.instructions.get_active_instruction(
+ actuator_id=actuator.id, timestamp=current_timezone_time()
+ )
+ if instruction is not None:
+ self.test_logger.info(f"Instruction found: {instruction}")
+ if (
+ instruction is not None
+ and instruction.operation_mode != current_operation_mode.id
+ ):
+ self.test_logger.info(f"Updating instruction status.")
+ await self.send_actuator_status(
+ FRBCActuatorStatus(
+ message_id=uuid.uuid4(),
+ active_operation_mode_id=instruction.operation_mode,
+ actuator_id=instruction.actuator_id,
+ operation_mode_factor=instruction.operation_mode_factor,
+ previous_operation_mode_id=current_operation_mode.id,
+ transition_timestamp=current_timezone_time(),
+ )
+ )
+
+ current_operation_mode = self.controller.get_active_operation_mode(
+ actuator.id
+ )
+
+ # Not sure what to do with the others here... only using 0
+ fill_rate_range = current_operation_mode.elements[0].fill_rate
+ fill_rate = (
+ fill_rate_range.start_of_range
+ + (fill_rate_range.end_of_range - fill_rate_range.start_of_range)
+ * self.operation_mode_factor
+ )
+ self.fill_level += fill_rate * delta_time.seconds
+
+ if self.fill_level > 1:
+ self.fill_level = 1
+ elif self.fill_level < 0:
+ self.fill_level = 0
+
+ self.test_logger.info("Updating storage status.")
+ await self.controller.update_storage_status(
+ self.channel,
+ FRBCStorageStatus(
+ message_id=uuid.uuid4(), present_fill_level=self.fill_level
+ ),
+ )
+
+ def forecast(self) -> FRBCUsageForecast:
+ return FRBCUsageForecast(
+ message_id=uuid.uuid4(),
+ start_time=current_timezone_time(),
+ elements=[
+ FRBCUsageForecastElement(
+ duration=Duration(1000 * 3600),
+ usage_rate_expected=0,
+ usage_rate_lower_68PPR=None,
+ usage_rate_lower_95PPR=None,
+ usage_rate_lower_limit=None,
+ usage_rate_upper_68PPR=None,
+ usage_rate_upper_95PPR=None,
+ usage_rate_upper_limit=None,
+ )
+ ],
+ )
+
+ async def validate_instruction(
+ self,
+ instruction: FRBCInstruction,
+ # status_update: InstructionStatusUpdate,
+ # status_update_reception_status: ReceptionStatus,
+ ):
+ self.assertIsNotNone(instruction)
+ self.assertEqual(type(instruction), FRBCInstruction)
+
+ # self.assertIsNotNone(status_update)
+ # self.assertEqual(type(status_update), FRBCInstruction)
+
+ # self.assertIsNotNone(status_update_reception_status)
+ # self.assertEqual(type(status_update_reception_status), ReceptionStatus)
+
+ async def send_storage_status(self):
+ storage_status = FRBCStorageStatus(
+ message_id=uuid.uuid4(), present_fill_level=self.fill_level
+ )
+
+ return await super().send_storage_status(storage_status)
+
+ async def send_initial_info(self):
+ for initial_status in self.actuator_initial_status.values():
+ await self.send_actuator_status(initial_status)
+
+ await self.send_storage_status()
+
+ await self.send_power_measurement(
+ PowerMeasurement(
+ message_id=uuid.uuid4(),
+ measurement_timestamp=current_timezone_time(),
+ values=[
+ PowerValue(commodity_quantity=self.commodity_quantity, value=0)
+ ],
+ )
+ )
+
+ forecast = self.forecast()
+ await self.send_usage_forecast(forecast)
+
+ async def execute_simulation_step(self):
+ await self.update()
+
+ async def change_to_discharging(self):
+ actuator = list(self.controller.actuators.values())[0]
+
+ discharge_om = actuator.get_named_operation_mode("discharge")
+ active_operation_mode_id = self.controller.actuator_status[
+ actuator.id
+ ].active_operation_mode_id
+
+ if active_operation_mode_id != discharge_om:
+ await self.send_actuator_status(
+ FRBCActuatorStatus(
+ message_id=uuid.uuid4(),
+ active_operation_mode_id=discharge_om.id,
+ actuator_id=actuator.id,
+ operation_mode_factor=0.5,
+ previous_operation_mode_id=active_operation_mode_id,
+ transition_timestamp=current_timezone_time(),
+ )
+ )
+
+ async def generate_tests(self):
+ await super().generate_tests()
+
+ await self.add_trigger_method(self.send_frbc_system_description)
+ await self.add_trigger_method(self.send_leakage_behaviour)
+
+ await self.add_trigger_method(self.send_initial_info)
+
+ NUM_SIMULATION_ITERATIONS = 2
+ SIMULATION_STEP_DURATION = 10
+ for i in range(NUM_SIMULATION_ITERATIONS):
+ await self.add_trigger_method(
+ self.execute_simulation_step, wait_time=SIMULATION_STEP_DURATION
+ )
+
+ await self.add_trigger_method(
+ self.change_to_discharging,
+ )
+
+ MAIN_NUM_SIMULATION_ITERATIONS = 20
+ for i in range(MAIN_NUM_SIMULATION_ITERATIONS):
+ await self.add_trigger_method(
+ self.execute_simulation_step, wait_time=SIMULATION_STEP_DURATION
+ )
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/ev_scenario.py b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/ev_scenario.py
new file mode 100644
index 0000000..3977ef8
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/frbc_test_cases/ev_scenario.py
@@ -0,0 +1,406 @@
+import asyncio
+from dataclasses import dataclass
+import datetime
+import uuid
+from connectivity.config import FRBCCEMTestConfig
+from connectivity.s2_channel import S2Channel
+from s2python.frbc import (
+ FRBCSystemDescription,
+ FRBCStorageDescription,
+ FRBCActuatorDescription,
+ FRBCOperationMode,
+ FRBCOperationModeElement,
+ FRBCLeakageBehaviour,
+ FRBCLeakageBehaviourElement,
+ FRBCUsageForecast,
+ FRBCUsageForecastElement,
+ FRBCActuatorStatus,
+ FRBCStorageStatus,
+ FRBCInstruction,
+)
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+)
+
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.controllers.cem import FRBCCEMController
+from testsuites.controllers.cem.frbc_controller import ActuatorInformation
+from testsuites.test_logger import TestLogger
+from testsuites.util import current_timezone_time
+from .base import FRBCCEMTestCase, FRBCCEMTestCase
+
+CHARGE_EFFICIENCY: float = 1.0
+CAPACITY_WH: float = 20_000.0
+INITIAL_FILL_LEVEL: float = 0.5
+
+
+class FRBCElectricVehicleScenarioTestCase(FRBCCEMTestCase):
+ name = "Electric Vehicle Scenario Test Case"
+
+ commodity = Commodity.ELECTRICITY
+ commodity_quantity = CommodityQuantity.ELECTRIC_POWER_L1
+
+ def __init__(
+ self,
+ config: FRBCCEMTestConfig,
+ channel: S2Channel,
+ controller: FRBCCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ async def create_ev_not_plugged_in_frbc_system_description(self):
+ self.controller.reset_system_description()
+
+ self.controller.set_storage_description(
+ FRBCStorageDescription(
+ diagnostic_label="Battery SoC",
+ fill_level_label="EV Battery SoC",
+ provides_leakage_behaviour=False,
+ provides_fill_level_target_profile=True,
+ provides_usage_forecast=False,
+ fill_level_range=NumberRange(start_of_range=0, end_of_range=100),
+ )
+ )
+
+ actuator = ActuatorInformation(
+ supported_commodities=[self.commodity],
+ )
+ self.controller.add_actuator(actuator)
+
+ off_operation_mode = actuator.add_operation_mode(
+ FRBCOperationMode(
+ id=uuid.uuid4(),
+ elements=[
+ FRBCOperationModeElement(
+ fill_level_range=NumberRange(
+ start_of_range=0.0, end_of_range=0.0
+ ),
+ fill_rate=NumberRange(start_of_range=0.0, end_of_range=0.0),
+ power_ranges=[
+ PowerRange(
+ start_of_range=0.0,
+ end_of_range=0.0,
+ commodity_quantity=self.commodity_quantity,
+ )
+ ],
+ )
+ ],
+ diagnostic_label="Off",
+ abnormal_condition_only=False,
+ ),
+ name="off",
+ )
+ self.controller.generate_system_description()
+
+ await self.send_frbc_system_description()
+
+ self.controller.set_leakage_behaviour(
+ FRBCLeakageBehaviour(
+ message_id=uuid.uuid4(),
+ valid_from=current_timezone_time(),
+ elements=[
+ FRBCLeakageBehaviourElement(
+ fill_level_range=NumberRange(start_of_range=0, end_of_range=0),
+ leakage_rate=0,
+ )
+ ],
+ )
+ )
+
+ await self.send_leakage_behaviour()
+
+ actuator_status = FRBCActuatorStatus(
+ message_id=uuid.uuid4(),
+ actuator_id=actuator.id,
+ active_operation_mode_id=off_operation_mode.id,
+ operation_mode_factor=0,
+ previous_operation_mode_id=None,
+ transition_timestamp=None,
+ )
+ await self.send_actuator_status(actuator_status)
+
+ storage_status = FRBCStorageStatus(
+ message_id=uuid.uuid4(), present_fill_level=0
+ )
+ self.last_updated = current_timezone_time()
+ await self.send_storage_status(storage_status)
+
+ async def create_ev_charging_ev_frbc_system_description(self):
+ self.controller.reset_system_description()
+
+ self.controller.set_storage_description(
+ FRBCStorageDescription(
+ diagnostic_label="Battery SoC",
+ fill_level_label="EV Battery SoC",
+ provides_leakage_behaviour=False,
+ provides_fill_level_target_profile=True,
+ provides_usage_forecast=False,
+ fill_level_range=NumberRange(start_of_range=0, end_of_range=100),
+ )
+ )
+
+ actuator = ActuatorInformation(
+ supported_commodities=[self.commodity],
+ )
+ self.controller.add_actuator(actuator)
+
+ off_operation_mode = actuator.add_operation_mode(
+ FRBCOperationMode(
+ id=uuid.uuid4(),
+ elements=[
+ FRBCOperationModeElement(
+ fill_level_range=NumberRange(
+ start_of_range=0.0, end_of_range=100.0
+ ),
+ fill_rate=NumberRange(start_of_range=0.0, end_of_range=0.0),
+ power_ranges=[
+ PowerRange(
+ start_of_range=0.0,
+ end_of_range=0.0,
+ commodity_quantity=self.commodity_quantity,
+ )
+ ],
+ )
+ ],
+ diagnostic_label="Off",
+ abnormal_condition_only=False,
+ ),
+ name="off",
+ )
+
+ charging_operation_mode = actuator.add_operation_mode(
+ FRBCOperationMode(
+ id=uuid.uuid4(),
+ elements=[
+ FRBCOperationModeElement(
+ fill_level_range=NumberRange(
+ start_of_range=0.0, end_of_range=100.0
+ ),
+ fill_rate=NumberRange(
+ start_of_range=0.5
+ * CHARGE_EFFICIENCY
+ * ((5000.0 / CAPACITY_WH) / 3600),
+ end_of_range=CHARGE_EFFICIENCY
+ * (5000.0 / CAPACITY_WH / 3600.0),
+ ),
+ power_ranges=[
+ PowerRange(
+ start_of_range=1400,
+ end_of_range=11000,
+ commodity_quantity=self.commodity_quantity,
+ )
+ ],
+ )
+ ],
+ diagnostic_label="Charging",
+ abnormal_condition_only=False,
+ ),
+ name="charging",
+ )
+
+ actuator.add_transition(
+ Transition(
+ **{
+ "id": uuid.uuid4(),
+ "from": off_operation_mode.id,
+ "to": charging_operation_mode.id,
+ "start_timers": [],
+ "blocking_timers": [],
+ "transition_duration": Duration(3000),
+ "abnormal_condition_only": False,
+ }
+ )
+ )
+ actuator.add_transition(
+ Transition(
+ **{
+ "id": uuid.uuid4(),
+ "from": charging_operation_mode.id,
+ "to": off_operation_mode.id,
+ "start_timers": [],
+ "blocking_timers": [],
+ "transition_duration": Duration(3000),
+ "abnormal_condition_only": False,
+ }
+ ),
+ )
+
+ self.controller.generate_system_description()
+ await self.send_frbc_system_description()
+
+ self.controller.set_leakage_behaviour(
+ FRBCLeakageBehaviour(
+ message_id=uuid.uuid4(),
+ valid_from=current_timezone_time(),
+ elements=[
+ FRBCLeakageBehaviourElement(
+ fill_level_range=NumberRange(start_of_range=0, end_of_range=0),
+ leakage_rate=0,
+ )
+ ],
+ )
+ )
+
+ await self.send_leakage_behaviour()
+
+ actuator_status = FRBCActuatorStatus(
+ message_id=uuid.uuid4(),
+ actuator_id=actuator.id,
+ active_operation_mode_id=charging_operation_mode.id,
+ operation_mode_factor=0.5,
+ previous_operation_mode_id=None,
+ transition_timestamp=current_timezone_time(),
+ )
+ await self.send_actuator_status(actuator_status)
+
+ storage_status = FRBCStorageStatus(
+ message_id=uuid.uuid4(), present_fill_level=INITIAL_FILL_LEVEL
+ )
+
+ self.last_updated = current_timezone_time()
+ await self.send_storage_status(storage_status)
+
+ async def execute_simulation_step(self):
+ delta_time = current_timezone_time() - self.last_updated
+ self.last_updated = current_timezone_time()
+
+ if self.controller.storage_status is None:
+ raise ValueError("Storage status must be set to start the simulation.")
+
+ fill_level = self.controller.storage_status.present_fill_level
+
+ # TODO: CHECK INSTRUCTION
+ self.test_logger.info("Running Step")
+ for actuator in self.controller.actuators.values():
+ if actuator.id in self.controller.actuator_status:
+ actuator_status = self.controller.actuator_status[actuator.id]
+ else:
+ self.test_logger.error("ACTUATOR NOT AVAILABLE")
+ continue
+
+ current_operation_mode = self.controller.get_active_operation_mode(
+ actuator.id
+ )
+
+ instruction = self.controller.instructions.get_active_instruction(
+ actuator_id=actuator.id, timestamp=current_timezone_time()
+ )
+ if instruction is not None:
+ self.test_logger.info(f"Instruction found: {instruction}")
+ if (
+ instruction is not None
+ and instruction.operation_mode != current_operation_mode.id
+ ):
+ self.test_logger.info(f"Updating instruction status.")
+ actuator_status = FRBCActuatorStatus(
+ message_id=uuid.uuid4(),
+ active_operation_mode_id=instruction.operation_mode,
+ actuator_id=instruction.actuator_id,
+ operation_mode_factor=instruction.operation_mode_factor,
+ previous_operation_mode_id=current_operation_mode.id,
+ transition_timestamp=current_timezone_time(),
+ )
+ await self.send_actuator_status(actuator_status)
+
+ current_operation_mode = self.controller.get_active_operation_mode(
+ actuator.id
+ )
+
+ # Not sure what to do with the others here... only using 0
+ fill_rate_range = current_operation_mode.elements[0].fill_rate
+ fill_rate = (
+ fill_rate_range.start_of_range
+ + (fill_rate_range.end_of_range - fill_rate_range.start_of_range)
+ * actuator_status.operation_mode_factor
+ )
+ fill_level += fill_rate * delta_time.seconds
+ await self.send_power_measurement(
+ PowerMeasurement(
+ message_id=uuid.uuid4(),
+ measurement_timestamp=current_timezone_time(),
+ values=[
+ PowerValue(
+ commodity_quantity=self.commodity_quantity, value=fill_rate
+ )
+ ],
+ )
+ )
+
+ if fill_level > 1:
+ fill_level = 1
+ elif fill_level < 0:
+ fill_level = 0
+
+ self.test_logger.info("Updating storage status.")
+ await self.send_storage_status(
+ FRBCStorageStatus(
+ message_id=uuid.uuid4(), present_fill_level=fill_level
+ ),
+ )
+
+ async def generate_tests(self):
+ await super().generate_tests()
+
+ await self.add_trigger_method(
+ self.create_ev_not_plugged_in_frbc_system_description, wait_time=10
+ )
+
+ NUM_SIMULATION_ITERATIONS = 2
+ SIMULATION_STEP_DURATION = 10
+ for i in range(NUM_SIMULATION_ITERATIONS):
+ await self.add_trigger_method(
+ self.execute_simulation_step, wait_time=SIMULATION_STEP_DURATION
+ )
+
+ await self.add_trigger_method(
+ self.create_ev_charging_ev_frbc_system_description, wait_time=10
+ )
+
+ NUM_SIMULATION_ITERATIONS = 2
+ SIMULATION_STEP_DURATION = 10
+ for i in range(NUM_SIMULATION_ITERATIONS):
+ await self.add_trigger_method(
+ self.execute_simulation_step, wait_time=SIMULATION_STEP_DURATION
+ )
+
+ # power_measurement = PowerMeasurement(
+ # message_id=uuid.uuid4(),
+ # measurement_timestamp=current_timezone_time(),
+ # values=[
+ # PowerValue(commodity_quantity=self.commodity_quantity, value=10000)
+ # ],
+ # )
+ # await self.add_trigger_method(
+ # self.send_power_measurement, power_measurement, wait_time=2
+ # )
+
+ # await self.add_trigger_method(
+ # None,
+ # self._received_instruction_event,
+ # wait_time=self.config.instruction_wait_timeout,
+ # )
+
+ # # Wait 10 seconds after receiving instruction
+ # await self.add_trigger_method(None, wait_time=10)
+
+ async def validate_instruction(self, instruction: FRBCInstruction):
+ self.test_logger.info("RECEIVED INSTRUCTION.")
+ self.test_logger.info(instruction)
+
+ self.assertIsNotNone(instruction)
+ self.assertEqual(type(instruction), FRBCInstruction)
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/not_controllable.py b/packages/test-suites/src/testsuites/test_suite/cem/not_controllable.py
new file mode 100644
index 0000000..68edf3a
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/not_controllable.py
@@ -0,0 +1,214 @@
+import uuid
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerForecastElement,
+ PowerForecastValue,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+ ReceptionStatus,
+ ReceptionStatusValues,
+)
+from s2python.message import S2Message
+
+
+from testsuites.certificate.certificate import ComplianceReport, TestResultStatus
+from testsuites.controllers.cem.not_controllable_controller import (
+ NotControllableCEMController,
+)
+from testsuites.util import current_timezone_time
+from testsuites.test_logger import TestLogger
+from connectivity.s2_channel import S2Channel
+
+from connectivity.config import BaseTestConfig
+
+from testsuites.test_suite.test_suite import S2TestCase
+
+
+class NotControllableCEMTestCase(S2TestCase):
+ name = "CEM Not Controllable Control Type Test Case"
+ control_type = ProtocolControlType.NOT_CONTROLABLE
+
+ TIMEOUT = 5
+
+ controller: NotControllableCEMController
+ config: BaseTestConfig
+
+ def __init__(
+ self,
+ config: BaseTestConfig,
+ channel: S2Channel,
+ controller: NotControllableCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ async def generate_tests(self):
+ await self.add_trigger_method(
+ self.send_valid_power_measurement_test, wait_time=0
+ )
+ # await self.add_trigger_method(self.send_power_forecast, wait_time=5)
+ await self.add_trigger_method(self.send_invalid_measurement, wait_time=0)
+ # await self.add_trigger_method(self.send_invalid_forecast, wait_time=5)
+
+ def update_resource_manager_details_precondition(
+ self, precondition_id: str | None = None
+ ):
+ """Tests the system description precondition.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ # TODO: Maybe a better way to do this, but the fact that the control type was selected suggests this is passed.
+
+ self.assertTrue(
+ True,
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Resource Manager Details' not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Resource Manager Details' is complete."
+ )
+
+ def get_system_description_commodity_quantities(self) -> list[CommodityQuantity]:
+ if self.controller.resource_manager_details is None:
+ raise ValueError("Resource Manager Details not set.")
+
+ return self.controller.resource_manager_details.provides_power_measurement_types
+
+ async def base_message_send_validate(
+ self, message: S2Message, reception_status: ReceptionStatus
+ ):
+ self.assertIsNotNone(message)
+ self.assertIsNotNone(reception_status)
+ self.assertEqual(reception_status.status, ReceptionStatusValues.OK)
+ self.assertNotEqual(type(message), ReceptionStatus)
+ self.assertEqual(message.message_id, reception_status.subject_message_id) # type: ignore
+
+ async def send_power_measurement(self, power_measurement: PowerMeasurement):
+ reception_status = await self.controller.send_power_measurement(
+ self.channel, power_measurement
+ )
+ await self.add_test_method(
+ "Update power measurement",
+ self.base_message_send_validate,
+ power_measurement,
+ reception_status,
+ )
+
+ async def send_power_forecast(self, power_forecast: PowerForecast):
+ reception_status = await self.controller.send_power_forecast(
+ self.channel, power_forecast
+ )
+ await self.add_test_method(
+ "Update power forecast",
+ self.base_message_send_validate,
+ power_forecast,
+ reception_status,
+ )
+
+ async def send_test_power_measurement(
+ self,
+ name: str,
+ commodity_quantities: list[CommodityQuantity],
+ expected_reception_status: ReceptionStatusValues,
+ ):
+ values = []
+
+ for commodity_quantity in commodity_quantities:
+ values.append(
+ PowerValue(
+ commodity_quantity=commodity_quantity,
+ value=0,
+ )
+ )
+
+ power_measurement = PowerMeasurement(
+ measurement_timestamp=current_timezone_time(),
+ message_id=uuid.uuid4(),
+ values=values,
+ )
+
+ reception_status = await self.controller.send_power_measurement(
+ self.channel, power_measurement
+ )
+
+ await self.add_test_method(
+ name,
+ self.validate_reception_status,
+ reception_status,
+ expected_reception_status,
+ # Since I'm this isn't that important it's a soft fail
+ fail_result_status=TestResultStatus.SOFT_FAIL,
+ )
+
+ async def send_valid_power_measurement_test(self):
+ await self.send_test_power_measurement(
+ "9.2.4. Communicate Power Measurement",
+ self.controller.resource_manager_details.provides_power_measurement_types,
+ ReceptionStatusValues.OK,
+ )
+
+ async def send_invalid_measurement(self):
+ all_commodities = set(list(CommodityQuantity))
+ supported_commodities = set(
+ self.controller.resource_manager_details.provides_power_measurement_types
+ )
+
+ not_supported_commodities = list(all_commodities - supported_commodities)
+
+ await self.send_test_power_measurement(
+ "9.2.4. Communicate Power Measurement - Not supported Commodity Quantity",
+ not_supported_commodities,
+ ReceptionStatusValues.INVALID_CONTENT,
+ )
+
+ async def validate_reception_status(
+ self, reception_status: ReceptionStatus, expected_status: ReceptionStatusValues
+ ):
+ self.update_resource_manager_details_precondition("9.2.4.2.")
+ self.assertEqual(reception_status.status, expected_status)
+
+ # TODO: Rethink
+ # @S2TestCase.test(name="9.2.5. Update Power Forecast")
+ # async def test_update_power_forecast(self):
+ # self.update_resource_manager_details_precondition("9.2.4.2.")
+
+ # commodity_quantities = self.get_system_description_commodity_quantities()
+
+ # elements = [
+ # PowerForecastElement(
+ # duration=Duration(3600.0),
+ # power_values=[
+ # PowerForecastValue(
+ # commodity_quantity=q,
+ # value_expected=100,
+ # value_lower_limit=None,
+ # value_upper_limit=None,
+ # value_lower_68PPR=None,
+ # value_upper_68PPR=None,
+ # value_lower_95PPR=None,
+ # value_upper_95PPR=None,
+ # )
+ # for q in commodity_quantities
+ # ],
+ # )
+ # ]
+
+ # power_forecast = PowerForecast(
+ # message_id=uuid.uuid4(),
+ # start_time=current_timezone_time(),
+ # elements=elements,
+ # )
+
+ # await self.controller.send_power_forecast(self.channel, power_forecast)
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/__init__.py b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/__init__.py
new file mode 100644
index 0000000..995c0ec
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/__init__.py
@@ -0,0 +1,2 @@
+from .pv_installation_scenario import PEBCPVPanelScenarioTestCase
+from .ev_scenario import PEBCElectricVehicleCurtailScenarioTestCase
\ No newline at end of file
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/base.py b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/base.py
new file mode 100644
index 0000000..f89cc44
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/base.py
@@ -0,0 +1,296 @@
+import abc
+import asyncio
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+from typing import Awaitable
+import uuid
+from connectivity.s2_channel import S2Channel
+
+from connectivity.config import PEBCCEMTestConfig
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.controllers.cem.pebc_controller import PEBCCEMController
+from testsuites.test_logger import TestLogger
+from testsuites.test_suite.cem.not_controllable import NotControllableCEMTestCase
+from testsuites.test_suite.test_suite import NotApplicableTestException, S2TestCase
+from testsuites.util import current_timezone_time
+
+logger = logging.getLogger(__name__)
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerForecastElement,
+ PowerForecastValue,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+ ReceptionStatusValues,
+ ReceptionStatus,
+ RevokableObjects,
+)
+from s2python.message import S2Message
+from s2python.pebc import (
+ PEBCPowerConstraints,
+ PEBCEnergyConstraint,
+ PEBCAllowedLimitRange,
+ PEBCInstruction,
+ PEBCPowerEnvelope,
+ PEBCPowerEnvelopeConsequenceType,
+ PEBCPowerEnvelopeElement,
+ PEBCPowerEnvelopeLimitType,
+)
+
+
+@dataclass
+class PowerForecastData:
+ commodity_quantity: CommodityQuantity
+ value: int
+ duration: int = 3600
+ variance: float = 0.5
+
+
+class PEBCBaseScenarioTestCase(NotControllableCEMTestCase):
+ control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL
+
+ TIMEOUT = 5
+
+ controller: PEBCCEMController
+ config: PEBCCEMTestConfig
+
+ _received_instruction_event: asyncio.Event
+
+ def __init__(
+ self,
+ config: PEBCCEMTestConfig,
+ channel: S2Channel,
+ controller: PEBCCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self._pebc_power_constraints_sent_event = asyncio.Event()
+ self._pebc_energy_constraints_sent_event = asyncio.Event()
+ self._received_instruction_event = asyncio.Event()
+
+ self.message_handlers[RevokeObject] = self.handle_revoke
+ self.message_handlers[PEBCInstruction] = self.handle_instruction
+
+ async def setup(self):
+ await super().setup()
+
+ self.assertEqual(
+ self.controller.control_type,
+ ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL,
+ "Precondition Activate Control Type not met",
+ )
+ self.test_logger.success("Precondition Activate Control Type Met")
+
+ def create_energy_constraint(
+ self,
+ upper_average_power,
+ lower_average_power,
+ commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
+ ):
+ return PEBCEnergyConstraint(
+ id=uuid.uuid4(),
+ message_id=uuid.uuid4(),
+ commodity_quantity=commodity_quantity,
+ upper_average_power=upper_average_power,
+ lower_average_power=lower_average_power,
+ valid_from=current_timezone_time(),
+ valid_until=current_timezone_time() + timedelta(hours=1),
+ )
+
+ def create_power_measurement(
+ self, power_values: list[tuple[CommodityQuantity, float]]
+ ) -> PowerMeasurement:
+ measurements = []
+ for commodity_quantity, value in power_values:
+ measurements.append(
+ PowerValue(
+ commodity_quantity=commodity_quantity,
+ value=value,
+ )
+ )
+
+ return PowerMeasurement(
+ message_id=uuid.uuid4(),
+ measurement_timestamp=current_timezone_time(),
+ values=measurements,
+ )
+
+ def update_power_constraints_precondition(self, precondition_id: str | None = None):
+ """Tests the power constraints have been set as precondition.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ # try:
+ self.assertTrue(
+ self._pebc_power_constraints_sent_event.is_set(),
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Power Constraint' not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Power Constraint' is complete."
+ )
+
+ def update_energy_constraints_precondition(
+ self, precondition_id: str | None = None
+ ):
+ """Tests the energy constraints have been set as precondition.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ # try:
+ self.assertTrue(
+ self._pebc_energy_constraints_sent_event.is_set(),
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Energy Constraint' not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Energy Constraint' is complete."
+ )
+
+ async def send_power_constraint(self, power_constraints: PEBCPowerConstraints):
+ reception_status = await self.controller.send_pebc_power_constraint(
+ self.channel, power_constraints
+ )
+
+ test_name = "9.3.1. Update Power Constraints"
+ if not self._pebc_power_constraints_sent_event.is_set():
+ test_name += " (Initial)"
+ self._pebc_power_constraints_sent_event.set()
+
+ await self.add_test_method(
+ test_name,
+ self.base_message_send_validate,
+ power_constraints,
+ reception_status,
+ )
+
+ async def revoke_power_constraint(self):
+ await self.controller.revoke_pebc_power_constraint(self.channel)
+ self._pebc_power_constraints_sent_event.clear()
+
+ async def send_energy_constraint(self, energy_constraint: PEBCEnergyConstraint):
+ reception_status = await self.controller.send_pebc_energy_constraint(
+ self.channel, energy_constraint
+ )
+
+ test_name = "Update Energy Constraints"
+ if not self._pebc_energy_constraints_sent_event.is_set():
+ test_name += " (Initial)"
+
+ self._pebc_energy_constraints_sent_event.set()
+
+ await self.add_test_method(
+ test_name,
+ self.base_message_send_validate,
+ energy_constraint,
+ reception_status,
+ )
+
+ async def revoke_energy_constraint(self):
+ await self.controller.revoke_pebc_energy_constraint(self.channel)
+ self._pebc_energy_constraints_sent_event.clear()
+
+ async def send_power_measurement(self, power_measurement: PowerMeasurement):
+ reception_status = await self.controller.send_power_measurement(
+ self.channel, power_measurement
+ )
+
+ await self.add_test_method(
+ "9.2.4. Communicate Power Measurement",
+ self.base_message_send_validate,
+ power_measurement,
+ reception_status,
+ )
+
+ async def send_power_forecast(self, power_forecast: PowerForecast):
+ reception_status = await self.controller.send_power_forecast(
+ self.channel, power_forecast
+ )
+
+ await self.add_test_method(
+ "9.2.5. Update Power Forecast",
+ self.base_message_send_validate,
+ power_forecast,
+ reception_status,
+ )
+
+ def create_power_forecast(
+ self, values: list[list[PowerForecastData]]
+ ) -> PowerForecast:
+ elements = []
+ for period_value in values:
+ for period_data in period_value:
+ elements.append(
+ PowerForecastElement(
+ duration=Duration(period_data.duration),
+ power_values=[
+ PowerForecastValue(
+ commodity_quantity=period_data.commodity_quantity,
+ value_expected=period_data.value,
+ value_lower_limit=period_data.value
+ - period_data.variance * period_data.value,
+ value_upper_limit=period_data.value
+ + period_data.variance * period_data.value,
+ value_lower_68PPR=None,
+ value_upper_68PPR=None,
+ value_lower_95PPR=None,
+ value_upper_95PPR=None,
+ )
+ ],
+ )
+ )
+
+ return PowerForecast(
+ message_id=uuid.uuid4(),
+ elements=elements,
+ start_time=current_timezone_time(),
+ )
+
+ @abc.abstractmethod
+ async def validate_instruction(self, instruction: PEBCInstruction):
+ pass
+
+ async def handle_instruction(
+ self, instruction: PEBCInstruction, channel: S2Channel, send_okay: Awaitable
+ ):
+
+ self.test_logger.info(instruction)
+
+ await self.add_test_method(
+ "Receive Instruction", self.validate_instruction, instruction
+ )
+
+ await send_okay
+ self._received_instruction_event.set()
+
+ async def handle_revoke(
+ self, revoke_message: RevokeObject, channel: S2Channel, send_okay: Awaitable
+ ):
+ await self.handle_with_original_handler(revoke_message, channel, send_okay)
+
+ await self.add_test_method(
+ "Revoke PEBC Instruction.", self.validate_receive_revoke_message
+ )
+
+ async def validate_receive_revoke_message(self, revoke_message):
+ # The only thing that the CEM can revoke in an instruction.
+ self.assertIn(
+ revoke_message,
+ [RevokableObjects.PEBC_Instruction],
+ "Invalid revoke message received.",
+ )
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/ev_scenario.py b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/ev_scenario.py
new file mode 100644
index 0000000..5c15b79
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/ev_scenario.py
@@ -0,0 +1,161 @@
+import asyncio
+import uuid
+from datetime import timedelta
+from connectivity.config import PEBCCEMTestConfig
+from connectivity.s2_channel import S2Channel
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.controllers.cem.pebc_controller import PEBCCEMController
+from testsuites.test_logger import TestLogger
+from .base import PEBCBaseScenarioTestCase, PowerForecastData
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerForecastElement,
+ PowerForecastValue,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+)
+from s2python.pebc import (
+ PEBCPowerConstraints,
+ PEBCEnergyConstraint,
+ PEBCAllowedLimitRange,
+ PEBCInstruction,
+ PEBCPowerEnvelope,
+ PEBCPowerEnvelopeConsequenceType,
+ PEBCPowerEnvelopeElement,
+ PEBCPowerEnvelopeLimitType,
+)
+from testsuites.util import current_timezone_time
+
+
+class PEBCElectricVehicleCurtailScenarioTestCase(PEBCBaseScenarioTestCase):
+ name = "Electric Vehicle Curtail Charging Scenario Test Case"
+
+ _received_instruction_event: asyncio.Event
+
+ def __init__(
+ self,
+ config: PEBCCEMTestConfig,
+ channel: S2Channel,
+ controller: PEBCCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self.create_power_constraint()
+
+ self._received_instruction_event = asyncio.Event()
+
+ def create_power_constraint(self):
+
+ self.upper_limit = PEBCAllowedLimitRange(
+ abnormal_condition_only=False,
+ commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
+ limit_type=PEBCPowerEnvelopeLimitType.UPPER_LIMIT,
+ range_boundary=NumberRange(start_of_range=0, end_of_range=2000),
+ )
+ self.lower_limit = PEBCAllowedLimitRange(
+ abnormal_condition_only=False,
+ commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
+ limit_type=PEBCPowerEnvelopeLimitType.LOWER_LIMIT,
+ range_boundary=NumberRange(start_of_range=0, end_of_range=0),
+ )
+
+ self.power_constraint = PEBCPowerConstraints(
+ message_id=uuid.uuid4(),
+ id=uuid.uuid4(),
+ allowed_limit_ranges=[self.lower_limit, self.upper_limit],
+ consequence_type=PEBCPowerEnvelopeConsequenceType.DEFER,
+ valid_from=current_timezone_time(),
+ valid_until=current_timezone_time() + timedelta(hours=1),
+ )
+
+ async def generate_tests(self):
+ await super().generate_tests()
+
+ await self.add_trigger_method(self.send_power_constraint, self.power_constraint)
+
+ off_energy_constraint = self.create_energy_constraint(0, 0)
+ await self.add_trigger_method(
+ self.send_energy_constraint, off_energy_constraint
+ )
+
+ await self.add_trigger_method(
+ self.send_power_forecast,
+ self.create_power_forecast(
+ [
+ [
+ PowerForecastData(
+ CommodityQuantity.ELECTRIC_POWER_L1, 0, variance=0
+ )
+ ],
+ [
+ PowerForecastData(
+ CommodityQuantity.ELECTRIC_POWER_L1, 0, variance=0
+ )
+ ],
+ ]
+ ),
+ )
+
+ await self.add_trigger_method(
+ self.send_power_measurement,
+ self.create_power_measurement([(CommodityQuantity.ELECTRIC_POWER_L1, 0)]),
+ )
+
+ await self.add_trigger_method(None, wait_time=10)
+
+ # Car plugged in. Starting to charge.
+ charging_energy_constraint = self.create_energy_constraint(2000, 1000)
+ await self.add_trigger_method(
+ self.send_energy_constraint, charging_energy_constraint
+ )
+
+ await self.add_trigger_method(
+ self.send_power_forecast,
+ self.create_power_forecast(
+ [
+ [
+ PowerForecastData(
+ CommodityQuantity.ELECTRIC_POWER_L1, 2000, variance=0
+ )
+ ],
+ [
+ PowerForecastData(
+ CommodityQuantity.ELECTRIC_POWER_L1, 2000, variance=0
+ )
+ ],
+ ]
+ ),
+ )
+
+ await self.add_trigger_method(
+ self.send_power_measurement,
+ self.create_power_measurement(
+ [(CommodityQuantity.ELECTRIC_POWER_L1, 2000)]
+ ),
+ )
+
+ await self.add_trigger_method(
+ None,
+ event=self._received_instruction_event,
+ wait_time=self.config.instruction_wait_timeout,
+ )
+
+ # Wait 10 seconds after receiving instruction
+ await self.add_trigger_method(None, wait_time=10)
+
+ await self.add_trigger_method(self.revoke_power_constraint)
+ await self.add_trigger_method(self.revoke_energy_constraint)
diff --git a/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/pv_installation_scenario.py b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/pv_installation_scenario.py
new file mode 100644
index 0000000..86e8f2d
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/cem/pebc_test_cases/pv_installation_scenario.py
@@ -0,0 +1,121 @@
+import asyncio
+from typing import Awaitable
+import uuid
+from datetime import timedelta
+from connectivity.config import PEBCCEMTestConfig
+from connectivity.s2_channel import S2Channel
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.controllers.cem.pebc_controller import PEBCCEMController
+from testsuites.test_logger import TestLogger
+from .base import PEBCBaseScenarioTestCase, PowerForecastData
+
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ EnergyManagementRole,
+ PowerForecast,
+ PowerForecastElement,
+ PowerForecastValue,
+ PowerMeasurement,
+ PowerValue,
+ ResourceManagerDetails,
+ Handshake,
+ NumberRange,
+ Commodity,
+ PowerRange,
+ CommodityQuantity,
+ Transition,
+ Duration,
+ RevokeObject,
+ RevokableObjects,
+)
+from s2python.pebc import (
+ PEBCPowerConstraints,
+ PEBCEnergyConstraint,
+ PEBCAllowedLimitRange,
+ PEBCPowerEnvelopeConsequenceType,
+ PEBCPowerEnvelopeElement,
+ PEBCPowerEnvelopeLimitType,
+ PEBCInstruction,
+)
+from testsuites.util import current_timezone_time
+
+
+class PEBCPVPanelScenarioTestCase(PEBCBaseScenarioTestCase):
+ name = "Photovoltaic Panel Scenario Test Case"
+
+ def __init__(
+ self,
+ config: PEBCCEMTestConfig,
+ channel: S2Channel,
+ controller: PEBCCEMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self.create_power_constraint()
+ self.create_energy_constraint()
+
+ def create_power_constraint(self):
+
+ self.upper_limit = PEBCAllowedLimitRange(
+ abnormal_condition_only=False,
+ commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
+ limit_type=PEBCPowerEnvelopeLimitType.UPPER_LIMIT,
+ range_boundary=NumberRange(start_of_range=0, end_of_range=0),
+ )
+ self.lower_limit = PEBCAllowedLimitRange(
+ abnormal_condition_only=False,
+ commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
+ limit_type=PEBCPowerEnvelopeLimitType.LOWER_LIMIT,
+ range_boundary=NumberRange(start_of_range=0, end_of_range=-2000),
+ )
+
+ self.power_constraint = PEBCPowerConstraints(
+ message_id=uuid.uuid4(),
+ id=uuid.uuid4(),
+ allowed_limit_ranges=[self.lower_limit, self.upper_limit],
+ consequence_type=PEBCPowerEnvelopeConsequenceType.VANISH,
+ valid_from=current_timezone_time(),
+ valid_until=current_timezone_time() + timedelta(hours=1),
+ )
+
+ def create_energy_constraint(self):
+ # PV doesn't have energy constraint
+ pass
+
+ async def generate_tests(self):
+ await super().generate_tests()
+
+ await self.add_trigger_method(
+ self.send_power_constraint, self.power_constraint, wait_time=0
+ )
+
+ await self.add_trigger_method(
+ self.send_power_forecast,
+ self.create_power_forecast(
+ [
+ [PowerForecastData(CommodityQuantity.ELECTRIC_POWER_L1, 0)],
+ ]
+ ),
+ wait_time=0,
+ )
+ await self.add_trigger_method(
+ self.send_power_measurement,
+ self.create_power_measurement(
+ [(CommodityQuantity.ELECTRIC_POWER_L1, -2000)]
+ ),
+ wait_time=0,
+ )
+
+ await self.add_trigger_method(
+ None,
+ event=self._received_instruction_event,
+ wait_time=self.config.instruction_wait_timeout,
+ )
+
+ # Wait 10 seconds after receiving instruction
+ await self.add_trigger_method(None, wait_time=10)
+
+ await self.add_trigger_method(self.revoke_power_constraint)
+ await self.add_trigger_method(self.revoke_energy_constraint)
diff --git a/packages/test-suites/src/testsuites/test_suite/rm/__init__.py b/packages/test-suites/src/testsuites/test_suite/rm/__init__.py
new file mode 100644
index 0000000..475c5a1
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/rm/__init__.py
@@ -0,0 +1,6 @@
+from .pebc_test_cases import (
+ PEBCTestCase,
+)
+from .frbc_test_cases import (
+ FRBCTestCase
+)
\ No newline at end of file
diff --git a/packages/test-suites/src/testsuites/test_suite/rm/base_test_case.py b/packages/test-suites/src/testsuites/test_suite/rm/base_test_case.py
new file mode 100644
index 0000000..36a2a34
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/rm/base_test_case.py
@@ -0,0 +1,117 @@
+import asyncio
+
+from connectivity.s2_channel import S2Channel
+
+from testsuites.test_suite import NotApplicableTestException
+from testsuites.controllers.controller import Controller
+from testsuites.test_logger import AbstractTestLogger
+from ...certificate.certificate import (
+ TestSuiteResults,
+ ComplianceReport,
+ TestResultStatus,
+)
+from testsuites.controllers import BaseRMController
+from testsuites.test_suite.test_suite import S2TestCase, TestLogger
+
+from s2python.common import (
+ PowerForecast,
+ PowerMeasurement,
+ ControlType as ProtocolControlType,
+)
+from connectivity.config import BaseTestConfig, NoSelectionRMTestConfig
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class NotControllableRMTestCase(S2TestCase):
+ control_type = ProtocolControlType.NOT_CONTROLABLE
+
+ controller: BaseRMController
+ config: NoSelectionRMTestConfig
+ name = "Not Controllable Control Tasks"
+
+ TIMEOUT = 5
+
+ async def setup(self):
+ await self.controller._resource_manager_details_received.wait()
+
+ self.message_handlers[PowerForecast] = self.handle_power_forecast
+ self.message_handlers[PowerMeasurement] = self.handle_power_measurements
+
+ async def handle_power_forecast(
+ self, message: PowerForecast, channel: "S2Channel", send_okay
+ ):
+ await self.add_test_method(
+ "9.2.5. Update Power Forecast", self.validate_power_forecast, message
+ )
+ await send_okay
+
+ async def handle_power_measurements(
+ self, message: PowerForecast, channel: "S2Channel", send_okay
+ ):
+ await self.add_test_method(
+ "9.2.4. Communicate Power Measurement",
+ self.validate_power_measurement,
+ message,
+ )
+ await send_okay
+
+ def update_resource_manager_details_precondition(
+ self, precondition_id: str | None = None
+ ):
+ """Tests the system description precondition.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ self.assertIsNotNone(
+ self.controller.resource_manager_details,
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Resource Manager Details' not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update Resource Manager Details' is complete."
+ )
+
+ async def validate_power_forecast(self, message: PowerForecast):
+ self.update_resource_manager_details_precondition("9.2.5.2.")
+
+ # Sanity check
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), PowerForecast)
+
+ if self.controller.resource_manager_details is None:
+ raise AssertionError("Resource Manager details not set in controller!")
+
+ self.assertFalse(
+ self.controller.resource_manager_details.provides_forecast,
+ "Received Power Forecast despite RM Details `provides_forecast` being false.",
+ )
+
+ for element in message.elements:
+ for value in element.power_values:
+ # TODO: Check if this is a valid check.
+ # ! Unsure
+ self.assertTrue(
+ value.commodity_quantity
+ not in self.controller.resource_manager_details.provides_power_measurement_types,
+ "Received forecast for commodity that isn't present in RM Details `provides_power_measurement_types`",
+ )
+
+ async def validate_power_measurement(self, message: PowerMeasurement):
+ self.update_resource_manager_details_precondition("9.2.5.2.")
+
+ # Sanity check
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), PowerMeasurement)
+
+ if self.controller.resource_manager_details is None:
+ raise AssertionError("Resource Manager details not set in controller!")
+
+ for value in message.values:
+ self.assertTrue(
+ value.commodity_quantity
+ not in self.controller.resource_manager_details.provides_power_measurement_types,
+ "Received power measurement for commodity that isn't present in RM Details `provides_power_measurement_types`",
+ )
diff --git a/packages/test-suites/src/testsuites/test_suite/rm/frbc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/rm/frbc_test_cases.py
new file mode 100644
index 0000000..219a81a
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/rm/frbc_test_cases.py
@@ -0,0 +1,356 @@
+import asyncio
+from dataclasses import field
+import logging
+from typing import Dict, List
+import uuid
+
+from testsuites.certificate.certificate import (
+ ComplianceReport,
+)
+from connectivity.config import FRBCRMTestConfig
+from testsuites.controllers import FRBCRMController
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ Transition,
+ InstructionStatus,
+ ReceptionStatusValues,
+ ReceptionStatus,
+)
+from testsuites.test_suite.test_suite import S2TestCase, TestLogger
+from connectivity.s2_channel import S2Channel
+from .base_test_case import NotControllableRMTestCase
+
+from s2python.frbc import (
+ FRBCActuatorStatus,
+ FRBCStorageStatus,
+ FRBCSystemDescription,
+ FRBCUsageForecast,
+ FRBCLeakageBehaviour,
+ FRBCInstruction,
+ FRBCOperationMode,
+ FRBCActuatorDescription,
+)
+from testsuites.util import current_timezone_time
+
+logger = logging.getLogger(__name__)
+
+
+def dfs_traverse_transition_graph(
+ current_op_mode: uuid.UUID,
+ operation_modes: Dict[uuid.UUID, FRBCOperationMode],
+ transitions: Dict[uuid.UUID, List[Transition]],
+) -> List[Transition]:
+ traversed = set()
+ steps: List[Transition] = []
+
+ stuck = False
+ while not stuck:
+ possible_transitions = transitions[current_op_mode]
+
+ for transition in possible_transitions:
+ if transition.id not in traversed:
+ break
+ if transition.id not in traversed:
+ logger.info(
+ "Transition from %s to %s",
+ operation_modes[current_op_mode].diagnostic_label,
+ operation_modes[transition.to].diagnostic_label,
+ )
+ current_op_mode = transition.to
+ steps.append(transition)
+ traversed.add(transition.id)
+ else:
+ stuck = True
+
+ return steps
+
+
+class FRBCTestCase(NotControllableRMTestCase):
+ name = "FRBC Test Case"
+ control_type = ProtocolControlType.FILL_RATE_BASED_CONTROL
+
+ controller: FRBCRMController
+ config: FRBCRMTestConfig
+
+ _system_description_received_event: asyncio.Event
+ _initial_storage_status: asyncio.Event
+
+ transitions_traversed: set
+
+ def __init__(
+ self,
+ config: FRBCRMTestConfig,
+ channel: S2Channel,
+ controller: FRBCRMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self._system_description_received_event = asyncio.Event()
+ self._initial_storage_status = asyncio.Event()
+
+ self.message_handlers[FRBCSystemDescription] = (
+ self.handle_frbc_system_description
+ )
+ self.message_handlers[FRBCUsageForecast] = self.handle_usage_forecast
+ self.message_handlers[FRBCLeakageBehaviour] = self.handle_leakage_behaviour
+ self.message_handlers[FRBCActuatorStatus] = self.handle_actuator_status
+ self.message_handlers[FRBCStorageStatus] = self.handle_storage_status
+
+ self.transitions_traversed = set()
+
+ async def generate_tests(self):
+ await super().generate_tests()
+
+ # Wait until the system description received since this handler will add additional triggers based on actuators
+ await self.add_trigger_method(
+ None, wait_time=5, event=self._system_description_received_event
+ )
+
+ # Wait until the initial storage status comes in since it's necessary for other checks.
+ await self.add_trigger_method(
+ None, wait_time=5, event=self._initial_storage_status
+ )
+
+ def update_system_description_precondition(
+ self, precondition_id: str | None = None
+ ):
+ """
+ A function that can be called at the start of a validation to check that
+ system description has been received as a precondition for the test.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ self.assertTrue(
+ self.controller._system_description_received.is_set(),
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update System Description' not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Update System Description' is complete."
+ )
+
+ async def handle_frbc_system_description(
+ self, message: FRBCSystemDescription, channel: "S2Channel", send_okay
+ ):
+ """
+ Handler for the FRBC System Description Message.
+ Uses the provided actuators to perform a Depth First Traversal of transitions graph.
+
+ """
+ await self.handle_with_original_handler(message, channel, send_okay)
+
+ await self.add_test_method(
+ "Update FRBC System Description", self.validate_system_description, message
+ )
+
+ for actuator in self.controller.actuators.values():
+ # Use a Graph Depth First Search to try traverse all of the operation modes.
+ # This is a very simple DFS implementation so it can get stuck.
+ # TODO: Use a more advanced traversal.
+ steps = dfs_traverse_transition_graph(
+ list(actuator.operation_modes.values())[0].id,
+ actuator.operation_modes,
+ actuator.from_transitions_map,
+ )
+ for transition in steps:
+ # Add a trigger which sends this instruction. Wait some time to see how the RM reacts
+ # TODO: Make time configurable
+ await self.add_trigger_method(
+ self.send_operation_mode_transition_instruction,
+ actuator.id,
+ transition.to,
+ 0,
+ wait_time=15,
+ )
+
+ logger.info("FRBC Test Received System Description.")
+ self._system_description_received_event.set()
+
+ async def send_operation_mode_transition_instruction(
+ self,
+ actuator_id: uuid.UUID,
+ operation_mode_id: uuid.UUID,
+ operation_mode_factor: int,
+ ):
+ instruction = FRBCInstruction(
+ message_id=uuid.uuid4(),
+ id=uuid.uuid4(),
+ abnormal_condition=False,
+ actuator_id=actuator_id,
+ execution_time=current_timezone_time(),
+ operation_mode=operation_mode_id,
+ operation_mode_factor=operation_mode_factor,
+ )
+
+ reception_status = await self.controller.send_instruction_message(
+ instruction, self.channel, raise_on_error=False
+ )
+
+ # Storage status is also passed here since this is added to a queue and by the time the validation is run the storage status might have changed.
+ await self.add_test_method(
+ "Test Correct Reception Status after sending Instruction.",
+ self.validate_instruction_reception_status,
+ instruction,
+ reception_status,
+ self.controller.storage_status,
+ )
+
+ async def validate_instruction_reception_status(
+ self,
+ instruction: FRBCInstruction,
+ reception_status: ReceptionStatus,
+ storage_status: FRBCStorageStatus,
+ ):
+
+ actuator = self.controller.actuators.get(instruction.actuator_id, None)
+ if actuator is None:
+ raise ValueError("Actuator not available.")
+
+ operation_mode = actuator.operation_modes[instruction.operation_mode]
+
+ within_operation_mode_range = False
+ for element in operation_mode.elements:
+ if (
+ storage_status is not None
+ and storage_status.present_fill_level
+ > element.fill_level_range.start_of_range
+ and storage_status.present_fill_level
+ < element.fill_level_range.end_of_range
+ ):
+ within_operation_mode_range = True
+ break
+
+ # This check makes sure that if the reception status is OK then the storage level must be within the op mode's allowed range
+ # and if the status is not OK then it should be outside of all of the allowed ranges.
+ self.assertTrue(
+ (
+ reception_status.status == ReceptionStatusValues.OK
+ and within_operation_mode_range
+ )
+ or (
+ reception_status.status != ReceptionStatusValues.OK
+ and not within_operation_mode_range
+ )
+ )
+
+ async def setup(self):
+ await self.controller._system_description_received.wait()
+
+ async def handle_usage_forecast(
+ self, message: FRBCUsageForecast, channel: "S2Channel", send_okay
+ ):
+ await self.handle_with_original_handler(message, channel, send_okay)
+ await self.add_test_method(
+ "9.6.5. Update Usage Forecast", self.validate_usage_forecast, message
+ )
+ await send_okay
+
+ async def handle_leakage_behaviour(
+ self, message: FRBCLeakageBehaviour, channel: "S2Channel", send_okay
+ ):
+ await self.handle_with_original_handler(message, channel, send_okay)
+ await self.add_test_method(
+ "9.6.3. Update Leakage Behaviour", self.validate_leakage_behaviour, message
+ )
+ await send_okay
+
+ async def handle_actuator_status(
+ self, message: FRBCLeakageBehaviour, channel: "S2Channel", send_okay
+ ):
+ await self.handle_with_original_handler(message, channel, send_okay)
+ await self.add_test_method(
+ "Update Actuator Status", self.validate_actuator_status, message
+ )
+
+ async def handle_storage_status(
+ self, message: FRBCStorageStatus, channel: "S2Channel", send_okay
+ ):
+ # Get the old storage status before passing the new one to the handler
+ await self.handle_with_original_handler(message, channel, send_okay)
+ await self.add_test_method(
+ "Update Storage Status",
+ self.validate_storage_status,
+ message,
+ )
+
+ # Just set it every time since we never reset it.
+ self._initial_storage_status.set()
+
+ async def validate_system_description(self, message: FRBCSystemDescription):
+ # Sanity checks
+ self.assertIsNotNone(message) # Cannot be none.
+ self.assertEqual(type(message), FRBCSystemDescription)
+
+ async def validate_leakage_behaviour(self, message: FRBCLeakageBehaviour):
+ self.update_system_description_precondition("9.6.3.2")
+
+ # Sanity checks
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), FRBCLeakageBehaviour)
+
+ if self.controller.system_description is None:
+ raise AssertionError("System Description not set on controller.")
+
+ self.assertTrue(
+ self.controller.system_description.storage.provides_leakage_behaviour,
+ "Received unexpected leakage behaviour.",
+ )
+
+ async def validate_actuator_status(self, message: FRBCActuatorStatus):
+ self.update_system_description_precondition()
+
+ # Sanity checks
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), FRBCActuatorStatus)
+
+ instruction, instruction_status_update = (
+ self.controller.get_latest_actuator_instruction(
+ message.actuator_id, succeeded=True
+ )
+ )
+
+ if instruction is None:
+ return
+
+ self.assertEqual(instruction.operation_mode, message.active_operation_mode_id)
+
+ async def validate_storage_status(
+ self,
+ message: FRBCStorageStatus,
+ ):
+ self.update_system_description_precondition()
+
+ # Sanity checks
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), FRBCStorageStatus)
+
+ if self.controller.system_description is None:
+ raise AssertionError("System Description not set on controller.")
+
+ fill_range = self.controller.system_description.storage.fill_level_range
+
+ self.assertTrue(
+ message.present_fill_level < fill_range.end_of_range
+ and message.present_fill_level > fill_range.start_of_range,
+ "Storage fill level is outside of allowed range!",
+ )
+
+ # Could check the general trajectory for the storage status here based on the current operation modes
+ # but this seems unnecessarily precise
+
+ async def validate_usage_forecast(self, message: FRBCUsageForecast):
+ self.update_system_description_precondition("9.6.5.2.")
+
+ # Sanity checks
+ self.assertIsNotNone(message)
+ self.assertEqual(type(message), FRBCUsageForecast)
+
+ if self.controller.system_description is None:
+ raise AssertionError("System Description not set on controller.")
+
+ self.assertTrue(
+ self.controller.system_description.storage.provides_usage_forecast,
+ "Received unexpected frbc usage forecast.",
+ )
diff --git a/packages/test-suites/src/testsuites/test_suite/rm/pebc_test_cases.py b/packages/test-suites/src/testsuites/test_suite/rm/pebc_test_cases.py
new file mode 100644
index 0000000..758c472
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/rm/pebc_test_cases.py
@@ -0,0 +1,344 @@
+import asyncio
+from dataclasses import dataclass
+import logging
+from typing import Dict, List, Optional, Tuple
+import uuid
+
+from testsuites.certificate.certificate import (
+ ComplianceReport,
+)
+from connectivity.config import PEBCRMTestConfig
+from connectivity.s2_channel import S2Channel
+from testsuites.controllers import PEBCRMController
+
+from testsuites.test_suite.rm.base_test_case import NotControllableRMTestCase
+from testsuites.test_suite.test_suite import (
+ NotApplicableTestException,
+ S2TestCase,
+ TestLogger,
+)
+from testsuites.certificate.certificate import (
+ TestResultStatus,
+)
+from s2python.common import (
+ ControlType as ProtocolControlType,
+ InstructionStatusUpdate,
+ CommodityQuantity,
+ NumberRange,
+ InstructionStatus,
+ ReceptionStatus,
+ Duration,
+)
+from s2python.pebc import (
+ PEBCAllowedLimitRange,
+ PEBCPowerConstraints,
+ PEBCPowerEnvelope,
+ PEBCPowerEnvelopeElement,
+ PEBCPowerEnvelopeLimitType,
+ PEBCEnergyConstraint,
+ PEBCInstruction,
+)
+
+from itertools import product
+
+from testsuites.util import current_timezone_time
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class InstructionTestState:
+ instruction: PEBCInstruction
+ reception_status: ReceptionStatus
+ expected_status: InstructionStatus
+ status_update: Optional[InstructionStatusUpdate] = None
+
+
+class PEBCTestCase(NotControllableRMTestCase):
+ name = "9.3. Power Envelope Based Control Tasks"
+ control_type = ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL
+ controller: PEBCRMController
+ config: PEBCRMTestConfig
+
+ _power_constraints_received_event: asyncio.Event
+ _energy_constraints_received_event: asyncio.Event
+
+ instruction_test_states: Dict[uuid.UUID, InstructionTestState] = {}
+
+ def __init__(
+ self,
+ config: PEBCRMTestConfig,
+ channel: S2Channel,
+ controller: PEBCRMController,
+ report: ComplianceReport,
+ logger: TestLogger,
+ ):
+ super().__init__(config, channel, controller, report, logger)
+
+ self._power_constraints_received_event = asyncio.Event()
+ self._energy_constraints_received_event = asyncio.Event()
+
+ self.message_handlers[PEBCEnergyConstraint] = self.handle_energy_constraints
+ self.message_handlers[PEBCPowerConstraints] = self.handle_power_constraints
+ self.message_handlers[InstructionStatusUpdate] = (
+ self.handle_instruction_status_update
+ )
+
+ async def generate_tests(self):
+ await super().generate_tests()
+
+ await self.add_trigger_method(
+ None, wait_time=None, event=self._power_constraints_received_event
+ )
+
+ if self.config.sends_energy_constraints:
+ await self.add_trigger_method(
+ None,
+ wait_time=self.config.energy_constraints_wait_timeout,
+ event=self._power_constraints_received_event,
+ )
+
+ async def control_type_set_pebc_precondition(
+ self, precondition_id: str | None = None
+ ):
+ """Tests the system description precondition.
+
+ Args:
+ precondition_id (string): The ID from the S2 Specification
+ """
+ # try:
+ self.assertEqual(
+ self.controller.control_type,
+ ProtocolControlType.POWER_ENVELOPE_BASED_CONTROL,
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Activate Control Type' where ControlType is PEBC not complete.",
+ )
+ self.test_logger.success(
+ f"{precondition_id + ' ' if precondition_id is not None else '' }Task Precondition 'Activate Control Type' where ControlType is PEBC is complete.",
+ )
+
+ async def handle_power_constraints(
+ self, message: PEBCPowerConstraints, channel: "S2Channel", send_okay
+ ):
+ await self.handle_with_original_handler(message, channel, send_okay)
+
+ await self.add_test_method(
+ "Update PEBC Power Constraint", self.validate_power_constraints, message
+ )
+
+ # First phase of testing is just letting the RM send readings. Energy constraints will be waited for first.
+ await self.add_trigger_method(
+ None, wait_time=self.config.instruction_trigger_wait_time, event=None
+ )
+
+ # Generate curtailment instruction triggers.
+ await self.generate_set_limit_range_instruction_triggers()
+
+ self._power_constraints_received_event.set()
+
+ async def handle_energy_constraints(
+ self, message: PEBCEnergyConstraint, channel: "S2Channel", send_okay
+ ):
+ await self.handle_with_original_handler(message, channel, send_okay)
+
+ await self.add_test_method(
+ "Update PEBC Energy Constraint", self.validate_energy_constraints, message
+ )
+ self._energy_constraints_received_event.set()
+
+ async def handle_instruction_status_update(
+ self, message: InstructionStatusUpdate, channel: "S2Channel", send_okay
+ ):
+ await send_okay
+
+ await self.add_test_method(
+ "Validate Instruction Status Update",
+ self.validate_instruction_status_update,
+ message,
+ )
+
+ async def validate_instruction_status_update(
+ self, instruction_status_update: InstructionStatusUpdate
+ ):
+
+ self.assertIsNotNone(instruction_status_update)
+ self.assertEqual(type(instruction_status_update), InstructionStatusUpdate)
+
+ state = self.instruction_test_states.get(
+ instruction_status_update.instruction_id, None
+ )
+
+ if state is None:
+ raise AssertionError("Instruction not recognised. State doesn't exist.")
+
+ instruction = state.instruction
+
+ # Sanity Checks
+ self.assertIsNotNone(instruction)
+ self.assertEqual(type(instruction), PEBCInstruction)
+ self.assertEqual(instruction_status_update.instruction_id, instruction.id)
+
+ # Check that the status matches what we expect given the range we provided.
+ self.assertEqual(
+ instruction_status_update.status_type,
+ state.expected_status,
+ f"Instruction status {instruction_status_update.status_type} does not match expected {state.expected_status}",
+ )
+
+ async def validate_power_constraints(self, power_constraint: PEBCPowerConstraints):
+ await self.control_type_set_pebc_precondition("9.3.1.2.")
+
+ self.assertIsNotNone(power_constraint)
+ self.assertEqual(type(power_constraint), PEBCPowerConstraints)
+
+ async def validate_energy_constraints(
+ self,
+ energy_constraint: PEBCEnergyConstraint,
+ ):
+ await self.control_type_set_pebc_precondition("9.3.1.2.")
+ self.assertIsNotNone(energy_constraint)
+ self.assertEqual(type(energy_constraint), PEBCEnergyConstraint)
+
+ power_constraint = self.controller.get_power_constraint(
+ energy_constraint.valid_from, energy_constraint.valid_until
+ )
+
+ self.assertIsNotNone(
+ power_constraint,
+ f"Provided energy constraint does not fit withing the valid_from/to range of the power constraints.",
+ )
+
+ def create_power_envelope(
+ self, commodity_quantity, lower_limit, upper_limit, duration=3600
+ ) -> PEBCPowerEnvelope:
+ return PEBCPowerEnvelope(
+ id=str(uuid.uuid4()), # type: ignore
+ commodity_quantity=commodity_quantity,
+ power_envelope_elements=[
+ PEBCPowerEnvelopeElement(
+ lower_limit=lower_limit,
+ upper_limit=upper_limit,
+ duration=Duration(duration),
+ )
+ ],
+ )
+
+ async def send_power_envelope(
+ self,
+ commodity_quantity: CommodityQuantity,
+ lower_limit: NumberRange,
+ upper_limit: NumberRange,
+ duration: int,
+ expected_instruction_status: InstructionStatus,
+ ):
+ self.test_logger.info(
+ f"Sending instruction with expected instruction status {expected_instruction_status}"
+ )
+
+ power_envelope = self.create_power_envelope(
+ commodity_quantity=commodity_quantity,
+ lower_limit=lower_limit,
+ upper_limit=upper_limit,
+ duration=duration,
+ )
+
+ instruction, reception_status = (
+ await self.controller.send_power_envelope_instruction(
+ self.channel, [power_envelope]
+ )
+ )
+
+ self.instruction_test_states[instruction.id] = InstructionTestState(
+ instruction=instruction,
+ reception_status=reception_status,
+ expected_status=expected_instruction_status,
+ )
+
+ # Wait the specified instruction processing time before proceeding.
+ if (
+ self.controller.resource_manager_details
+ and self.config.wait_instruction_processing_time
+ ):
+ wait_time = (
+ self.controller.resource_manager_details.instruction_processing_delay.to_timedelta().seconds
+ )
+ await asyncio.sleep(wait_time)
+
+ async def generate_curtail_commodity_quantity_instruction_triggers(
+ self,
+ commodity_quantity: CommodityQuantity,
+ limit_ranges: List[PEBCAllowedLimitRange],
+ duration=3600,
+ ):
+ """This function generates a curtailment instruction trigger for each combination of upper and lower limits."""
+
+ logger.info("Curtailing %s", commodity_quantity)
+
+ lower_limits: List[NumberRange] = []
+ upper_limits: List[NumberRange] = []
+
+ for limit in limit_ranges:
+ 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)
+ )
+
+ limits = []
+ for lower_limit, upper_limit in limit_range_pairs:
+ 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),
+ ]
+
+ # Remove duplicates
+ limits = list(set(limits))
+
+ for lower, upper in limits:
+ await self.add_trigger_method(
+ self.send_power_envelope,
+ commodity_quantity=commodity_quantity,
+ lower_limit=lower,
+ upper_limit=upper,
+ duration=duration,
+ expected_instruction_status=InstructionStatus.SUCCEEDED,
+ wait_time=self.config.instruction_trigger_wait_time,
+ )
+
+ await self.add_trigger_method(
+ 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,
+ wait_time=self.config.instruction_trigger_wait_time,
+ )
+
+ async def generate_set_limit_range_instruction_triggers(self):
+ # TODO: During the time waiting for all of the instructions to send there could be a new power constraint. Not really sure how to solve this just yet.
+ power_constraints = self.controller.get_power_constraint(
+ current_timezone_time(), None
+ )
+
+ 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.generate_curtail_commodity_quantity_instruction_triggers(
+ commodity_quantity=commodity_quantity,
+ limit_ranges=ranges,
+ )
diff --git a/packages/test-suites/src/testsuites/test_suite/test_suite.py b/packages/test-suites/src/testsuites/test_suite/test_suite.py
new file mode 100644
index 0000000..86c6dc5
--- /dev/null
+++ b/packages/test-suites/src/testsuites/test_suite/test_suite.py
@@ -0,0 +1,475 @@
+import abc
+import asyncio
+from enum import Enum
+import functools
+import inspect
+import json
+import logging
+import time
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Awaitable,
+ Callable,
+ Coroutine,
+ Dict,
+ List,
+ Optional,
+ ParamSpec,
+ Tuple,
+ Type,
+ TypeVar,
+)
+import unittest
+
+from testsuites.certificate.certificate import (
+ TestSuiteResults,
+ TestResult,
+ ComplianceReport,
+ TestResultStatus,
+)
+from connectivity.config import BaseTestConfig, ControlTypeRMTestConfig, RoleTestConfig
+from testsuites.controllers.controller import Controller
+from s2python.common import ControlType as ProtocolControlType, EnergyManagementRole
+from s2python.message import S2Message
+from s2python.s2_validation_error import S2ValidationError
+from connectivity.channel import Channel
+from testsuites.test_logger import (
+ AbstractTestLogger,
+ TestLogger,
+ ServerTestLogger,
+ TestLoggerLevel,
+)
+
+from connectivity.s2_channel import S2Channel
+
+logger = logging.getLogger(__name__)
+
+
+class NotApplicableTestException(Exception):
+ """Raise in a test case if the situation has not arisen to properly test this."""
+
+
+class PreconditionNotMet(Exception):
+ """ "Raised when the preconditions of a test method haven't been met yet."""
+
+
+class S2TestCase(unittest.TestCase):
+ control_type: ProtocolControlType = ProtocolControlType.NO_SELECTION
+ config: BaseTestConfig
+
+ name: str
+
+ test_logger: AbstractTestLogger
+
+ TIMEOUT = 10
+
+ # tests: List[Tuple[str, Callable, Tuple, Dict, TestResultStatus]]
+ tests: asyncio.Queue[Tuple[str, Callable, Tuple, Dict, TestResultStatus]]
+
+ # A list tuples containing a method and a int duration (seconds). Each trigger is called after the timeout of the previous one is complete.
+ triggers: asyncio.Queue[
+ Tuple[
+ Optional[Callable[..., Awaitable]], # Trigger function
+ Optional[int], # Wait time. If event provided then timeout
+ Optional[asyncio.Event], # Trigger event
+ Tuple, # Args
+ Dict, # Kwargs
+ ]
+ ]
+
+ controller: Controller
+ config: BaseTestConfig
+
+ # Mechanism to allow temporary replacement of controller handler methods with test ones.
+ message_handlers: Dict[Type[S2Message], Callable[..., Awaitable[None]]] = {}
+ original_handlers: Dict[Type[S2Message], Callable[..., Awaitable[None]]] = {}
+
+ def __init__(
+ self,
+ config: BaseTestConfig,
+ channel: S2Channel,
+ controller: Controller,
+ report: ComplianceReport,
+ logger: AbstractTestLogger,
+ ):
+ super().__init__()
+ self.channel = channel
+ self.controller = controller
+ self.config = config
+ self.report = report
+
+ try:
+ if self.name is None:
+ raise Exception()
+ except:
+ raise ValueError(
+ f"Test case name must be declared as a constant for a test case class ({self.__class__})"
+ )
+
+ self.tests = asyncio.Queue()
+ self.triggers = asyncio.Queue()
+ self._triggers_complete_event = asyncio.Event()
+
+ self.test_logger = logger
+
+ self.test_logger.info(self.name, ident=0)
+
+ async def add_test_method(
+ self,
+ name,
+ method: Callable[..., Awaitable[None]],
+ *args,
+ fail_result_status=TestResultStatus.FAIL,
+ **kwargs,
+ ):
+ """
+ Add a test method to the list to be executed.
+ This is either executed in the __init__ function or in a `generate_tests` function.
+ In the init function we are adding static tests to our list of tests.
+ In the generate function we are adding parametrized tests.
+
+ Args:
+ name (str): Name of the test for the report and logs
+ method (Callable): A callable function that will tag the args and kwargs as parameters
+ fail_result_status (TestResultStatus): The status that the result should get on fail. Should be either FAIL or SOFT_FAIL
+ """
+
+ await self.tests.put((name, method, args, kwargs, fail_result_status))
+ # self.tests.append((name, method, args, kwargs, fail_result_status))
+
+ async def add_trigger_method(
+ self,
+ method: Callable[..., Awaitable[None]] | None,
+ *args,
+ wait_time: Optional[int] = None,
+ event: Optional[asyncio.Event] = None,
+ **kwargs,
+ ):
+ """The triggers are used to make events occur after a certain period of time.
+ For example send an instruction and wait a certain period to see how the device reacts.
+ The main test loop waits for the triggers task to complete and then exits.
+
+ Args:
+ method (Callable[..., Awaitable[None]] | None): _description_
+ wait_time (int, optional): _description_. Defaults to 30.
+ """
+ await self.triggers.put((method, wait_time, event, args, kwargs))
+
+ async def generate_tests(self):
+ """
+ This method is run just before the tests are to be executed.
+ This can be used to generate parametrized test cases.
+ Asynchronous tasks can be done/waited here in order to create the tests.
+ """
+
+ # Gather all the static tests and add them to the list of tests to be executed.
+ # The tests generated at runtime are added during the execution
+ for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
+ if getattr(method, "_is_test_method", False):
+ await self.add_test_method(method.test_name, method) # type: ignore
+
+ async def cleanup(self):
+ pass
+
+ def replace_controller_handlers(self):
+ for k, v in self.message_handlers.items():
+ if self.controller.handlers.get(k, None) is not None:
+ self.original_handlers[k] = self.controller.handlers[k]
+ self.controller.handlers[k] = v
+
+ async def handle_with_original_handler(
+ self, message: S2Message, channel: "S2Channel", send_okay: Awaitable
+ ):
+ if type(message) in self.original_handlers:
+ await self.original_handlers[type(message)](message, channel, send_okay)
+
+ def undo_controller_handler_replacement(self):
+ for k, v in self.message_handlers.items():
+ if self.original_handlers.get(k, None) is not None:
+ self.controller.handlers[k] = self.original_handlers[k]
+ elif k in self.controller.handlers:
+ # If there wasn't a handler there before then we remove the key
+ del self.controller.handlers[k]
+
+ async def handle_validation_error(
+ self, exception: S2ValidationError | json.JSONDecodeError
+ ):
+ self.test_logger.error(f"Failed to validate S2 Message: {exception}")
+ # TODO: Use this...
+
+ result = TestResult(
+ name=f"S2 Message Validation - {exception}",
+ status=TestResultStatus.FAIL,
+ duration=0,
+ message="Failed to validate S2 Message.",
+ )
+
+ @classmethod
+ def test(cls, name=None):
+ """
+ Decorator which designates a method as a static test case.
+ Used like: `@S2TestCase.test(name="Test Name")
+ """
+
+ 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."""
+ pass
+
+ async def teardown(self):
+ """Override in subclass for per-test teardown."""
+ pass
+
+ async def triggers_task(self):
+ """This is the triggers event loop. It iterates over each trigger in the triggers
+ list and performs them with the specified wait after each. It can also wait for events.
+ If an event and a wait time is provided then the wait time behaves as a timeout for the event wait.
+ Once there are no more triggers remaining the `_triggers_complete_event` will be set which will terminate the test loop.
+ """
+ try:
+ while True:
+ method, wait_time, event, args, kwargs = self.triggers.get_nowait()
+ if method is not None:
+ await method(*args, **kwargs)
+
+ if event is not None:
+ logger.info("Triggering task. Waiting until event set.")
+ if wait_time is not None:
+ try:
+ await asyncio.wait_for(event.wait(), wait_time)
+ except TimeoutError:
+ logger.info("Event not triggered. Timed out.")
+ continue
+ else:
+ await event.wait()
+ logger.info("Trigger complete.")
+ elif wait_time is not None:
+ logger.info("Triggering task. Waiting %d seconds.", wait_time)
+ await asyncio.sleep(wait_time)
+ logger.info("Trigger complete.")
+ elif method is None:
+ raise ValueError("Either event or wait time must be provided.")
+
+ except asyncio.QueueEmpty:
+ pass
+ finally:
+ self._triggers_complete_event.set()
+
+ async def run_test(
+ self, name: str, method: Callable, args: tuple, kwargs: dict, fail_result_status
+ ) -> TestResult:
+ case_start_time = time.time()
+ status = fail_result_status
+ message: Optional[str] = None
+ try:
+ await self.setup()
+ await method(*args, **kwargs)
+ await self.teardown()
+
+ self.test_logger.success(f"{name}")
+ status = TestResultStatus.PASS
+ except NotApplicableTestException as e:
+ message = str(e)
+ status = TestResultStatus.N_A
+ except AssertionError as e:
+ message = str(e)
+ self.test_logger.error(f"Assertion error: {e}")
+ except Exception as e:
+ message = str(e)
+ self.test_logger.error(f"Error: {e}")
+
+ case_end_time = time.time()
+
+ # Convert the test args to a dict to be added to the report.
+ report_parameters = {
+ **{f"arg_{index}": str(value) for index, value in enumerate(args)},
+ **{key: str(value) for key, value in kwargs.items()},
+ }
+ if report_parameters == {}:
+ report_parameters = None
+
+ result = TestResult(
+ name=name,
+ status=status,
+ duration=round(case_end_time - case_start_time, 2),
+ message=message,
+ parameters=report_parameters,
+ )
+
+ return result
+
+ async def execute(self) -> TestSuiteResults:
+ """Execute the tests from this test case and returns the result information for the report.
+
+ Returns:
+ TestSuiteResults: Result information.
+ """
+
+ # Control type set to the controller's control type since the NOT_CONTROLLABLE tests are run for each control type and could have different behaviors in each.
+ test_suite_result = TestSuiteResults(
+ name=self.name, control_type=self.controller.control_type
+ )
+
+ self._triggers_complete_event.clear()
+ asyncio.create_task(self.triggers_task())
+
+ if self.config.enabled:
+ # Put any handlers in place
+ self.replace_controller_handlers()
+ start_time = time.time()
+
+ # Generates the parametrized 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", self.name)
+
+ tests_executed = 0
+
+ # Now we run each test case that is in the tests list with the args provided.
+ logger.info("Starting test case: %s", self.__class__.__name__)
+
+ if self.triggers.empty():
+ await self.add_trigger_method(None, wait_time=30)
+
+ # Run the test loop until the triggers task is complete.
+ while not self._triggers_complete_event.is_set():
+ try:
+ name, method, args, kwargs, fail_result_status = (
+ await asyncio.wait_for(self.tests.get(), 1)
+ )
+
+ result = await self.run_test(
+ name, method, args, kwargs, fail_result_status
+ )
+
+ test_suite_result.add_test_result(result)
+ tests_executed += 1
+
+ except asyncio.TimeoutError:
+ continue
+
+ if tests_executed < 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.undo_controller_handler_replacement()
+ else:
+ self.test_logger.info("Skipping disabled test case.")
+ test_suite_result.status = TestResultStatus.N_A
+ test_suite_result.duration = None
+
+ return test_suite_result
+
+
+class TestSuite:
+ config: RoleTestConfig
+ test_cases: Dict[ProtocolControlType, List[Type[S2TestCase]]]
+ report: ComplianceReport
+
+ test_logger: AbstractTestLogger
+
+ def __init__(
+ self,
+ config: RoleTestConfig,
+ report: ComplianceReport,
+ test_logger: AbstractTestLogger,
+ ):
+ 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)
+ else:
+ self.test_cases[test_case.control_type] = [test_case]
+
+ async def execute(
+ self, channel: S2Channel, controller: Controller, role: EnergyManagementRole
+ ):
+ control_type = controller.control_type
+ try:
+ # ! THIS AVOIDS A PASS BY REFERENCE. Otherwise the following list concatenation modifies the self.test_cases
+ test_cases = self.test_cases[ProtocolControlType.NOT_CONTROLABLE].copy()
+ except KeyError:
+ test_cases = []
+
+ if control_type != ProtocolControlType.NOT_CONTROLABLE:
+ test_cases += self.test_cases.get(control_type, [])
+
+ logger.info(
+ "Executing test suite for %s control type. %s test cases to execute.",
+ control_type,
+ len(test_cases),
+ )
+ for TestCase in test_cases:
+ config = self.config.get_control_type_config(role, TestCase.control_type)
+
+ if config is None:
+ raise ValueError("No config passed.")
+
+ self.test_case = TestCase(
+ config, # type: ignore
+ channel,
+ controller,
+ self.report,
+ self.test_logger,
+ )
+
+ result = await self.test_case.execute()
+
+ if result is not None:
+ self.report.add_test_suite_result(result)
+
+ async def handle_s2_validation_error(
+ self, exception: S2ValidationError | json.JSONDecodeError
+ ):
+ if self.test_case is not None:
+ await self.test_case.handle_validation_error(exception)
+ else:
+ result = TestResult(
+ name=f"S2 Message Validation - {exception}",
+ status=TestResultStatus.FAIL,
+ duration=0,
+ message="Failed to validate S2 Message.",
+ )
+ suite_results = TestSuiteResults(
+ name="S2 Validation", control_type=None, duration=0
+ )
+ suite_results.add_test_result(result)
+ self.report.add_test_suite_result(suite_results)
+ self.test_logger.error("S2 Validation Error")
+
+
+class TestSuiteBuilder:
+ def __init__(
+ self,
+ config: RoleTestConfig,
+ report: ComplianceReport,
+ test_logger: AbstractTestLogger,
+ ):
+ self.test_suite = TestSuite(config, report, test_logger)
+
+ def with_test_case(self, test_case):
+ self.test_suite.add_test_case(test_case)
+ return self
+
+ def build(self):
+ return self.test_suite
diff --git a/packages/test-suites/src/testsuites/util.py b/packages/test-suites/src/testsuites/util.py
new file mode 100644
index 0000000..40d14c4
--- /dev/null
+++ b/packages/test-suites/src/testsuites/util.py
@@ -0,0 +1,73 @@
+import asyncio
+from datetime import datetime, timezone
+import logging
+from typing import Dict, Optional
+from pydantic import ValidationError
+from s2python.common import EnergyManagementRole
+
+logger = logging.getLogger(__name__)
+
+TIMEOUT = 1
+
+
+async def wait_for_event_or_stop(
+ event: asyncio.Event,
+ stop_event: asyncio.Event,
+ stop_check_timout: float = TIMEOUT,
+ timeout: Optional[int] = None,
+ 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.
+ """
+ time_waited = 0
+ while not (event.is_set() or stop_event.is_set()):
+ try:
+ await asyncio.wait_for(
+ asyncio.shield(event.wait()), timeout=stop_check_timout
+ )
+
+ 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
+
+ if timeout is not None and time_waited > timeout:
+ logger.info(f"Total timeout reached 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
+
+ time_waited += stop_check_timout
+
+from zoneinfo import ZoneInfo
+# TIMEZONE = timezone.utc
+TIMEZONE = ZoneInfo("Europe/Amsterdam")
+def current_timezone_time():
+ return datetime.now(tz=TIMEZONE)
+
+def pretty_print_pydantic_validation_error(exc : ValidationError):
+ pretty = []
+ for err in exc.errors():
+ loc = ".".join(str(x) for x in err["loc"])
+ msg = err["msg"]
+ typ = err["type"]
+ pretty.append(f" • {loc} [{typ}]: {msg!r}")
+ return "\n".join(pretty)
diff --git a/packages/test-suites/uv.lock b/packages/test-suites/uv.lock
new file mode 100644
index 0000000..d071765
--- /dev/null
+++ b/packages/test-suites/uv.lock
@@ -0,0 +1,572 @@
+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 = "cffi"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload_time = "2024-09-04T20:45:21.852Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload_time = "2024-09-04T20:43:30.027Z" },
+ { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload_time = "2024-09-04T20:43:32.108Z" },
+ { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload_time = "2024-09-04T20:43:34.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload_time = "2024-09-04T20:43:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload_time = "2024-09-04T20:43:38.586Z" },
+ { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload_time = "2024-09-04T20:43:40.084Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload_time = "2024-09-04T20:43:41.526Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload_time = "2024-09-04T20:43:43.117Z" },
+ { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload_time = "2024-09-04T20:43:45.256Z" },
+ { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload_time = "2024-09-04T20:43:46.779Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload_time = "2024-09-04T20:43:48.186Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload_time = "2024-09-04T20:43:49.812Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload_time = "2024-09-04T20:43:51.124Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload_time = "2024-09-04T20:43:52.872Z" },
+ { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload_time = "2024-09-04T20:43:56.123Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload_time = "2024-09-04T20:43:57.891Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload_time = "2024-09-04T20:44:00.18Z" },
+ { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload_time = "2024-09-04T20:44:01.585Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload_time = "2024-09-04T20:44:03.467Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload_time = "2024-09-04T20:44:05.023Z" },
+ { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload_time = "2024-09-04T20:44:06.444Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload_time = "2024-09-04T20:44:08.206Z" },
+ { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload_time = "2024-09-04T20:44:09.481Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload_time = "2024-09-04T20:44:10.873Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload_time = "2024-09-04T20:44:12.232Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload_time = "2024-09-04T20:44:13.739Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload_time = "2024-09-04T20:44:15.231Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload_time = "2024-09-04T20:44:17.188Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload_time = "2024-09-04T20:44:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload_time = "2024-09-04T20:44:20.248Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload_time = "2024-09-04T20:44:21.673Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload_time = "2024-09-04T20:44:23.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload_time = "2024-09-04T20:44:24.757Z" },
+ { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload_time = "2024-09-04T20:44:26.208Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload_time = "2024-09-04T20:44:27.578Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload_time = "2024-09-04T20:44:28.956Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload_time = "2024-09-04T20:44:30.289Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload_time = "2024-09-04T20:44:32.01Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload_time = "2024-09-04T20:44:33.606Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload_time = "2024-09-04T20:44:35.191Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload_time = "2024-09-04T20:44:36.743Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload_time = "2024-09-04T20:44:38.492Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload_time = "2024-09-04T20:44:40.046Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload_time = "2024-09-04T20:44:41.616Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload_time = "2024-09-04T20:44:43.733Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" },
+]
+
+[[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 = "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 = "cryptography"
+version = "45.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload_time = "2025-06-10T00:03:51.297Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload_time = "2025-06-10T00:02:38.826Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload_time = "2025-06-10T00:02:41.64Z" },
+ { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload_time = "2025-06-10T00:02:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload_time = "2025-06-10T00:02:45.334Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload_time = "2025-06-10T00:02:47.359Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload_time = "2025-06-10T00:02:49.412Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload_time = "2025-06-10T00:02:50.976Z" },
+ { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload_time = "2025-06-10T00:02:52.542Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload_time = "2025-06-10T00:02:54.63Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload_time = "2025-06-10T00:02:56.689Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload_time = "2025-06-10T00:02:58.467Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload_time = "2025-06-10T00:03:00.14Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload_time = "2025-06-10T00:03:01.726Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload_time = "2025-06-10T00:03:03.94Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload_time = "2025-06-10T00:03:05.589Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload_time = "2025-06-10T00:03:09.172Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload_time = "2025-06-10T00:03:10.835Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload_time = "2025-06-10T00:03:12.448Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload_time = "2025-06-10T00:03:13.976Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload_time = "2025-06-10T00:03:16.248Z" },
+ { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload_time = "2025-06-10T00:03:18.4Z" },
+ { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload_time = "2025-06-10T00:03:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload_time = "2025-06-10T00:03:22.563Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload_time = "2025-06-10T00:03:24.586Z" },
+ { url = "https://files.pythonhosted.org/packages/16/33/b38e9d372afde56906a23839302c19abdac1c505bfb4776c1e4b07c3e145/cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", size = 3580103, upload_time = "2025-06-10T00:03:26.207Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732, upload_time = "2025-06-10T00:03:27.896Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424, upload_time = "2025-06-10T00:03:29.992Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438, upload_time = "2025-06-10T00:03:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622, upload_time = "2025-06-10T00:03:33.491Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ab/e3a055c34e97deadbf0d846e189237d3385dca99e1a7e27384c3b2292041/cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", size = 3328911, upload_time = "2025-06-10T00:03:35.035Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload_time = "2025-06-10T00:03:36.982Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload_time = "2025-06-10T00:03:38.659Z" },
+ { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload_time = "2025-06-10T00:03:40.233Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload_time = "2025-06-10T00:03:41.827Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload_time = "2025-06-10T00:03:43.493Z" },
+ { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload_time = "2025-06-10T00:03:45.523Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload_time = "2025-05-10T17:42:51.123Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload_time = "2025-05-10T17:42:49.33Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[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 = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload_time = "2024-03-30T13:22:22.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload_time = "2024-03-30T13:22:20.476Z" },
+]
+
+[[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 = "pytest"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload_time = "2025-06-02T17:36:30.03Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload_time = "2025-06-02T17:36:27.859Z" },
+]
+
+[[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" },
+]
+
+[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", marker = "extra == 'ws'", specifier = "~=13.1" },
+]
+provides-extras = ["ws", "testing", "development", "docs"]
+
+[[package]]
+name = "test-suites"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "connectivity" },
+ { name = "cryptography" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "s2-python" },
+ { name = "websockets" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "connectivity", editable = "../connectivity" },
+ { name = "cryptography", specifier = ">=45.0.4" },
+ { name = "pydantic", specifier = ">=2.11.3" },
+ { name = "pyyaml", specifier = ">=6.0.2" },
+ { name = "s2-python", editable = "../s2-python" },
+ { name = "websockets", specifier = ">=13.1" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.4.0" }]
+
+[[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 = "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/renderer.html b/renderer.html
new file mode 100644
index 0000000..f482501
--- /dev/null
+++ b/renderer.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+ S2 Self Certifier Certificate
+
+
+
+
+
+
+
+
+
S2 Self Certifier Certificate
+
+
+
+
+
Overview
+
Timestamp: {{ timestamp }}
+
Result: TODO
+
+
+
+
+
Device Information
+
Manufacturer: {{ device.manufacturer }}
+
Name: {{ device.name }}
+
+
+
+
+
Test Suite Results
+
+
+
+
+
+
+
+
+
+
+
+
+
Message:
+
{{ test.message }}
+
+
+
Parameters:
+
+ -
+ {{ key }}: {{ value }}
+
+
+
+
No parameters.
+
+
+
+
+
+
No test methods found in this suite.
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..d3e5d17
--- /dev/null
+++ b/s2-self-cert-server/pyproject.toml
@@ -0,0 +1,25 @@
+[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",
+ "connectivity",
+ "test-suites",
+ "cryptography>=45.0.4",
+ "pytest-asyncio>=1.0.0",
+]
+
+
+[tool.uv.sources]
+test-suites = { path = "../packages/test-suites", editable = true }
+connectivity = { path = "../packages/connectivity", editable = true }
+
+[dependency-groups]
+dev = [
+ "pytest>=8.4.0",
+ "pytest-mock>=3.14.1",
+]
diff --git a/s2-self-cert-server/run.sh b/s2-self-cert-server/run.sh
new file mode 100755
index 0000000..3a3c407
--- /dev/null
+++ b/s2-self-cert-server/run.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+SERVER_KEY_PATH="./server_key.pem" KEYS_STORAGE_PATH="./data/keys.json" uv run fastapi dev ./src/main.py --port 8000 --host 0.0.0.0
\ No newline at end of file
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/certifier.py b/s2-self-cert-server/src/certifier.py
new file mode 100644
index 0000000..777f56a
--- /dev/null
+++ b/s2-self-cert-server/src/certifier.py
@@ -0,0 +1,288 @@
+import abc
+import asyncio
+import base64
+import json
+import os
+import threading
+from typing import Optional, Union
+from fastapi import Path
+from testsuites.certificate.certificate import ComplianceReport
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes
+from testsuites.message_handlers import CertificationMessageHandler
+import yaml
+from connectivity.channel import Channel
+from testsuites.util import wait_for_event_or_stop
+from testsuites.envelope_models import (
+ ServerMessageEnvelope,
+ CertificationEnvelope,
+ CertificationMessageType,
+ KeyRegistrationRequestMessage,
+ ChallengeMessage,
+ ChallengeProofMessage,
+ RawCertificateMessage,
+ ClientSignedCertificateMessage,
+ DoubleSignedCertificateMessage,
+ SignatureStatusResponseCertificateMessage,
+ StatusResponseEnum,
+ SignatureException,
+ CertificationMessage,
+ parse_certification_message,
+)
+from testsuites.certificate.signature import (
+ SimpleCertifier,
+ ReportSigner,
+ CertificationEncoder,
+ ServerReportSigner,
+)
+from pathlib import Path
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class KeyRepository(abc.ABC):
+ """Abstract key repository that is used to store the public keys registered by organisations who wish to certify their S2 Implementations."""
+
+ @abc.abstractmethod
+ def store_key(self, client_id: str, public_key: str):
+ """
+ Store a public key and it's associated client_id
+ """
+
+ @abc.abstractmethod
+ def get_key(self, client_id: str) -> Optional[str]:
+ """
+ Retrieve the public key for the given client_id.
+ Returns None if no key is stored for that client.
+ """
+
+
+class TextFileKeyRepository(KeyRepository):
+ """
+ A KeyRepository that persists client public keys in a JSON text file.
+ """
+
+ def __init__(self, file_path: Union[str, Path]):
+ self.file_path = Path(file_path)
+ self._lock = threading.Lock()
+
+ # Ensure the file exists and contains at least an empty JSON object
+ if not self.file_path.exists():
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
+ self.file_path.write_text("{}", encoding="utf-8")
+
+ def store_key(self, client_id: str, public_key: str):
+ logger.info("Storing key for client %s", client_id)
+
+ with self._lock:
+ # Read existing data
+ # TODO: This could be problematic if the file gets large.
+ try:
+ content = self.file_path.read_text(encoding="utf-8")
+ data = json.loads(content)
+ except (json.JSONDecodeError, OSError):
+ data = {}
+
+ # Update the key for this client
+ data[client_id] = public_key
+
+ # Atomically write back the full map
+ tmp_path = self.file_path.with_suffix(".tmp")
+ with tmp_path.open("w", encoding="utf-8") as tf:
+ json.dump(data, tf, indent=2)
+ tf.flush()
+ tmp_path.replace(self.file_path)
+
+ def get_key(self, client_id: str) -> Optional[str]:
+ """
+ Retrieve the public key for the given client_id.
+ Returns None if no key is stored for that client.
+ """
+ logger.info("Retrieving key for client %s", client_id)
+
+ with self._lock:
+ try:
+ content = self.file_path.read_text(encoding="utf-8")
+ data = json.loads(content)
+ except (json.JSONDecodeError, OSError):
+ return None
+
+ return data.get(client_id)
+
+
+class ServerSideCertificationHandler(CertificationMessageHandler):
+
+ _challenge_sent_event: asyncio.Event
+ _challenge_complete_event: asyncio.Event
+ _certificate_signing_complete: asyncio.Event
+
+ client_id: str
+ challenge_bytes: bytes
+ challenge_status: Optional[bool] = None
+
+ signer: ServerReportSigner
+ key_repository: KeyRepository
+
+ client_public_key: PublicKeyTypes
+
+ previous_message: Optional[CertificationMessage] = None
+
+ def __init__(self, signer: ServerReportSigner, key_repository: KeyRepository):
+ super().__init__()
+
+ self.signer = signer
+ self.key_repository = key_repository
+
+ self._challenge_sent_event = asyncio.Event()
+ self._challenge_complete_event = asyncio.Event()
+
+ self._certificate_signing_complete = asyncio.Event()
+
+ self.add_handler(
+ KeyRegistrationRequestMessage, self.handle_key_registration_request
+ )
+ self.add_handler(ChallengeProofMessage, self.handle_challenge_proof)
+ self.add_handler(
+ ClientSignedCertificateMessage, self.handle_client_signed_certificate
+ )
+
+ async def send_message(
+ self,
+ message: CertificationMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ await channel.send(CertificationEnvelope(message=message))
+ self.previous_message = message
+
+ def generate_challenge(self) -> bytes:
+ challenge_bytes = os.urandom(32)
+ return challenge_bytes
+
+ async def handle_key_registration_request(
+ self,
+ message: KeyRegistrationRequestMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ logger.info("Handling Key Registration Request")
+ self.client_id = message.client_id
+
+ # Key is already UTF-8 Encoded.
+ public_key_pem_str = CertificationEncoder.decode(message.public_key)
+
+ self.client_public_key = self.signer.load_public_key_from_pem(
+ public_key_pem_str.decode("utf-8")
+ )
+
+ self.challenge_bytes = self.generate_challenge()
+
+ challenge_bytes_b64 = CertificationEncoder.encode(self.challenge_bytes)
+
+ envelope = CertificationEnvelope(
+ message=ChallengeMessage(challenge=challenge_bytes_b64)
+ )
+
+ await channel.send(envelope)
+
+ self._challenge_sent_event.set()
+
+ async def handle_challenge_proof(
+ self,
+ message: ChallengeProofMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ logger.info("Handling Challenge Proof Message")
+
+ response_message = SignatureStatusResponseCertificateMessage(
+ status=StatusResponseEnum.SUCCESS,
+ message="Challenge Successful",
+ response_message_type=message.message_type,
+ )
+ try:
+ logger.info("Starting validation")
+
+ # Convert signature from base64 string back to bytes
+ signature_bytes = CertificationEncoder.decode(message.signature)
+
+ # Verify the signature using the client's public key
+ certificate_valid = self.signer.verify_bytes(
+ signature_bytes, self.challenge_bytes, self.client_public_key
+ )
+
+ if certificate_valid:
+ logger.info("Challenge Certificate is Valid.")
+ self.key_repository.store_key(
+ self.client_id,
+ CertificationEncoder.encode(
+ self.client_public_key.public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ ),
+ )
+ else:
+ logger.warning("Challenge Failed. Invalid Signature.")
+ response_message.message = "Challenge Failed. Invalid signature."
+ response_message.status = StatusResponseEnum.ERROR
+
+ except Exception as e:
+ certificate_valid = False
+ logger.exception("Challenge Failed. Invalid Signature.")
+ response_message.message = "Challenge Failed due to verification error."
+ response_message.status = StatusResponseEnum.ERROR
+
+ envelope = CertificationEnvelope(message=response_message)
+ self.challenge_status = certificate_valid
+
+ await channel.send(envelope)
+
+ self._challenge_complete_event.set()
+
+ async def begin_signing_process(
+ self, report: ComplianceReport, channel: Channel[ServerMessageEnvelope, str]
+ ):
+ logger.info("Starting Signing Process")
+ # Set the client ID to make sure it matches.
+ report.signature.client_id = self.client_id
+ envelope = CertificationEnvelope(
+ message=RawCertificateMessage(certificate=report)
+ )
+ await channel.send(envelope)
+
+ async def handle_client_signed_certificate(
+ self,
+ message: ClientSignedCertificateMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ logger.info("Handling Client Signed Certificate")
+ report = message.certificate
+
+ # Verify that the client signature is valid given the key provided at the start
+ if not self.signer.verify_client_signed(
+ report, self.client_id, self.client_public_key
+ ):
+ logger.info("Signature not valid. Sending ERROR response.")
+ envelope = CertificationEnvelope(
+ message=SignatureStatusResponseCertificateMessage(
+ status=StatusResponseEnum.ERROR,
+ message="Client Signature is not valid.",
+ response_message_type=CertificationMessageType.CLIENT_SIGNED_CERTIFICATE,
+ )
+ )
+ raise SignatureException("Client Signature not valid.")
+
+ logger.info("Signature is valid. Double Signing with server key.")
+ # Sign the report with the server certificate. Client signature is included in the body - double signed
+ report = self.signer.sign_report(report, self.client_id)
+
+ # Send the double signed report back to the client.
+ envelope = CertificationEnvelope(
+ message=DoubleSignedCertificateMessage(certificate=report)
+ )
+
+ await channel.send(envelope)
+
+ self._certificate_signing_complete.set()
diff --git a/s2-self-cert-server/src/executor.py b/s2-self-cert-server/src/executor.py
new file mode 100644
index 0000000..7fc34a2
--- /dev/null
+++ b/s2-self-cert-server/src/executor.py
@@ -0,0 +1,205 @@
+from importlib.metadata import version
+import json
+from typing import Optional
+import asyncio
+from connectivity.connection_adapter import ConnectionAdapter
+from testsuites.certification_executor import AbstractCertificationExecutor
+from connectivity.config import Config
+from connectivity.s2_channel import S2Channel
+from testsuites.test_executor import IntegrationTestExecutor
+from testsuites.setup import create_test_executor
+from testsuites.certificate.certificate import ComplianceReport, Signature
+
+from testsuites.envelope_models import (
+ ClientInfoControlMessage,
+ LogMessage,
+ ServerMessageEnvelope,
+ ControlMessage,
+ ConfigControlMessage,
+)
+from testsuites.test_logger import (
+ ServerTestLogger,
+)
+from connectivity.channel import Channel
+from testsuites.util import wait_for_event_or_stop
+
+import logging
+
+from .certifier import ServerSideCertificationHandler
+
+logger = logging.getLogger(__name__)
+
+
+class MessageQueueConnectionAdapter(ConnectionAdapter):
+ """This is the connection adapter that is passed to the integration test executor.
+ It is designed to mimic a regular connection adapter using a websocket but is actually
+ receiving S2 messages after they are received in an envelope from the client.
+ """
+
+ 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):
+ return await self.outgoing_queue.put(message)
+
+ 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]):
+ """
+ This channel writes messages sent to the `MessageQueueConnectionAdapter`.
+ For it's current purpose it should only receive JSON serialized S2 messages.
+ """
+
+ connection: MessageQueueConnectionAdapter
+
+ async def send(self, message: str):
+ """Writes a message to the incoming queue of the connection adapter."""
+ await self.connection.put_incoming(message)
+
+ async def receive(self) -> str:
+ """Pops a message off the outgoing message queue of the connection adapter."""
+ return await self.connection.get_next_outgoing()
+
+
+class ServerSideCertificationExecutor(AbstractCertificationExecutor):
+ s2_connection_adapter: ConnectionAdapter
+ config: Config
+
+ _config_received_event: asyncio.Event
+ _client_info_received_event: asyncio.Event
+
+ # This executes the tests. Needs to be run on it's own task. Receives and sends messages via the s2_connection_adapter
+ test_executor: IntegrationTestExecutor
+
+ # All certification messages are passed to this handler.
+ certification_handler: ServerSideCertificationHandler
+
+ report: ComplianceReport
+
+ def __init__(self, certification_handler):
+ super().__init__(certification_handler)
+
+ self._config_received_event = asyncio.Event()
+ self._client_info_received_event = asyncio.Event()
+
+ self.add_handler(ConfigControlMessage, self.handle_config_message)
+ self.add_handler(ClientInfoControlMessage, self.handle_client_info)
+
+ async def handle_config_message(self, message: ConfigControlMessage):
+ self.config = message.config
+ self._config_received_event.set()
+
+ async def handle_client_info(self, message: ClientInfoControlMessage):
+ """This message contains information about the client software, such as package versions to be checked.
+ Can be expanded in future to include additional checks.
+ """
+
+ connectivity_version = version("connectivity")
+ testsuites_version = version("test-suites")
+
+ client_info = message.client_info
+
+ if connectivity_version != client_info.connectivity_version:
+ self.test_logger.error(
+ 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:
+ self.test_logger.error(
+ 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}.",
+ )
+
+ self._client_info_received_event.set()
+
+ async def handle_control_message(self, message: ControlMessage):
+ await self.handle_message(message)
+
+ async def handle_log_message(self, message: LogMessage):
+ raise ValueError("Log message cannot be sent to the server!")
+
+ async def main_loop(self):
+
+ # Wait until the config is received before setting anything up.
+ await wait_for_event_or_stop(
+ self._config_received_event,
+ self._stop_event,
+ description="Config Received event.",
+ )
+
+ self.report = ComplianceReport(device=self.config.device_details)
+
+ # Use the standard setup method for the executor. This is the same one used on the client.
+ # Guarantees that the testing is as close to identical as possible.
+ self.test_executor = create_test_executor(self.config, self.test_logger)
+
+ # Setup the S2Channel which the executor uses. We are giving it a message queue conn. adapter
+ # so that we can write messages to it
+ 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 = await self.get_compliance_report()
+
+ logger.info("Starting signing process...")
+ if report is not None and self.certification_handler is not None:
+ report = await self.certification_handler.begin_signing_process(
+ report, self.server_channel
+ )
+
+ await wait_for_event_or_stop(
+ self.certification_handler._certificate_signing_complete,
+ self._stop_event,
+ )
+
+ logger.info("Report sent. Exiting Main Loop.")
+ else:
+ logger.error("Report is None. Cannot send. Exiting Main Loop...")
+
+ await self.stop()
+
+ async def run(
+ self,
+ server_channel: Optional[Channel[ServerMessageEnvelope, str]],
+ *args,
+ **kwargs,
+ ):
+ self._config_received_event.clear()
+ self._client_info_received_event.clear()
+
+ if server_channel is not None:
+ self.test_logger = ServerTestLogger(server_channel)
+ else:
+ raise ValueError("Server Channel is not set.")
+
+ self.s2_connection_adapter = MessageQueueConnectionAdapter()
+ s2_channel_mock = MockChannel(self.s2_connection_adapter)
+ return await super().run(s2_channel_mock, server_channel, *args, **kwargs)
+
+ async def get_compliance_report(self):
+ return await self.test_executor.get_compliance_report()
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..e7c8548
--- /dev/null
+++ b/s2-self-cert-server/src/main.py
@@ -0,0 +1,173 @@
+from contextlib import asynccontextmanager
+import os
+from fastapi import FastAPI, HTTPException
+import logging
+import logging.config
+from fastapi import UploadFile, WebSocket
+from pydantic import BaseModel
+from testsuites.certificate.certificate import ComplianceReport
+from testsuites.server_websocket_envelope_channel import (
+ ServerWebsocketConnectionChannel,
+)
+
+from testsuites.certificate.signature import (
+ ServerReportSigner,
+ CertificationEncoder,
+)
+import yaml
+from .certifier import (
+ KeyRepository,
+ ServerSideCertificationHandler,
+ TextFileKeyRepository,
+)
+from .executor import ServerSideCertificationExecutor
+from .log import LOGGING_CONFIG
+from .ws_adapter import FastAPIWebSocketAdapter
+
+logging.config.dictConfig(LOGGING_CONFIG)
+logger = logging.getLogger(__name__)
+
+SERVER_KEY_PATH = os.environ.get("SERVER_KEY_PATH", "./server_key.pem")
+KEYS_STORAGE_PATH = os.environ.get("KEYS_STORAGE_PATH", "./keys.json")
+
+
+signer: ServerReportSigner | None = None
+public_key_repository: KeyRepository | None = None
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ global signer
+ global public_key_repository
+
+ # Used to sign and verify things with the server's public key.
+ logger.info(f"Loading Server Key from `{SERVER_KEY_PATH}`")
+ signer = ServerReportSigner(SERVER_KEY_PATH)
+
+ # The place where the public keys for the organisations are stored.
+ # Creates a relationship between a given organisation and their public key.
+ # This can be changed to a different storage later if necessary.
+ logger.info(f"Client Keys File at `{KEYS_STORAGE_PATH}`")
+ public_key_repository = TextFileKeyRepository(KEYS_STORAGE_PATH)
+
+ yield
+
+
+app = FastAPI(lifespan=lifespan)
+
+
+# This is the certification websocket endpoint.
+# The client S2 Self Cert instances should connect to this to perform remote testing and certification.
+# Once done the client will have a double signed testing certificate.
+@app.websocket("/certification")
+async def connect_tester(websocket: WebSocket):
+ global signer
+ global public_key_repository
+ if signer is None or public_key_repository is None:
+ logger.error("Signer and Key Repository not loaded...")
+ await websocket.close()
+ return
+
+ await websocket.accept()
+
+ # The wrapper around the FastAPI websocket for a consistent API with the python WebSockets package
+ connection = FastAPIWebSocketAdapter(websocket)
+
+ # The communication channel used to send and receive messages to the client via the above connection.
+ server_channel = ServerWebsocketConnectionChannel(connection)
+
+ certifier = ServerSideCertificationHandler(
+ key_repository=public_key_repository, signer=signer
+ )
+
+ # The central part! This is what coordinated the execution and the test suit and certification.
+ executor = ServerSideCertificationExecutor(certifier)
+
+ await executor.run(server_channel)
+
+ # Once the executor returns the testing and certification is done. Any cleanup should happen here.
+
+ logger.info("Disconnected WebSocket.")
+
+
+class CertificateStatusResponse(BaseModel):
+ valid: bool
+
+
+@app.post("/certificate/verify")
+async def verify_certificate(file: UploadFile) -> CertificateStatusResponse:
+ """
+ Used to verify a double signed certificate that was produced by the certification websocket endpoint.
+
+ Checks:
+ - server signature is valid
+ - client id is present in the key repository
+ - client signature valid with key from specified client id
+
+ Args:
+ file (UploadFile): The YAML file containing the certificate.
+
+ Returns:
+ Status Respon: _description_
+ """
+
+ global signer
+ global public_key_repository
+
+ if signer is None or public_key_repository is None:
+ logger.error("Signer and Key Repository not loaded...")
+ raise HTTPException(status_code=500, detail="Configuration issue on server.")
+
+ # Check valid content
+ if file.content_type not in ("text/yaml", "application/x-yaml", "text/x-yaml"):
+ raise HTTPException(
+ status_code=400, detail="Invalid file type. Please upload a YAML file."
+ )
+
+ # Read the file contents
+ raw = await file.read()
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ raise HTTPException(
+ status_code=400, detail="Unable to decode file as UTF-8 text."
+ )
+
+ # Parse YAML
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as e:
+ raise HTTPException(status_code=400, detail=f"YAML parsing error: {e}")
+
+ certificate = ComplianceReport.model_validate(data)
+
+ if certificate.signature.client_id is None:
+
+ raise HTTPException(
+ status_code=400, detail="Signature client id must be provided."
+ )
+
+ public_key_encoded_str = public_key_repository.get_key(
+ certificate.signature.client_id
+ )
+
+ if public_key_encoded_str is None:
+ raise HTTPException(
+ status_code=400, detail="No organisation exists with that client_id."
+ )
+
+ # Decode the public key from string to bytes
+ public_key_pem_str = CertificationEncoder.decode(public_key_encoded_str)
+
+ public_key = signer.load_public_key_from_pem(public_key_pem_str.decode("utf-8"))
+
+ result = signer.verify_double_signed(
+ certificate, certificate.signature.client_id, public_key
+ )
+
+ return CertificateStatusResponse(valid=result)
+
+
+@app.get("/healthcheck")
+def healthcheck() -> dict[str, str]:
+ return {"status": "OK"}
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..c04fe27
--- /dev/null
+++ b/s2-self-cert-server/src/ws_adapter.py
@@ -0,0 +1,52 @@
+from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
+from connectivity.connection_adapter import (
+ ConnectionAdapter,
+ ConnectionClosed,
+ ConnectionError,
+)
+
+
+class FastAPIWebSocketAdapter(ConnectionAdapter[str]):
+ """Wrap the FastAPI websocket in the adapter since the websockets package has a different API."""
+
+ def __init__(self, websocket: WebSocket):
+ self.connected = True
+ self.websocket = websocket
+
+ async def receive(self) -> str:
+ try:
+ 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}")
+
+ 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
+ self.connected = False
+ raise ConnectionClosed(f"Websocket is closed: {e}")
+ except Exception as e:
+ raise ConnectionError(f"Unknown websocket error: {e}")
+
+ @property
+ def open(self) -> bool:
+ return (
+ self.websocket.application_state == WebSocketState.CONNECTED
+ and self.connected
+ )
+
+ async def close(self, code: int = 1000, reason: str = ""):
+ try:
+ 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-server/tests/__init__.py b/s2-self-cert-server/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/s2-self-cert-server/tests/test_server_certification_handler.py b/s2-self-cert-server/tests/test_server_certification_handler.py
new file mode 100644
index 0000000..7712440
--- /dev/null
+++ b/s2-self-cert-server/tests/test_server_certification_handler.py
@@ -0,0 +1,110 @@
+import asyncio
+import base64
+from src.certifier import ServerSideCertificationHandler
+
+from testsuites.envelope_models import (
+ ServerMessageEnvelope,
+ CertificationEnvelope,
+ CertificationMessageType,
+ KeyRegistrationRequestMessage,
+ ChallengeMessage,
+ ChallengeProofMessage,
+ ChallengeStatusMessage,
+ CertificationMessage,
+ parse_certification_message,
+)
+from unittest.mock import AsyncMock
+
+import base64
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_handler(mocker):
+ mock_signer = mocker.Mock()
+ mock_key_repository = mocker.Mock()
+ mock_channel = mocker.Mock()
+
+ # Make the send method async
+ mock_channel.send = AsyncMock()
+
+ client_id = "client"
+ pub_key = "some key"
+
+ handler = ServerSideCertificationHandler(mock_signer, mock_key_repository)
+
+ key_reg_message = KeyRegistrationRequestMessage(
+ public_key=pub_key, client_id=client_id
+ )
+
+ await handler.handle_key_registration_request(key_reg_message, mock_channel)
+
+ # Verify channel.send was called once
+ mock_channel.send.assert_called_once()
+
+ # Get the actual argument passed to send()
+ call_args = mock_channel.send.call_args[0][0]
+
+ # Verify it's a CertificationEnvelope with ChallengeMessage
+ assert isinstance(call_args, CertificationEnvelope)
+ assert isinstance(call_args.message, ChallengeMessage)
+
+ # Verify the challenge bytes are correctly encoded
+
+ # Verify handler state was updated correctly
+ assert handler.client_id == client_id
+ assert handler.public_key_pem == pub_key
+ assert handler.challenge_bytes is not None
+
+ # Verify the event was set
+ assert handler._challenge_sent_event.is_set()
+
+
+@pytest.mark.asyncio
+async def test_handle_challenge_proof_success(mocker):
+ mock_signer = mocker.Mock()
+ mock_key_repository = mocker.Mock()
+ mock_channel = mocker.Mock()
+
+ mock_channel.send = AsyncMock()
+
+ handler = ServerSideCertificationHandler(mock_signer, mock_key_repository)
+
+ # Set up handler state (normally done by handle_key_registration_request)
+ handler.client_id = "test_client"
+ handler.public_key_pem = "test_public_key"
+ handler.challenge_bytes = b"test_challenge"
+
+ # Mock the signer methods
+ mock_public_key = mocker.Mock()
+ mock_signer.load_public_key_from_pem.return_value = mock_public_key
+ mock_signer.verify_bytes.return_value = True
+
+ # Create test message
+ test_signature = base64.b64encode(b"test_signature").decode("ascii")
+ proof_message = ChallengeProofMessage(signature=test_signature)
+
+ await handler.handle_challenge_proof(proof_message, mock_channel)
+
+ # Verify signer methods were called correctly
+ mock_signer.load_public_key_from_pem.assert_called_once_with("test_public_key")
+ mock_signer.verify_bytes.assert_called_once_with(
+ b"test_signature", b"test_challenge", mock_public_key
+ )
+
+ # Verify key was stored
+ mock_key_repository.store_key.assert_called_once_with(
+ "test_client", "test_public_key"
+ )
+
+ # Verify response was sent
+ mock_channel.send.assert_called_once()
+ call_args = mock_channel.send.call_args[0][0]
+ assert isinstance(call_args, CertificationEnvelope)
+ assert isinstance(call_args.message, ChallengeStatusMessage)
+ assert call_args.message.success is True
+ assert call_args.message.message == "Challenge Successful"
+
+ # Verify handler state
+ assert handler.challenge_status is True
+ assert handler._challenge_complete_event.is_set()
diff --git a/s2-self-cert-server/uv.lock b/s2-self-cert-server/uv.lock
new file mode 100644
index 0000000..cb039c0
--- /dev/null
+++ b/s2-self-cert-server/uv.lock
@@ -0,0 +1,1098 @@
+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 = "cffi"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload_time = "2024-09-04T20:45:21.852Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload_time = "2024-09-04T20:43:30.027Z" },
+ { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload_time = "2024-09-04T20:43:32.108Z" },
+ { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload_time = "2024-09-04T20:43:34.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload_time = "2024-09-04T20:43:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload_time = "2024-09-04T20:43:38.586Z" },
+ { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload_time = "2024-09-04T20:43:40.084Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload_time = "2024-09-04T20:43:41.526Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload_time = "2024-09-04T20:43:43.117Z" },
+ { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload_time = "2024-09-04T20:43:45.256Z" },
+ { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload_time = "2024-09-04T20:43:46.779Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload_time = "2024-09-04T20:43:48.186Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload_time = "2024-09-04T20:43:49.812Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload_time = "2024-09-04T20:43:51.124Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload_time = "2024-09-04T20:43:52.872Z" },
+ { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload_time = "2024-09-04T20:43:56.123Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload_time = "2024-09-04T20:43:57.891Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload_time = "2024-09-04T20:44:00.18Z" },
+ { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload_time = "2024-09-04T20:44:01.585Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload_time = "2024-09-04T20:44:03.467Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload_time = "2024-09-04T20:44:05.023Z" },
+ { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload_time = "2024-09-04T20:44:06.444Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload_time = "2024-09-04T20:44:08.206Z" },
+ { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload_time = "2024-09-04T20:44:09.481Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload_time = "2024-09-04T20:44:10.873Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload_time = "2024-09-04T20:44:12.232Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload_time = "2024-09-04T20:44:13.739Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload_time = "2024-09-04T20:44:15.231Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload_time = "2024-09-04T20:44:17.188Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload_time = "2024-09-04T20:44:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload_time = "2024-09-04T20:44:20.248Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload_time = "2024-09-04T20:44:21.673Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload_time = "2024-09-04T20:44:23.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload_time = "2024-09-04T20:44:24.757Z" },
+ { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload_time = "2024-09-04T20:44:26.208Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload_time = "2024-09-04T20:44:27.578Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload_time = "2024-09-04T20:44:28.956Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload_time = "2024-09-04T20:44:30.289Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload_time = "2024-09-04T20:44:32.01Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload_time = "2024-09-04T20:44:33.606Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload_time = "2024-09-04T20:44:35.191Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload_time = "2024-09-04T20:44:36.743Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload_time = "2024-09-04T20:44:38.492Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload_time = "2024-09-04T20:44:40.046Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload_time = "2024-09-04T20:44:41.616Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload_time = "2024-09-04T20:44:43.733Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" },
+]
+
+[[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 = "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 = "cryptography"
+version = "45.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload_time = "2025-06-10T00:03:51.297Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload_time = "2025-06-10T00:02:38.826Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload_time = "2025-06-10T00:02:41.64Z" },
+ { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload_time = "2025-06-10T00:02:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload_time = "2025-06-10T00:02:45.334Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload_time = "2025-06-10T00:02:47.359Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload_time = "2025-06-10T00:02:49.412Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload_time = "2025-06-10T00:02:50.976Z" },
+ { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload_time = "2025-06-10T00:02:52.542Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload_time = "2025-06-10T00:02:54.63Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload_time = "2025-06-10T00:02:56.689Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload_time = "2025-06-10T00:02:58.467Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload_time = "2025-06-10T00:03:00.14Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload_time = "2025-06-10T00:03:01.726Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload_time = "2025-06-10T00:03:03.94Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload_time = "2025-06-10T00:03:05.589Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload_time = "2025-06-10T00:03:09.172Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload_time = "2025-06-10T00:03:10.835Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload_time = "2025-06-10T00:03:12.448Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload_time = "2025-06-10T00:03:13.976Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload_time = "2025-06-10T00:03:16.248Z" },
+ { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload_time = "2025-06-10T00:03:18.4Z" },
+ { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload_time = "2025-06-10T00:03:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload_time = "2025-06-10T00:03:22.563Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload_time = "2025-06-10T00:03:24.586Z" },
+ { url = "https://files.pythonhosted.org/packages/16/33/b38e9d372afde56906a23839302c19abdac1c505bfb4776c1e4b07c3e145/cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", size = 3580103, upload_time = "2025-06-10T00:03:26.207Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732, upload_time = "2025-06-10T00:03:27.896Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424, upload_time = "2025-06-10T00:03:29.992Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438, upload_time = "2025-06-10T00:03:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622, upload_time = "2025-06-10T00:03:33.491Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ab/e3a055c34e97deadbf0d846e189237d3385dca99e1a7e27384c3b2292041/cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", size = 3328911, upload_time = "2025-06-10T00:03:35.035Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload_time = "2025-06-10T00:03:36.982Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload_time = "2025-06-10T00:03:38.659Z" },
+ { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload_time = "2025-06-10T00:03:40.233Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload_time = "2025-06-10T00:03:41.827Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload_time = "2025-06-10T00:03:43.493Z" },
+ { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload_time = "2025-06-10T00:03:45.523Z" },
+]
+
+[[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 = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[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 = "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 = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload_time = "2024-03-30T13:22:22.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload_time = "2024-03-30T13:22:20.476Z" },
+]
+
+[[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 = "pytest"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload_time = "2025-06-02T17:36:30.03Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload_time = "2025-06-02T17:36:27.859Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload_time = "2025-05-26T04:54:40.484Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload_time = "2025-05-26T04:54:39.035Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload_time = "2025-05-26T13:58:45.167Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload_time = "2025-05-26T13:58:43.487Z" },
+]
+
+[[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 = "../packages/s2-python" }
+dependencies = [
+ { name = "click" },
+ { name = "pydantic" },
+ { name = "pytz" },
+]
+
+[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", marker = "extra == 'ws'", specifier = "~=13.1" },
+]
+provides-extras = ["ws", "testing", "development", "docs"]
+
+[[package]]
+name = "s2-server"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "connectivity" },
+ { name = "cryptography" },
+ { name = "fastapi", extra = ["standard"] },
+ { name = "pytest-asyncio" },
+ { name = "python-multipart" },
+ { name = "test-suites" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-mock" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "connectivity", editable = "../packages/connectivity" },
+ { name = "cryptography", specifier = ">=45.0.4" },
+ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
+ { name = "pytest-asyncio", specifier = ">=1.0.0" },
+ { name = "python-multipart", specifier = ">=0.0.20" },
+ { name = "test-suites", editable = "../packages/test-suites" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.4.0" },
+ { name = "pytest-mock", specifier = ">=3.14.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 = "test-suites"
+version = "0.1.0"
+source = { editable = "../packages/test-suites" }
+dependencies = [
+ { name = "connectivity" },
+ { name = "cryptography" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "s2-python" },
+ { name = "websockets" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "connectivity", editable = "../packages/connectivity" },
+ { name = "cryptography", specifier = ">=45.0.4" },
+ { 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.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.4.0" }]
+
+[[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 = "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" },
+]
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/README.md b/s2-self-cert/README.md
new file mode 100644
index 0000000..eca56b2
--- /dev/null
+++ 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
diff --git a/s2-self-cert/data/config.example.yaml b/s2-self-cert/data/config.example.yaml
new file mode 100644
index 0000000..c384b08
--- /dev/null
+++ b/s2-self-cert/data/config.example.yaml
@@ -0,0 +1,41 @@
+device_details:
+ name: Some Device
+ manufacturer: ABCD
+ model: basic
+ firmware_version: "0"
+mode: certification # or testing
+certification:
+ client_id: "example"
+ uri: ws://localhost:8001/certification
+ key_path: ./org_key.pem
+connection:
+ mode: client # or server
+ host: 0.0.0.0
+ port: 8000
+ uri: wss://some-cem.com/connection-path
+report:
+ yaml: ./data/report.yaml
+ xml: ./data/report.xml
+ log_path: ./data/tests.log
+ message_log: ./data/messages.log
+ xml_soft_fail_is_fail: true
+ include_test_parameters: false
+
+roles:
+ rm:
+ enabled: false
+ not_controllable: null
+ pebc:
+ enabled : true
+ frbc:
+ enabled: true
+ cem:
+ enabled: true
+ not_controllable:
+ enabled: true
+ pebc:
+ enabled: false
+ instruction_wait_timeout: 0
+ frbc:
+ enabled: false
+ instruction_wait_timeout: 0
\ No newline at end of file
diff --git a/s2-self-cert/pyproject.toml b/s2-self-cert/pyproject.toml
new file mode 100644
index 0000000..916da4a
--- /dev/null
+++ b/s2-self-cert/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "s2selfcert"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.10"
+
+dependencies = [
+ "connectivity",
+ "cryptography>=45.0.4",
+ "test-suites",
+]
+
+[tool.uv.sources]
+test-suites = { path = "../packages/test-suites", editable = true }
+connectivity = { path = "../packages/connectivity", editable = true }
+
+[project.scripts]
+s2-self-cert = "s2selfcert.main:main"
diff --git a/s2-self-cert/src/s2selfcert/__init__.py b/s2-self-cert/src/s2selfcert/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/s2-self-cert/src/s2selfcert/certifier.py b/s2-self-cert/src/s2selfcert/certifier.py
new file mode 100644
index 0000000..d960169
--- /dev/null
+++ b/s2-self-cert/src/s2selfcert/certifier.py
@@ -0,0 +1,159 @@
+import asyncio
+from typing import Optional
+from testsuites.message_handlers import CertificationMessageHandler
+from connectivity.channel import Channel
+from testsuites.envelope_models import (
+ ServerMessageEnvelope,
+ CertificationEnvelope,
+ CertificationMessageType,
+ KeyRegistrationRequestMessage,
+ ChallengeMessage,
+ ChallengeProofMessage,
+ RawCertificateMessage,
+ ClientSignedCertificateMessage,
+ StatusResponseEnum,
+ DoubleSignedCertificateMessage,
+ SignatureStatusResponseCertificateMessage,
+ CertificationMessage,
+)
+from testsuites.certificate.signature import (
+ CertificationEncoder,
+ ClientReportSigner,
+)
+from testsuites.certificate.certificate import ComplianceReport
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ClientSideCertifier(CertificationMessageHandler):
+
+ # Performs all the cryptographic signing processes
+ signer: ClientReportSigner
+
+ _challenge_request_sent_event: asyncio.Event
+ _challenge_proof_sent_event: asyncio.Event
+ _challenge_complete_event: asyncio.Event
+
+ _signing_started_event: asyncio.Event
+ _signing_complete_event: asyncio.Event
+
+ signing_valid: Optional[bool] = None
+ signed_certificate: Optional[ComplianceReport] = None
+
+ client_id: str
+ challenge_status: Optional[bool] = None
+
+ previous_message: Optional[CertificationMessage] = None
+
+ def __init__(self, client_id: str, signer: ClientReportSigner):
+ super().__init__()
+
+ self.signer = signer
+ self.client_id = client_id
+
+ self._challenge_request_sent_event = asyncio.Event()
+ self._challenge_proof_sent_event = asyncio.Event()
+ self._challenge_complete_event = asyncio.Event()
+
+ self._signing_complete_event = asyncio.Event()
+ self._signing_started_event = asyncio.Event()
+
+ self.add_handler(ChallengeMessage, self.handle_challenge_message)
+ self.add_handler(RawCertificateMessage, self.handle_raw_certificate_message)
+ self.add_handler(
+ DoubleSignedCertificateMessage,
+ self.handle_double_signed_certificate_message,
+ )
+ self.add_handler(
+ SignatureStatusResponseCertificateMessage, self.handle_status_message
+ )
+
+ async def send_message(
+ self,
+ message: CertificationMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ await channel.send(CertificationEnvelope(message=message))
+ self.previous_message = message
+
+ async def send_key_registration_request(
+ self, channel: Channel[ServerMessageEnvelope, str]
+ ):
+ pub_key_bytes = self.signer.get_serialized_public_key()
+
+ message = KeyRegistrationRequestMessage(
+ public_key=CertificationEncoder.encode(pub_key_bytes),
+ client_id=self.client_id,
+ )
+ await self.send_message(message, channel)
+ self._challenge_request_sent_event.set()
+
+ async def handle_challenge_message(
+ self, message: ChallengeMessage, channel: Channel[ServerMessageEnvelope, str]
+ ):
+ challenge_bytes = CertificationEncoder.decode(message.challenge)
+
+ signature_bytes = self.signer.sign_bytes(challenge_bytes)
+
+ signature = CertificationEncoder.encode(signature_bytes)
+
+ message = ChallengeProofMessage(signature=signature)
+ await self.send_message(message, channel)
+
+ self._challenge_proof_sent_event.set()
+
+ async def handle_raw_certificate_message(
+ self,
+ message: RawCertificateMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ # Receive the unsigned report, sign it with the private key, send back to be signed by server
+ # Server will check if signature valid for key provided during Challenge
+
+ if not self._challenge_complete_event.is_set():
+ raise ValueError(
+ "Received raw certificate but challenge is not complete..."
+ )
+
+ certificate = self.signer.sign_report(message.certificate)
+
+ message = ClientSignedCertificateMessage(certificate=certificate)
+
+ await self.send_message(message, channel)
+
+ async def handle_double_signed_certificate_message(
+ self,
+ message: DoubleSignedCertificateMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ # Receive the certificate which has been signed by both this client and the server.
+ # This is just saved to a variable and can be read by an external class.
+
+ self.signing_valid = True
+ self.signed_certificate = message.certificate
+ self._signing_complete_event.set()
+
+ async def handle_status_message(
+ self,
+ message: SignatureStatusResponseCertificateMessage,
+ channel: Channel[ServerMessageEnvelope, str],
+ ):
+ # Status messages are sent at the end of the Challenge and during Certification if there is a problem
+ if (
+ self.previous_message.message_type
+ == CertificationMessageType.CHALLENGE_PROOF
+ and message.response_message_type
+ == CertificationMessageType.CHALLENGE_PROOF
+ ):
+ self.challenge_status = message.status == StatusResponseEnum.SUCCESS
+ self._challenge_complete_event.set()
+ elif (
+ self.previous_message.message_type
+ == CertificationMessageType.CLIENT_SIGNED_CERTIFICATE
+ and message.response_message_type
+ == CertificationMessageType.CLIENT_SIGNED_CERTIFICATE
+ ):
+ self.signing_valid = message.status == StatusResponseEnum.SUCCESS
+ self._signing_complete_event.set()
diff --git a/s2-self-cert/src/s2selfcert/log.py b/s2-self-cert/src/s2selfcert/log.py
new file mode 100644
index 0000000..6a6f678
--- /dev/null
+++ b/s2-self-cert/src/s2selfcert/log.py
@@ -0,0 +1,124 @@
+import datetime
+import json
+import logging
+import logging.config
+from typing import Dict
+
+
+class JsonFormatter(logging.Formatter):
+ def format(self, record):
+ # log_record = {
+ # "timestamp": datetime.datetime.fromtimestamp(record.created).isoformat(),
+ # "level": record.levelname,
+ # "message": record.getMessage(),
+ # "s2_message": record.__dict__["s2_message"],
+ # "sender": record.__dict__["sender"],
+ # "receiver": record.__dict__["receiver"],
+ # }
+
+ s2_message = record.__dict__["s2_message"]
+ sender = record.__dict__["sender"]
+ receiver = record.__dict__["receiver"]
+
+ message = f"{record.levelname}: {sender} -> {receiver} ({s2_message['message_type']})\n{json.dumps(s2_message, indent=2, default=str)}"
+
+ return message
+ # return json.dumps(log_record, default=str)
+
+
+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",
+ },
+ "message-logger": {
+ "()": "s2selfcert.log.JsonFormatter",
+ "fmt": "%(asctime)s [MESSAGE LOGGER] %(message)s",
+ },
+ "plain": {
+ "()": "logging.Formatter",
+ "fmt": "%(message)s",
+ },
+ },
+ "handlers": {
+ "console": {
+ "class": "logging.StreamHandler",
+ "formatter": "short",
+ "stream": "ext://sys.stdout",
+ },
+ "messages-handler": {
+ "class": "logging.StreamHandler",
+ "formatter": "message-logger",
+ "stream": "ext://sys.stdout",
+ },
+ "messages-file-handler": {
+ "class": "logging.FileHandler",
+ "formatter": "message-logger",
+ "filename": "messages.log",
+ "mode": "w",
+ },
+ },
+ "loggers": {
+ "": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
+ "connectivity": {
+ "handlers": ["console"],
+ "level": "INFO",
+ "propagate": False,
+ },
+ "ws_adapter": {
+ "handlers": ["console"],
+ "level": "DEBUG",
+ "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,
+ },
+ "messages": {
+ "handlers": ["messages-file-handler"],
+ "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/s2selfcert/main.py b/s2-self-cert/src/s2selfcert/main.py
new file mode 100644
index 0000000..d5c6427
--- /dev/null
+++ b/s2-self-cert/src/s2selfcert/main.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python
+
+"""Echo server using the asyncio API."""
+
+import argparse
+import asyncio
+import logging
+import logging.config
+import os
+
+from connectivity.config import Config, load_config, ConfigError
+from pydantic import ValidationError
+import yaml
+from s2selfcert.certifier import ClientSideCertifier
+from s2selfcert.log import get_log_config
+from s2selfcert.server import S2WebSocketClient, S2WebSocketServer
+from s2selfcert.server_side_certification_orchestrator import CertificationTestExecutor
+from testsuites.certification_executor import AbstractCertificationExecutor
+from testsuites.setup import create_test_executor
+from testsuites.test_suite import TestLogger
+from testsuites.certificate.signature import SimpleCertifier, ClientReportSigner
+from testsuites.util import pretty_print_pydantic_validation_error
+
+CONFIG_PATH = os.environ.get("CONFIG_PATH", None)
+
+parser = argparse.ArgumentParser(prog="S2 Self Cert")
+parser.add_argument("-c", "--config", default=None)
+
+logger = logging.getLogger(__name__)
+
+
+def create_server_certification_executor(
+ config: Config, test_logger: TestLogger
+) -> CertificationTestExecutor:
+ # openssl genpkey -algorithm RSA -out server_key.pem -pkeyopt rsa_keygen_bits:2048
+ if config.certification is None:
+ raise ConfigError(
+ "Private Key path, Client ID and Certification server URI must be supplied when in certification mode."
+ )
+
+ signer = ClientReportSigner(config.certification.key_path)
+ certification_handler = ClientSideCertifier("something", signer)
+ return CertificationTestExecutor(config, test_logger, certification_handler)
+
+
+class OnCompleteCallback:
+ # When the test executor completes this callback is triggered to perform any cleanup or final output
+ # Currently this exports the report
+ config: Config
+
+ def __init__(self, config: Config):
+ self.config = config
+
+ async def __call__(self, executor: AbstractCertificationExecutor):
+ logger.info("Callback executed.")
+ report = await executor.get_compliance_report()
+ report.export(self.config.report)
+
+
+async def run_application(args):
+
+ try:
+ # The log file is where the test logs will be written to. This applies to both local testing and remote certification!
+ logging.config.dictConfig(get_log_config())
+ except Exception as e:
+ print(e)
+ return
+
+ if args.config is not None:
+ config_path = args.config
+ elif CONFIG_PATH is not None:
+ config_path = CONFIG_PATH
+ else:
+ logger.error(
+ "Config path must be provided! This can be done using the command param or with the CONFIG_PATH env var."
+ )
+ return
+
+ # Load the config
+ try:
+ config: Config = load_config(config_path)
+ except yaml.YAMLError as exc:
+ logger.error(f"Failed to load config from YAML file. Is it: {exc}")
+ return
+ except ValidationError as exc:
+ logger.error(
+ "Failed to load config due to validation errors:\n%s",
+ pretty_print_pydantic_validation_error(exc),
+ )
+ return
+
+ # Reload log config since we now know if we need to write a test log.
+ logging.config.dictConfig(get_log_config(config.report.log_path))
+
+ # Setup the test logger wrapper.
+ # This wrapper is what allows for logging to come back in server certification mode
+ # When in certification mode log messages are passed to the logger instance passed as a param
+ test_logger = TestLogger(logger=logging.getLogger("test-suite-logger"))
+
+ # Setup the executor depending on the mode set in the config
+ if config.mode == "certification":
+ test_executor = create_server_certification_executor(config, test_logger)
+ elif config.mode == "testing":
+ test_executor = create_test_executor(config, test_logger)
+ else:
+ raise ValueError("Invalid mode.")
+
+ logger.info("-" * 40)
+ logger.info(f"Starting in {config.mode} mode.")
+
+ # Setup the callback which is run once the session is complete. In this case it exports the certificate to a file.
+ callback = OnCompleteCallback(config)
+
+ # Startup happens in different ways depending on the connection code
+ # 1. Server will listen on a specified port and host for incoming S2 Device Connections
+ # 2. Client will create an outgoing websocket connection to a S2 Device
+ if config.connection.mode == "server":
+ s2_server = S2WebSocketServer(
+ config.connection,
+ test_executor,
+ callback,
+ config.mode,
+ )
+ await s2_server.start()
+ else:
+ s2_client = S2WebSocketClient(
+ config.connection,
+ test_executor,
+ callback,
+ config.mode,
+ )
+ await s2_client.start()
+
+
+def main():
+ args = parser.parse_args()
+ asyncio.run(run_application(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/s2-self-cert/src/s2selfcert/server.py b/s2-self-cert/src/s2selfcert/server.py
new file mode 100644
index 0000000..b6e841c
--- /dev/null
+++ b/s2-self-cert/src/s2selfcert/server.py
@@ -0,0 +1,136 @@
+import asyncio
+import logging
+import signal
+from typing import Awaitable, Callable, 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 s2selfcert.ws_adapter import WebSocketConnectionAdapter
+from connectivity.channel import Channel, BaseChannel
+from connectivity.config import Config
+from connectivity.s2_channel import S2Channel
+from connectivity.config import ConnectionConfig, CertificationConfig
+
+from testsuites.certification_executor import AbstractCertificationExecutor
+
+logger = logging.getLogger(__name__)
+
+
+class S2WebSocketBase:
+ """
+ The base class which is used in both server and client mode to take
+ the websocket connection and start the test executor.
+ """
+
+ executor: AbstractCertificationExecutor
+ mode: Literal["testing", "certification"]
+
+ config: ConnectionConfig
+
+ _exit_event: asyncio.Event
+
+ def __init__(
+ self,
+ config: ConnectionConfig,
+ executor: AbstractCertificationExecutor,
+ on_complete_callback: Callable[
+ [AbstractCertificationExecutor], Awaitable[None]
+ ],
+ mode: Literal["testing", "certification"] = "testing",
+ ):
+ self._exit_event = asyncio.Event()
+
+ self.config = config
+ self.mode = mode
+
+ self.executor = executor
+
+ self.on_complete_callback = on_complete_callback
+
+ 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.
+ This system is only meant to handle one connected device.
+ """
+ if not self.executor.is_running():
+ logger.info("Connection to RM opened.")
+ 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."
+ )
+ # Base Channel just leaves them as JSON strings.
+ # The S2 Parsing is done on the server side.
+ s2_channel = BaseChannel(connection)
+
+ await self.executor.run(s2_channel)
+
+ logger.info("Executor complete. Running callback.")
+ if self.on_complete_callback is not None:
+ await self.on_complete_callback(self.executor)
+
+ logger.info("Connection closed.")
+
+ await self.stop()
+ else:
+ 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()
+
+ 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.start_with_connection, self.config.host, self.config.port
+ ) as ws_server:
+ 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.")
+
+ 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 {self.config.uri}")
+ try:
+ async with connect(self.config.uri) as websocket:
+ await self.start_with_connection(websocket)
+ except OSError:
+ logger.error(f"Failed to connect to WebSocket. Exiting...")
+ return
+
+ async def stop(self):
+ logger.info("Stopping...")
+ await self.executor.stop()
diff --git a/s2-self-cert/src/s2selfcert/server_side_certification_orchestrator.py b/s2-self-cert/src/s2selfcert/server_side_certification_orchestrator.py
new file mode 100644
index 0000000..eac191c
--- /dev/null
+++ b/s2-self-cert/src/s2selfcert/server_side_certification_orchestrator.py
@@ -0,0 +1,147 @@
+import asyncio
+import logging
+from typing import Awaitable, Callable, Coroutine, Dict, Optional, Type
+
+from testsuites.certificate.certificate import ComplianceReport
+from websockets.asyncio.client import connect
+from connectivity.channel import Channel
+from testsuites.server_websocket_envelope_channel import (
+ ServerWebsocketConnectionChannel,
+)
+from testsuites.certification_executor import AbstractCertificationExecutor
+from connectivity.config import Config
+from s2selfcert.certifier import ClientSideCertifier
+from s2selfcert.ws_adapter import WebSocketConnectionAdapter
+
+
+from testsuites.envelope_models import (
+ ServerMessageEnvelope,
+ ClientInfo,
+ ClientInfoControlMessage,
+ ConfigControlMessage,
+ ControlMessageEnvelope,
+)
+from testsuites.util import wait_for_event_or_stop
+from testsuites.test_suite import TestLogger
+from testsuites.test_logger import AbstractTestLogger, TestLogger
+from importlib.metadata import version
+
+
+logger = logging.getLogger(__name__)
+
+
+class CertificationTestExecutor(AbstractCertificationExecutor):
+ """This is the executor which forwards all received S2 Messages to the Certification server."""
+
+ config: Config
+
+ report: Optional[ComplianceReport] = None
+
+ test_logger: TestLogger
+
+ certification_handler: ClientSideCertifier
+
+ def __init__(
+ self,
+ config: Config,
+ test_logger: AbstractTestLogger,
+ certification_handler: ClientSideCertifier,
+ ):
+ super().__init__(certification_handler)
+
+ self.config = config
+ self.test_logger = test_logger
+
+ async def handle_log_message(self, message):
+ if message.logger == "test":
+ self.test_logger.log(message.message, message.level, ident=message.ident)
+ else:
+ logger.info(
+ message.message,
+ )
+
+ async def connect_to_server(self) -> Channel[ServerMessageEnvelope, str]:
+ uri = self.config.certification.uri
+ 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 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)
+
+ await 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))
+
+ await self.certification_handler.send_key_registration_request(
+ self.server_channel
+ )
+
+ await wait_for_event_or_stop(
+ self.certification_handler._challenge_complete_event,
+ self._stop_event,
+ description="Received Report Event",
+ )
+
+ # exit if the challenge failed since it was invalid. Done from other side as well.
+ if not self.certification_handler.challenge_status:
+ logger.info("Invalid certificate challenge.")
+ await self.stop()
+ return
+
+ logger.info("Certificate Challenge Complete.")
+
+ await wait_for_event_or_stop(
+ self.certification_handler._signing_started_event,
+ self._stop_event,
+ description="Received Report Event",
+ )
+
+ logger.info("Testing complete. Signing has started.")
+
+ await wait_for_event_or_stop(
+ self.certification_handler._signing_complete_event,
+ self._stop_event,
+ description="Report Signing Complete Event",
+ )
+
+ logger.info("Singing complete!")
+
+ async def run(self, s2_channel, *args, **kwargs):
+ server_channel = await self.connect_to_server()
+
+ return await super().run(s2_channel, server_channel, *args, **kwargs)
+
+ async def get_compliance_report(self):
+ if self.certification_handler.signed_certificate is None:
+ # This state shouldn't really happen...
+ logger.info("Certificate is none. Waiting for signing to complete.")
+ await wait_for_event_or_stop(
+ self.certification_handler._signing_complete_event,
+ self._stop_event,
+ description="Report Signing Complete Event",
+ )
+
+ return self.certification_handler.signed_certificate
diff --git a/s2-self-cert/src/s2selfcert/ws_adapter.py b/s2-self-cert/src/s2selfcert/ws_adapter.py
new file mode 100644
index 0000000..b6c4571
--- /dev/null
+++ b/s2-self-cert/src/s2selfcert/ws_adapter.py
@@ -0,0 +1,63 @@
+import datetime
+import json
+from typing import Literal
+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[str]):
+ is_open = True
+
+ 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()
+ 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:
+ logger.exception("error whilst sending message")
+ raise ConnectionError(f"Unknown websocket error: {e}")
+
+ async def send(self, message: str):
+ try:
+ 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:
+ logger.exception("error whilst sending message")
+ raise ConnectionError(f"Unknown websocket error: {e}")
+
+ @property
+ def open(self) -> bool:
+ return self.is_open
+
+ 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}")
diff --git a/s2-self-cert/uv.lock b/s2-self-cert/uv.lock
new file mode 100644
index 0000000..8d50c16
--- /dev/null
+++ b/s2-self-cert/uv.lock
@@ -0,0 +1,479 @@
+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 = "cffi"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload_time = "2024-09-04T20:45:21.852Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload_time = "2024-09-04T20:43:30.027Z" },
+ { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload_time = "2024-09-04T20:43:32.108Z" },
+ { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload_time = "2024-09-04T20:43:34.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload_time = "2024-09-04T20:43:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload_time = "2024-09-04T20:43:38.586Z" },
+ { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload_time = "2024-09-04T20:43:40.084Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload_time = "2024-09-04T20:43:41.526Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload_time = "2024-09-04T20:43:43.117Z" },
+ { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload_time = "2024-09-04T20:43:45.256Z" },
+ { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload_time = "2024-09-04T20:43:46.779Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload_time = "2024-09-04T20:43:48.186Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload_time = "2024-09-04T20:43:49.812Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload_time = "2024-09-04T20:43:51.124Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload_time = "2024-09-04T20:43:52.872Z" },
+ { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload_time = "2024-09-04T20:43:56.123Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload_time = "2024-09-04T20:43:57.891Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload_time = "2024-09-04T20:44:00.18Z" },
+ { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload_time = "2024-09-04T20:44:01.585Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload_time = "2024-09-04T20:44:03.467Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload_time = "2024-09-04T20:44:05.023Z" },
+ { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload_time = "2024-09-04T20:44:06.444Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload_time = "2024-09-04T20:44:08.206Z" },
+ { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload_time = "2024-09-04T20:44:09.481Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload_time = "2024-09-04T20:44:10.873Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload_time = "2024-09-04T20:44:12.232Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload_time = "2024-09-04T20:44:13.739Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload_time = "2024-09-04T20:44:15.231Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload_time = "2024-09-04T20:44:17.188Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload_time = "2024-09-04T20:44:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload_time = "2024-09-04T20:44:20.248Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload_time = "2024-09-04T20:44:21.673Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload_time = "2024-09-04T20:44:23.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload_time = "2024-09-04T20:44:24.757Z" },
+ { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload_time = "2024-09-04T20:44:26.208Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload_time = "2024-09-04T20:44:27.578Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload_time = "2024-09-04T20:44:28.956Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload_time = "2024-09-04T20:44:30.289Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload_time = "2024-09-04T20:44:32.01Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload_time = "2024-09-04T20:44:33.606Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload_time = "2024-09-04T20:44:35.191Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload_time = "2024-09-04T20:44:36.743Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload_time = "2024-09-04T20:44:38.492Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload_time = "2024-09-04T20:44:40.046Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload_time = "2024-09-04T20:44:41.616Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload_time = "2024-09-04T20:44:43.733Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" },
+]
+
+[[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 = "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 = "cryptography"
+version = "45.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload_time = "2025-06-10T00:03:51.297Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload_time = "2025-06-10T00:02:38.826Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload_time = "2025-06-10T00:02:41.64Z" },
+ { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload_time = "2025-06-10T00:02:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload_time = "2025-06-10T00:02:45.334Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload_time = "2025-06-10T00:02:47.359Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload_time = "2025-06-10T00:02:49.412Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload_time = "2025-06-10T00:02:50.976Z" },
+ { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload_time = "2025-06-10T00:02:52.542Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload_time = "2025-06-10T00:02:54.63Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload_time = "2025-06-10T00:02:56.689Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload_time = "2025-06-10T00:02:58.467Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload_time = "2025-06-10T00:03:00.14Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload_time = "2025-06-10T00:03:01.726Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload_time = "2025-06-10T00:03:03.94Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload_time = "2025-06-10T00:03:05.589Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload_time = "2025-06-10T00:03:09.172Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload_time = "2025-06-10T00:03:10.835Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload_time = "2025-06-10T00:03:12.448Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload_time = "2025-06-10T00:03:13.976Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload_time = "2025-06-10T00:03:16.248Z" },
+ { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload_time = "2025-06-10T00:03:18.4Z" },
+ { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload_time = "2025-06-10T00:03:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload_time = "2025-06-10T00:03:22.563Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload_time = "2025-06-10T00:03:24.586Z" },
+ { url = "https://files.pythonhosted.org/packages/16/33/b38e9d372afde56906a23839302c19abdac1c505bfb4776c1e4b07c3e145/cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", size = 3580103, upload_time = "2025-06-10T00:03:26.207Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732, upload_time = "2025-06-10T00:03:27.896Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424, upload_time = "2025-06-10T00:03:29.992Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438, upload_time = "2025-06-10T00:03:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622, upload_time = "2025-06-10T00:03:33.491Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ab/e3a055c34e97deadbf0d846e189237d3385dca99e1a7e27384c3b2292041/cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", size = 3328911, upload_time = "2025-06-10T00:03:35.035Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload_time = "2025-06-10T00:03:36.982Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload_time = "2025-06-10T00:03:38.659Z" },
+ { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload_time = "2025-06-10T00:03:40.233Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload_time = "2025-06-10T00:03:41.827Z" },
+ { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload_time = "2025-06-10T00:03:43.493Z" },
+ { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload_time = "2025-06-10T00:03:45.523Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload_time = "2024-03-30T13:22:22.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload_time = "2024-03-30T13:22:20.476Z" },
+]
+
+[[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 = "../packages/s2-python" }
+dependencies = [
+ { name = "click" },
+ { name = "pydantic" },
+ { name = "pytz" },
+]
+
+[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", marker = "extra == 'ws'", specifier = "~=13.1" },
+]
+provides-extras = ["ws", "testing", "development", "docs"]
+
+[[package]]
+name = "s2selfcert"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "connectivity" },
+ { name = "cryptography" },
+ { name = "test-suites" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "connectivity", editable = "../packages/connectivity" },
+ { name = "cryptography", specifier = ">=45.0.4" },
+ { name = "test-suites", editable = "../packages/test-suites" },
+]
+
+[[package]]
+name = "test-suites"
+version = "0.1.0"
+source = { editable = "../packages/test-suites" }
+dependencies = [
+ { name = "connectivity" },
+ { name = "cryptography" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "s2-python" },
+ { name = "websockets" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "connectivity", editable = "../packages/connectivity" },
+ { name = "cryptography", specifier = ">=45.0.4" },
+ { 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.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.4.0" }]
+
+[[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" },
+]