diff --git a/src/amcrest2mqtt/app.py b/src/amcrest2mqtt/app.py index 1ba134e..013a1bb 100644 --- a/src/amcrest2mqtt/app.py +++ b/src/amcrest2mqtt/app.py @@ -4,7 +4,6 @@ # permission notice in all copies or substantial portions of the software. # # The software is provided 'as is', without any warranty. - import asyncio import argparse from json_logging import setup_logging, get_logger @@ -24,7 +23,7 @@ def build_parser() -> argparse.ArgumentParser: return p -def main() -> int: +async def async_main() -> int: setup_logging() logger = get_logger(__name__) @@ -32,16 +31,8 @@ def main() -> int: args = parser.parse_args() try: - with Amcrest2Mqtt(args=args) as amcrest2mqtt: - try: - asyncio.run(amcrest2mqtt.main_loop()) - except RuntimeError as err: - if "asyncio.run() cannot be called from a running event loop" in str(err): - # Nested event loop (common in tests or Jupyter) — fall back gracefully - loop = asyncio.get_event_loop() - loop.run_until_complete(amcrest2mqtt.main_loop()) - else: - raise + async with Amcrest2Mqtt(args=args) as amcrest2mqtt: + await amcrest2mqtt.main_loop() except ConfigError as err: logger.error(f"Fatal config error was found: {err}") return 1 @@ -55,8 +46,20 @@ def main() -> int: logger.warning("Main loop cancelled.") return 1 except Exception as err: - logger.error(f"unhandled exception: {err}", exc_info=True) + logger.error(f"Unhandled exception: {err}", exc_info=True) return 1 finally: logger.info("amcrest2mqtt stopped.") + return 0 + + +def main() -> int: + try: + return asyncio.run(async_main()) + except RuntimeError as err: + # Fallback for nested loops (Jupyter, tests, etc.) + if "asyncio.run() cannot be called from a running event loop" in str(err): + loop = asyncio.get_event_loop() + return loop.run_until_complete(async_main()) + raise diff --git a/src/amcrest2mqtt/base.py b/src/amcrest2mqtt/base.py index 8261597..f14232f 100644 --- a/src/amcrest2mqtt/base.py +++ b/src/amcrest2mqtt/base.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse +import asyncio import argparse +import concurrent.futures from datetime import datetime import logging from mqtt_helper import MqttHelper @@ -20,6 +22,9 @@ class Base: def __init__(self: Amcrest2Mqtt, args: argparse.Namespace | None = None, **kwargs: Any): super().__init__(**kwargs) + self.loop = asyncio.get_running_loop() + self.loop.set_default_executor(concurrent.futures.ThreadPoolExecutor(max_workers=16)) + self.args = args self.logger = get_logger(__name__) @@ -66,18 +71,18 @@ class Base: self.last_call_date = datetime.now() self.rate_limited = False - def __enter__(self: Self) -> Amcrest2Mqtt: + async def __aenter__(self: Self) -> Amcrest2Mqtt: super_enter = getattr(super(), "__enter__", None) if callable(super_enter): super_enter() - cast(Any, self).mqttc_create() + await cast(Any, self).mqttc_create() cast(Any, self).restore_state() self.running = True return cast(Amcrest2Mqtt, self) - def __exit__(self: Self, exc_type: BaseException | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None: + async def __aexit__(self: Self, exc_type: BaseException | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None: super_exit = getattr(super(), "__exit__", None) if callable(super_exit): super_exit(exc_type, exc_val, exc_tb) @@ -87,7 +92,7 @@ class Base: if cast(Any, self).mqttc is not None: try: - cast(Any, self).publish_service_availability("offline") + await cast(Any, self).publish_service_availability("offline") cast(Any, self).mqttc.loop_stop() except Exception as err: self.logger.debug(f"Mqtt loop_stop failed: {err}") diff --git a/src/amcrest2mqtt/interface.py b/src/amcrest2mqtt/interface.py index 6e74b0d..46f1305 100644 --- a/src/amcrest2mqtt/interface.py +++ b/src/amcrest2mqtt/interface.py @@ -1,14 +1,16 @@ -from amcrest import AmcrestCamera +from amcrest import ApiWrapper from argparse import Namespace from asyncio import AbstractEventLoop -from datetime import datetime, timezone +from datetime import datetime from logging import Logger from mqtt_helper import MqttHelper from paho.mqtt.client import Client, MQTTMessage, ConnectFlags, DisconnectFlags from paho.mqtt.reasoncodes import ReasonCode from paho.mqtt.properties import Properties from types import FrameType -from typing import Protocol, Any +from typing import Protocol, Any, Callable, Coroutine, TypeVar + +_T = TypeVar("_T") class AmcrestServiceProtocol(Protocol): @@ -38,10 +40,10 @@ class AmcrestServiceProtocol(Protocol): storage_update_interval: int snapshot_update_interval: int states: dict[str, Any] - timezone: timezone async def build_camera(self, device: dict) -> str: ... async def build_component(self, device: dict) -> str: ... + async def build_device_states(self, device_id: str) -> bool: ... async def check_event_queue_loop(self) -> None: ... async def check_for_events(self) -> None: ... async def collect_all_device_events(self) -> None: ... @@ -50,26 +52,54 @@ class AmcrestServiceProtocol(Protocol): async def collect_snapshots_loop(self) -> None: ... async def connect_to_devices(self) -> dict[str, Any]: ... async def device_loop(self) -> None: ... + async def get_camera(self, host: str) -> ApiWrapper: ... + async def get_device(self, host: str, device_name: str, index: int) -> None: ... async def get_events_from_device(self, device_id: str) -> None: ... + async def get_ip_address(self, string: str) -> str: ... + async def get_motion_detection(self, device_id: str) -> bool: ... + async def get_privacy_mode(self, device_id: str) -> bool: ... async def get_snapshot_from_device(self, device_id: str) -> str | None: ... + async def get_storage_stats(self, device_id: str) -> dict[str, str | float]: ... + async def handle_device_command(self, device_id: str, handler: str, message: str) -> None: ... + async def handle_device_topic(self, components: list[str], payload: str) -> None: ... + async def handle_homeassistant_message(self, payload: str) -> None: ... + async def handle_service_command(self, handler: str, message: str) -> None: ... async def heartbeat(self) -> None: ... async def main_loop(self) -> None: ... + async def mqtt_on_connect( + self, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None + ) -> None: ... + async def mqtt_on_disconnect( + self, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None + ) -> None: ... + async def mqtt_on_message(self, client: Client, userdata: Any, msg: MQTTMessage) -> None: ... + async def mqtt_on_subscribe(self, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: ... + async def mqtt_on_log(self, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: ... + async def mqttc_create(self) -> None: ... async def process_device_event(self, device_id: str, code: str, payload: Any) -> None: ... + async def publish_device_availability(self, device_id: str, online: bool = True) -> None: ... + async def publish_device_discovery(self, device_id: str) -> None: ... + async def publish_device_state(self, device_id: str) -> None: ... + async def publish_service_availability(self, status: str = "online") -> None: ... + async def publish_service_discovery(self) -> None: ... + async def publish_service_state(self) -> None: ... + async def rediscover_all(self) -> None: ... async def refresh_all_devices(self) -> None: ... + async def set_motion_detection(self, device_id: str, switch: bool) -> None: ... + async def set_privacy_mode(self, device_id: str, switch: bool) -> None: ... async def setup_device_list(self) -> None: ... async def store_recording_in_media(self, device_id: str, amcrest_file: str) -> str | None: ... - def _decode_payload(self, raw: bytes) -> Any: ... - def _handle_device_topic(self, components: list[str], payload: str) -> None: ... - def _handle_homeassistant_message(self, payload: str) -> None: ... def _parse_device_topic(self, components: list[str]) -> list[str | None] | None: ... + def _wrap_async( + self, + coro_func: Callable[..., Coroutine[Any, Any, _T]], + ) -> Callable[..., None]: ... def assert_no_tuples(self, data: Any, path: str = "root") -> None: ... def b_to_gb(self, total: int) -> float: ... def b_to_mb(self, total: int) -> float: ... - def build_device_states(self, device_id: str) -> bool: ... def classify_device(self, device: dict) -> str: ... - def get_camera(self, host: str) -> AmcrestCamera: ... def get_component_type(self, device_id: str) -> str: ... def get_component(self, device_id: str) -> dict[str, Any]: ... def get_device_availability_topic(self, device_id: str) -> str: ... @@ -77,18 +107,11 @@ class AmcrestServiceProtocol(Protocol): def get_device_name(self, device_id: str) -> str: ... def get_device_name_slug(self, device_id: str) -> str: ... def get_device_state_topic(self, device_id: str, mode_name: str = "") -> str: ... - def get_device(self, host: str, device_name: str, index: int) -> None: ... - def get_ip_address(self, string: str) -> str: ... def get_mode(self, device_id: str, mode_name: str) -> dict[str, Any]: ... def get_modes(self, device_id: str) -> dict[str, Any]: ... - def get_motion_detection(self, device_id: str) -> bool: ... def get_next_event(self) -> dict[str, Any] | None: ... - def get_privacy_mode(self, device_id: str) -> bool: ... def get_recorded_file(self, device_id: str, file: str, encode: bool = True) -> str | None: ... def get_snapshot(self, device_id: str) -> str | None: ... - def get_storage_stats(self, device_id: str) -> dict[str, str | float]: ... - def handle_device_command(self, device_id: str, handler: str, message: str) -> None: ... - def handle_service_command(self, handler: str, message: str) -> None: ... def handle_signal(self, signum: int, _: FrameType | None) -> Any: ... def heartbeat_ready(self) -> None: ... def increase_api_calls(self) -> None: ... @@ -99,28 +122,10 @@ class AmcrestServiceProtocol(Protocol): def load_config(self, config_arg: Any | None) -> dict[str, Any]: ... def mark_ready(self) -> None: ... def mb_to_b(self, total: int) -> int: ... - def mqtt_on_connect( - self, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None - ) -> None: ... - def mqtt_on_disconnect(self, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None) -> None: ... - def mqtt_on_message(self, client: Client, userdata: Any, msg: MQTTMessage) -> None: ... - def mqtt_on_subscribe(self, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: ... - def mqtt_on_log(self, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: ... - def mqttc_create(self) -> None: ... - def publish_device_availability(self, device_id: str, online: bool = True) -> None: ... - def publish_device_discovery(self, device_id: str) -> None: ... - def publish_device_state(self, device_id: str) -> None: ... - def publish_service_availability(self, status: str = "online") -> None: ... - def publish_service_discovery(self) -> None: ... - def publish_service_state(self) -> None: ... def read_file(self, file_name: str) -> str: ... def reboot_device(self, device_id: str) -> None: ... - def rediscover_all(self) -> None: ... def restore_state(self) -> None: ... - def restore_state_values(self, api_calls: int, last_call_date: str) -> None: ... def safe_split_device(self, topic: str, segment: str) -> list[str]: ... def save_state(self) -> None: ... - def set_motion_detection(self, device_id: str, switch: bool) -> None: ... - def set_privacy_mode(self, device_id: str, switch: bool) -> None: ... def upsert_device(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ... def upsert_state(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ... diff --git a/src/amcrest2mqtt/mixins/amcrest.py b/src/amcrest2mqtt/mixins/amcrest.py index b48e14e..776bc38 100644 --- a/src/amcrest2mqtt/mixins/amcrest.py +++ b/src/amcrest2mqtt/mixins/amcrest.py @@ -12,19 +12,25 @@ class AmcrestMixin: self.logger.debug("setting up device list from config") amcrest_devices = await self.connect_to_devices() - self.publish_service_state() + await self.publish_service_state() - seen_devices = set() + seen_devices: set[str] = set() - for device in amcrest_devices.values(): - created = await self.build_component(device) - if created: - seen_devices.add(created) + # Build all components concurrently + tasks = [self.build_component(device) for device in amcrest_devices.values()] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Collect successful device IDs + for result in results: + if isinstance(result, Exception): + self.logger.error(f"error during build_component: {result}", exc_info=True) + elif result and isinstance(result, str): + seen_devices.add(result) # Mark missing devices offline missing_devices = set(self.devices.keys()) - seen_devices for device_id in missing_devices: - self.publish_device_availability(device_id, online=False) + await self.publish_device_availability(device_id, online=False) self.logger.warning(f"device {device_id} not seen in Amcrest API list — marked offline") # Handle first discovery completion @@ -369,13 +375,13 @@ class AmcrestMixin: "recording_url": "", }, ) - self.build_device_states(device_id) + await self.build_device_states(device_id) if not self.states[device_id]["internal"].get("discovered", None): self.logger.info(f'added new camera: "{device["device_name"]}" {device["vendor"]} {device["device_type"]}] ({device_id})') - self.publish_device_discovery(device_id) - self.publish_device_availability(device_id, online=True) - self.publish_device_state(device_id) + await self.publish_device_discovery(device_id) + await self.publish_device_availability(device_id, online=True) + await self.publish_device_state(device_id) return device_id diff --git a/src/amcrest2mqtt/mixins/amcrest_api.py b/src/amcrest2mqtt/mixins/amcrest_api.py index 708ecd6..822bb6c 100644 --- a/src/amcrest2mqtt/mixins/amcrest_api.py +++ b/src/amcrest2mqtt/mixins/amcrest_api.py @@ -1,9 +1,10 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse -from amcrest import AmcrestCamera +from amcrest import AmcrestCamera, ApiWrapper from amcrest.exceptions import LoginError, AmcrestError, CommError import asyncio import base64 +from collections.abc import Sequence from datetime import datetime, timedelta import random from typing import TYPE_CHECKING, Any, cast @@ -28,7 +29,7 @@ class AmcrestAPIMixin: async def _connect_device(host: str, name: str, index: int) -> None: async with semaphore: - await asyncio.to_thread(self.get_device, host, name, index) + await self.get_device(host, name, index) self.logger.debug(f'connecting to: {self.amcrest_config["hosts"]}') @@ -42,18 +43,39 @@ class AmcrestAPIMixin: self.logger.info("connecting to hosts done") return {d: self.amcrest_devices[d]["config"] for d in self.amcrest_devices.keys()} - def get_camera(self: Amcrest2Mqtt, host: str) -> AmcrestCamera: + async def get_camera(self: Amcrest2Mqtt, host: str) -> ApiWrapper: config = self.amcrest_config - self.increase_api_calls() - return AmcrestCamera(host, config["port"], config["username"], config["password"], verbose=False) + return AmcrestCamera( + host, + config["port"], + config["username"], + config["password"], + verbose=False, + retries_connection=0, # don’t multiply wall time at startup + timeout_protocol=(4.0, 4.0), # (connect, read) in seconds + ).camera + + async def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None: + def clean_value(value: str | Sequence[str], prefix: str = "") -> str: + # Normalize to a string first + if not isinstance(value, str): + # Handle list/tuple cases + if isinstance(value, Sequence) and len(value) > 0: + value = value[0] + else: + # Graceful fallback if value is empty or weird + return "" + + # At this point, value is guaranteed to be a str + if prefix and value.startswith(prefix): + value = value[len(prefix) :] + return value.strip() - def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None: camera = None try: - host_ip = self.get_ip_address(host) - device = self.get_camera(host_ip) - camera = device.camera + host_ip = await self.get_ip_address(host) + camera = await self.get_camera(host_ip) self.increase_api_calls() except LoginError: self.logger.error(f'invalid username/password to connect to device "{host}", fix in config.yaml') @@ -65,24 +87,50 @@ class AmcrestAPIMixin: self.logger.error(f"error connecting to {host}: {err}") return - serial_number = camera.serial_number + ( + serial_number, + device_type, + sw_info, + net_config, + device_class, + hardware_version, + vendor_info, + ) = await asyncio.gather( + camera.async_serial_number, + camera.async_device_type, + camera.async_software_information, + camera.async_network_config, + camera.async_device_class, + camera.async_hardware_version, + camera.async_vendor_information, + ) + + serial_number = clean_value(serial_number, "SerialNumber=") + device_class = clean_value(device_class, "deviceClass=") + device_type = clean_value(device_type, "type=") - device_type = camera.device_type.replace("type=", "").strip() is_ad110 = device_type == "AD110" is_ad410 = device_type == "AD410" is_doorbell = is_ad110 or is_ad410 - version = camera.software_information[0].replace("version=", "").strip() - build = camera.software_information[1].strip() + version = sw_info[0].replace("version=", "").strip() + build = sw_info[1].strip() sw_version = f"{version} ({build})" - network_config = dict(item.split("=") for item in camera.network_config.splitlines()) - interface = network_config["table.Network.DefaultInterface"] - ip_address = network_config[f"table.Network.{interface}.IPAddress"] - mac_address = network_config[f"table.Network.{interface}.PhysicalAddress"].upper() + network_config = dict(item.split("=", 1) for item in net_config[0].splitlines() if "=" in item) + + interface = network_config.get("table.Network.DefaultInterface") + if not interface: + # Find first interface key dynamically + candidates = [k.split(".")[2] for k in network_config if k.startswith("table.Network.") and ".IPAddress" in k] + interface = candidates[0] if candidates else "eth0" + self.logger.debug(f"No DefaultInterface key; using {interface}") + + ip_address = network_config.get(f"table.Network.{interface}.IPAddress", "0.0.0.0") + mac_address = network_config.get(f"table.Network.{interface}.PhysicalAddress", "00:00:00:00:00:00").upper() - if camera.serial_number not in self.amcrest_devices: - self.logger.info(f"connected to {host} with serial number {camera.serial_number}") + if serial_number not in self.amcrest_devices: + self.logger.info(f"connected to {host} with serial number {serial_number}") self.amcrest_devices[serial_number] = { "camera": camera, @@ -92,14 +140,14 @@ class AmcrestAPIMixin: "host_ip": host_ip, "device_name": device_name, "device_type": device_type, - "device_class": camera.device_class, + "device_class": device_class, "is_ad110": is_ad110, "is_ad410": is_ad410, "is_doorbell": is_doorbell, "serial_number": serial_number, "software_version": sw_version, - "hardware_version": camera.hardware_version, - "vendor": camera.vendor_information, + "hardware_version": hardware_version, + "vendor": vendor_info, "network": { "interface": interface, "ip_address": ip_address, @@ -132,7 +180,7 @@ class AmcrestAPIMixin: # Storage stats ------------------------------------------------------------------------------- - def get_storage_stats(self: Amcrest2Mqtt, device_id: str) -> dict[str, str | float]: + async def get_storage_stats(self: Amcrest2Mqtt, device_id: str) -> dict[str, str | float]: device = self.amcrest_devices[device_id] states = self.states[device_id] @@ -148,8 +196,7 @@ class AmcrestAPIMixin: return current try: - storage = device["camera"].storage_all - self.increase_api_calls() + storage = await device["camera"].async_storage_all except CommError as err: self.logger.error(f"failed to get storage stats from ({self.get_device_name(device_id)}): {err}") return current @@ -157,6 +204,8 @@ class AmcrestAPIMixin: self.logger.error(f"failed to auth to ({self.get_device_name(device_id)}): {err}") return current + self.increase_api_calls() + return { "used_percent": storage.get("used_percent", "unknown"), "used": self.b_to_gb(storage["used"][0]), @@ -165,7 +214,7 @@ class AmcrestAPIMixin: # Privacy config ------------------------------------------------------------------------------ - def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool: + async def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool: device = self.amcrest_devices[device_id] states = self.states[device_id] @@ -177,10 +226,7 @@ class AmcrestAPIMixin: return current try: - privacy = device["camera"].privacy_config().split() - privacy_mode = True if privacy[0].split("=")[1] == "true" else False - device["privacy_mode"] = privacy_mode - self.increase_api_calls() + privacy = await device["camera"].async_privacy_config() except CommError as err: self.logger.error(f"failed to get privacy mode from ({self.get_device_name(device_id)}): {err}") return current @@ -188,21 +234,25 @@ class AmcrestAPIMixin: self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}") return current + self.increase_api_calls() + if not privacy or not isinstance(privacy, list) or len(privacy) < 1: + return current + privacy_mode = True if privacy[0].split("=")[1] == "true" else False return privacy_mode - def set_privacy_mode(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None: + async def set_privacy_mode(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None: device = self.amcrest_devices[device_id] if not device["camera"]: self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") return None try: - response = str(device["camera"].set_privacy(switch)).strip() + response = str(await device["camera"].async_set_privacy(switch)).strip() self.increase_api_calls() self.logger.debug(f"Set privacy_mode on {self.get_device_name(device_id)} to {switch}, got back: {response}") if response == "OK": self.upsert_state(device_id, switch={"privacy": "ON" if switch else "OFF"}) - self.publish_device_state(device_id) + await self.publish_device_state(device_id) except CommError as err: self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {err}") except LoginError as err: @@ -212,7 +262,7 @@ class AmcrestAPIMixin: # Motion detection config --------------------------------------------------------------------- - def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool: + async def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool: device = self.amcrest_devices[device_id] states = self.states[device_id] @@ -224,8 +274,7 @@ class AmcrestAPIMixin: return current try: - motion_detection = bool(device["camera"].is_motion_detector_on()) - self.increase_api_calls() + motion_detection = bool(await device["camera"].async_is_motion_detector_on()) except CommError as err: self.logger.error(f"failed to get motion detection switch on ({self.get_device_name(device_id)}): {err}") return current @@ -233,9 +282,10 @@ class AmcrestAPIMixin: self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}") return current + self.increase_api_calls() return motion_detection - def set_motion_detection(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None: + async def set_motion_detection(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None: device = self.amcrest_devices[device_id] if not device["camera"]: @@ -243,12 +293,12 @@ class AmcrestAPIMixin: return None try: - response = bool(device["camera"].set_motion_detection(switch)) + response = bool(await device["camera"].async_set_motion_detection(switch)) self.increase_api_calls() self.logger.debug(f"Set motion_detection on {self.get_device_name(device_id)} to {switch}, got back: {response}") if response: self.upsert_state(device_id, switch={"motion_detection": "ON" if switch else "OFF"}) - self.publish_device_state(device_id) + await self.publish_device_state(device_id) except CommError: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections") except LoginError: @@ -298,7 +348,7 @@ class AmcrestAPIMixin: device_id, image={"snapshot": encoded}, ) - self.publish_device_state(device_id) + await self.publish_device_state(device_id) self.logger.debug(f"got snapshot from {self.get_device_name(device_id)} {len(image_bytes)} raw bytes -> {len(encoded)} b64 chars") return encoded diff --git a/src/amcrest2mqtt/mixins/events.py b/src/amcrest2mqtt/mixins/events.py index 9f168fc..f63d2cc 100644 --- a/src/amcrest2mqtt/mixins/events.py +++ b/src/amcrest2mqtt/mixins/events.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse +import asyncio from typing import TYPE_CHECKING from datetime import datetime, timezone @@ -74,5 +75,6 @@ class EventsMixin: else: self.logger.debug(f'ignored event for "{self.get_device_name(device_id)}": {event} with {payload}') - for id in needs_publish: - self.publish_device_state(id) + tasks = [self.publish_device_state(id) for id in needs_publish] + if tasks: + await asyncio.gather(*tasks) diff --git a/src/amcrest2mqtt/mixins/helpers.py b/src/amcrest2mqtt/mixins/helpers.py index ae89bac..88e464c 100644 --- a/src/amcrest2mqtt/mixins/helpers.py +++ b/src/amcrest2mqtt/mixins/helpers.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse +import asyncio from deepmerge.merger import Merger import ipaddress import os @@ -26,14 +27,17 @@ class ConfigError(ValueError): class HelpersMixin: - def build_device_states(self: Amcrest2Mqtt, device_id: str) -> bool: + async def build_device_states(self: Amcrest2Mqtt, device_id: str) -> bool: if self.is_rebooting(device_id): self.logger.debug(f"skipping device states for {self.get_device_name(device_id)}, still rebooting") return False - storage = self.get_storage_stats(device_id) - privacy = self.get_privacy_mode(device_id) - motion_detection = self.get_motion_detection(device_id) + # get properties from device + storage, privacy, motion_detection = await asyncio.gather( + self.get_storage_stats(device_id), + self.get_privacy_mode(device_id), + self.get_motion_detection(device_id), + ) changed = self.upsert_state( device_id, @@ -51,22 +55,22 @@ class HelpersMixin: # send command to Amcrest ----------------------------------------------------------------------- - def handle_device_command(self: Amcrest2Mqtt, device_id: str, handler: str, message: str) -> None: + async def handle_device_command(self: Amcrest2Mqtt, device_id: str, handler: str, message: str) -> None: match handler: case "save_recordings": if message == "ON" and "path" not in self.config["media"]: self.logger.error("user tried to turn on save_recordings, but there is no media path set") return self.upsert_state(device_id, switch={"save_recordings": message}) - self.publish_device_state(device_id) + await self.publish_device_state(device_id) case "motion_detection": - self.set_motion_detection(device_id, message == "ON") + await self.set_motion_detection(device_id, message == "ON") case "privacy": - self.set_privacy_mode(device_id, message == "ON") + await self.set_privacy_mode(device_id, message == "ON") case "reboot": self.reboot_device(device_id) - def handle_service_command(self: Amcrest2Mqtt, handler: str, message: str) -> None: + async def handle_service_command(self: Amcrest2Mqtt, handler: str, message: str) -> None: match handler: case "storage_refresh": self.device_interval = int(message) @@ -76,18 +80,18 @@ class HelpersMixin: self.snapshot_update_interval = int(message) case "refresh_device_list": if message == "refresh": - self.rediscover_all() + await self.rediscover_all() case _: self.logger.error(f"unrecognized message to {self.mqtt_helper.service_slug}: {handler} -> {message}") return - self.publish_service_state() + await self.publish_service_state() - def rediscover_all(self: Amcrest2Mqtt) -> None: - self.publish_service_discovery() - self.publish_service_state() + async def rediscover_all(self: Amcrest2Mqtt) -> None: + await self.publish_service_discovery() + await self.publish_service_state() for device_id in self.devices: - self.publish_device_discovery(device_id) - self.publish_device_state(device_id) + await self.publish_device_discovery(device_id) + await self.publish_device_state(device_id) # Utility functions --------------------------------------------------------------------------- @@ -118,16 +122,18 @@ class HelpersMixin: except ValueError: return False - def get_ip_address(self: Amcrest2Mqtt, string: str) -> str: + async def get_ip_address(self: Amcrest2Mqtt, string: str) -> str: if self.is_ipv4(string): return string + try: - for i in socket.getaddrinfo(string, None): - if i[0] == socket.AddressFamily.AF_INET: - return str(i[4][0]) + infos = await self.loop.getaddrinfo(string, None, family=socket.AF_INET) + # getaddrinfo returns a list of 5-tuples; [4][0] holds the IP string + return infos[0][4][0] except socket.gaierror as err: - raise Exception(f"failed to resolve {string}: {err}") - raise Exception(f"failed to find IP address for {string}") + raise Exception(f"failed to resolve {string}: {err}") from err + except IndexError: + raise Exception(f"failed to find IP address for {string}") def list_from_env(self: Amcrest2Mqtt, env_name: str) -> list[str]: v = os.getenv(env_name) diff --git a/src/amcrest2mqtt/mixins/loops.py b/src/amcrest2mqtt/mixins/loops.py index 057e3db..f4ce509 100644 --- a/src/amcrest2mqtt/mixins/loops.py +++ b/src/amcrest2mqtt/mixins/loops.py @@ -56,15 +56,13 @@ class LoopsMixin: # main loop async def main_loop(self: Amcrest2Mqtt) -> None: - await self.setup_device_list() - - self.loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): try: signal.signal(sig, self.handle_signal) except Exception: self.logger.debug(f"Cannot install handler for {sig}") + await self.setup_device_list() self.running = True self.mark_ready() diff --git a/src/amcrest2mqtt/mixins/mqtt.py b/src/amcrest2mqtt/mixins/mqtt.py index 8f1015d..6a1d891 100644 --- a/src/amcrest2mqtt/mixins/mqtt.py +++ b/src/amcrest2mqtt/mixins/mqtt.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse +import asyncio from datetime import datetime, timedelta import json import paho.mqtt.client as mqtt @@ -10,11 +11,13 @@ from paho.mqtt.packettypes import PacketTypes from paho.mqtt.reasoncodes import ReasonCode from paho.mqtt.enums import CallbackAPIVersion import ssl -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable, Coroutine, TypeVar if TYPE_CHECKING: from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt +_T = TypeVar("_T") + class MqttError(ValueError): """Raised when the connection to the MQTT server fails""" @@ -23,7 +26,7 @@ class MqttError(ValueError): class MqttMixin: - def mqttc_create(self: Amcrest2Mqtt) -> None: + async def mqttc_create(self: Amcrest2Mqtt) -> None: self.mqttc = mqtt.Client( client_id=self.client_id, callback_api_version=CallbackAPIVersion.VERSION2, @@ -45,11 +48,11 @@ class MqttMixin: password=self.mqtt_config.get("password", ""), ) - self.mqttc.on_connect = self.mqtt_on_connect - self.mqttc.on_disconnect = self.mqtt_on_disconnect - self.mqttc.on_message = self.mqtt_on_message - self.mqttc.on_subscribe = self.mqtt_on_subscribe - self.mqttc.on_log = self.mqtt_on_log + self.mqttc.on_connect = self._wrap_async(self.mqtt_on_connect) + self.mqttc.on_disconnect = self._wrap_async(self.mqtt_on_disconnect) + self.mqttc.on_message = self._wrap_async(self.mqtt_on_message) + self.mqttc.on_subscribe = self._wrap_async(self.mqtt_on_subscribe) + self.mqttc.on_log = self._wrap_async(self.mqtt_on_log) # Define a "last will" message (LWT): self.mqttc.will_set(self.mqtt_helper.avty_t("service"), "offline", qos=1, retain=True) @@ -76,7 +79,16 @@ class MqttMixin: self.running = False raise SystemExit(1) - def mqtt_on_connect( + def _wrap_async( + self: Amcrest2Mqtt, + coro_func: Callable[..., Coroutine[Any, Any, _T]], + ) -> Callable[..., None]: + def wrapper(*args: Any, **kwargs: Any) -> None: + self.loop.call_soon_threadsafe(lambda: asyncio.create_task(coro_func(*args, **kwargs))) + + return wrapper + + async def mqtt_on_connect( self: Amcrest2Mqtt, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None ) -> None: # send our helper the client @@ -85,9 +97,9 @@ class MqttMixin: if reason_code.value != 0: raise MqttError(f"MQTT failed to connect ({reason_code.getName()})") - self.publish_service_discovery() - self.publish_service_availability() - self.publish_service_state() + await self.publish_service_discovery() + await self.publish_service_availability() + await self.publish_service_state() self.logger.debug("subscribing to topics on MQTT") client.subscribe("homeassistant/status") @@ -96,7 +108,7 @@ class MqttMixin: client.subscribe(f"{self.mqtt_helper.service_slug}/+/switch/+/set") client.subscribe(f"{self.mqtt_helper.service_slug}/+/button/+/set") - def mqtt_on_disconnect( + async def mqtt_on_disconnect( self: Amcrest2Mqtt, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None ) -> None: # clear the client on our helper @@ -110,49 +122,47 @@ class MqttMixin: if self.running and (self.mqtt_connect_time is None or datetime.now() > self.mqtt_connect_time + timedelta(seconds=10)): # lets use a new client_id for a reconnect attempt self.client_id = self.mqtt_helper.client_id() - self.mqttc_create() + await self.mqttc_create() else: self.logger.info("Mqtt disconnect — stopping service loop") self.running = False - def mqtt_on_log(self: Amcrest2Mqtt, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: + async def mqtt_on_log(self: Amcrest2Mqtt, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: if paho_log_level == LogLevel.MQTT_LOG_ERR: self.logger.error(f"Mqtt logged: {msg}") if paho_log_level == LogLevel.MQTT_LOG_WARNING: self.logger.warning(f"Mqtt logged: {msg}") - def mqtt_on_message(self: Amcrest2Mqtt, client: Client, userdata: Any, msg: MQTTMessage) -> None: + async def mqtt_on_message(self: Amcrest2Mqtt, client: Client, userdata: Any, msg: MQTTMessage) -> None: topic = msg.topic - payload = self._decode_payload(msg.payload) components = topic.split("/") + try: + payload = json.loads(msg.payload) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): + try: + payload = msg.payload.decode("utf-8") + except Exception: + self.logger.warning("failed to decode MQTT payload: {err}") + return None + if components[0] == self.mqtt_config["discovery_prefix"]: - return self._handle_homeassistant_message(payload) + return await self.handle_homeassistant_message(payload) if components[0] == self.mqtt_helper.service_slug and components[1] == "service": - return self.handle_service_command(components[2], payload) + return await self.handle_service_command(components[2], payload) if components[0] == self.mqtt_helper.service_slug: - return self._handle_device_topic(components, payload) + return await self.handle_device_topic(components, payload) self.logger.debug(f"ignoring unrelated MQTT topic: {topic}") - def _decode_payload(self: Amcrest2Mqtt, raw: bytes) -> Any: - try: - return json.loads(raw) - except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): - try: - return raw.decode("utf-8") - except Exception: - self.logger.warning("failed to decode MQTT payload: {err}") - return None - - def _handle_homeassistant_message(self: Amcrest2Mqtt, payload: str) -> None: + async def handle_homeassistant_message(self: Amcrest2Mqtt, payload: str) -> None: if payload == "online": - self.rediscover_all() + await self.rediscover_all() self.logger.info("home Assistant came (back?) online — resending device discovery") - def _handle_device_topic(self: Amcrest2Mqtt, components: list[str], payload: str) -> None: + async def handle_device_topic(self: Amcrest2Mqtt, components: list[str], payload: str) -> None: parsed = self._parse_device_topic(components) if not parsed: return @@ -169,7 +179,7 @@ class MqttMixin: return self.logger.info(f"got message for {self.get_device_name(device_id)}: set {components[-2]} to {payload}") - self.handle_device_command(device_id, attribute, payload) + await self.handle_device_command(device_id, attribute, payload) def _parse_device_topic(self: Amcrest2Mqtt, components: list[str]) -> list[str | None] | None: try: @@ -196,7 +206,9 @@ class MqttMixin: self.logger.warning(f"Ignoring malformed topic {topic}: {err}") return [] - def mqtt_on_subscribe(self: Amcrest2Mqtt, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: + async def mqtt_on_subscribe( + self: Amcrest2Mqtt, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties + ) -> None: reason_names = [rc.getName() for rc in reason_code_list] joined = "; ".join(reason_names) if reason_names else "none" self.logger.debug(f"Mqtt subscribed (mid={mid}): {joined}") diff --git a/src/amcrest2mqtt/mixins/publish.py b/src/amcrest2mqtt/mixins/publish.py index de7d164..3cbd718 100644 --- a/src/amcrest2mqtt/mixins/publish.py +++ b/src/amcrest2mqtt/mixins/publish.py @@ -1,5 +1,6 @@ -import json +import asyncio from datetime import timezone +import json from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -10,7 +11,7 @@ class PublishMixin: # Service ------------------------------------------------------------------------------------- - def publish_service_discovery(self: Amcrest2Mqtt) -> None: + async def publish_service_discovery(self: Amcrest2Mqtt) -> None: device_block = self.mqtt_helper.device_block( self.service_name, self.mqtt_helper.service_slug, @@ -161,7 +162,8 @@ class PublishMixin: qos=self.mqtt_config["qos"], retain=True, ) - self.mqtt_helper.safe_publish( + await asyncio.to_thread( + self.mqtt_helper.safe_publish, topic=self.mqtt_helper.disc_t("button", "refresh_device_list"), payload=json.dumps( { @@ -178,10 +180,10 @@ class PublishMixin: ) self.logger.debug(f"discovery published for {self.service} ({self.mqtt_helper.service_slug})") - def publish_service_availability(self: Amcrest2Mqtt, status: str = "online") -> None: - self.mqtt_helper.safe_publish(self.mqtt_helper.avty_t("service"), status, qos=self.qos, retain=True) + async def publish_service_availability(self: Amcrest2Mqtt, status: str = "online") -> None: + await asyncio.to_thread(self.mqtt_helper.safe_publish, self.mqtt_helper.avty_t("service"), status, qos=self.qos, retain=True) - def publish_service_state(self: Amcrest2Mqtt) -> None: + async def publish_service_state(self: Amcrest2Mqtt) -> None: # we keep last_call_date in localtime so it rolls-over the api call counter # at the right time (midnight, local) but we want to send last_call_date # to HomeAssistant as UTC @@ -199,7 +201,8 @@ class PublishMixin: } for key, value in service.items(): - self.mqtt_helper.safe_publish( + await asyncio.to_thread( + self.mqtt_helper.safe_publish, self.mqtt_helper.stat_t("service", "service", key), json.dumps(value) if isinstance(value, dict) else value, qos=self.mqtt_config["qos"], @@ -208,45 +211,45 @@ class PublishMixin: # Devices ------------------------------------------------------------------------------------- - def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None: - def _publish_one(dev_id: str, defn: dict, suffix: str = "") -> None: + async def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None: + async def _publish_one(dev_id: str, defn: dict, suffix: str = "") -> None: eff_device_id = dev_id if not suffix else f"{dev_id}_{suffix}" topic = self.mqtt_helper.disc_t(defn["component_type"], f"{dev_id}_{suffix}" if suffix else dev_id) payload = {k: v for k, v in defn.items() if k != "component_type"} - self.mqtt_helper.safe_publish(topic, json.dumps(payload), retain=True) + await asyncio.to_thread(self.mqtt_helper.safe_publish, topic, json.dumps(payload), retain=True) self.upsert_state(eff_device_id, internal={"discovered": True}) - _publish_one(device_id, self.get_component(device_id)) + await _publish_one(device_id, self.get_component(device_id)) # Publish any modes (0..n) modes = self.get_modes(device_id) for slug, mode in modes.items(): - _publish_one(device_id, mode, suffix=slug) + await _publish_one(device_id, mode, suffix=slug) - def publish_device_availability(self: Amcrest2Mqtt, device_id: str, online: bool = True) -> None: + async def publish_device_availability(self: Amcrest2Mqtt, device_id: str, online: bool = True) -> None: payload = "online" if online else "offline" avty_t = self.get_device_availability_topic(device_id) - self.mqtt_helper.safe_publish(avty_t, payload, retain=True) + await asyncio.to_thread(self.mqtt_helper.safe_publish, avty_t, payload, retain=True) - def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None: - def _publish_one(dev_id: str, defn: str | dict[str, Any], suffix: str = "") -> None: + async def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None: + async def _publish_one(dev_id: str, defn: str | dict[str, Any], suffix: str = "") -> None: topic = self.get_device_state_topic(dev_id, suffix) if isinstance(defn, dict): flat: dict[str, Any] = {k: v for k, v in defn.items() if k != "component_type"} meta = self.states[dev_id].get("meta") if isinstance(meta, dict) and "last_update" in meta: flat["last_update"] = meta["last_update"] - self.mqtt_helper.safe_publish(topic, json.dumps(flat), retain=True) + await asyncio.to_thread(self.mqtt_helper.safe_publish, topic, json.dumps(flat), retain=True) else: - self.mqtt_helper.safe_publish(topic, defn, retain=True) + await asyncio.to_thread(self.mqtt_helper.safe_publish, topic, defn, retain=True) if not self.is_discovered(device_id): self.logger.debug(f"discovery not complete for {device_id} yet, holding off on sending state") return states = self.states[device_id] - _publish_one(device_id, states[self.get_component_type(device_id)]) + await _publish_one(device_id, states[self.get_component_type(device_id)]) modes = self.get_modes(device_id) for name, mode in modes.items(): @@ -257,6 +260,6 @@ class PublishMixin: continue type_states = states[component_type][name] if isinstance(states[component_type], dict) else states[component_type] - _publish_one(device_id, type_states, name) + await _publish_one(device_id, type_states, name) - self.publish_service_state() + await self.publish_service_state() diff --git a/src/amcrest2mqtt/mixins/refresh.py b/src/amcrest2mqtt/mixins/refresh.py index 1aa9af5..df13772 100644 --- a/src/amcrest2mqtt/mixins/refresh.py +++ b/src/amcrest2mqtt/mixins/refresh.py @@ -15,9 +15,9 @@ class RefreshMixin: async def _refresh(device_id: str) -> None: async with semaphore: - changed = await asyncio.to_thread(self.build_device_states, device_id) + changed = await self.build_device_states(device_id) if changed: - await asyncio.to_thread(self.publish_device_state, device_id) + await self.publish_device_state(device_id) tasks = [] for device_id in self.devices: diff --git a/uv.lock b/uv.lock index fcd987d..0e58eb9 100644 --- a/uv.lock +++ b/uv.lock @@ -188,37 +188,37 @@ wheels = [ [[package]] name = "coverage" -version = "7.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, - { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, - { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, - { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, - { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, - { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, - { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, - { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, - { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, - { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +version = "7.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/12/3e2d2ec71796e0913178478e693a06af6a3bc9f7f9cb899bf85a426d8370/coverage-7.11.1.tar.gz", hash = "sha256:b4b3a072559578129a9e863082a2972a2abd8975bc0e2ec57da96afcd6580a8a", size = 814037, upload-time = "2025-11-07T10:52:41.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c2/ac2c3417eaa4de1361036ebbc7da664242b274b2e00c4b4a1cfc7b29920b/coverage-7.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0305463c45c5f21f0396cd5028de92b1f1387e2e0756a85dd3147daa49f7a674", size = 216967, upload-time = "2025-11-07T10:51:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a3/afef455d03c468ee303f9df9a6f407e8bea64cd576fca914ff888faf52ca/coverage-7.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4d468d5efa1eb6e3062be8bd5f45cbf28257a37b71b969a8c1da2652dfec77", size = 217298, upload-time = "2025-11-07T10:51:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/59/6e2fb3fb58637001132dc32228b4fb5b332d75d12f1353cb00fe084ee0ba/coverage-7.11.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2b2f5fc8fe383cbf2d5c77d6c4b2632ede553bc0afd0cdc910fa5390046c290", size = 248337, upload-time = "2025-11-07T10:51:49.48Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5e/ce442bab963e3388658da8bde6ddbd0a15beda230afafaa25e3c487dc391/coverage-7.11.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde6488c1ad509f4fb1a4f9960fd003d5a94adef61e226246f9699befbab3276", size = 250853, upload-time = "2025-11-07T10:51:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2f/43f94557924ca9b64e09f1c3876da4eec44a05a41e27b8a639d899716c0e/coverage-7.11.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a69e0d6fa0b920fe6706a898c52955ec5bcfa7e45868215159f45fd87ea6da7c", size = 252190, upload-time = "2025-11-07T10:51:53.262Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fa/a04e769b92bc5628d4bd909dcc3c8219efe5e49f462e29adc43e198ecfde/coverage-7.11.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:976e51e4a549b80e4639eda3a53e95013a14ff6ad69bb58ed604d34deb0e774c", size = 248335, upload-time = "2025-11-07T10:51:55.388Z" }, + { url = "https://files.pythonhosted.org/packages/99/d0/b98ab5d2abe425c71117a7c690ead697a0b32b83256bf0f566c726b7f77b/coverage-7.11.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d61fcc4d384c82971a3d9cf00d0872881f9ded19404c714d6079b7a4547e2955", size = 250209, upload-time = "2025-11-07T10:51:57.263Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3f/b9c4fbd2e6d1b64098f99fb68df7f7c1b3e0a0968d24025adb24f359cdec/coverage-7.11.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:284c5df762b533fae3ebd764e3b81c20c1c9648d93ef34469759cb4e3dfe13d0", size = 248163, upload-time = "2025-11-07T10:51:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/08/fc/3e4d54fb6368b0628019eefd897fc271badbd025410fd5421a65fb58758f/coverage-7.11.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:bab32cb1d4ad2ac6dcc4e17eee5fa136c2a1d14ae914e4bce6c8b78273aece3c", size = 247983, upload-time = "2025-11-07T10:52:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4a/a5700764a12e932b35afdddb2f59adbca289c1689455d06437f609f3ef35/coverage-7.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36f2fed9ce392ca450fb4e283900d0b41f05c8c5db674d200f471498be3ce747", size = 249646, upload-time = "2025-11-07T10:52:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2c/45ed33d9e80a1cc9b44b4bd535d44c154d3204671c65abd90ec1e99522a2/coverage-7.11.1-cp314-cp314-win32.whl", hash = "sha256:853136cecb92a5ba1cc8f61ec6ffa62ca3c88b4b386a6c835f8b833924f9a8c5", size = 219700, upload-time = "2025-11-07T10:52:05.05Z" }, + { url = "https://files.pythonhosted.org/packages/90/d7/5845597360f6434af1290118ebe114642865f45ce47e7e822d9c07b371be/coverage-7.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:77443d39143e20927259a61da0c95d55ffc31cf43086b8f0f11a92da5260d592", size = 220516, upload-time = "2025-11-07T10:52:07.259Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d0/d311a06f9cf7a48a98ffcfd0c57db0dcab6da46e75c439286a50dc648161/coverage-7.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:829acb88fa47591a64bf5197e96a931ce9d4b3634c7f81a224ba3319623cdf6c", size = 219091, upload-time = "2025-11-07T10:52:09.216Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3d/c6a84da4fa9b840933045b19dd19d17b892f3f2dd1612903260291416dba/coverage-7.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2ad1fe321d9522ea14399de83e75a11fb6a8887930c3679feb383301c28070d9", size = 217700, upload-time = "2025-11-07T10:52:11.348Z" }, + { url = "https://files.pythonhosted.org/packages/94/10/a4fc5022017dd7ac682dc423849c241dfbdad31734b8f96060d84e70b587/coverage-7.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f69c332f0c3d1357c74decc9b1843fcd428cf9221bf196a20ad22aa1db3e1b6c", size = 217968, upload-time = "2025-11-07T10:52:13.203Z" }, + { url = "https://files.pythonhosted.org/packages/59/2d/a554cd98924d296de5816413280ac3b09e42a05fb248d66f8d474d321938/coverage-7.11.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:576baeea4eebde684bf6c91c01e97171c8015765c8b2cfd4022a42b899897811", size = 259334, upload-time = "2025-11-07T10:52:15.079Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/d484cb659ec33958ca96b6f03438f56edc23b239d1ad0417b7a97fc1848a/coverage-7.11.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28ad84c694fa86084cfd3c1eab4149844b8cb95bd8e5cbfc4a647f3ee2cce2b3", size = 261445, upload-time = "2025-11-07T10:52:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/920cba122cc28f4557c0507f8bd7c6e527ebcc537d0309186f66464a8fd9/coverage-7.11.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1043ff958f09fc3f552c014d599f3c6b7088ba97d7bc1bd1cce8603cd75b520", size = 263858, upload-time = "2025-11-07T10:52:19.836Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a0/036397bdbee0f3bd46c2e26fdfbb1a61b2140bf9059240c37b61149047fa/coverage-7.11.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6681add5060c2742dafcf29826dff1ff8eef889a3b03390daeed84361c428bd", size = 258381, upload-time = "2025-11-07T10:52:21.687Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/2533926eb8990f182eb287f4873216c8ca530cc47241144aabf46fe80abe/coverage-7.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:773419b225ec9a75caa1e941dd0c83a91b92c2b525269e44e6ee3e4c630607db", size = 261321, upload-time = "2025-11-07T10:52:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/32/6e/618f7e203a998e4f6b8a0fa395744a416ad2adbcdc3735bc19466456718a/coverage-7.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a9cb272a0e0157dbb9b2fd0b201b759bd378a1a6138a16536c025c2ce4f7643b", size = 258933, upload-time = "2025-11-07T10:52:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/22/40/6b1c27f772cb08a14a338647ead1254a57ee9dabbb4cacbc15df7f278741/coverage-7.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e09adb2a7811dc75998eef68f47599cf699e2b62eed09c9fefaeb290b3920f34", size = 257756, upload-time = "2025-11-07T10:52:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/73/07/f9cd12f71307a785ea15b009c8d8cc2543e4a867bd04b8673843970b6b43/coverage-7.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1335fa8c2a2fea49924d97e1e3500cfe8d7c849f5369f26bb7559ad4259ccfab", size = 260086, upload-time = "2025-11-07T10:52:29.776Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/31c5394f6f5d72a466966bcfdb61ce5a19862d452816d6ffcbb44add16ee/coverage-7.11.1-cp314-cp314t-win32.whl", hash = "sha256:4782d71d2a4fa7cef95e853b7097c8bbead4dbd0e6f9c7152a6b11a194b794db", size = 220483, upload-time = "2025-11-07T10:52:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/7f/96/81e1ef5fbfd5090113a96e823dbe055e4c58d96ca73b1fb0ad9d26f9ec36/coverage-7.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:939f45e66eceb63c75e8eb8fc58bb7077c00f1a41b0e15c6ef02334a933cfe93", size = 221592, upload-time = "2025-11-07T10:52:33.724Z" }, + { url = "https://files.pythonhosted.org/packages/38/7a/a5d050de44951ac453a2046a0f3fb5471a4a557f0c914d00db27d543d94c/coverage-7.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:01c575bdbef35e3f023b50a146e9a75c53816e4f2569109458155cd2315f87d9", size = 219627, upload-time = "2025-11-07T10:52:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/bd9f48c28e23b2f08946f8e83983617b00619f5538dbd7e1045fa7e88c00/coverage-7.11.1-py3-none-any.whl", hash = "sha256:0fa848acb5f1da24765cee840e1afe9232ac98a8f9431c6112c15b34e880b9e8", size = 208689, upload-time = "2025-11-07T10:52:38.646Z" }, ] [[package]] @@ -568,28 +568,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, - { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, - { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, - { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, - { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, + { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, + { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, ] [[package]]