From c80da36d7806a8dab7af036acbbcaf5d80838f03 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Wed, 26 Feb 2025 08:34:31 +0000 Subject: [PATCH 01/11] feat: local functionality --- .devcontainer/Dockerfile | 42 ++++ .devcontainer/devcontainer.json | 22 ++ .gitignore | 4 + Makefile | 5 + src/bootinfo.py | 271 +++++++++++++++++++++++++ src/callbacks.py | 11 + src/configuration.py | 81 ++++++++ src/index.py | 157 +++++++++++++++ src/rovercom.py | 345 ++++++++++++++++++++++++++++++++ src/streams.py | 214 ++++++++++++++++++++ src/testing.py | 40 ++++ 11 files changed, 1192 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 Makefile create mode 100644 src/bootinfo.py create mode 100644 src/callbacks.py create mode 100644 src/configuration.py create mode 100644 src/index.py create mode 100644 src/rovercom.py create mode 100644 src/streams.py create mode 100644 src/testing.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b18a3ea --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,42 @@ +# 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 \ + 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 \ + && rm -rf /var/lib/apt/lists/* + +# Install Python 3.12.1 from source +RUN curl -fsSL "https://www.python.org/ftp/python/3.12.1/Python-3.12.1.tgz" -o "Python-3.12.1.tgz" && \ + tar -xzf "Python-3.12.1.tgz" --strip-components=1 -C /usr/local/src && \ + cd /usr/local/src && \ + ./configure --enable-optimizations && \ + make -j$(nproc) && \ + make altinstall && \ + ln -s /usr/local/bin/python3.12 /usr/local/bin/python3 && \ + ln -s /usr/local/bin/pip3.12 /usr/local/bin/pip3 && \ + cd / && \ + rm -rf /usr/local/src "Python-3.12.1.tgz" + +# Verify installation +RUN python3 --version && pip3 --version + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4716d75 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "name": "ASE-roverlib-python", + "build": { + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "extensions": [ + "streetsidesoftware.code-spell-checker", + "ms-vscode.makefile-tools", // Makefile Tools extension + "dbankier.vscode-quick-select" // Quick select with cmd/ctrl+k " + ] + } + }, + "capAdd": [ + "SYS_PTRACE" + ], + "securityOpt": [ + "seccomp=unconfined" + ], + "forwardPorts": [] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 15201ac..10a5131 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,7 @@ cython_debug/ # PyPI configuration file .pypirc +src/h +src/sample.py +src/sample2.py +.vscode diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2269e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +test: + python3 src/sample.py --debug + +testnd: + python3 src/sample.py \ No newline at end of file diff --git a/src/bootinfo.py b/src/bootinfo.py new file mode 100644 index 0000000..0d4d63b --- /dev/null +++ b/src/bootinfo.py @@ -0,0 +1,271 @@ +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: + 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/callbacks.py b/src/callbacks.py new file mode 100644 index 0000000..0abf1de --- /dev/null +++ b/src/callbacks.py @@ -0,0 +1,11 @@ +""" +d +""" + +import signal +from typing import Callable +from configuration import Service, ServiceConfiguration + +MainCallBack = Callable[[Service, ServiceConfiguration], Exception] + +TerminationCallBack = Callable[[signal.Signals], Exception] diff --git a/src/configuration.py b/src/configuration.py new file mode 100644 index 0000000..71f0c90 --- /dev/null +++ b/src/configuration.py @@ -0,0 +1,81 @@ +import threading +import time +from loguru import logger +from bootinfo import Service, TypeEnum + + +class ServiceConfiguration: + def __init__(self): + self.floatOptions : dict[str, float] = {} + self.stringOptions : dict[str, str] = {} + self.tunable : dict[str, bool] = {} + self.lock = threading.RLock() + self.lastUpdate : 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): + logger.debug(self.floatOptions) + if name not in self.floatOptions: + return None, "No float configuration option with name %s" % name + + return self.floatOptions[name], None + + def GetFloatSafe(self, name : str): + 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): + if name not in self.stringOptions: + return None, "No string configuration option with name %s" % name + + return self.stringOptions[name], None + + def GetStringSafe(self, name : 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.floatOptions: + logger.error("%s : %s Is not of type Float" % (name, value)) + self.floatOptions[name] = value + logger.info("%s : %f Set float configuration option" % (name, value)) + else: + logger.error("%s : %f Attempted to set non-tunable float configuration option" % (name, value)) + + # 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.stringOptions: + logger.error("%s : %f Is not of type String" % (name, value)) + return None + self.floatOptions[name] = value + logger.info("%s : %s Set string configuration option" % (name, value)) + else: + logger.error("%s : %s Attempted to set non-tunable string configuration option" % (name, value)) + + + +def NewServiceConfiguration(service : Service): + config = ServiceConfiguration() + for c in service.configuration: + + if c.type == TypeEnum.NUMBER: + config.floatOptions[c.name] = c.value + elif c.type == TypeEnum.STRING: + config.stringOptions[c.name] = c.value + + if c.tunable is True: + config.tunable[c.name] = c.tunable + + return config + diff --git a/src/index.py b/src/index.py new file mode 100644 index 0000000..00cd62e --- /dev/null +++ b/src/index.py @@ -0,0 +1,157 @@ +import argparse +import os +import signal +import sys +import threading +import time +import json +from bootinfo import Service, service_from_dict +from callbacks import MainCallBack, TerminationCallBack +from configuration import ServiceConfiguration, NewServiceConfiguration +#from configuration import * +#from callbacks import MainCallBack, TerminationCallBack + +import zmq +from loguru import logger +import rovercom +from google.protobuf import message + + + +def handleSignals(onTerminate: TerminationCallBack): + def signalHandler(sig, frame): + logger.warning(f"Signal received: {sig}") + + # callback to the service + err = onTerminate(sig) + + if err: + logger.error(f"Error during termination: {sig}") + sys.exit(1) + else: + sys.exit(0) + # catch SIGTERM or SIGINT + signal.signal(signal.SIGTERM, signalHandler) + signal.signal(signal.SIGINT, signalHandler) + + logger.info("Listening for signals...") + + +# Configures log level and output +def setupLogging(debug: bool, output_path: str, serviceName="unknown"): + + logger.remove() + log_format = "{time: HH:mm} {level} [%s] {file}:{line} > {message}" % serviceName + + #TODO format for last 3 elements of path + def format_file_path(file_path: str): + path_parts = file_path.split(os.sep) + return os.path.join(*path_parts[-3:]) + + # set level + logger.add(sys.stderr, format=log_format, level="DEBUG" if debug else "INFO") + + 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 OtaTuning(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.lastUpdate): + 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, onTerminate: 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?") + + serviceDict = json.loads(definition) + service = service_from_dict(serviceDict) + + + + # enable logging using loguru + setupLogging(debug, output, service.name) + + + + # setup for catching SIGTERM and SIGINT, once setup this will run in the background; no active thread needed + handleSignals(onTerminate) + + # 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: + threadTuning = threading.Thread(target=OtaTuning, args=(service, configuration), daemon=True) + threadTuning.start() + + # Run the user program + err = main(service, configuration) + + # Handle termination + if err is not None: + logger.critical("Service quit unexpectedly. Exiting...") + sys.exit(1) + else: + logger.info("Service finished successfully") diff --git a/src/rovercom.py b/src/rovercom.py new file mode 100644 index 0000000..89525a3 --- /dev/null +++ b/src/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/streams.py b/src/streams.py new file mode 100644 index 0000000..0a9abcd --- /dev/null +++ b/src/streams.py @@ -0,0 +1,214 @@ +import zmq +from loguru import logger +import rovercom +import betterproto +from bootinfo import Service + +CONTEXT = zmq.Context() + + + +class ServiceStream: + def __init__(self, address : str, sockType : zmq.Socket): + self.address = address # zmq address + self.socket = None # initialized as None, before lazy loading + self.sockType = sockType + 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 _initLazy(self): + s = self.stream + + # already initialized + if s.socket is not None: + return None + + try: + #create a new socket + socket = CONTEXT.socket(s.sockType) + socket.bind(s.address) + except zmq.ZMQError as e: + if socket: + socket.close() + return f"Failed to create/bind write socket at {s.address}: {str(e)}" + + s.socket = socket + s.bytes = 0 + return None + + # Write byte data to the stream + def WriteBytes(self, data : bytes): + s = self.stream + + if s.socket is None: + + err = self._initLazy() + + if err: + return f"Error during initialization: {str(err)}" + + # Check if the socket writable + if s.sockType != zmq.PUB: + return f"Cannot write to a read-only stream" + + try: + # Write the data + s.socket.send(data) + except zmq.ZMQError as e: + return f"Failed to write to stream: {str(e)}" + + if isinstance(data, (bytes, str)): + s.bytes += len(data) + else: + s.bytes += 1 + + + return None + + # Write a rovercom sensor output message to the stream + def Write(self, output : rovercom.SensorOutput): + if output is None: + return "Cannot write nil output" + + try: + # Convert to over-the-wire format + buf = output.SerializeToString() + except Exception as e: + return 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 _initLazy(self): + s = self.stream + + # Already initialized + if s.socket is not None: + return None + + try: + # Create a new socket + socket = CONTEXT.socket(s.sockType) + socket.connect(s.address) + socket.setsockopt_string(zmq.SUBSCRIBE, "") + except zmq.ZMQError as e: + if socket: + socket.close() + return f"Failed to create/connect/subscribe read socket at {s.address}: {str(e)}" + + s.socket = socket + s.bytes = 0 + return None + + # Read byte data from the stream + def ReadBytes(self): + s = self.stream + + if s.socket is None: + + err = self._initLazy() + if err: + return None, f"Error during initialization: {str(err)}" + + # Check if the socket is readable + if s.sockType != zmq.SUB: + return None, f"Cannot write to a read-only stream" + + try: + # Read the data + data = s.socket.recv() + except zmq.ZMQError as e: + return None, f"failed to read from stream: {str(e)}" + + s.bytes += len(data) + return data, None + + # Read a rovercom sensor output message from the stream + def Read(self): + # Read the Data + buf, err = self.ReadBytes() + + if err is not None: + return None, err + + try: + # Convert from over-the-wire format + output = rovercom.SensorOutput().parse(buf) + except Exception as e: + return None, f"Failed to parse sensor data: {str(e)}" + + return output, None + + +# Map of all already handed out streams to the user program (to preserve singletons) +writeStreams : dict[str, WriteStream] = {} +readStreams : dict[str, ReadStream] = {} + + + +# Get a stream that you can write to (i.e. an output stream). +# This function returns None if the stream does not exist. +def GetWriteStream(self : Service, name : str): + # Is this stream already handed out? + if name in writeStreams: + return writeStreams[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) + writeStreams[name] = res + return res + + logger.critical("Output stream %s does not exist. Update your program code or service.yaml" % name) + return None + + +# Get a stream that you can read from (i.e. an input stream). +# This function returns None if the stream does not exist. +def GetReadStream(self : Service, service : str, name : str): + streamName = f"{service}-{name}" + + # Is this stream already handed out? + if streamName in readStreams: + return readStreams[streamName] + + # 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) + readStreams[streamName] = res + return res + + logger.critical("Input stream %s does not exist. Update your program code or service.yaml" % streamName) + return None + + +# Attach to Service object +Service.GetWriteStream = GetWriteStream +Service.GetReadStream = GetReadStream + \ No newline at end of file diff --git a/src/testing.py b/src/testing.py new file mode 100644 index 0000000..bba940b --- /dev/null +++ b/src/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:788"}, #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) + + From 42f29bc602c5e566d6a8723e8f9ef57904245cbf Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Wed, 26 Feb 2025 09:26:40 +0000 Subject: [PATCH 02/11] chore: added testign files --- .gitignore | 4 +-- sample.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ sample2.py | 49 +++++++++++++++++++++++++++++++ setup.py | 18 ++++++++++++ src/__init__.py | 0 src/index.py | 9 ------ 6 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 sample.py create mode 100644 sample2.py create mode 100644 setup.py create mode 100644 src/__init__.py diff --git a/.gitignore b/.gitignore index 10a5131..e0e0d51 100644 --- a/.gitignore +++ b/.gitignore @@ -169,7 +169,5 @@ cython_debug/ # PyPI configuration file .pypirc -src/h -src/sample.py -src/sample2.py +h .vscode diff --git a/sample.py b/sample.py new file mode 100644 index 0000000..cd344ee --- /dev/null +++ b/sample.py @@ -0,0 +1,78 @@ +from index import Run +from bootinfo import Service +from configuration import ServiceConfiguration +from streams import ReadStream, WriteStream +from loguru import logger +import time +import signal +import rovercom +from testing import inject_valid_service + + +def run(service : Service, configuration : ServiceConfiguration): + time.sleep(1000) + + speed, err = configuration.GetFloatSafe("speed") + logger.info(speed) + + configuration._setString("speed", 1) + + speed, err = configuration.GetFloatSafe("speed") + logger.info(speed) + logger.error(err) + + ll, err = configuration.GetStringSafe("log-level") + logger.info(ll) + logger.error(err) + + maxIt, err = configuration.GetFloat("max-iterations") + logger.info(maxIt) + logger.error(err) + + ###################################################### + + wr : WriteStream = service.GetWriteStream("motor_movement") + + logger.critical(wr.stream.address) + + + + return None + + while True: + err = wr.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 + ), + ) + ) + logger.error(err) + logger.debug("done1") + + + + + logger.critical(err) + + logger.info(wr) + + logger.info(err) + + +def onTerminate(sig : signal): + logger.info("Terminating") + return None + + + + +inject_valid_service() + + +Run(run, onTerminate) diff --git a/sample2.py b/sample2.py new file mode 100644 index 0000000..e550637 --- /dev/null +++ b/sample2.py @@ -0,0 +1,49 @@ +from index import Run +from bootinfo import Service +from configuration import ServiceConfiguration +from streams import ReadStream, WriteStream +from loguru import logger +import time +import signal +import rovercom +import zmq +from testing import inject_valid_service + + + +def run(service : Service, configuration : ServiceConfiguration): + ###################################################### + + context = zmq.Context() + + socket = context.socket(zmq.PUB) + socket.bind("tcp://*:8829") + + + while True: + tuning = rovercom.TuningState(timestamp=int(time.time() * 1000), dynamic_parameters=[ + rovercom.TuningStateParameter(number=rovercom.TuningStateParameterNumberParameter(key="max-iterations",value=5)) + ] + ).SerializeToString() + + + + socket.send(tuning) + + + + + + + +def onTerminate(sig : signal): + logger.info("Terminating") + return None + + + + +inject_valid_service() + + +Run(run, onTerminate) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c3e4255 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, find_packages +from os import path +dir = path.abspath(path.dirname(__file__)) + +with open(path.join(dir, "README.md"), encoding="utf-8") as file: + description = file.read() + + +setup( + name="roverlib", + version="0.0.1", + url = "https://github.com/VU-ASE/roverlib-python", + author="VU-ASE", + long_description=description, + long_description_content_type="text/markdown", + packages=find_packages(), + install_requires=[], +) \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/index.py b/src/index.py index 00cd62e..486602a 100644 --- a/src/index.py +++ b/src/index.py @@ -8,13 +8,9 @@ from bootinfo import Service, service_from_dict from callbacks import MainCallBack, TerminationCallBack from configuration import ServiceConfiguration, NewServiceConfiguration -#from configuration import * -#from callbacks import MainCallBack, TerminationCallBack - import zmq from loguru import logger import rovercom -from google.protobuf import message @@ -43,11 +39,6 @@ def setupLogging(debug: bool, output_path: str, serviceName="unknown"): logger.remove() log_format = "{time: HH:mm} {level} [%s] {file}:{line} > {message}" % serviceName - #TODO format for last 3 elements of path - def format_file_path(file_path: str): - path_parts = file_path.split(os.sep) - return os.path.join(*path_parts[-3:]) - # set level logger.add(sys.stderr, format=log_format, level="DEBUG" if debug else "INFO") From 7d731aab67feba48a40234c9e1f4f777d6568bc0 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Wed, 26 Feb 2025 10:48:20 +0000 Subject: [PATCH 03/11] chore: changed most to snake_case, added requirements.txt --- README.md | 2 ++ requirements.txt | 4 +++ sample2.py | 6 ++--- src/callbacks.py | 8 ++---- src/configuration.py | 63 ++++++++++++++++++++++---------------------- src/index.py | 42 +++++++++++++---------------- src/streams.py | 43 +++++++++++++++--------------- 7 files changed, 82 insertions(+), 86 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index cf11b6a..9bddf6a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # roverlib-python Building a service that runs on the rover? Then you'll need the roverlib. This is the variant for Python. + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..345194e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pyzmq +loguru +betterproto + diff --git a/sample2.py b/sample2.py index e550637..6815e8e 100644 --- a/sample2.py +++ b/sample2.py @@ -30,12 +30,12 @@ def run(service : Service, configuration : ServiceConfiguration): socket.send(tuning) - - - + + + def onTerminate(sig : signal): logger.info("Terminating") return None diff --git a/src/callbacks.py b/src/callbacks.py index 0abf1de..97882ad 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -1,11 +1,7 @@ -""" -d -""" - import signal from typing import Callable from configuration import Service, ServiceConfiguration -MainCallBack = Callable[[Service, ServiceConfiguration], Exception] +MainCallback = Callable[[Service, ServiceConfiguration], Exception] -TerminationCallBack = Callable[[signal.Signals], Exception] +TerminationCallback = Callable[[signal.Signals], Exception] diff --git a/src/configuration.py b/src/configuration.py index 71f0c90..523daff 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -6,22 +6,22 @@ class ServiceConfiguration: def __init__(self): - self.floatOptions : dict[str, float] = {} - self.stringOptions : dict[str, str] = {} + self.float_options : dict[str, float] = {} + self.string_options : dict[str, str] = {} self.tunable : dict[str, bool] = {} self.lock = threading.RLock() - self.lastUpdate : int = int(time.time() * 1000) - + 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): - logger.debug(self.floatOptions) - if name not in self.floatOptions: - return None, "No float configuration option with name %s" % name - - return self.floatOptions[name], None - + logger.debug(self.float_options) + if name not in self.float_options: + return None, f"No float configuration option with name {name}" + + return self.float_options[name], None + def GetFloatSafe(self, name : str): with self.lock: return self.GetFloat(name) @@ -31,37 +31,37 @@ def GetFloatSafe(self, name : str): # 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): - if name not in self.stringOptions: - return None, "No string configuration option with name %s" % name - - return self.stringOptions[name], None - + if name not in self.string_options: + return None, f"No string configuration option with name {name}" + + return self.string_options[name], None + def GetStringSafe(self, name : 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): + def _SetFloat(self, name : str, value : float): with self.lock: if name in self.tunable: - if name not in self.floatOptions: - logger.error("%s : %s Is not of type Float" % (name, value)) - self.floatOptions[name] = value - logger.info("%s : %f Set float configuration option" % (name, value)) + 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("%s : %f Attempted to set non-tunable float configuration option" % (name, value)) - + 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): + def _SetString(self, name : str, value : str): with self.lock: if name in self.tunable: - if name not in self.stringOptions: - logger.error("%s : %f Is not of type String" % (name, value)) + if name not in self.string_options: + logger.error(f"{name} : {value} Is not of type String") return None - self.floatOptions[name] = value - logger.info("%s : %s Set string configuration option" % (name, value)) + self.float_options[name] = value + logger.info(f"{name} : {value} Set string configuration option") else: - logger.error("%s : %s Attempted to set non-tunable string configuration option" % (name, value)) + logger.error(f"{name} : {value} Attempted to set non-tunable string configuration option") @@ -70,12 +70,11 @@ def NewServiceConfiguration(service : Service): for c in service.configuration: if c.type == TypeEnum.NUMBER: - config.floatOptions[c.name] = c.value + config.float_options[c.name] = c.value elif c.type == TypeEnum.STRING: - config.stringOptions[c.name] = c.value - + config.string_options[c.name] = c.value + if c.tunable is True: config.tunable[c.name] = c.tunable return config - diff --git a/src/index.py b/src/index.py index 486602a..6e04250 100644 --- a/src/index.py +++ b/src/index.py @@ -6,7 +6,7 @@ import time import json from bootinfo import Service, service_from_dict -from callbacks import MainCallBack, TerminationCallBack +from callbacks import MainCallback, TerminationCallback from configuration import ServiceConfiguration, NewServiceConfiguration import zmq from loguru import logger @@ -14,12 +14,12 @@ -def handleSignals(onTerminate: TerminationCallBack): - def signalHandler(sig, frame): +def handle_signals(on_terminate: TerminationCallback): + def signal_handler(sig, frame): logger.warning(f"Signal received: {sig}") # callback to the service - err = onTerminate(sig) + err = on_terminate(sig) if err: logger.error(f"Error during termination: {sig}") @@ -27,17 +27,17 @@ def signalHandler(sig, frame): else: sys.exit(0) # catch SIGTERM or SIGINT - signal.signal(signal.SIGTERM, signalHandler) - signal.signal(signal.SIGINT, signalHandler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) logger.info("Listening for signals...") # Configures log level and output -def setupLogging(debug: bool, output_path: str, serviceName="unknown"): +def setup_logging(debug: bool, output_path: str, service_name="unknown"): logger.remove() - log_format = "{time: HH:mm} {level} [%s] {file}:{line} > {message}" % serviceName + 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") @@ -49,7 +49,7 @@ def setupLogging(debug: bool, output_path: str, serviceName="unknown"): logger.info("Logger initialized") -def OtaTuning(service : Service, configuration : ServiceConfiguration): +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) @@ -79,7 +79,7 @@ def OtaTuning(service : Service, configuration : ServiceConfiguration): tuning : rovercom.TuningState = rovercom.TuningState().parse(res) # Is the timestamp later than the last update? - if(tuning.timestamp <= configuration.lastUpdate): + if(tuning.timestamp <= configuration.last_update): logger.info("Received new tuning values with an outdated timestamp, ignoring...") continue @@ -87,19 +87,15 @@ def OtaTuning(service : Service, configuration : ServiceConfiguration): 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) + 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) + configuration._SetString(p.string.key, p.string.value) else: logger.warning("Unknown tuning value type") - - - - -def Run(main: MainCallBack, onTerminate: TerminationCallBack): +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)") @@ -115,18 +111,18 @@ def Run(main: MainCallBack, onTerminate: TerminationCallBack): 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?") - serviceDict = json.loads(definition) - service = service_from_dict(serviceDict) + service_dict = json.loads(definition) + service = service_from_dict(service_dict) # enable logging using loguru - setupLogging(debug, output, service.name) + setup_logging(debug, output, service.name) # setup for catching SIGTERM and SIGINT, once setup this will run in the background; no active thread needed - handleSignals(onTerminate) + handle_signals(on_terminate) # Create a configuration for this service that will be shared with the user program configuration = NewServiceConfiguration(service) @@ -134,8 +130,8 @@ def Run(main: MainCallBack, onTerminate: TerminationCallBack): # Support ota tuning in this thread # (the user program can fetch the latest value from the configuration) if service.tuning.enabled: - threadTuning = threading.Thread(target=OtaTuning, args=(service, configuration), daemon=True) - threadTuning.start() + thread_tuning = threading.Thread(target=ota_tuning, args=(service, configuration), daemon=True) + thread_tuning.start() # Run the user program err = main(service, configuration) diff --git a/src/streams.py b/src/streams.py index 0a9abcd..99f9338 100644 --- a/src/streams.py +++ b/src/streams.py @@ -1,7 +1,6 @@ import zmq from loguru import logger import rovercom -import betterproto from bootinfo import Service CONTEXT = zmq.Context() @@ -9,10 +8,10 @@ class ServiceStream: - def __init__(self, address : str, sockType : zmq.Socket): + def __init__(self, address : str, sock_type : zmq.Socket): self.address = address # zmq address self.socket = None # initialized as None, before lazy loading - self.sockType = sockType + self.sock_type = sock_type self.bytes = 0 # amount of bytes read/written so far @@ -23,7 +22,7 @@ def __init__(self, stream : ServiceStream): self.stream = stream # Initial setup of the stream (done lazily, on the first write) - def _initLazy(self): + def _initialize(self): s = self.stream # already initialized @@ -32,7 +31,7 @@ def _initLazy(self): try: #create a new socket - socket = CONTEXT.socket(s.sockType) + socket = CONTEXT.socket(s.sock_type) socket.bind(s.address) except zmq.ZMQError as e: if socket: @@ -49,14 +48,14 @@ def WriteBytes(self, data : bytes): if s.socket is None: - err = self._initLazy() + err = self._initialize() if err: return f"Error during initialization: {str(err)}" - # Check if the socket writable - if s.sockType != zmq.PUB: - return f"Cannot write to a read-only stream" + # Check if the socket writable + if s.sock_type != zmq.PUB: + return "Cannot write to a read-only stream" try: # Write the data @@ -91,7 +90,7 @@ def __init__(self, stream : ServiceStream): self.stream = stream # initial setup of the stream (done lazily, on the first read) - def _initLazy(self): + def _initialize(self): s = self.stream # Already initialized @@ -118,12 +117,12 @@ def ReadBytes(self): if s.socket is None: - err = self._initLazy() + err = self._initialize() if err: return None, f"Error during initialization: {str(err)}" # Check if the socket is readable - if s.sockType != zmq.SUB: + if s.sock_type != zmq.SUB: return None, f"Cannot write to a read-only stream" try: @@ -153,8 +152,8 @@ def Read(self): # Map of all already handed out streams to the user program (to preserve singletons) -writeStreams : dict[str, WriteStream] = {} -readStreams : dict[str, ReadStream] = {} +write_streams : dict[str, WriteStream] = {} +read_streams : dict[str, ReadStream] = {} @@ -162,8 +161,8 @@ def Read(self): # This function returns None if the stream does not exist. def GetWriteStream(self : Service, name : str): # Is this stream already handed out? - if name in writeStreams: - return writeStreams[name] + if name in write_streams: + return write_streams[name] # Does this stream exist? for output in self.outputs: @@ -175,7 +174,7 @@ def GetWriteStream(self : Service, name : str): stream = ServiceStream(address, zmq.PUB) res = WriteStream(stream) - writeStreams[name] = res + write_streams[name] = res return res logger.critical("Output stream %s does not exist. Update your program code or service.yaml" % name) @@ -185,11 +184,11 @@ def GetWriteStream(self : Service, name : str): # Get a stream that you can read from (i.e. an input stream). # This function returns None if the stream does not exist. def GetReadStream(self : Service, service : str, name : str): - streamName = f"{service}-{name}" + stream_name = f"{service}-{name}" # Is this stream already handed out? - if streamName in readStreams: - return readStreams[streamName] + if stream_name in read_streams: + return read_streams[stream_name] # Does this stream exist for input in self.inputs: @@ -201,10 +200,10 @@ def GetReadStream(self : Service, service : str, name : str): stream = ServiceStream(stream.address, zmq.SUB) res = ReadStream(stream) - readStreams[streamName] = res + read_streams[stream_name] = res return res - logger.critical("Input stream %s does not exist. Update your program code or service.yaml" % streamName) + logger.critical("Input stream %s does not exist. Update your program code or service.yaml" % stream_name) return None From 622c12473a593bcd3f797c30aede608f43fc3674 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Wed, 26 Feb 2025 11:40:03 +0000 Subject: [PATCH 04/11] chore: updated README --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ sample.py | 4 +-- src/index.py | 4 +-- src/streams.py | 42 +++++++++++++++--------------- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9bddf6a..b90177b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,73 @@ # roverlib-python Building a service that runs on the rover? Then you'll need the roverlib. This is the variant for Python. +# Requirements +In order to use roverlib, you will need the following 3 requirements: + +- pyzmq +- loguru +- betterproto + +these can be installed by running: + +```bash +pip install pyzmq loguru betterproto +``` + +# Installation + +To install roverlib-python, simply run: + +```bash +pip install roverlib +``` + +# Usage + +After installation, you can use roverlib as follows: + +```python +import roverlib +import signal +import time + +def run(service : Service, configuration : ServiceConfiguration): + speed, err = configuration.GetFloatSafe("speed") + if err is not None: + logger.error(err) + + name, err = configuration.GetStringSafe("name") + if err is not None: + logger.error(err) + + write_stream = service.GetWriteStream("motor_movement") + if write_stream is None: + return ValueError("WriteStream motor_movement not found") + + err = 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 + ), + ) + ) + + if err is not None: + logger.error(err) + + return None + + +def on_terminate(sig : signal): + logger.info("Terminating") + return None + + +roverlib.Run(run, on_terminate) +``` \ No newline at end of file diff --git a/sample.py b/sample.py index cd344ee..3adf184 100644 --- a/sample.py +++ b/sample.py @@ -10,11 +10,10 @@ def run(service : Service, configuration : ServiceConfiguration): - time.sleep(1000) + time.sleep(1) speed, err = configuration.GetFloatSafe("speed") logger.info(speed) - configuration._setString("speed", 1) speed, err = configuration.GetFloatSafe("speed") @@ -37,7 +36,6 @@ def run(service : Service, configuration : ServiceConfiguration): - return None while True: err = wr.Write( diff --git a/src/index.py b/src/index.py index 6e04250..18c7dcd 100644 --- a/src/index.py +++ b/src/index.py @@ -15,7 +15,7 @@ def handle_signals(on_terminate: TerminationCallback): - def signal_handler(sig, frame): + def signal_handler(sig): logger.warning(f"Signal received: {sig}") # callback to the service @@ -138,7 +138,7 @@ def Run(main: MainCallback, on_terminate: TerminationCallback): # Handle termination if err is not None: - logger.critical("Service quit unexpectedly. Exiting...") + logger.critical(f"Service quit unexpectedly: {err} Exiting...") sys.exit(1) else: logger.info("Service finished successfully") diff --git a/src/streams.py b/src/streams.py index 99f9338..77e7d1a 100644 --- a/src/streams.py +++ b/src/streams.py @@ -70,17 +70,17 @@ def WriteBytes(self, data : bytes): return None - + # Write a rovercom sensor output message to the stream def Write(self, output : rovercom.SensorOutput): if output is None: return "Cannot write nil output" - + try: # Convert to over-the-wire format buf = output.SerializeToString() except Exception as e: - return f"Failed to serialize sensor data: {str(e)}" + return f"Failed to serialize sensor data: {str(e)}" # Write the data return self.WriteBytes(buf) @@ -96,7 +96,7 @@ def _initialize(self): # Already initialized if s.socket is not None: return None - + try: # Create a new socket socket = CONTEXT.socket(s.sockType) @@ -106,11 +106,11 @@ def _initialize(self): if socket: socket.close() return f"Failed to create/connect/subscribe read socket at {s.address}: {str(e)}" - + s.socket = socket s.bytes = 0 return None - + # Read byte data from the stream def ReadBytes(self): s = self.stream @@ -120,11 +120,11 @@ def ReadBytes(self): err = self._initialize() if err: return None, f"Error during initialization: {str(err)}" - + # Check if the socket is readable if s.sock_type != zmq.SUB: - return None, f"Cannot write to a read-only stream" - + return None, "Cannot write to a read-only stream" + try: # Read the data data = s.socket.recv() @@ -141,13 +141,13 @@ def Read(self): if err is not None: return None, err - + try: # Convert from over-the-wire format output = rovercom.SensorOutput().parse(buf) except Exception as e: return None, f"Failed to parse sensor data: {str(e)}" - + return output, None @@ -163,21 +163,21 @@ def GetWriteStream(self : Service, name : str): # 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("Output stream %s does not exist. Update your program code or service.yaml" % name) + + logger.critical(f"Output stream {name} does not exist. Update your program code or service.yaml") return None @@ -189,25 +189,25 @@ def GetReadStream(self : Service, service : str, name : str): # 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 + read_streams[stream_name] = res return res - - logger.critical("Input stream %s does not exist. Update your program code or service.yaml" % stream_name) + + logger.critical(f"Input stream {stream_name} does not exist. Update your program code or service.yaml") return None # Attach to Service object Service.GetWriteStream = GetWriteStream Service.GetReadStream = GetReadStream - \ No newline at end of file + \ No newline at end of file From da1a3487cf9f0c1fb135388feee66a03ef960a8c Mon Sep 17 00:00:00 2001 From: maxgallup Date: Wed, 26 Feb 2025 14:33:13 +0000 Subject: [PATCH 05/11] chore: added tooling and docs --- .devcontainer/Dockerfile | 38 +- .devcontainer/devcontainer.json | 34 +- .devcontainer/scripts/.bashrc | 61 +++ .devcontainer/scripts/gu | 21 + .gitignore | 3 + .python-version | 1 + Makefile | 61 ++- README.md | 10 +- docs/00-overview.md | 3 + docs/01-development.md | 19 + sample.py => old.roverlib/sample.py | 0 sample2.py => old.roverlib/sample2.py | 0 setup.py => old.roverlib/setup.py | 0 {src => old.roverlib/src}/__init__.py | 0 {src => old.roverlib/src}/bootinfo.py | 0 {src => old.roverlib/src}/callbacks.py | 0 {src => old.roverlib/src}/configuration.py | 0 {src => old.roverlib/src}/index.py | 0 {src => old.roverlib/src}/rovercom.py | 0 {src => old.roverlib/src}/streams.py | 0 {src => old.roverlib/src}/testing.py | 0 pyproject.toml | 41 ++ requirements.txt | 26 +- src/roverlib/__init__.py | 4 + src/roverlib/hello.py | 7 + src/roverlib/py.typed | 0 tests/__init__.py | 0 tests/unit_tests.py | 12 + uv.lock | 457 +++++++++++++++++++++ 29 files changed, 754 insertions(+), 44 deletions(-) create mode 100755 .devcontainer/scripts/.bashrc create mode 100755 .devcontainer/scripts/gu create mode 100644 .python-version create mode 100644 docs/00-overview.md create mode 100644 docs/01-development.md rename sample.py => old.roverlib/sample.py (100%) rename sample2.py => old.roverlib/sample2.py (100%) rename setup.py => old.roverlib/setup.py (100%) rename {src => old.roverlib/src}/__init__.py (100%) rename {src => old.roverlib/src}/bootinfo.py (100%) rename {src => old.roverlib/src}/callbacks.py (100%) rename {src => old.roverlib/src}/configuration.py (100%) rename {src => old.roverlib/src}/index.py (100%) rename {src => old.roverlib/src}/rovercom.py (100%) rename {src => old.roverlib/src}/streams.py (100%) rename {src => old.roverlib/src}/testing.py (100%) create mode 100644 pyproject.toml create mode 100644 src/roverlib/__init__.py create mode 100644 src/roverlib/hello.py create mode 100644 src/roverlib/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/unit_tests.py create mode 100644 uv.lock diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b18a3ea..992f1c9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,6 +7,7 @@ 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 \ @@ -22,21 +23,24 @@ RUN apt-get update && \ libsqlite3-dev \ libreadline-dev \ libffi-dev \ - libbz2-dev \ - && rm -rf /var/lib/apt/lists/* - -# Install Python 3.12.1 from source -RUN curl -fsSL "https://www.python.org/ftp/python/3.12.1/Python-3.12.1.tgz" -o "Python-3.12.1.tgz" && \ - tar -xzf "Python-3.12.1.tgz" --strip-components=1 -C /usr/local/src && \ - cd /usr/local/src && \ - ./configure --enable-optimizations && \ - make -j$(nproc) && \ - make altinstall && \ - ln -s /usr/local/bin/python3.12 /usr/local/bin/python3 && \ - ln -s /usr/local/bin/pip3.12 /usr/local/bin/pip3 && \ - cd / && \ - rm -rf /usr/local/src "Python-3.12.1.tgz" - -# Verify installation -RUN python3 --version && pip3 --version + 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 index 4716d75..909c567 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,22 +1,24 @@ { - "name": "ASE-roverlib-python", + "name": "ASE-roverlib-python-dev", "build": { - "dockerfile": "Dockerfile" + "dockerfile": "./Dockerfile", + "context": "..", + "options": [ + "--network=host" + ] }, "customizations": { - "vscode": { - "extensions": [ - "streetsidesoftware.code-spell-checker", - "ms-vscode.makefile-tools", // Makefile Tools extension - "dbankier.vscode-quick-select" // Quick select with cmd/ctrl+k " - ] - } + "vscode": { + "extensions": [ + "tamasfe.even-better-toml", // TOML syntax support + "dbankier.vscode-quick-select", // Quick select with cmd/ctrl+k " + "charliermarsh.ruff" + ] + } }, - "capAdd": [ - "SYS_PTRACE" + "runArgs": [ + "--network=host" ], - "securityOpt": [ - "seccomp=unconfined" - ], - "forwardPorts": [] -} \ No newline at end of file + "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 e0e0d51..566d85f 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ cython_debug/ .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 index d2269e9..6828b2c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,58 @@ -test: - python3 src/sample.py --debug -testnd: - python3 src/sample.py \ No newline at end of file + + +# 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 + + +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 9bddf6a..d349bd5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# 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

