diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..992f1c9
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,46 @@
+# Use Ubuntu 22.04 as the base image
+FROM ubuntu:22.04
+
+# Set environment variables to prevent interactive prompts
+ARG DEBIAN_FRONTEND=noninteractive
+
+# Update package list and install necessary tools and dependencies
+RUN apt-get update && \
+ apt-get install -y \
+ sudo \
+ git \
+ curl \
+ gnupg \
+ make \
+ build-essential \
+ pkg-config \
+ libzmq3-dev \
+ libssl-dev \
+ zlib1g-dev \
+ libncurses5-dev \
+ libgdbm-dev \
+ libnss3-dev \
+ libsqlite3-dev \
+ libreadline-dev \
+ libffi-dev \
+ libbz2-dev
+
+
+
+# Make a 'dev' user
+RUN echo 'root:dev' | chpasswd
+RUN useradd -ms /bin/bash dev && echo 'dev:dev' | chpasswd && adduser dev sudo
+RUN echo 'dev ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
+
+COPY --chown=dev:dev .devcontainer/scripts/gu /usr/local/bin/gu
+COPY --chown=dev:dev .devcontainer/scripts/.bashrc /home/dev/.bashrc
+
+USER dev
+
+# Install Python 3 using apt
+RUN sudo apt-get install -y python3 python3-pip
+
+# Install uv
+RUN curl -LsSf https://astral.sh/uv/install.sh | sh
+
+
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..909c567
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,24 @@
+{
+ "name": "ASE-roverlib-python-dev",
+ "build": {
+ "dockerfile": "./Dockerfile",
+ "context": "..",
+ "options": [
+ "--network=host"
+ ]
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "tamasfe.even-better-toml", // TOML syntax support
+ "dbankier.vscode-quick-select", // Quick select with cmd/ctrl+k "
+ "charliermarsh.ruff"
+ ]
+ }
+ },
+ "runArgs": [
+ "--network=host"
+ ],
+ "remoteUser": "dev"
+}
+
diff --git a/.devcontainer/scripts/.bashrc b/.devcontainer/scripts/.bashrc
new file mode 100755
index 0000000..9ae5abd
--- /dev/null
+++ b/.devcontainer/scripts/.bashrc
@@ -0,0 +1,61 @@
+# personalized PS1 prompt
+PS1="\W \e[01;31m$\e[m "
+
+# If not running interactively, don't do anything
+case $- in
+ *i*) ;;
+ *) return;;
+esac
+
+# don't put duplicate lines or lines starting with space in the history.
+# See bash(1) for more options
+HISTCONTROL=ignoreboth
+
+# append to the history file, don't overwrite it
+shopt -s histappend
+
+# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)
+HISTSIZE=1000
+HISTFILESIZE=2000
+
+# custom
+alias python="python3"
+alias l="ls -lah"
+alias ..="cd .."
+
+# git aliases
+alias gs="git status"
+alias gc="git commit -m "
+alias ga="git add "
+alias gp="git push"
+alias gpl="git pull"
+alias gl="git log --pretty=oneline"
+
+# check the window size after each command and, if necessary
+# update the values of LINES and COLUMNS.
+shopt -s checkwinsize
+
+# make less more friendly for non-text input files, see lesspipe(1)
+[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
+
+
+# Alias definitions in bash_aliases
+if [ -f ~/work/scripts/.bash_aliases ]; then
+ . ~/work/scripts/.bash_aliases
+fi
+
+# enable programmable completion features (you don't need to enable
+# this, if it's already enabled in /etc/bash.bashrc and /etc/profile
+# sources /etc/bash.bashrc).
+if ! shopt -oq posix; then
+ if [ -f /usr/share/bash-completion/bash_completion ]; then
+ . /usr/share/bash-completion/bash_completion
+ elif [ -f /etc/bash_completion ]; then
+ . /etc/bash_completion
+ fi
+fi
+
+export PATH=$PATH:/home/dev/.local/bin
+
+if [ -f "$HOME/.cargo/env" ]; then . "$HOME/.cargo/env"; fi
+
diff --git a/.devcontainer/scripts/gu b/.devcontainer/scripts/gu
new file mode 100755
index 0000000..4ec9b4a
--- /dev/null
+++ b/.devcontainer/scripts/gu
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+STATUS=$(git status)
+
+TEST=$(echo $STATUS | awk '{print $NF}')
+
+if [ "$TEST" != "clean" ]; then
+ git add .
+ git status
+ echo -n "Sure you want to commit and push? (y/n) "
+ read INPUT
+ if [ "$INPUT" = "y" ]; then
+ echo -n "Commit message: "
+ read INPUT
+ git commit -m "$INPUT"
+ git push
+ else
+ echo "cancelled"
+ fi
+fi
+
diff --git a/.gitignore b/.gitignore
index 15201ac..566d85f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -169,3 +169,8 @@ cython_debug/
# PyPI configuration file
.pypirc
+h
+.vscode
+
+.ruff_cache
+
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/Makefile b/Makefile
new file mode 100644
index 0000000..b49dbf4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,62 @@
+
+
+
+# Since we are using uv to manage our dependencies, we can utilize the "uv run python ..."
+# command which makes sure to launch the python script with the dependencies installed as
+# defined by the pyproject.toml file. That makes it the single source of truth and we
+# can generate a requirements.txt file from there.
+
+
+
+# We always want to run lint checks before we build or test
+lint:
+ @echo "Linting src and tests"
+ @uv run ruff check src tests
+
+# This will generate a python package that can be published and installs it locally for
+# testing. Note, that it installs it with the -e (editable) option which means that when
+# changing any library files, you can immediately test them and don't have to re-install
+# with 'uv build'. However if we want to publish, we must always build.
+build: lint
+ @rm -rf dist/
+ @uv build
+ @uv pip compile pyproject.toml -o requirements.txt
+
+# Add all test files into /tests they will all get run when this target is invoked
+test: lint
+ @uv run pytest
+
+# Open a python repl for quick debugging
+repl:
+ uv run python
+
+
+clean:
+ uv cache clean
+ rm -r .pytest_cache .ruff_cache .venv dist
+
+
+
+# Set token: export UV_PUBLISH_TOKEN=(token)
+# check token: echo $UV_PUBLISH_TOKEN
+
+check-publish-token:
+ @if [ -z "$(UV_PUBLISH_TOKEN)" ]; then \
+ echo "Error: UV_PUBLISH_TOKEN environment variable is not set"; \
+ exit 1; \
+ else \
+ echo "UV_PUBLISH_TOKEN is set"; \
+ fi
+
+
+
+publish-test: check-publish-token build
+ @echo "Publishing to test.pypi.org"
+ @uv publish dist/* --index testpypi
+
+
+publish: check-publish-token build
+ @echo "Publishing to pypi.org"
+ @uv publish dist/* --index pypi
+
+
diff --git a/README.md b/README.md
index cf11b6a..2d912b2 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,9 @@
-# roverlib-python
-Building a service that runs on the rover? Then you'll need the roverlib. This is the variant for Python.
+
roverlib-python library
+
+
+
+
diff --git a/docs/00-usage.md b/docs/00-usage.md
new file mode 100644
index 0000000..cd2209b
--- /dev/null
+++ b/docs/00-usage.md
@@ -0,0 +1,43 @@
+# Usage
+
+After installation, you can use roverlib as follows:
+
+```python
+#!/usr/bin/python3
+import roverlib
+import signal
+import time
+import roverlib.rovercom as rovercom
+
+def run(service : roverlib.Service, configuration : roverlib.ServiceConfiguration):
+
+ # Unlike roverlib-go, these functions do not return an error object, but rather throw an error on failure
+ speed = configuration.GetFloatSafe("speed")
+
+ name = configuration.GetStringSafe("name")
+
+ write_stream : roverlib.WriteStream = service.GetWriteStream("motor_movement")
+
+ write_stream.Write(
+
+ rovercom.SensorOutput(
+ sensor_id=2,
+ timestamp=int(time.time() * 1000),
+ controller_output=rovercom.ControllerOutput(
+ steering_angle=float(1),
+ left_throttle=float(speed),
+ right_throttle=float(speed),
+ front_lights=False
+ ),
+ )
+ )
+
+
+
+
+def on_terminate(sig : signal):
+ logger.info("Terminating")
+
+
+roverlib.Run(run, on_terminate)
+```
diff --git a/docs/01-development.md b/docs/01-development.md
new file mode 100644
index 0000000..5c74af7
--- /dev/null
+++ b/docs/01-development.md
@@ -0,0 +1,19 @@
+# For Developers
+
+The name of the package is `roverlib` which is the same as the module that is then imported by end users (`pip install roverlib` and `import roverlib` to keep consistency).
+
+The following directory structure aims to separate library code from testing code:
+* `src/roverlib` - contains all source code of the library
+* `tests/` - contains python files that will all be part of the testing process
+
+This repository is self-contained and relies heavily on `uv` for dependency management and building packages. The most important commands are wrapped by Makefile targets:
+
+* `make build` - builds the library to `dist/` and installs it locally for quick testing
+* `make test` - runs all files in the `tests/` directory
+* `make publish-test` - requires setting an external token with `export PUBLISH_TOKEN=pypi-abc...` and uploads to pypi's test
+* `make publish` - requires setting an external token with `export PUBLISH_TOKEN=pypi-def...` and uploads to the official pypi index
+
+
+Before running the `make publish*` targets, make sure to set the correct token depending on which index you are uploading to.
+
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..ccee75d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,41 @@
+[project]
+name = "roverlib"
+version = "0.5.3"
+description = "ASE roverlib"
+readme = "README.md"
+authors = [
+ { name = "NielsD1", email = "n.j.dijkstra@student.vu.nl" },
+ { name = "maxgallup", email = "m.gallup@student.vu.nl" },
+
+]
+requires-python = ">=3.10"
+dependencies = ["pyzmq", "loguru", "betterproto"]
+
+[project.urls]
+"Repository" = "https://github.com/vu-ase/roverlib-python"
+"Issues" = "https://github.com/vu-ase/roverlib-python/issues"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[dependency-groups]
+dev = ["pytest>=8.3.4", "ruff>=0.9.7"]
+
+# Tell pytest that all files in the /tests directory are tests
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = "tests/*.py"
+python_functions = "test_*"
+python_classes = "Test*"
+
+
+[[tool.uv.index]]
+name = "pypi"
+url = "https://pypi.org/simple/"
+publish-url = "https://upload.pypi.org/legacy/"
+
+[[tool.uv.index]]
+name = "testpypi"
+url = "https://test.pypi.org/simple/"
+publish-url = "https://test.pypi.org/legacy/"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..bdd7586
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,22 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile pyproject.toml -o requirements.txt
+betterproto==1.2.5
+ # via roverlib (pyproject.toml)
+grpclib==0.4.7
+ # via betterproto
+h2==4.2.0
+ # via grpclib
+hpack==4.1.0
+ # via h2
+hyperframe==6.1.0
+ # via h2
+loguru==0.7.3
+ # via roverlib (pyproject.toml)
+multidict==6.1.0
+ # via grpclib
+pyzmq==26.2.1
+ # via roverlib (pyproject.toml)
+stringcase==1.2.0
+ # via betterproto
+typing-extensions==4.12.2
+ # via multidict
diff --git a/src/roverlib/__init__.py b/src/roverlib/__init__.py
new file mode 100644
index 0000000..69688cc
--- /dev/null
+++ b/src/roverlib/__init__.py
@@ -0,0 +1,8 @@
+from .index import Run
+from .bootinfo import Service, service_from_dict
+from .configuration import ServiceConfiguration
+from .streams import ReadStream, WriteStream
+import roverlib.rovercom as rovercom
+
+
+__all__ = ["Run", "Service", "service_from_dict", "ServiceConfiguration", "ReadStream", "WriteStream", "rovercom"]
diff --git a/src/roverlib/bootinfo.py b/src/roverlib/bootinfo.py
new file mode 100644
index 0000000..597af2d
--- /dev/null
+++ b/src/roverlib/bootinfo.py
@@ -0,0 +1,269 @@
+from enum import Enum
+from typing import Optional, Union, Any, List, TypeVar, Type, Callable, cast
+
+T = TypeVar("T")
+EnumT = TypeVar("EnumT", bound=Enum)
+
+
+def from_str(x: Any) -> str:
+ assert isinstance(x, str)
+ return x
+
+
+def from_none(x: Any) -> Any:
+ assert x is None
+ return x
+
+
+def from_union(fs, x):
+ for f in fs:
+ try:
+ return f(x)
+ except Exception:
+ pass
+ assert False
+
+
+def from_bool(x: Any) -> bool:
+ assert isinstance(x, bool)
+ return x
+
+
+def from_float(x: Any) -> float:
+ assert isinstance(x, (float, int)) and not isinstance(x, bool)
+ return float(x)
+
+
+def to_enum(c: Type[EnumT], x: Any) -> EnumT:
+ assert isinstance(x, c)
+ return x.value
+
+
+def to_float(x: Any) -> float:
+ assert isinstance(x, (int, float))
+ return x
+
+
+def from_list(f: Callable[[Any], T], x: Any) -> List[T]:
+ assert isinstance(x, list)
+ return [f(y) for y in x]
+
+
+def to_class(c: Type[T], x: Any) -> dict:
+ assert isinstance(x, c)
+ return cast(Any, x).to_dict()
+
+
+class TypeEnum(Enum):
+ """The type of this configuration option"""
+
+ NUMBER = "number"
+ STRING = "string"
+
+
+class Configuration:
+ name: Optional[str]
+ """Unique name of this configuration option"""
+
+ tunable: Optional[bool]
+ """Whether or not this value can be tuned (ota)"""
+
+ type: Optional[TypeEnum]
+ """The type of this configuration option"""
+
+ value: Optional[Union[float, str]]
+ """The value of this configuration option, which can be a string or float"""
+
+ def __init__(self, name: Optional[str], tunable: Optional[bool], type: Optional[TypeEnum], value: Optional[Union[float, str]]) -> None:
+ self.name = name
+ self.tunable = tunable
+ self.type = type
+ self.value = value
+
+ @staticmethod
+ def from_dict(obj: Any) -> 'Configuration':
+ assert isinstance(obj, dict)
+ name = from_union([from_str, from_none], obj.get("name"))
+ tunable = from_union([from_bool, from_none], obj.get("tunable"))
+ type = from_union([TypeEnum, from_none], obj.get("type"))
+ value = from_union([from_float, from_str, from_none], obj.get("value"))
+ return Configuration(name, tunable, type, value)
+
+ def to_dict(self) -> dict:
+ result: dict = {}
+ if self.name is not None:
+ result["name"] = from_union([from_str, from_none], self.name)
+ if self.tunable is not None:
+ result["tunable"] = from_union([from_bool, from_none], self.tunable)
+ if self.type is not None:
+ result["type"] = from_union([lambda x: to_enum(TypeEnum, x), from_none], self.type)
+ if self.value is not None:
+ result["value"] = from_union([to_float, from_str, from_none], self.value)
+ return result
+
+
+class Stream:
+ address: Optional[str]
+ """The (zmq) socket address that input can be read on"""
+
+ name: Optional[str]
+ """The name of the stream as outputted by the dependency service"""
+
+ def __init__(self, address: Optional[str], name: Optional[str]) -> None:
+ self.address = address
+ self.name = name
+
+ @staticmethod
+ def from_dict(obj: Any) -> 'Stream':
+ assert isinstance(obj, dict)
+ address = from_union([from_str, from_none], obj.get("address"))
+ name = from_union([from_str, from_none], obj.get("name"))
+ return Stream(address, name)
+
+ def to_dict(self) -> dict:
+ result: dict = {}
+ if self.address is not None:
+ result["address"] = from_union([from_str, from_none], self.address)
+ if self.name is not None:
+ result["name"] = from_union([from_str, from_none], self.name)
+ return result
+
+
+class Input:
+ service: Optional[str]
+ """The name of the service for this dependency"""
+
+ streams: Optional[List[Stream]]
+
+ def __init__(self, service: Optional[str], streams: Optional[List[Stream]]) -> None:
+ self.service = service
+ self.streams = streams
+
+ @staticmethod
+ def from_dict(obj: Any) -> 'Input':
+ assert isinstance(obj, dict)
+ service = from_union([from_str, from_none], obj.get("service"))
+ streams = from_union([lambda x: from_list(Stream.from_dict, x), from_none], obj.get("streams"))
+ return Input(service, streams)
+
+ def to_dict(self) -> dict:
+ result: dict = {}
+ if self.service is not None:
+ result["service"] = from_union([from_str, from_none], self.service)
+ if self.streams is not None:
+ result["streams"] = from_union([lambda x: from_list(lambda x: to_class(Stream, x), x), from_none], self.streams)
+ return result
+
+
+class Output:
+ address: Optional[str]
+ """The (zmq) socket address that output can be written to"""
+
+ name: Optional[str]
+ """Name of the output published by this service"""
+
+ def __init__(self, address: Optional[str], name: Optional[str]) -> None:
+ self.address = address
+ self.name = name
+
+ @staticmethod
+ def from_dict(obj: Any) -> 'Output':
+ assert isinstance(obj, dict)
+ address = from_union([from_str, from_none], obj.get("address"))
+ name = from_union([from_str, from_none], obj.get("name"))
+ return Output(address, name)
+
+ def to_dict(self) -> dict:
+ result: dict = {}
+ if self.address is not None:
+ result["address"] = from_union([from_str, from_none], self.address)
+ if self.name is not None:
+ result["name"] = from_union([from_str, from_none], self.name)
+ return result
+
+
+class Tuning:
+ address: Optional[str]
+ """(If enabled) the (zmq) socket address that tuning data can be read from"""
+
+ enabled: Optional[bool]
+ """Whether or not live (ota) tuning is enabled"""
+
+ def __init__(self, address: Optional[str], enabled: Optional[bool]) -> None:
+ self.address = address
+ self.enabled = enabled
+
+ @staticmethod
+ def from_dict(obj: Any) -> 'Tuning':
+ assert isinstance(obj, dict)
+ address = from_union([from_str, from_none], obj.get("address"))
+ enabled = from_union([from_bool, from_none], obj.get("enabled"))
+ return Tuning(address, enabled)
+
+ def to_dict(self) -> dict:
+ result: dict = {}
+ if self.address is not None:
+ result["address"] = from_union([from_str, from_none], self.address)
+ if self.enabled is not None:
+ result["enabled"] = from_union([from_bool, from_none], self.enabled)
+ return result
+
+
+class Service:
+ """The object that injected into a rover process by roverd and then parsed by roverlib to be
+ made available for the user process
+ """
+ configuration: List[Configuration]
+ inputs: List[Input]
+ """The resolved input dependencies"""
+
+ name: Optional[str]
+ """The name of the service (only lowercase letters and hyphens)"""
+
+ outputs: List[Output]
+ tuning: Tuning
+ version: Optional[str]
+ """The specific version of the service"""
+
+ service: Any
+
+ def __init__(self, configuration: List[Configuration], inputs: List[Input], name: Optional[str], outputs: List[Output], tuning: Tuning, version: Optional[str], service: Any) -> None:
+ self.configuration = configuration
+ self.inputs = inputs
+ self.name = name
+ self.outputs = outputs
+ self.tuning = tuning
+ self.version = version
+ self.service = service
+
+ @staticmethod
+ def from_dict(obj: Any) -> 'Service':
+ assert isinstance(obj, dict)
+ configuration = from_list(Configuration.from_dict, obj.get("configuration"))
+ inputs = from_list(Input.from_dict, obj.get("inputs"))
+ name = from_union([from_str, from_none], obj.get("name"))
+ outputs = from_list(Output.from_dict, obj.get("outputs"))
+ tuning = Tuning.from_dict(obj.get("tuning"))
+ version = from_union([from_str, from_none], obj.get("version"))
+ service = obj.get("service")
+ return Service(configuration, inputs, name, outputs, tuning, version, service)
+
+ def to_dict(self) -> dict:
+ result: dict = {}
+ result["configuration"] = from_list(lambda x: to_class(Configuration, x), self.configuration)
+ result["inputs"] = from_list(lambda x: to_class(Input, x), self.inputs)
+ if self.name is not None:
+ result["name"] = from_union([from_str, from_none], self.name)
+ result["outputs"] = from_list(lambda x: to_class(Output, x), self.outputs)
+ result["tuning"] = to_class(Tuning, self.tuning)
+ if self.version is not None:
+ result["version"] = from_union([from_str, from_none], self.version)
+ result["service"] = self.service
+ return result
+
+def service_from_dict(s: Any) -> Service:
+ return Service.from_dict(s)
+
+
+def service_to_dict(x: Service) -> Any:
+ return to_class(Service, x)
diff --git a/src/roverlib/callbacks.py b/src/roverlib/callbacks.py
new file mode 100644
index 0000000..5e16201
--- /dev/null
+++ b/src/roverlib/callbacks.py
@@ -0,0 +1,7 @@
+import signal
+from typing import Callable
+from roverlib.configuration import Service, ServiceConfiguration
+
+MainCallback = Callable[[Service, ServiceConfiguration], None]
+
+TerminationCallback = Callable[[signal.Signals], None]
diff --git a/src/roverlib/configuration.py b/src/roverlib/configuration.py
new file mode 100644
index 0000000..f9839a5
--- /dev/null
+++ b/src/roverlib/configuration.py
@@ -0,0 +1,82 @@
+import threading
+import time
+from loguru import logger
+from roverlib.bootinfo import Service, TypeEnum
+
+
+class ServiceConfiguration:
+ def __init__(self):
+ self.float_options : dict[str, float] = {}
+ self.string_options : dict[str, str] = {}
+ self.tunable : dict[str, bool] = {}
+ self.lock = threading.RLock()
+ self.last_update : int = int(time.time() * 1000)
+
+ # Returns the float value of the configuration option with the given name, returns an error if the option does not exist or does not exist for this type
+ # Reading is NOT thread-safe, but we accept the risks because we assume that the user program will read the configuration values repeatedly
+ # If you want to read the configuration values concurrently, you should use the GetFloatSafe method
+ def GetFloat(self, name : str) -> float:
+ logger.debug(self.float_options)
+ if name not in self.float_options:
+ logger.critical(f"No float configuration option with name {name}")
+ raise NameError(f"No float configuration option with name {name}")
+
+ return self.float_options[name]
+
+ def GetFloatSafe(self, name : str) -> float:
+ with self.lock:
+ return self.GetFloat(name)
+
+
+ # Returns the string value of the configuration option with the given name, returns an error if the option does not exist or does not exist for this type
+ # Reading is NOT thread-safe, but we accept the risks because we assume that the user program will read the configuration values repeatedly
+ # If you want to read the configuration values concurrently, you should use the GetStringSafe method
+ def GetString(self, name : str) -> str:
+ if name not in self.string_options:
+ logger.critical(f"No string configuration option with name {name}")
+ raise NameError(f"No string configuration option with name {name}")
+
+ return self.string_options[name]
+
+ def GetStringSafe(self, name : str) -> str:
+ with self.lock:
+ return self.GetString(name)
+
+ # Set the float value of the configuration option with the given name (thread-safe)
+ def _SetFloat(self, name : str, value : float):
+ with self.lock:
+ if name in self.tunable:
+ if name not in self.float_options:
+ logger.error(f"{name} : {value} Is not of type Float")
+ self.float_options[name] = value
+ logger.info(f"{name} : {value} Set float configuration option")
+ else:
+ logger.error(f"{name} : {value} Attempted to set non-tunable float configuration option")
+
+ # Set the string value of the configuration option with the given name (thread-safe)
+ def _SetString(self, name : str, value : str):
+ with self.lock:
+ if name in self.tunable:
+ if name not in self.string_options:
+ logger.error(f"{name} : {value} Is not of type String")
+ return None
+ self.float_options[name] = value
+ logger.info(f"{name} : {value} Set string configuration option")
+ else:
+ logger.error(f"{name} : {value} Attempted to set non-tunable string configuration option")
+
+
+
+def NewServiceConfiguration(service : Service) -> ServiceConfiguration:
+ config = ServiceConfiguration()
+ for c in service.configuration:
+
+ if c.type == TypeEnum.NUMBER:
+ config.float_options[c.name] = c.value
+ elif c.type == TypeEnum.STRING:
+ config.string_options[c.name] = c.value
+
+ if c.tunable is True:
+ config.tunable[c.name] = c.tunable
+
+ return config
diff --git a/src/roverlib/index.py b/src/roverlib/index.py
new file mode 100644
index 0000000..0d4cf56
--- /dev/null
+++ b/src/roverlib/index.py
@@ -0,0 +1,133 @@
+import argparse
+import os
+import signal
+import sys
+import threading
+import time
+import json
+from roverlib.bootinfo import Service, service_from_dict
+from roverlib.callbacks import MainCallback, TerminationCallback
+from roverlib.configuration import ServiceConfiguration, NewServiceConfiguration
+import zmq
+from loguru import logger
+import roverlib.rovercom as rovercom
+
+
+
+def handle_signals(on_terminate: TerminationCallback):
+ def signal_handler(sig, frame):
+ logger.warning(f"Signal received: {sig}")
+
+ # callback to the service
+ on_terminate(sig)
+
+ # catch SIGTERM or SIGINT
+ signal.signal(signal.SIGTERM, signal_handler)
+ signal.signal(signal.SIGINT, signal_handler)
+
+ logger.info("Listening for signals...")
+
+
+# Configures log level and output
+def setup_logging(debug: bool, output_path: str, service_name="unknown"):
+
+ logger.remove()
+ log_format = "{time: HH:mm} {level} [%s] {file}:{line} > {message}" % service_name
+
+ # set level
+ logger.add(sys.stderr, format=log_format, level="DEBUG" if debug else "INFO", colorize=True)
+
+ if output_path:
+ logger.add(output_path, format=log_format, level="DEBUG" if debug else "INFO")
+ logger.info(f"Logging to file {output_path}")
+
+ logger.info("Logger initialized")
+
+
+def ota_tuning(service : Service, configuration : ServiceConfiguration):
+ context = zmq.Context()
+ while True:
+ logger.info("Attempting to subscribe to OTA tuning service at %s" % service.tuning.address)
+ # Initialize zmq socket to retrieve OTA tuning values from the service responsible for this
+
+ socket = context.socket(zmq.SUB)
+
+ try:
+ socket.connect(service.tuning.address)
+ # subscribe to all messages
+ socket.setsockopt_string(zmq.SUBSCRIBE, "")
+ except zmq.ZMQError as e:
+ logger.error(f"Failed to connect/subscribe to OTA tuning service: {e}")
+ socket.close()
+ time.sleep(5)
+ continue
+
+ while True:
+ logger.info("Waiting for new tuning values")
+
+ # Receive new configuration, and update this in the shared configuration
+ res = socket.recv()
+
+ logger.info("Received new tuning values")
+
+ # convert from over-the-wire format to TuningState struct
+ tuning : rovercom.TuningState = rovercom.TuningState().parse(res)
+
+ # Is the timestamp later than the last update?
+ if(tuning.timestamp <= configuration.last_update):
+ logger.info("Received new tuning values with an outdated timestamp, ignoring...")
+ continue
+
+ # Update the configuration (will ignore values that are not tunable)
+ for p in tuning.dynamic_parameters:
+ if p.number:
+ logger.info("%s : %s Setting tuning value", p.number.key, p.number.value)
+ configuration._SetFloat(p.number.key, p.number.value)
+ elif p.string:
+ logger.info("%s : %f Setting tuning value", p.string.key, p.string.value)
+ configuration._SetString(p.string.key, p.string.value)
+ else:
+ logger.warning("Unknown tuning value type")
+
+
+def Run(main: MainCallback, on_terminate: TerminationCallback):
+ # parse args
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--debug", action="store_true", help="show all logs (including debug)")
+ parser.add_argument("--output", type=str, default="", help="path of the output file to log to")
+
+ args = parser.parse_args()
+
+ debug = args.debug
+ output = args.output
+
+ # Fetch and parse service definition as injected by roverd
+ definition = os.getenv("ASE_SERVICE")
+ if definition is None:
+ raise RuntimeError("No service definition found in environment variable ASE_SERVICE. Are you sure that this service is started by roverd?")
+
+ service_dict = json.loads(definition)
+ service = service_from_dict(service_dict)
+
+
+
+ # enable logging using loguru
+ setup_logging(debug, output, service.name)
+
+
+
+ # setup for catching SIGTERM and SIGINT, once setup this will run in the background; no active thread needed
+ handle_signals(on_terminate)
+
+ # Create a configuration for this service that will be shared with the user program
+ configuration = NewServiceConfiguration(service)
+
+ # Support ota tuning in this thread
+ # (the user program can fetch the latest value from the configuration)
+ if service.tuning.enabled:
+ thread_tuning = threading.Thread(target=ota_tuning, args=(service, configuration), daemon=True)
+ thread_tuning.start()
+
+ # Run the user program
+ main(service, configuration)
+
diff --git a/src/roverlib/py.typed b/src/roverlib/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/roverlib/rovercom.py b/src/roverlib/rovercom.py
new file mode 100644
index 0000000..89525a3
--- /dev/null
+++ b/src/roverlib/rovercom.py
@@ -0,0 +1,345 @@
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# sources: control/control.proto, debug/debug.proto, infrastructure/finish-line.proto, outputs/battery.proto, outputs/camera.proto, outputs/controller.proto, outputs/distance.proto, outputs/imu.proto, outputs/laptime.proto, outputs/lux.proto, outputs/rpm.proto, outputs/speed.proto, outputs/wrapper.proto, segmentation/segmentation.proto, simulator/simulator.proto, tuning/tuning.proto
+# plugin: python-betterproto
+from dataclasses import dataclass
+from typing import List
+
+import betterproto
+
+
+class DetectedObjects(betterproto.Enum):
+ """Possible Objects the Imaging Module may detect"""
+
+ FINISH_LINE = 0
+ OFF_TRACK = 1
+ OBSTACLE = 2
+ INTERSECTION = 3
+ MISSING_LEFT_LANE = 4
+ MISSING_RIGHT_LANE = 5
+ SHARP_RIGHT = 6
+ SHARP_LEFT = 7
+ U_TURN = 8
+ S_TURN = 9
+
+
+class SimStatus(betterproto.Enum):
+ """Possible Sim Requests. Useful for interfaces with Gym"""
+
+ SIM_PAUSED = 0
+ SIM_REQ_STEP = 1
+ SIM_REQ_RESET = 2
+
+
+@dataclass
+class ConnectionState(betterproto.Message):
+ """Tell a client if a given client/rover is connected or not"""
+
+ client: str = betterproto.string_field(1)
+ connected: bool = betterproto.bool_field(2)
+ timestamp_offset: int = betterproto.int64_field(3)
+
+
+@dataclass
+class ControlError(betterproto.Message):
+ message: str = betterproto.string_field(1)
+ timestamp: int = betterproto.int64_field(2)
+
+
+@dataclass
+class ServiceIdentifier(betterproto.Message):
+ """Used to identify a service within the pipeline"""
+
+ name: str = betterproto.string_field(1)
+ pid: int = betterproto.int32_field(2)
+
+
+@dataclass
+class ServiceEndpoint(betterproto.Message):
+ """An endpoint that is made available by a service"""
+
+ name: str = betterproto.string_field(1)
+ address: str = betterproto.string_field(2)
+
+
+@dataclass
+class DebugOutput(betterproto.Message):
+ """
+ When the transceivers picks up a SensorOutput from a service, it will wrap
+ it in a ServiceMessage message, so that the receiver can determine from
+ which process the message originated
+ """
+
+ service: "ServiceIdentifier" = betterproto.message_field(1)
+ endpoint: "ServiceEndpoint" = betterproto.message_field(2)
+ sent_at: int = betterproto.int64_field(4)
+ message: bytes = betterproto.bytes_field(3)
+
+
+@dataclass
+class FinishLineEvent(betterproto.Message):
+ timestamp: int = betterproto.uint64_field(1)
+
+
+@dataclass
+class BatterySensorOutput(betterproto.Message):
+ current_output_voltage: float = betterproto.float_field(1)
+ warn_voltage: float = betterproto.float_field(2)
+ kill_voltage: float = betterproto.float_field(3)
+
+
+@dataclass
+class CanvasObject(betterproto.Message):
+ line: "CanvasObjectLine" = betterproto.message_field(1, group="object")
+ rectangle: "CanvasObjectRectangle" = betterproto.message_field(2, group="object")
+ circle: "CanvasObjectCircle" = betterproto.message_field(3, group="object")
+
+
+@dataclass
+class CanvasObjectPoint(betterproto.Message):
+ x: int = betterproto.uint32_field(1)
+ y: int = betterproto.uint32_field(2)
+
+
+@dataclass
+class CanvasObjectColor(betterproto.Message):
+ r: int = betterproto.uint32_field(1)
+ g: int = betterproto.uint32_field(2)
+ b: int = betterproto.uint32_field(3)
+ a: int = betterproto.uint32_field(4)
+
+
+@dataclass
+class CanvasObjectLine(betterproto.Message):
+ start: "CanvasObjectPoint" = betterproto.message_field(1)
+ end: "CanvasObjectPoint" = betterproto.message_field(2)
+ width: int = betterproto.uint32_field(3)
+ color: "CanvasObjectColor" = betterproto.message_field(4)
+
+
+@dataclass
+class CanvasObjectRectangle(betterproto.Message):
+ top_left: "CanvasObjectPoint" = betterproto.message_field(1)
+ bottom_right: "CanvasObjectPoint" = betterproto.message_field(2)
+ width: int = betterproto.uint32_field(3)
+ color: "CanvasObjectColor" = betterproto.message_field(4)
+
+
+@dataclass
+class CanvasObjectCircle(betterproto.Message):
+ center: "CanvasObjectPoint" = betterproto.message_field(1)
+ radius: int = betterproto.uint32_field(2)
+ width: int = betterproto.uint32_field(3)
+ color: "CanvasObjectColor" = betterproto.message_field(4)
+
+
+@dataclass
+class Canvas(betterproto.Message):
+ width: int = betterproto.uint32_field(1)
+ height: int = betterproto.uint32_field(2)
+ objects: List["CanvasObject"] = betterproto.message_field(3)
+
+
+@dataclass
+class CameraSensorOutput(betterproto.Message):
+ """
+ The following sensor outputs are specific to the sensor type, bring your
+ own sensor and add your own output here!
+ """
+
+ trajectory: "CameraSensorOutputTrajectory" = betterproto.message_field(1)
+ debug_frame: "CameraSensorOutputDebugFrame" = betterproto.message_field(2)
+ objects: "CameraSensorOutputObjects" = betterproto.message_field(3)
+
+
+@dataclass
+class CameraSensorOutputTrajectory(betterproto.Message):
+ """Defined by the Path Planner"""
+
+ points: List["CameraSensorOutputTrajectoryPoint"] = betterproto.message_field(1)
+ width: int = betterproto.uint32_field(2)
+ height: int = betterproto.uint32_field(3)
+
+
+@dataclass
+class CameraSensorOutputTrajectoryPoint(betterproto.Message):
+ x: int = betterproto.int32_field(1)
+ y: int = betterproto.int32_field(2)
+
+
+@dataclass
+class CameraSensorOutputDebugFrame(betterproto.Message):
+ jpeg: bytes = betterproto.bytes_field(1)
+ # if image livestreaming is disabled, or imaging module wants to draw
+ # additional information on the image, it can be done here
+ canvas: "Canvas" = betterproto.message_field(5)
+
+
+@dataclass
+class CameraSensorOutputObjects(betterproto.Message):
+ items: List["DetectedObjects"] = betterproto.enum_field(1)
+
+
+@dataclass
+class ControllerOutput(betterproto.Message):
+ # Steering angle (-1.0 to 1.0 <-> left - right)
+ steering_angle: float = betterproto.float_field(2)
+ # Throttle (-1.0 to 1.0 <-> full reverse - full forward)
+ left_throttle: float = betterproto.float_field(3)
+ right_throttle: float = betterproto.float_field(4)
+ # Onboard lights (0.0 to 1.0 <-> off - on)
+ front_lights: bool = betterproto.bool_field(5)
+ # Fan speed (0.0 to 1.0 <-> off - full speed)
+ fan_speed: float = betterproto.float_field(6)
+ # Useful for debugging
+ raw_error: float = betterproto.float_field(7)
+ scaled_error: float = betterproto.float_field(8)
+
+
+@dataclass
+class DistanceSensorOutput(betterproto.Message):
+ # distance in meters
+ distance: float = betterproto.float_field(1)
+
+
+@dataclass
+class ImuSensorOutput(betterproto.Message):
+ temperature: int = betterproto.int32_field(1)
+ magnetometer: "ImuSensorOutputVector" = betterproto.message_field(2)
+ gyroscope: "ImuSensorOutputVector" = betterproto.message_field(3)
+ euler: "ImuSensorOutputVector" = betterproto.message_field(4)
+ accelerometer: "ImuSensorOutputVector" = betterproto.message_field(5)
+ linear_accelerometer: "ImuSensorOutputVector" = betterproto.message_field(6)
+ velocity: "ImuSensorOutputVector" = betterproto.message_field(7)
+ speed: float = betterproto.float_field(8)
+
+
+@dataclass
+class ImuSensorOutputVector(betterproto.Message):
+ x: float = betterproto.float_field(1)
+ y: float = betterproto.float_field(2)
+ z: float = betterproto.float_field(3)
+
+
+@dataclass
+class LapTimeOutput(betterproto.Message):
+ lap_time: int = betterproto.uint64_field(1)
+ lap_start_time: int = betterproto.uint64_field(2)
+
+
+@dataclass
+class LuxSensorOutput(betterproto.Message):
+ lux: int = betterproto.int32_field(1)
+
+
+@dataclass
+class RpmSensorOutput(betterproto.Message):
+ left_rpm: float = betterproto.float_field(1)
+ left_angle: float = betterproto.float_field(2)
+ right_rpm: float = betterproto.float_field(3)
+ right_angle: float = betterproto.float_field(4)
+
+
+@dataclass
+class SpeedSensorOutput(betterproto.Message):
+ rpm: int = betterproto.int32_field(1)
+
+
+@dataclass
+class SensorOutput(betterproto.Message):
+ # Every sensor has a unique ID to support multiple sensors of the same type
+ sensor_id: int = betterproto.uint32_field(1)
+ # Add a timestamp to the output to make debugging, logging and
+ # synchronisation easier
+ timestamp: int = betterproto.uint64_field(2)
+ # Report an error if the sensor is not working correctly (controller can
+ # decide to ignore or stop the car) 0 = no error, any other value = error
+ status: int = betterproto.uint32_field(3)
+ camera_output: "CameraSensorOutput" = betterproto.message_field(
+ 4, group="sensorOutput"
+ )
+ distance_output: "DistanceSensorOutput" = betterproto.message_field(
+ 5, group="sensorOutput"
+ )
+ speed_output: "SpeedSensorOutput" = betterproto.message_field(
+ 6, group="sensorOutput"
+ )
+ controller_output: "ControllerOutput" = betterproto.message_field(
+ 7, group="sensorOutput"
+ )
+ imu_output: "ImuSensorOutput" = betterproto.message_field(8, group="sensorOutput")
+ battery_output: "BatterySensorOutput" = betterproto.message_field(
+ 9, group="sensorOutput"
+ )
+ rpm_ouput: "RpmSensorOutput" = betterproto.message_field(10, group="sensorOutput")
+ lux_output: "LuxSensorOutput" = betterproto.message_field(11, group="sensorOutput")
+ laptime_output: "LapTimeOutput" = betterproto.message_field(
+ 12, group="sensorOutput"
+ )
+
+
+@dataclass
+class Segment(betterproto.Message):
+ """
+ Control messages exchanged by client(s), the server and the car to send
+ data in multiple segments
+ """
+
+ packet_id: int = betterproto.int64_field(1)
+ segment_id: int = betterproto.int64_field(2)
+ total_segments: int = betterproto.int64_field(3)
+ data: bytes = betterproto.bytes_field(4)
+
+
+@dataclass
+class SimulatorImageOutput(betterproto.Message):
+ """Simulator sensor outputs."""
+
+ width: int = betterproto.uint32_field(2)
+ height: int = betterproto.uint32_field(3)
+ pixels: bytes = betterproto.bytes_field(4)
+
+
+@dataclass
+class SimulatorState(betterproto.Message):
+ """Generic state of Simulator"""
+
+ speed: float = betterproto.float_field(1)
+ wheel_off_track: List[bool] = betterproto.bool_field(2)
+ image: "SimulatorImageOutput" = betterproto.message_field(3)
+ pos: List[float] = betterproto.float_field(4)
+ is_drifting: bool = betterproto.bool_field(5)
+
+
+@dataclass
+class TuningState(betterproto.Message):
+ timestamp: int = betterproto.uint64_field(1)
+ dynamic_parameters: List["TuningStateParameter"] = betterproto.message_field(2)
+
+
+@dataclass
+class TuningStateParameter(betterproto.Message):
+ number: "TuningStateParameterNumberParameter" = betterproto.message_field(
+ 1, group="parameter"
+ )
+ string: "TuningStateParameterStringParameter" = betterproto.message_field(
+ 3, group="parameter"
+ )
+
+
+@dataclass
+class TuningStateParameterNumberParameter(betterproto.Message):
+ """
+ note: it may seem weird to not extract the key from the oneof, but this is
+ so that the parser can easily determine the type of the parameter
+ extracting it to a separate field on the same level as oneof would make it
+ ambiguous
+ """
+
+ key: str = betterproto.string_field(1)
+ value: float = betterproto.float_field(2)
+
+
+@dataclass
+class TuningStateParameterStringParameter(betterproto.Message):
+ key: str = betterproto.string_field(1)
+ value: str = betterproto.string_field(2)
diff --git a/src/roverlib/streams.py b/src/roverlib/streams.py
new file mode 100644
index 0000000..d24fb1e
--- /dev/null
+++ b/src/roverlib/streams.py
@@ -0,0 +1,208 @@
+import zmq
+import roverlib.rovercom as rovercom
+from roverlib.bootinfo import Service
+from loguru import logger
+
+CONTEXT = zmq.Context()
+
+
+
+class ServiceStream:
+ def __init__(self, address : str, sock_type : zmq.Socket):
+ self.address = address # zmq address
+ self.socket = None # initialized as None, before lazy loading
+ self.sock_type = sock_type
+ self.bytes = 0 # amount of bytes read/written so far
+
+
+
+
+class WriteStream:
+ def __init__(self, stream : ServiceStream):
+ self.stream = stream
+
+ # Initial setup of the stream (done lazily, on the first write)
+ def _initialize(self):
+ s = self.stream
+
+ # already initialized
+ if s.socket is not None:
+ return
+
+ try:
+ #create a new socket
+ socket = CONTEXT.socket(s.sock_type)
+ socket.bind(s.address)
+ except zmq.ZMQError as e:
+ if socket:
+ socket.close()
+ logger.critical(f"Failed to create/bind write socket at {s.address}: {str(e)}")
+ raise zmq.ZMQError(f"Failed to create/bind write socket at {s.address}: {str(e)}")
+
+ s.socket = socket
+ s.bytes = 0
+
+ # Write byte data to the stream
+ def WriteBytes(self, data : bytes):
+ s = self.stream
+
+ if s.socket is None:
+ self._initialize()
+
+ # Check if the socket writable
+ if s.sock_type != zmq.PUB:
+ logger.critical("Cannot write to a read-only stream")
+ raise TypeError("Cannot write to a read-only stream")
+
+ try:
+ # Write the data
+ s.socket.send(data)
+ except zmq.ZMQError as e:
+ logger.critical(f"Failed to write to stream: {str(e)}")
+ raise zmq.ZMQError(f"Failed to write to stream: {str(e)}")
+
+ if isinstance(data, (bytes, str)):
+ s.bytes += len(data)
+ else:
+ s.bytes += 1
+
+
+ # Write a rovercom sensor output message to the stream
+ def Write(self, output : rovercom.SensorOutput):
+ if output is None:
+ logger.critical("Cannot write nil output")
+ raise ValueError("Cannot write nil output")
+
+ try:
+ # Convert to over-the-wire format
+ buf = output.SerializeToString()
+ except Exception as e:
+ logger.critical(f"Failed to serialize sensor data: {str(e)}")
+ raise RuntimeError(f"Failed to serialize sensor data: {str(e)}")
+
+ # Write the data
+ return self.WriteBytes(buf)
+
+class ReadStream:
+ def __init__(self, stream : ServiceStream):
+ self.stream = stream
+
+ # initial setup of the stream (done lazily, on the first read)
+ def _initialize(self):
+ s = self.stream
+
+ # Already initialized
+ if s.socket is not None:
+ return
+
+ try:
+ # Create a new socket
+ socket = CONTEXT.socket(s.sock_type)
+ socket.connect(s.address)
+ socket.setsockopt_string(zmq.SUBSCRIBE, "")
+ except zmq.ZMQError as e:
+ if socket:
+ socket.close()
+ logger.critical(f"Failed to create/connect/subscribe read socket at {s.address}: {str(e)}")
+ raise zmq.ZMQError(f"Failed to create/connect/subscribe read socket at {s.address}: {str(e)}")
+
+ s.socket = socket
+ s.bytes = 0
+
+ # Read byte data from the stream
+ def ReadBytes(self) -> bytes:
+ s = self.stream
+
+ if s.socket is None:
+ self._initialize()
+
+ # Check if the socket is readable
+ if s.sock_type != zmq.SUB:
+ logger.critical("Cannot write to a read-only stream")
+ raise TypeError("Cannot write to a read-only stream")
+
+ try:
+ # Read the data
+ data = s.socket.recv()
+ except zmq.ZMQError as e:
+ logger.critical(f"failed to read from stream: {str(e)}")
+ raise zmq.ZMQError(f"failed to read from stream: {str(e)}")
+
+ s.bytes += len(data)
+ return data
+
+ # Read a rovercom sensor output message from the stream
+ def Read(self) -> rovercom.SensorOutput:
+ # Read the Data
+ buf = self.ReadBytes()
+
+ try:
+ # Convert from over-the-wire format
+ output = rovercom.SensorOutput().parse(buf)
+ except Exception as e:
+ logger.critical(f"Failed to parse sensor data: {str(e)}")
+ raise RuntimeError(f"Failed to parse sensor data: {str(e)}")
+
+ return output
+
+
+# Map of all already handed out streams to the user program (to preserve singletons)
+write_streams : dict[str, WriteStream] = {}
+read_streams : dict[str, ReadStream] = {}
+
+
+
+# Get a stream that you can write to (i.e. an output stream).
+# This function throws an error if the stream does not exist.
+def GetWriteStream(self : Service, name : str) -> WriteStream:
+ # Is this stream already handed out?
+ if name in write_streams:
+ return write_streams[name]
+
+ # Does this stream exist?
+ for output in self.outputs:
+ if output.name == name:
+ # ZMQ wants to bind write streams to tcp://*:port addresses, so if roverd gave us a localhost, we need to change it to *
+ address = output.address.replace("localhost", "*", 1)
+
+ # Create a new stream
+ stream = ServiceStream(address, zmq.PUB)
+
+ res = WriteStream(stream)
+ write_streams[name] = res
+ return res
+
+ logger.critical(f"Output stream {name} does not exist. Update your program code or service.yaml")
+ raise NameError(f"Output stream {name} does not exist. Update your program code or service.yaml")
+
+
+# Get a stream that you can read from (i.e. an input stream).
+# This function throws an error if the stream does not exist.
+def GetReadStream(self : Service, service : str, name : str) -> ReadStream:
+ stream_name = f"{service}-{name}"
+
+ # Is this stream already handed out?
+ if stream_name in read_streams:
+ return read_streams[stream_name]
+
+ # Does this stream exist
+ for input in self.inputs:
+ if input.service == service:
+ for stream in input.streams:
+ if stream.name == name:
+
+ # Create a new stream
+ stream = ServiceStream(stream.address, zmq.SUB)
+
+ res = ReadStream(stream)
+ read_streams[stream_name] = res
+ return res
+
+ logger.critical(f"Input stream {stream_name} does not exist. Update your program code or service.yaml")
+ raise NameError(f"Input stream {stream_name} does not exist. Update your program code or service.yaml")
+
+
+# Attach to Service object
+Service.GetWriteStream = GetWriteStream
+Service.GetReadStream = GetReadStream
+
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/basic.py b/tests/basic.py
new file mode 100644
index 0000000..108a66f
--- /dev/null
+++ b/tests/basic.py
@@ -0,0 +1,87 @@
+"""
+Basic tests
+"""
+
+import roverlib as rover
+import time
+import signal
+from loguru import logger
+import roverlib.rovercom as rovercom
+import threading
+from .testing import inject_valid_service
+
+runThread = True
+
+
+
+
+def send_continuous(stream : rover.WriteStream):
+ while runThread:
+ time.sleep(1)
+ stream.Write(
+ rovercom.SensorOutput(
+ sensor_id=2,
+ timestamp=int(time.time() * 1000),
+ controller_output=rovercom.ControllerOutput(
+ steering_angle=float(1),
+ left_throttle=float(0),
+ right_throttle=float(0),
+ front_lights=False
+ ),
+ )
+ )
+
+def run(service : rover.Service, configuration : rover.ServiceConfiguration):
+ time.sleep(1)
+
+ speed = configuration.GetFloatSafe("speed")
+ logger.info(speed)
+ assert speed == 1.5
+
+
+ ll = configuration.GetStringSafe("log-level")
+ logger.info(ll)
+ assert ll == "debug"
+
+ maxIt = configuration.GetFloat("max-iterations")
+ logger.info(maxIt)
+ assert maxIt == 100
+
+ ######################################################
+
+ wr : rover.WriteStream = service.GetWriteStream("motor_movement")
+ rd : rover.ReadStream = service.GetReadStream("imaging", "track_data")
+
+
+ thread_send = threading.Thread(target=send_continuous, args=(wr,), daemon=True)
+ thread_send.start()
+
+
+
+ output = rd.Read()
+
+ global runThread
+ runThread = False
+
+
+ assert output.sensor_id == 2
+
+
+
+
+
+
+
+
+def onTerminate(sig : signal):
+ logger.info("Terminating")
+ return None
+
+
+
+inject_valid_service()
+
+
+rover.Run(run, onTerminate)
+
+
diff --git a/tests/testing.py b/tests/testing.py
new file mode 100644
index 0000000..d8f958a
--- /dev/null
+++ b/tests/testing.py
@@ -0,0 +1,40 @@
+import os
+import json
+
+def inject_valid_service():
+ service = {
+ "name": "controller",
+ "version": "1.0.1",
+ "inputs": [
+ {
+ "service": "imaging",
+ "streams": [
+ {"name": "track_data", "address": "tcp://localhost:7882"}, #7890
+ {"name": "debug_info", "address": "tcp://unix:7891"}
+ ]
+ },
+ {
+ "service": "navigation",
+ "streams": [
+ {"name": "location_data", "address": "tcp://unix:7892"}
+ ]
+ }
+ ],
+ "outputs": [
+ {"name": "motor_movement", "address": "tcp://*:7882"},
+ {"name": "sensor_data", "address": "tcp://unix:7883"}
+ ],
+ "configuration": [
+ {"name": "max-iterations", "type": "number", "tunable": True, "value": 100},
+ {"name": "speed", "type": "number", "tunable": True, "value": 1.5},
+ {"name": "log-level", "type": "string", "tunable": False, "value": "debug"}
+ ],
+ "tuning": {
+ "enabled": True,
+ "address": "tcp://localhost:8829"
+ }
+ }
+
+ os.environ["ASE_SERVICE"] = json.dumps(service)
+
+
diff --git a/tests/tuning.py b/tests/tuning.py
new file mode 100644
index 0000000..2f8f7d2
--- /dev/null
+++ b/tests/tuning.py
@@ -0,0 +1,58 @@
+"""
+tests for tuning
+"""
+
+
+import roverlib as rover
+import time
+import signal
+from loguru import logger
+import roverlib.rovercom as rovercom
+import zmq
+from .testing import inject_valid_service
+
+
+
+def run(service : rover.Service, configuration : rover.ServiceConfiguration):
+ ######################################################
+
+ time.sleep(2)
+
+ context = zmq.Context()
+
+ socket = context.socket(zmq.PUB)
+ socket.bind("tcp://*:8829")
+
+ assert abs(configuration.GetFloatSafe("speed") - 1.5) < 0.01
+
+
+ tuning = rovercom.TuningState(timestamp=int(time.time() * 1000), dynamic_parameters=[
+ rovercom.TuningStateParameter(number=rovercom.TuningStateParameterNumberParameter(key="speed",value=1.1))
+ ]
+ ).SerializeToString()
+
+ for i in range(5):
+ socket.send(tuning)
+ time.sleep(0.05)
+
+
+ assert abs(configuration.GetFloatSafe("speed") - 1.1) < 0.01
+
+
+
+
+
+
+
+
+def onTerminate(sig : signal):
+ logger.info("Terminating")
+ return None
+
+
+
+
+inject_valid_service()
+
+
+rover.Run(run, onTerminate)
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..848d4f6
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,457 @@
+version = 1
+revision = 1
+requires-python = ">=3.10"
+
+[[package]]
+name = "betterproto"
+version = "1.2.5"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "grpclib" },
+ { name = "stringcase" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ff/2e/abfed7a721928e14aeb900182ff695be474c4ee5f07ef0874cc5ecd5b0b1/betterproto-1.2.5.tar.gz", hash = "sha256:74a3ab34646054f674d236d1229ba8182dc2eae86feb249b8590ef496ce9803d", size = 26098 }
+
+[[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 }
+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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 },
+ { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 },
+ { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
+ { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
+]
+
+[[package]]
+name = "grpclib"
+version = "0.4.7"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "h2" },
+ { name = "multidict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/b9/55936e462a5925190d7427e880b3033601d1effd13809b483d13a926061a/grpclib-0.4.7.tar.gz", hash = "sha256:2988ef57c02b22b7a2e8e961792c41ccf97efc2ace91ae7a5b0de03c363823c3", size = 61254 }
+
+[[package]]
+name = "h2"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957 },
+]
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
+]
+
+[[package]]
+name = "multidict"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/68/259dee7fd14cf56a17c554125e534f6274c2860159692a414d0b402b9a6d/multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60", size = 48628 },
+ { url = "https://files.pythonhosted.org/packages/50/79/53ba256069fe5386a4a9e80d4e12857ced9de295baf3e20c68cdda746e04/multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1", size = 29327 },
+ { url = "https://files.pythonhosted.org/packages/ff/10/71f1379b05b196dae749b5ac062e87273e3f11634f447ebac12a571d90ae/multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53", size = 29689 },
+ { url = "https://files.pythonhosted.org/packages/71/45/70bac4f87438ded36ad4793793c0095de6572d433d98575a5752629ef549/multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5", size = 126639 },
+ { url = "https://files.pythonhosted.org/packages/80/cf/17f35b3b9509b4959303c05379c4bfb0d7dd05c3306039fc79cf035bbac0/multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581", size = 134315 },
+ { url = "https://files.pythonhosted.org/packages/ef/1f/652d70ab5effb33c031510a3503d4d6efc5ec93153562f1ee0acdc895a57/multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56", size = 129471 },
+ { url = "https://files.pythonhosted.org/packages/a6/64/2dd6c4c681688c0165dea3975a6a4eab4944ea30f35000f8b8af1df3148c/multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429", size = 124585 },
+ { url = "https://files.pythonhosted.org/packages/87/56/e6ee5459894c7e554b57ba88f7257dc3c3d2d379cb15baaa1e265b8c6165/multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748", size = 116957 },
+ { url = "https://files.pythonhosted.org/packages/36/9e/616ce5e8d375c24b84f14fc263c7ef1d8d5e8ef529dbc0f1df8ce71bb5b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db", size = 128609 },
+ { url = "https://files.pythonhosted.org/packages/8c/4f/4783e48a38495d000f2124020dc96bacc806a4340345211b1ab6175a6cb4/multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056", size = 123016 },
+ { url = "https://files.pythonhosted.org/packages/3e/b3/4950551ab8fc39862ba5e9907dc821f896aa829b4524b4deefd3e12945ab/multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76", size = 133542 },
+ { url = "https://files.pythonhosted.org/packages/96/4d/f0ce6ac9914168a2a71df117935bb1f1781916acdecbb43285e225b484b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160", size = 130163 },
+ { url = "https://files.pythonhosted.org/packages/be/72/17c9f67e7542a49dd252c5ae50248607dfb780bcc03035907dafefb067e3/multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7", size = 126832 },
+ { url = "https://files.pythonhosted.org/packages/71/9f/72d719e248cbd755c8736c6d14780533a1606ffb3fbb0fbd77da9f0372da/multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0", size = 26402 },
+ { url = "https://files.pythonhosted.org/packages/04/5a/d88cd5d00a184e1ddffc82aa2e6e915164a6d2641ed3606e766b5d2f275a/multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d", size = 28800 },
+ { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 },
+ { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 },
+ { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 },
+ { url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067 },
+ { url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507 },
+ { url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905 },
+ { url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004 },
+ { url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308 },
+ { url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608 },
+ { url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029 },
+ { url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594 },
+ { url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556 },
+ { url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993 },
+ { url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405 },
+ { url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795 },
+ { url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713 },
+ { url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516 },
+ { url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557 },
+ { url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170 },
+ { url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836 },
+ { url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475 },
+ { url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049 },
+ { url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370 },
+ { url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178 },
+ { url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567 },
+ { url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822 },
+ { url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656 },
+ { url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360 },
+ { url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382 },
+ { url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529 },
+ { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 },
+ { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 },
+ { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 },
+ { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 },
+ { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 },
+ { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 },
+ { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 },
+ { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 },
+ { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 },
+ { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 },
+ { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 },
+ { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 },
+ { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 },
+ { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 },
+ { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 },
+ { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 },
+]
+
+[[package]]
+name = "packaging"
+version = "24.2"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.4"
+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 = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
+]
+
+[[package]]
+name = "pyzmq"
+version = "26.2.1"
+source = { registry = "https://pypi.org/simple/" }
+dependencies = [
+ { name = "cffi", marker = "implementation_name == 'pypy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/e3/8d0382cb59feb111c252b54e8728257416a38ffcb2243c4e4775a3c990fe/pyzmq-26.2.1.tar.gz", hash = "sha256:17d72a74e5e9ff3829deb72897a175333d3ef5b5413948cae3cf7ebf0b02ecca", size = 278433 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/3d/c2d9d46c033d1b51692ea49a22439f7f66d91d5c938e8b5c56ed7a2151c2/pyzmq-26.2.1-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:f39d1227e8256d19899d953e6e19ed2ccb689102e6d85e024da5acf410f301eb", size = 1345451 },
+ { url = "https://files.pythonhosted.org/packages/0e/df/4754a8abcdeef280651f9bb51446c47659910940b392a66acff7c37f5cef/pyzmq-26.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a23948554c692df95daed595fdd3b76b420a4939d7a8a28d6d7dea9711878641", size = 942766 },
+ { url = "https://files.pythonhosted.org/packages/74/da/e6053a3b13c912eded6c2cdeee22ff3a4c33820d17f9eb24c7b6e957ffe7/pyzmq-26.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95f5728b367a042df146cec4340d75359ec6237beebf4a8f5cf74657c65b9257", size = 678488 },
+ { url = "https://files.pythonhosted.org/packages/9e/50/614934145244142401ca174ca81071777ab93aa88173973ba0154f491e09/pyzmq-26.2.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f7b01b3f275504011cf4cf21c6b885c8d627ce0867a7e83af1382ebab7b3ff", size = 917115 },
+ { url = "https://files.pythonhosted.org/packages/80/2b/ebeb7bc4fc8e9e61650b2e09581597355a4341d413fa9b2947d7a6558119/pyzmq-26.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a00370a2ef2159c310e662c7c0f2d030f437f35f478bb8b2f70abd07e26b24", size = 874162 },
+ { url = "https://files.pythonhosted.org/packages/79/48/93210621c331ad16313dc2849801411fbae10d91d878853933f2a85df8e7/pyzmq-26.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8531ed35dfd1dd2af95f5d02afd6545e8650eedbf8c3d244a554cf47d8924459", size = 874180 },
+ { url = "https://files.pythonhosted.org/packages/f0/8b/40924b4d8e33bfdd54c1970fb50f327e39b90b902f897cf09b30b2e9ac48/pyzmq-26.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cdb69710e462a38e6039cf17259d328f86383a06c20482cc154327968712273c", size = 1208139 },
+ { url = "https://files.pythonhosted.org/packages/c8/b2/82d6675fc89bd965eae13c45002c792d33f06824589844b03f8ea8fc6d86/pyzmq-26.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e7eeaef81530d0b74ad0d29eec9997f1c9230c2f27242b8d17e0ee67662c8f6e", size = 1520666 },
+ { url = "https://files.pythonhosted.org/packages/9d/e2/5ff15f2d3f920dcc559d477bd9bb3faacd6d79fcf7c5448e585c78f84849/pyzmq-26.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:361edfa350e3be1f987e592e834594422338d7174364763b7d3de5b0995b16f3", size = 1420056 },
+ { url = "https://files.pythonhosted.org/packages/40/a2/f9bbeccf7f75aa0d8963e224e5730abcefbf742e1f2ae9ea60fd9d6ff72b/pyzmq-26.2.1-cp310-cp310-win32.whl", hash = "sha256:637536c07d2fb6a354988b2dd1d00d02eb5dd443f4bbee021ba30881af1c28aa", size = 583874 },
+ { url = "https://files.pythonhosted.org/packages/56/b1/44f513135843272f0e12f5aebf4af35839e2a88eb45411f2c8c010d8c856/pyzmq-26.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:45fad32448fd214fbe60030aa92f97e64a7140b624290834cc9b27b3a11f9473", size = 647367 },
+ { url = "https://files.pythonhosted.org/packages/27/9c/1bef14a37b02d651a462811bbdb1390b61cd4a5b5e95cbd7cc2d60ef848c/pyzmq-26.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d9da0289d8201c8a29fd158aaa0dfe2f2e14a181fd45e2dc1fbf969a62c1d594", size = 561784 },
+ { url = "https://files.pythonhosted.org/packages/b9/03/5ecc46a6ed5971299f5c03e016ca637802d8660e44392bea774fb7797405/pyzmq-26.2.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:c059883840e634a21c5b31d9b9a0e2b48f991b94d60a811092bc37992715146a", size = 1346032 },
+ { url = "https://files.pythonhosted.org/packages/40/51/48fec8f990ee644f461ff14c8fe5caa341b0b9b3a0ad7544f8ef17d6f528/pyzmq-26.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed038a921df836d2f538e509a59cb638df3e70ca0fcd70d0bf389dfcdf784d2a", size = 943324 },
+ { url = "https://files.pythonhosted.org/packages/c1/f4/f322b389727c687845e38470b48d7a43c18a83f26d4d5084603c6c3f79ca/pyzmq-26.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9027a7fcf690f1a3635dc9e55e38a0d6602dbbc0548935d08d46d2e7ec91f454", size = 678418 },
+ { url = "https://files.pythonhosted.org/packages/a8/df/2834e3202533bd05032d83e02db7ac09fa1be853bbef59974f2b2e3a8557/pyzmq-26.2.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d75fcb00a1537f8b0c0bb05322bc7e35966148ffc3e0362f0369e44a4a1de99", size = 915466 },
+ { url = "https://files.pythonhosted.org/packages/b5/e2/45c0f6e122b562cb8c6c45c0dcac1160a4e2207385ef9b13463e74f93031/pyzmq-26.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0019cc804ac667fb8c8eaecdb66e6d4a68acf2e155d5c7d6381a5645bd93ae4", size = 873347 },
+ { url = "https://files.pythonhosted.org/packages/de/b9/3e0fbddf8b87454e914501d368171466a12550c70355b3844115947d68ea/pyzmq-26.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f19dae58b616ac56b96f2e2290f2d18730a898a171f447f491cc059b073ca1fa", size = 874545 },
+ { url = "https://files.pythonhosted.org/packages/1f/1c/1ee41d6e10b2127263b1994bc53b9e74ece015b0d2c0a30e0afaf69b78b2/pyzmq-26.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f5eeeb82feec1fc5cbafa5ee9022e87ffdb3a8c48afa035b356fcd20fc7f533f", size = 1208630 },
+ { url = "https://files.pythonhosted.org/packages/3d/a9/50228465c625851a06aeee97c74f253631f509213f979166e83796299c60/pyzmq-26.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:000760e374d6f9d1a3478a42ed0c98604de68c9e94507e5452951e598ebecfba", size = 1519568 },
+ { url = "https://files.pythonhosted.org/packages/c6/f2/6360b619e69da78863c2108beb5196ae8b955fe1e161c0b886b95dc6b1ac/pyzmq-26.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:817fcd3344d2a0b28622722b98500ae9c8bfee0f825b8450932ff19c0b15bebd", size = 1419677 },
+ { url = "https://files.pythonhosted.org/packages/da/d5/f179da989168f5dfd1be8103ef508ade1d38a8078dda4f10ebae3131a490/pyzmq-26.2.1-cp311-cp311-win32.whl", hash = "sha256:88812b3b257f80444a986b3596e5ea5c4d4ed4276d2b85c153a6fbc5ca457ae7", size = 582682 },
+ { url = "https://files.pythonhosted.org/packages/60/50/e5b2e9de3ffab73ff92bee736216cf209381081fa6ab6ba96427777d98b1/pyzmq-26.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:ef29630fde6022471d287c15c0a2484aba188adbfb978702624ba7a54ddfa6c1", size = 648128 },
+ { url = "https://files.pythonhosted.org/packages/d9/fe/7bb93476dd8405b0fc9cab1fd921a08bd22d5e3016aa6daea1a78d54129b/pyzmq-26.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:f32718ee37c07932cc336096dc7403525301fd626349b6eff8470fe0f996d8d7", size = 562465 },
+ { url = "https://files.pythonhosted.org/packages/9c/b9/260a74786f162c7f521f5f891584a51d5a42fd15f5dcaa5c9226b2865fcc/pyzmq-26.2.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:a6549ecb0041dafa55b5932dcbb6c68293e0bd5980b5b99f5ebb05f9a3b8a8f3", size = 1348495 },
+ { url = "https://files.pythonhosted.org/packages/bf/73/8a0757e4b68f5a8ccb90ddadbb76c6a5f880266cdb18be38c99bcdc17aaa/pyzmq-26.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0250c94561f388db51fd0213cdccbd0b9ef50fd3c57ce1ac937bf3034d92d72e", size = 945035 },
+ { url = "https://files.pythonhosted.org/packages/cf/de/f02ec973cd33155bb772bae33ace774acc7cc71b87b25c4829068bec35de/pyzmq-26.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36ee4297d9e4b34b5dc1dd7ab5d5ea2cbba8511517ef44104d2915a917a56dc8", size = 671213 },
+ { url = "https://files.pythonhosted.org/packages/d1/80/8fc583085f85ac91682744efc916888dd9f11f9f75a31aef1b78a5486c6c/pyzmq-26.2.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2a9cb17fd83b7a3a3009901aca828feaf20aa2451a8a487b035455a86549c09", size = 908750 },
+ { url = "https://files.pythonhosted.org/packages/c3/25/0b4824596f261a3cc512ab152448b383047ff5f143a6906a36876415981c/pyzmq-26.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:786dd8a81b969c2081b31b17b326d3a499ddd1856e06d6d79ad41011a25148da", size = 865416 },
+ { url = "https://files.pythonhosted.org/packages/a1/d1/6fda77a034d02034367b040973fd3861d945a5347e607bd2e98c99f20599/pyzmq-26.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2d88ba221a07fc2c5581565f1d0fe8038c15711ae79b80d9462e080a1ac30435", size = 865922 },
+ { url = "https://files.pythonhosted.org/packages/ad/81/48f7fd8a71c427412e739ce576fc1ee14f3dc34527ca9b0076e471676183/pyzmq-26.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c84c1297ff9f1cd2440da4d57237cb74be21fdfe7d01a10810acba04e79371a", size = 1201526 },
+ { url = "https://files.pythonhosted.org/packages/c7/d8/818f15c6ef36b5450e435cbb0d3a51599fc884a5d2b27b46b9c00af68ef1/pyzmq-26.2.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46d4ebafc27081a7f73a0f151d0c38d4291656aa134344ec1f3d0199ebfbb6d4", size = 1512808 },
+ { url = "https://files.pythonhosted.org/packages/d9/c4/b3edb7d0ae82ad6fb1a8cdb191a4113c427a01e85139906f3b655b07f4f8/pyzmq-26.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:91e2bfb8e9a29f709d51b208dd5f441dc98eb412c8fe75c24ea464734ccdb48e", size = 1411836 },
+ { url = "https://files.pythonhosted.org/packages/69/1c/151e3d42048f02cc5cd6dfc241d9d36b38375b4dee2e728acb5c353a6d52/pyzmq-26.2.1-cp312-cp312-win32.whl", hash = "sha256:4a98898fdce380c51cc3e38ebc9aa33ae1e078193f4dc641c047f88b8c690c9a", size = 581378 },
+ { url = "https://files.pythonhosted.org/packages/b6/b9/d59a7462848aaab7277fddb253ae134a570520115d80afa85e952287e6bc/pyzmq-26.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a0741edbd0adfe5f30bba6c5223b78c131b5aa4a00a223d631e5ef36e26e6d13", size = 643737 },
+ { url = "https://files.pythonhosted.org/packages/55/09/f37e707937cce328944c1d57e5e50ab905011d35252a0745c4f7e5822a76/pyzmq-26.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:e5e33b1491555843ba98d5209439500556ef55b6ab635f3a01148545498355e5", size = 558303 },
+ { url = "https://files.pythonhosted.org/packages/4f/2e/fa7a91ce349975971d6aa925b4c7e1a05abaae99b97ade5ace758160c43d/pyzmq-26.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:099b56ef464bc355b14381f13355542e452619abb4c1e57a534b15a106bf8e23", size = 942331 },
+ { url = "https://files.pythonhosted.org/packages/64/2b/1f10b34b6dc7ff4b40f668ea25ba9b8093ce61d874c784b90229b367707b/pyzmq-26.2.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:651726f37fcbce9f8dd2a6dab0f024807929780621890a4dc0c75432636871be", size = 1345831 },
+ { url = "https://files.pythonhosted.org/packages/4c/8d/34884cbd4a8ec050841b5fb58d37af136766a9f95b0b2634c2971deb09da/pyzmq-26.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57dd4d91b38fa4348e237a9388b4423b24ce9c1695bbd4ba5a3eada491e09399", size = 670773 },
+ { url = "https://files.pythonhosted.org/packages/0f/f4/d4becfcf9e416ad2564f18a6653f7c6aa917da08df5c3760edb0baa1c863/pyzmq-26.2.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d51a7bfe01a48e1064131f3416a5439872c533d756396be2b39e3977b41430f9", size = 908836 },
+ { url = "https://files.pythonhosted.org/packages/07/fa/ab105f1b86b85cb2e821239f1d0900fccd66192a91d97ee04661b5436b4d/pyzmq-26.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7154d228502e18f30f150b7ce94f0789d6b689f75261b623f0fdc1eec642aab", size = 865369 },
+ { url = "https://files.pythonhosted.org/packages/c9/48/15d5f415504572dd4b92b52db5de7a5befc76bb75340ba9f36f71306a66d/pyzmq-26.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f1f31661a80cc46aba381bed475a9135b213ba23ca7ff6797251af31510920ce", size = 865676 },
+ { url = "https://files.pythonhosted.org/packages/7e/35/2d91bcc7ccbb56043dd4d2c1763f24a8de5f05e06a134f767a7fb38e149c/pyzmq-26.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:290c96f479504439b6129a94cefd67a174b68ace8a8e3f551b2239a64cfa131a", size = 1201457 },
+ { url = "https://files.pythonhosted.org/packages/6d/bb/aa7c5119307a5762b8dca6c9db73e3ab4bccf32b15d7c4f376271ff72b2b/pyzmq-26.2.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f2c307fbe86e18ab3c885b7e01de942145f539165c3360e2af0f094dd440acd9", size = 1513035 },
+ { url = "https://files.pythonhosted.org/packages/4f/4c/527e6650c2fccec7750b783301329c8a8716d59423818afb67282304ce5a/pyzmq-26.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b314268e716487bfb86fcd6f84ebbe3e5bec5fac75fdf42bc7d90fdb33f618ad", size = 1411881 },
+ { url = "https://files.pythonhosted.org/packages/89/9f/e4412ea1b3e220acc21777a5edba8885856403d29c6999aaf00a9459eb03/pyzmq-26.2.1-cp313-cp313-win32.whl", hash = "sha256:edb550616f567cd5603b53bb52a5f842c0171b78852e6fc7e392b02c2a1504bb", size = 581354 },
+ { url = "https://files.pythonhosted.org/packages/55/cd/f89dd3e9fc2da0d1619a82c4afb600c86b52bc72d7584953d460bc8d5027/pyzmq-26.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:100a826a029c8ef3d77a1d4c97cbd6e867057b5806a7276f2bac1179f893d3bf", size = 643560 },
+ { url = "https://files.pythonhosted.org/packages/a7/99/5de4f8912860013f1116f818a0047659bc20d71d1bc1d48f874bdc2d7b9c/pyzmq-26.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:6991ee6c43e0480deb1b45d0c7c2bac124a6540cba7db4c36345e8e092da47ce", size = 558037 },
+ { url = "https://files.pythonhosted.org/packages/06/0b/63b6d7a2f07a77dbc9768c6302ae2d7518bed0c6cee515669ca0d8ec743e/pyzmq-26.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:25e720dba5b3a3bb2ad0ad5d33440babd1b03438a7a5220511d0c8fa677e102e", size = 938580 },
+ { url = "https://files.pythonhosted.org/packages/85/38/e5e2c3ffa23ea5f95f1c904014385a55902a11a67cd43c10edf61a653467/pyzmq-26.2.1-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:9ec6abfb701437142ce9544bd6a236addaf803a32628d2260eb3dbd9a60e2891", size = 1339670 },
+ { url = "https://files.pythonhosted.org/packages/d2/87/da5519ed7f8b31e4beee8f57311ec02926822fe23a95120877354cd80144/pyzmq-26.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e1eb9d2bfdf5b4e21165b553a81b2c3bd5be06eeddcc4e08e9692156d21f1f6", size = 660983 },
+ { url = "https://files.pythonhosted.org/packages/f6/e8/1ca6a2d59562e04d326a026c9e3f791a6f1a276ebde29da478843a566fdb/pyzmq-26.2.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90dc731d8e3e91bcd456aa7407d2eba7ac6f7860e89f3766baabb521f2c1de4a", size = 896509 },
+ { url = "https://files.pythonhosted.org/packages/5c/e5/0b4688f7c74bea7e4f1e920da973fcd7d20175f4f1181cb9b692429c6bb9/pyzmq-26.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b6a93d684278ad865fc0b9e89fe33f6ea72d36da0e842143891278ff7fd89c3", size = 853196 },
+ { url = "https://files.pythonhosted.org/packages/8f/35/c17241da01195001828319e98517683dad0ac4df6fcba68763d61b630390/pyzmq-26.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1bb37849e2294d519117dd99b613c5177934e5c04a5bb05dd573fa42026567e", size = 855133 },
+ { url = "https://files.pythonhosted.org/packages/d2/14/268ee49bbecc3f72e225addeac7f0e2bd5808747b78c7bf7f87ed9f9d5a8/pyzmq-26.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:632a09c6d8af17b678d84df442e9c3ad8e4949c109e48a72f805b22506c4afa7", size = 1191612 },
+ { url = "https://files.pythonhosted.org/packages/5e/02/6394498620b1b4349b95c534f3ebc3aef95f39afbdced5ed7ee315c49c14/pyzmq-26.2.1-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:fc409c18884eaf9ddde516d53af4f2db64a8bc7d81b1a0c274b8aa4e929958e8", size = 1500824 },
+ { url = "https://files.pythonhosted.org/packages/17/fc/b79f0b72891cbb9917698add0fede71dfb64e83fa3481a02ed0e78c34be7/pyzmq-26.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:17f88622b848805d3f6427ce1ad5a2aa3cf61f12a97e684dab2979802024d460", size = 1399943 },
+ { url = "https://files.pythonhosted.org/packages/65/d1/e630a75cfb2534574a1258fda54d02f13cf80b576d4ce6d2aa478dc67829/pyzmq-26.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:380816d298aed32b1a97b4973a4865ef3be402a2e760204509b52b6de79d755d", size = 847743 },
+ { url = "https://files.pythonhosted.org/packages/27/df/f94a711b4f6c4b41e227f9a938103f52acf4c2e949d91cbc682495a48155/pyzmq-26.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97cbb368fd0debdbeb6ba5966aa28e9a1ae3396c7386d15569a6ca4be4572b99", size = 570991 },
+ { url = "https://files.pythonhosted.org/packages/bf/08/0c6f97fb3c9dbfa23382f0efaf8f9aa1396a08a3358974eaae3ee659ed5c/pyzmq-26.2.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf7b5942c6b0dafcc2823ddd9154f419147e24f8df5b41ca8ea40a6db90615c", size = 799664 },
+ { url = "https://files.pythonhosted.org/packages/05/14/f4d4fd8bb8988c667845734dd756e9ee65b9a17a010d5f288dfca14a572d/pyzmq-26.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fe6e28a8856aea808715f7a4fc11f682b9d29cac5d6262dd8fe4f98edc12d53", size = 758156 },
+ { url = "https://files.pythonhosted.org/packages/e3/fe/72e7e166bda3885810bee7b23049133e142f7c80c295bae02c562caeea16/pyzmq-26.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bd8fdee945b877aa3bffc6a5a8816deb048dab0544f9df3731ecd0e54d8c84c9", size = 556563 },
+]
+
+[[package]]
+name = "roverlib"
+version = "0.5.3"
+source = { editable = "." }
+dependencies = [
+ { name = "betterproto" },
+ { name = "loguru" },
+ { name = "pyzmq" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "betterproto" },
+ { name = "loguru" },
+ { name = "pyzmq" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.3.4" },
+ { name = "ruff", specifier = ">=0.9.7" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.9.7"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588 },
+ { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848 },
+ { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525 },
+ { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580 },
+ { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674 },
+ { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151 },
+ { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128 },
+ { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858 },
+ { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046 },
+ { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834 },
+ { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307 },
+ { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039 },
+ { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177 },
+ { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122 },
+ { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751 },
+ { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987 },
+ { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 },
+]
+
+[[package]]
+name = "stringcase"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/1f/1241aa3d66e8dc1612427b17885f5fcd9c9ee3079fc0d28e9a3aeeb36fa3/stringcase-1.2.0.tar.gz", hash = "sha256:48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008", size = 2958 }
+
+[[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 }
+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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
+ { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
+ { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
+ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
+ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple/" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
+]