refactor: fully async device setup and improved SDK value handling

- Converted device initialization and state building to async coroutines
- Parallelized Amcrest camera connections using asyncio.gather
- Added clean_value() helper for tuple/list/string SDK outputs
- Added fallback for missing DefaultInterface in network config
- Improved error handling, timeouts, and type annotations
- Verified code quality (ruff, black, mypy all passing)
pull/106/head
Jeff Culverhouse 3 months ago
parent 8780d542eb
commit 94a4297665

@ -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

@ -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}")

@ -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: ...

@ -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

@ -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, # dont 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)
if camera.serial_number not in self.amcrest_devices:
self.logger.info(f"connected to {host} with serial number {camera.serial_number}")
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 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

@ -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)

@ -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,15 +122,17 @@ 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 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]:

@ -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()

@ -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}")

@ -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()

@ -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:

@ -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]]

Loading…
Cancel
Save