+
+ + Documentation +
+
+
diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 0000000..ba15784 --- /dev/null +++ b/docs/00-overview.md @@ -0,0 +1,3 @@ +# Overview + +TODO: add roverlib-python specific documentation for end-users 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/sample.py b/old.roverlib/sample.py similarity index 100% rename from sample.py rename to old.roverlib/sample.py diff --git a/sample2.py b/old.roverlib/sample2.py similarity index 100% rename from sample2.py rename to old.roverlib/sample2.py diff --git a/setup.py b/old.roverlib/setup.py similarity index 100% rename from setup.py rename to old.roverlib/setup.py diff --git a/src/__init__.py b/old.roverlib/src/__init__.py similarity index 100% rename from src/__init__.py rename to old.roverlib/src/__init__.py diff --git a/src/bootinfo.py b/old.roverlib/src/bootinfo.py similarity index 100% rename from src/bootinfo.py rename to old.roverlib/src/bootinfo.py diff --git a/src/callbacks.py b/old.roverlib/src/callbacks.py similarity index 100% rename from src/callbacks.py rename to old.roverlib/src/callbacks.py diff --git a/src/configuration.py b/old.roverlib/src/configuration.py similarity index 100% rename from src/configuration.py rename to old.roverlib/src/configuration.py diff --git a/src/index.py b/old.roverlib/src/index.py similarity index 100% rename from src/index.py rename to old.roverlib/src/index.py diff --git a/src/rovercom.py b/old.roverlib/src/rovercom.py similarity index 100% rename from src/rovercom.py rename to old.roverlib/src/rovercom.py diff --git a/src/streams.py b/old.roverlib/src/streams.py similarity index 100% rename from src/streams.py rename to old.roverlib/src/streams.py diff --git a/src/testing.py b/old.roverlib/src/testing.py similarity index 100% rename from src/testing.py rename to old.roverlib/src/testing.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eed2ccd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "roverlib" +version = "0.5.0" +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 index 345194e..bdd7586 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,22 @@ -pyzmq -loguru -betterproto - +# 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..2693432 --- /dev/null +++ b/src/roverlib/__init__.py @@ -0,0 +1,4 @@ + +from .hello import greet, bye + +__all__ = ['greet', 'bye'] diff --git a/src/roverlib/hello.py b/src/roverlib/hello.py new file mode 100644 index 0000000..8d271b0 --- /dev/null +++ b/src/roverlib/hello.py @@ -0,0 +1,7 @@ + +def greet(name: str): + return f"Hello, {name}!" + +def bye(): + return "Goodbye!" + diff --git a/src/roverlib/py.typed b/src/roverlib/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests.py b/tests/unit_tests.py new file mode 100644 index 0000000..7c799e8 --- /dev/null +++ b/tests/unit_tests.py @@ -0,0 +1,12 @@ +""" +General unit tests. +""" + +import roverlib as rover + +def test_greet(): + assert rover.greet("Test") == "Hello, Test!" + +def test_bye(): + assert rover.bye() == "Goodbye!" + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3b0ce18 --- /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.3.5" +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 }, +] From 81885846dff0c532df7a9271bd4fa40d04856f4f Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Fri, 28 Feb 2025 13:43:42 +0000 Subject: [PATCH 06/11] chore: user accessible methods now always throw an error on failure --- src/roverlib/__init__.py | 10 +- src/roverlib/bootinfo.py | 271 ++++++++++++++++++++++++++ src/roverlib/callbacks.py | 7 + src/roverlib/configuration.py | 80 ++++++++ src/roverlib/hello.py | 7 - src/roverlib/index.py | 144 ++++++++++++++ src/roverlib/rovercom.py | 345 ++++++++++++++++++++++++++++++++++ src/roverlib/streams.py | 196 +++++++++++++++++++ src/roverlib/testing.py | 40 ++++ tests/basic.py | 81 ++++++++ tests/unit_tests.py | 12 -- uv.lock | 2 +- 12 files changed, 1172 insertions(+), 23 deletions(-) create mode 100644 src/roverlib/bootinfo.py create mode 100644 src/roverlib/callbacks.py create mode 100644 src/roverlib/configuration.py delete mode 100644 src/roverlib/hello.py create mode 100644 src/roverlib/index.py create mode 100644 src/roverlib/rovercom.py create mode 100644 src/roverlib/streams.py create mode 100644 src/roverlib/testing.py create mode 100644 tests/basic.py delete mode 100644 tests/unit_tests.py diff --git a/src/roverlib/__init__.py b/src/roverlib/__init__.py index 2693432..8909a0c 100644 --- a/src/roverlib/__init__.py +++ b/src/roverlib/__init__.py @@ -1,4 +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 +from .testing import inject_valid_service -from .hello import greet, bye - -__all__ = ['greet', 'bye'] +__all__ = ["Run", "Service", "service_from_dict", "ServiceConfiguration", "ReadStream", "WriteStream", "rovercom", "inject_valid_service"] diff --git a/src/roverlib/bootinfo.py b/src/roverlib/bootinfo.py new file mode 100644 index 0000000..81919e8 --- /dev/null +++ b/src/roverlib/bootinfo.py @@ -0,0 +1,271 @@ +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..7e0916d --- /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], Exception] + +TerminationCallback = Callable[[signal.Signals], Exception] diff --git a/src/roverlib/configuration.py b/src/roverlib/configuration.py new file mode 100644 index 0000000..66c42e1 --- /dev/null +++ b/src/roverlib/configuration.py @@ -0,0 +1,80 @@ +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: + 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: + 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/hello.py b/src/roverlib/hello.py deleted file mode 100644 index 8d271b0..0000000 --- a/src/roverlib/hello.py +++ /dev/null @@ -1,7 +0,0 @@ - -def greet(name: str): - return f"Hello, {name}!" - -def bye(): - return "Goodbye!" - diff --git a/src/roverlib/index.py b/src/roverlib/index.py new file mode 100644 index 0000000..d6c3a8c --- /dev/null +++ b/src/roverlib/index.py @@ -0,0 +1,144 @@ +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): + logger.warning(f"Signal received: {sig}") + + # callback to the service + err = on_terminate(sig) + + if err: + logger.error(f"Error during termination: {sig}") + sys.exit(1) + else: + sys.exit(0) + # 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") + + 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 + err = main(service, configuration) + + # Handle termination + if err is not None: + logger.critical(f"Service quit unexpectedly: {err} Exiting...") + sys.exit(1) + else: + logger.info("Service finished successfully") 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..4a56bff --- /dev/null +++ b/src/roverlib/streams.py @@ -0,0 +1,196 @@ +import zmq +import roverlib.rovercom as rovercom +from roverlib.bootinfo import Service + +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() + 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: + raise TypeError("Cannot write to a read-only stream") + + try: + # Write the data + s.socket.send(data) + except zmq.ZMQError as 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: + raise ValueError("Cannot write nil output") + + try: + # Convert to over-the-wire format + buf = output.SerializeToString() + except Exception as 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.sockType) + socket.connect(s.address) + socket.setsockopt_string(zmq.SUBSCRIBE, "") + except zmq.ZMQError as e: + if socket: + socket.close() + 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: + raise TypeError("Cannot write to a read-only stream") + + try: + # Read the data + data = s.socket.recv() + except zmq.ZMQError as 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: + 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 + + 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 + + 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/src/roverlib/testing.py b/src/roverlib/testing.py new file mode 100644 index 0000000..bba940b --- /dev/null +++ b/src/roverlib/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:788"}, #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/basic.py b/tests/basic.py new file mode 100644 index 0000000..b7a86f9 --- /dev/null +++ b/tests/basic.py @@ -0,0 +1,81 @@ +""" +Basic test +""" + +import roverlib as rover +import time +import signal +from loguru import logger +import roverlib.rovercom as rovercom + + + + + +def run(service : rover.Service, configuration : rover.ServiceConfiguration): + time.sleep(1) + + speed = configuration.GetFloatSafe("speed") + logger.info(speed) + configuration._SetString("speed", 1) + + speed = configuration.GetFloatSafe("speed") + logger.info(speed) + + ll = configuration.GetStringSafe("log-level") + logger.info(ll) + + maxIt = configuration.GetFloat("max-iterations") + logger.info(maxIt) + + ###################################################### + + wr = service.GetWriteStream("motor_movement") + rd = service.GetReadStream("imaging", "track_data") + rd + logger.critical(wr.stream.address) + + rovercom.BatterySensorOutput + + + + + + # while True: + # err = wr.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 + # ), + # ) + # ) + # logger.error(err) + # logger.debug("done1") + + + + + # logger.critical(err) + + # logger.info(wr) + + # logger.info(err) + + +def onTerminate(sig : signal): + logger.info("Terminating") + return None + + + +rover.inject_valid_service() + + +rover.Run(run, onTerminate) + + diff --git a/tests/unit_tests.py b/tests/unit_tests.py deleted file mode 100644 index 7c799e8..0000000 --- a/tests/unit_tests.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -General unit tests. -""" - -import roverlib as rover - -def test_greet(): - assert rover.greet("Test") == "Hello, Test!" - -def test_bye(): - assert rover.bye() == "Goodbye!" - diff --git a/uv.lock b/uv.lock index 3b0ce18..355f482 100644 --- a/uv.lock +++ b/uv.lock @@ -341,7 +341,7 @@ wheels = [ [[package]] name = "roverlib" -version = "0.3.5" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "betterproto" }, From 240d0959ffa7218c2d7a986d07ab4ef44b5b2330 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Fri, 28 Feb 2025 15:12:39 +0000 Subject: [PATCH 07/11] chore: moved testing.py out of package --- src/roverlib/__init__.py | 4 +- src/roverlib/bootinfo.py | 8 +++- src/roverlib/index.py | 2 +- src/roverlib/streams.py | 2 +- tests/basic.py | 68 ++++++++++++++++-------------- {src/roverlib => tests}/testing.py | 2 +- tests/tuning.py | 58 +++++++++++++++++++++++++ 7 files changed, 107 insertions(+), 37 deletions(-) rename {src/roverlib => tests}/testing.py (98%) create mode 100644 tests/tuning.py diff --git a/src/roverlib/__init__.py b/src/roverlib/__init__.py index 8909a0c..69688cc 100644 --- a/src/roverlib/__init__.py +++ b/src/roverlib/__init__.py @@ -3,6 +3,6 @@ from .configuration import ServiceConfiguration from .streams import ReadStream, WriteStream import roverlib.rovercom as rovercom -from .testing import inject_valid_service -__all__ = ["Run", "Service", "service_from_dict", "ServiceConfiguration", "ReadStream", "WriteStream", "rovercom", "inject_valid_service"] + +__all__ = ["Run", "Service", "service_from_dict", "ServiceConfiguration", "ReadStream", "WriteStream", "rovercom"] diff --git a/src/roverlib/bootinfo.py b/src/roverlib/bootinfo.py index 81919e8..5285803 100644 --- a/src/roverlib/bootinfo.py +++ b/src/roverlib/bootinfo.py @@ -1,7 +1,6 @@ from enum import Enum from typing import Optional, Union, Any, List, TypeVar, Type, Callable, cast - T = TypeVar("T") EnumT = TypeVar("EnumT", bound=Enum) @@ -261,6 +260,13 @@ def to_dict(self) -> dict: result["version"] = from_union([from_str, from_none], self.version) result["service"] = self.service return result + + # These functions are manually added so they show up in the editor, implementation found in streams.py + def GetWriteStream(self, name : str): + pass + + def GetReadStream(self, service : str, name : str): + pass def service_from_dict(s: Any) -> Service: diff --git a/src/roverlib/index.py b/src/roverlib/index.py index d6c3a8c..7a3d390 100644 --- a/src/roverlib/index.py +++ b/src/roverlib/index.py @@ -15,7 +15,7 @@ def handle_signals(on_terminate: TerminationCallback): - def signal_handler(sig): + def signal_handler(sig, frame): logger.warning(f"Signal received: {sig}") # callback to the service diff --git a/src/roverlib/streams.py b/src/roverlib/streams.py index 4a56bff..714ff05 100644 --- a/src/roverlib/streams.py +++ b/src/roverlib/streams.py @@ -91,7 +91,7 @@ def _initialize(self): try: # Create a new socket - socket = CONTEXT.socket(s.sockType) + socket = CONTEXT.socket(s.sock_type) socket.connect(s.address) socket.setsockopt_string(zmq.SUBSCRIBE, "") except zmq.ZMQError as e: diff --git a/tests/basic.py b/tests/basic.py index b7a86f9..108a66f 100644 --- a/tests/basic.py +++ b/tests/basic.py @@ -1,5 +1,5 @@ """ -Basic test +Basic tests """ import roverlib as rover @@ -7,64 +7,70 @@ 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) - configuration._SetString("speed", 1) + assert speed == 1.5 - speed = configuration.GetFloatSafe("speed") - logger.info(speed) ll = configuration.GetStringSafe("log-level") logger.info(ll) + assert ll == "debug" maxIt = configuration.GetFloat("max-iterations") logger.info(maxIt) - + assert maxIt == 100 + ###################################################### - wr = service.GetWriteStream("motor_movement") - rd = service.GetReadStream("imaging", "track_data") - rd - logger.critical(wr.stream.address) + wr : rover.WriteStream = service.GetWriteStream("motor_movement") + rd : rover.ReadStream = service.GetReadStream("imaging", "track_data") - rovercom.BatterySensorOutput + + 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 - # while True: - # err = wr.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 - # ), - # ) - # ) - # logger.error(err) - # logger.debug("done1") + - # logger.critical(err) - - # logger.info(wr) - - # logger.info(err) def onTerminate(sig : signal): @@ -73,7 +79,7 @@ def onTerminate(sig : signal): -rover.inject_valid_service() +inject_valid_service() rover.Run(run, onTerminate) diff --git a/src/roverlib/testing.py b/tests/testing.py similarity index 98% rename from src/roverlib/testing.py rename to tests/testing.py index bba940b..d8f958a 100644 --- a/src/roverlib/testing.py +++ b/tests/testing.py @@ -9,7 +9,7 @@ def inject_valid_service(): { "service": "imaging", "streams": [ - {"name": "track_data", "address": "tcp://localhost:788"}, #7890 + {"name": "track_data", "address": "tcp://localhost:7882"}, #7890 {"name": "debug_info", "address": "tcp://unix:7891"} ] }, 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) From 185369177db332c28795a629c4c595cb061761ce Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Fri, 7 Mar 2025 13:04:50 +0100 Subject: [PATCH 08/11] chore: changed bootinfo back to original state --- docs/00-usage.md | 24 +++++++++--------------- pyproject.toml | 2 +- src/roverlib/bootinfo.py | 8 -------- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/docs/00-usage.md b/docs/00-usage.md index 260896c..256d9dd 100644 --- a/docs/00-usage.md +++ b/docs/00-usage.md @@ -1,28 +1,24 @@ # Usage -TODO: add more roverlib-python specific documentation for end-users - After installation, you can use roverlib as follows: ```python import roverlib import signal import time +import roverlib.rovercom as rovercom -def run(service : Service, configuration : ServiceConfiguration): - speed, err = configuration.GetFloatSafe("speed") - if err is not None: - logger.error(err) +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, err = configuration.GetStringSafe("name") - if err is not None: - logger.error(err) + name = configuration.GetStringSafe("name") - write_stream = service.GetWriteStream("motor_movement") - if write_stream is None: - return ValueError("WriteStream motor_movement not found") + write_stream : roverlib.WriteStream = service.GetWriteStream("motor_movement") - err = write_stream.Write( + write_stream.Write( + rovercom.SensorOutput( sensor_id=2, timestamp=int(time.time() * 1000), @@ -35,8 +31,6 @@ def run(service : Service, configuration : ServiceConfiguration): ) ) - if err is not None: - logger.error(err) return None diff --git a/pyproject.toml b/pyproject.toml index eed2ccd..807d594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roverlib" -version = "0.5.0" +version = "0.5.1" description = "ASE roverlib" readme = "README.md" authors = [ diff --git a/src/roverlib/bootinfo.py b/src/roverlib/bootinfo.py index 5285803..597af2d 100644 --- a/src/roverlib/bootinfo.py +++ b/src/roverlib/bootinfo.py @@ -260,14 +260,6 @@ def to_dict(self) -> dict: result["version"] = from_union([from_str, from_none], self.version) result["service"] = self.service return result - - # These functions are manually added so they show up in the editor, implementation found in streams.py - def GetWriteStream(self, name : str): - pass - - def GetReadStream(self, service : str, name : str): - pass - def service_from_dict(s: Any) -> Service: return Service.from_dict(s) From 129bcda98d594cbaf21f593477754b1a45086d57 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Fri, 7 Mar 2025 13:23:05 +0000 Subject: [PATCH 09/11] chore: changed run and on_terminate to throw, rather than return an error --- Makefile | 4 ++++ docs/00-usage.md | 3 +-- pyproject.toml | 2 +- src/roverlib/callbacks.py | 4 ++-- src/roverlib/index.py | 17 +++-------------- uv.lock | 2 +- 6 files changed, 12 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 6828b2c..b49dbf4 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,10 @@ 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"; \ diff --git a/docs/00-usage.md b/docs/00-usage.md index 256d9dd..cd2209b 100644 --- a/docs/00-usage.md +++ b/docs/00-usage.md @@ -3,6 +3,7 @@ After installation, you can use roverlib as follows: ```python +#!/usr/bin/python3 import roverlib import signal import time @@ -32,12 +33,10 @@ def run(service : roverlib.Service, configuration : roverlib.ServiceConfiguratio ) - return None def on_terminate(sig : signal): logger.info("Terminating") - return None roverlib.Run(run, on_terminate) diff --git a/pyproject.toml b/pyproject.toml index 807d594..6b5215a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roverlib" -version = "0.5.1" +version = "0.5.2" description = "ASE roverlib" readme = "README.md" authors = [ diff --git a/src/roverlib/callbacks.py b/src/roverlib/callbacks.py index 7e0916d..5e16201 100644 --- a/src/roverlib/callbacks.py +++ b/src/roverlib/callbacks.py @@ -2,6 +2,6 @@ from typing import Callable from roverlib.configuration import Service, ServiceConfiguration -MainCallback = Callable[[Service, ServiceConfiguration], Exception] +MainCallback = Callable[[Service, ServiceConfiguration], None] -TerminationCallback = Callable[[signal.Signals], Exception] +TerminationCallback = Callable[[signal.Signals], None] diff --git a/src/roverlib/index.py b/src/roverlib/index.py index 7a3d390..b0d67fb 100644 --- a/src/roverlib/index.py +++ b/src/roverlib/index.py @@ -19,13 +19,8 @@ def signal_handler(sig, frame): logger.warning(f"Signal received: {sig}") # callback to the service - err = on_terminate(sig) + on_terminate(sig) - if err: - logger.error(f"Error during termination: {sig}") - sys.exit(1) - else: - sys.exit(0) # catch SIGTERM or SIGINT signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) @@ -134,11 +129,5 @@ def Run(main: MainCallback, on_terminate: TerminationCallback): thread_tuning.start() # Run the user program - err = main(service, configuration) - - # Handle termination - if err is not None: - logger.critical(f"Service quit unexpectedly: {err} Exiting...") - sys.exit(1) - else: - logger.info("Service finished successfully") + main(service, configuration) + diff --git a/uv.lock b/uv.lock index 355f482..021bdca 100644 --- a/uv.lock +++ b/uv.lock @@ -341,7 +341,7 @@ wheels = [ [[package]] name = "roverlib" -version = "0.5.0" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "betterproto" }, From 1a88610a2f7bdcc60342d5d1a8f497871eca9139 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Fri, 7 Mar 2025 14:01:17 +0000 Subject: [PATCH 10/11] chore: errors in roverlib will now also log a message --- pyproject.toml | 2 +- src/roverlib/configuration.py | 2 ++ src/roverlib/index.py | 2 +- src/roverlib/streams.py | 12 ++++++++++++ uv.lock | 2 +- 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6b5215a..ccee75d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roverlib" -version = "0.5.2" +version = "0.5.3" description = "ASE roverlib" readme = "README.md" authors = [ diff --git a/src/roverlib/configuration.py b/src/roverlib/configuration.py index 66c42e1..f9839a5 100644 --- a/src/roverlib/configuration.py +++ b/src/roverlib/configuration.py @@ -18,6 +18,7 @@ def __init__(self): 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] @@ -32,6 +33,7 @@ def GetFloatSafe(self, name : str) -> float: # 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] diff --git a/src/roverlib/index.py b/src/roverlib/index.py index b0d67fb..0d4cf56 100644 --- a/src/roverlib/index.py +++ b/src/roverlib/index.py @@ -35,7 +35,7 @@ def setup_logging(debug: bool, output_path: str, service_name="unknown"): 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") + 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") diff --git a/src/roverlib/streams.py b/src/roverlib/streams.py index 714ff05..d24fb1e 100644 --- a/src/roverlib/streams.py +++ b/src/roverlib/streams.py @@ -1,6 +1,7 @@ import zmq import roverlib.rovercom as rovercom from roverlib.bootinfo import Service +from loguru import logger CONTEXT = zmq.Context() @@ -35,6 +36,7 @@ def _initialize(self): 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 @@ -49,12 +51,14 @@ def WriteBytes(self, data : bytes): # 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)): @@ -66,12 +70,14 @@ def WriteBytes(self, data : bytes): # 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 @@ -97,6 +103,7 @@ def _initialize(self): 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 @@ -111,12 +118,14 @@ def ReadBytes(self) -> bytes: # 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) @@ -131,6 +140,7 @@ def Read(self) -> rovercom.SensorOutput: # 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 @@ -162,6 +172,7 @@ def GetWriteStream(self : Service, name : str) -> WriteStream: 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") @@ -187,6 +198,7 @@ def GetReadStream(self : Service, service : str, name : str) -> ReadStream: 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") diff --git a/uv.lock b/uv.lock index 021bdca..848d4f6 100644 --- a/uv.lock +++ b/uv.lock @@ -341,7 +341,7 @@ wheels = [ [[package]] name = "roverlib" -version = "0.5.2" +version = "0.5.3" source = { editable = "." } dependencies = [ { name = "betterproto" }, From fe2d939d8ed506283c5e2794fbddbdaf656925f7 Mon Sep 17 00:00:00 2001 From: NielsD1 Date: Fri, 7 Mar 2025 14:54:24 +0000 Subject: [PATCH 11/11] chore: removed old.roverlib --- old.roverlib/sample.py | 76 ------- old.roverlib/sample2.py | 49 ----- old.roverlib/setup.py | 18 -- old.roverlib/src/__init__.py | 0 old.roverlib/src/bootinfo.py | 271 ----------------------- old.roverlib/src/callbacks.py | 7 - old.roverlib/src/configuration.py | 80 ------- old.roverlib/src/index.py | 144 ------------- old.roverlib/src/rovercom.py | 345 ------------------------------ old.roverlib/src/streams.py | 213 ------------------ old.roverlib/src/testing.py | 40 ---- 11 files changed, 1243 deletions(-) delete mode 100644 old.roverlib/sample.py delete mode 100644 old.roverlib/sample2.py delete mode 100644 old.roverlib/setup.py delete mode 100644 old.roverlib/src/__init__.py delete mode 100644 old.roverlib/src/bootinfo.py delete mode 100644 old.roverlib/src/callbacks.py delete mode 100644 old.roverlib/src/configuration.py delete mode 100644 old.roverlib/src/index.py delete mode 100644 old.roverlib/src/rovercom.py delete mode 100644 old.roverlib/src/streams.py delete mode 100644 old.roverlib/src/testing.py diff --git a/old.roverlib/sample.py b/old.roverlib/sample.py deleted file mode 100644 index 3adf184..0000000 --- a/old.roverlib/sample.py +++ /dev/null @@ -1,76 +0,0 @@ -from index import Run -from bootinfo import Service -from configuration import ServiceConfiguration -from streams import ReadStream, WriteStream -from loguru import logger -import time -import signal -import rovercom -from testing import inject_valid_service - - -def run(service : Service, configuration : ServiceConfiguration): - time.sleep(1) - - speed, err = configuration.GetFloatSafe("speed") - logger.info(speed) - configuration._setString("speed", 1) - - speed, err = configuration.GetFloatSafe("speed") - logger.info(speed) - logger.error(err) - - ll, err = configuration.GetStringSafe("log-level") - logger.info(ll) - logger.error(err) - - maxIt, err = configuration.GetFloat("max-iterations") - logger.info(maxIt) - logger.error(err) - - ###################################################### - - wr : WriteStream = service.GetWriteStream("motor_movement") - - logger.critical(wr.stream.address) - - - - - while True: - err = wr.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 - ), - ) - ) - logger.error(err) - logger.debug("done1") - - - - - logger.critical(err) - - logger.info(wr) - - logger.info(err) - - -def onTerminate(sig : signal): - logger.info("Terminating") - return None - - - - -inject_valid_service() - - -Run(run, onTerminate) diff --git a/old.roverlib/sample2.py b/old.roverlib/sample2.py deleted file mode 100644 index 6815e8e..0000000 --- a/old.roverlib/sample2.py +++ /dev/null @@ -1,49 +0,0 @@ -from index import Run -from bootinfo import Service -from configuration import ServiceConfiguration -from streams import ReadStream, WriteStream -from loguru import logger -import time -import signal -import rovercom -import zmq -from testing import inject_valid_service - - - -def run(service : Service, configuration : ServiceConfiguration): - ###################################################### - - context = zmq.Context() - - socket = context.socket(zmq.PUB) - socket.bind("tcp://*:8829") - - - while True: - tuning = rovercom.TuningState(timestamp=int(time.time() * 1000), dynamic_parameters=[ - rovercom.TuningStateParameter(number=rovercom.TuningStateParameterNumberParameter(key="max-iterations",value=5)) - ] - ).SerializeToString() - - - - socket.send(tuning) - - - - - - - -def onTerminate(sig : signal): - logger.info("Terminating") - return None - - - - -inject_valid_service() - - -Run(run, onTerminate) diff --git a/old.roverlib/setup.py b/old.roverlib/setup.py deleted file mode 100644 index c3e4255..0000000 --- a/old.roverlib/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -from setuptools import setup, find_packages -from os import path -dir = path.abspath(path.dirname(__file__)) - -with open(path.join(dir, "README.md"), encoding="utf-8") as file: - description = file.read() - - -setup( - name="roverlib", - version="0.0.1", - url = "https://github.com/VU-ASE/roverlib-python", - author="VU-ASE", - long_description=description, - long_description_content_type="text/markdown", - packages=find_packages(), - install_requires=[], -) \ No newline at end of file diff --git a/old.roverlib/src/__init__.py b/old.roverlib/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/old.roverlib/src/bootinfo.py b/old.roverlib/src/bootinfo.py deleted file mode 100644 index 0d4d63b..0000000 --- a/old.roverlib/src/bootinfo.py +++ /dev/null @@ -1,271 +0,0 @@ -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: - 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/old.roverlib/src/callbacks.py b/old.roverlib/src/callbacks.py deleted file mode 100644 index 97882ad..0000000 --- a/old.roverlib/src/callbacks.py +++ /dev/null @@ -1,7 +0,0 @@ -import signal -from typing import Callable -from configuration import Service, ServiceConfiguration - -MainCallback = Callable[[Service, ServiceConfiguration], Exception] - -TerminationCallback = Callable[[signal.Signals], Exception] diff --git a/old.roverlib/src/configuration.py b/old.roverlib/src/configuration.py deleted file mode 100644 index 523daff..0000000 --- a/old.roverlib/src/configuration.py +++ /dev/null @@ -1,80 +0,0 @@ -import threading -import time -from loguru import logger -from 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): - logger.debug(self.float_options) - if name not in self.float_options: - return None, f"No float configuration option with name {name}" - - return self.float_options[name], None - - def GetFloatSafe(self, name : str): - 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): - if name not in self.string_options: - return None, f"No string configuration option with name {name}" - - return self.string_options[name], None - - def GetStringSafe(self, name : 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): - 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/old.roverlib/src/index.py b/old.roverlib/src/index.py deleted file mode 100644 index 18c7dcd..0000000 --- a/old.roverlib/src/index.py +++ /dev/null @@ -1,144 +0,0 @@ -import argparse -import os -import signal -import sys -import threading -import time -import json -from bootinfo import Service, service_from_dict -from callbacks import MainCallback, TerminationCallback -from configuration import ServiceConfiguration, NewServiceConfiguration -import zmq -from loguru import logger -import rovercom - - - -def handle_signals(on_terminate: TerminationCallback): - def signal_handler(sig): - logger.warning(f"Signal received: {sig}") - - # callback to the service - err = on_terminate(sig) - - if err: - logger.error(f"Error during termination: {sig}") - sys.exit(1) - else: - sys.exit(0) - # 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") - - 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 - err = main(service, configuration) - - # Handle termination - if err is not None: - logger.critical(f"Service quit unexpectedly: {err} Exiting...") - sys.exit(1) - else: - logger.info("Service finished successfully") diff --git a/old.roverlib/src/rovercom.py b/old.roverlib/src/rovercom.py deleted file mode 100644 index 89525a3..0000000 --- a/old.roverlib/src/rovercom.py +++ /dev/null @@ -1,345 +0,0 @@ -# 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/old.roverlib/src/streams.py b/old.roverlib/src/streams.py deleted file mode 100644 index 77e7d1a..0000000 --- a/old.roverlib/src/streams.py +++ /dev/null @@ -1,213 +0,0 @@ -import zmq -from loguru import logger -import rovercom -from bootinfo import Service - -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 None - - try: - #create a new socket - socket = CONTEXT.socket(s.sock_type) - socket.bind(s.address) - except zmq.ZMQError as e: - if socket: - socket.close() - return f"Failed to create/bind write socket at {s.address}: {str(e)}" - - s.socket = socket - s.bytes = 0 - return None - - # Write byte data to the stream - def WriteBytes(self, data : bytes): - s = self.stream - - if s.socket is None: - - err = self._initialize() - - if err: - return f"Error during initialization: {str(err)}" - - # Check if the socket writable - if s.sock_type != zmq.PUB: - return "Cannot write to a read-only stream" - - try: - # Write the data - s.socket.send(data) - except zmq.ZMQError as e: - return f"Failed to write to stream: {str(e)}" - - if isinstance(data, (bytes, str)): - s.bytes += len(data) - else: - s.bytes += 1 - - - return None - - # Write a rovercom sensor output message to the stream - def Write(self, output : rovercom.SensorOutput): - if output is None: - return "Cannot write nil output" - - try: - # Convert to over-the-wire format - buf = output.SerializeToString() - except Exception as e: - return 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 None - - try: - # Create a new socket - socket = CONTEXT.socket(s.sockType) - socket.connect(s.address) - socket.setsockopt_string(zmq.SUBSCRIBE, "") - except zmq.ZMQError as e: - if socket: - socket.close() - return f"Failed to create/connect/subscribe read socket at {s.address}: {str(e)}" - - s.socket = socket - s.bytes = 0 - return None - - # Read byte data from the stream - def ReadBytes(self): - s = self.stream - - if s.socket is None: - - err = self._initialize() - if err: - return None, f"Error during initialization: {str(err)}" - - # Check if the socket is readable - if s.sock_type != zmq.SUB: - return None, "Cannot write to a read-only stream" - - try: - # Read the data - data = s.socket.recv() - except zmq.ZMQError as e: - return None, f"failed to read from stream: {str(e)}" - - s.bytes += len(data) - return data, None - - # Read a rovercom sensor output message from the stream - def Read(self): - # Read the Data - buf, err = self.ReadBytes() - - if err is not None: - return None, err - - try: - # Convert from over-the-wire format - output = rovercom.SensorOutput().parse(buf) - except Exception as e: - return None, f"Failed to parse sensor data: {str(e)}" - - return output, None - - -# 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 returns None if the stream does not exist. -def GetWriteStream(self : Service, name : str): - # 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") - return None - - -# Get a stream that you can read from (i.e. an input stream). -# This function returns None if the stream does not exist. -def GetReadStream(self : Service, service : str, name : str): - 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") - return None - - -# Attach to Service object -Service.GetWriteStream = GetWriteStream -Service.GetReadStream = GetReadStream - \ No newline at end of file diff --git a/old.roverlib/src/testing.py b/old.roverlib/src/testing.py deleted file mode 100644 index bba940b..0000000 --- a/old.roverlib/src/testing.py +++ /dev/null @@ -1,40 +0,0 @@ -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:788"}, #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) - -