feat: better error handling and logging

* better snapshot and recording handling
* better event handling
* better device discovery
* better device state handling
* better device command handling
* better device command response handling
* better device command error handling
* better device command timeout handling
* better device command retry handling
pull/106/head
Jeff Culverhouse 3 months ago
parent b3fe517158
commit 2d48726917

@ -34,10 +34,10 @@ async def async_main() -> int:
async with Amcrest2Mqtt(args=args) as amcrest2mqtt: async with Amcrest2Mqtt(args=args) as amcrest2mqtt:
await amcrest2mqtt.main_loop() await amcrest2mqtt.main_loop()
except ConfigError as err: except ConfigError as err:
logger.error(f"fatal config error was found: {err}") logger.error(f"fatal config error was found: {err!r}")
return 1 return 1
except MqttError as err: except MqttError as err:
logger.error(f"mqtt service problems: {err}") logger.error(f"mqtt service problems: {err!r}")
return 1 return 1
except KeyboardInterrupt: except KeyboardInterrupt:
logger.warning("shutdown requested (Ctrl+C). exiting gracefully...") logger.warning("shutdown requested (Ctrl+C). exiting gracefully...")
@ -46,7 +46,7 @@ async def async_main() -> int:
logger.warning("main loop cancelled.") logger.warning("main loop cancelled.")
return 1 return 1
except Exception as err: except Exception as err:
logger.error(f"unhandled exception: {err}", exc_info=True) logger.error(f"unhandled exception: {err!r}", exc_info=True)
return 1 return 1
finally: finally:
logger.info("amcrest2mqtt stopped.") logger.info("amcrest2mqtt stopped.")

@ -94,14 +94,14 @@ class Base:
await cast(Any, self).publish_service_availability("offline") await cast(Any, self).publish_service_availability("offline")
cast(Any, self).mqttc.loop_stop() cast(Any, self).mqttc.loop_stop()
except Exception as err: except Exception as err:
self.logger.debug(f"mqtt loop_stop failed: {err}") self.logger.debug(f"mqtt loop_stop failed: {err!r}")
if cast(Any, self).mqttc.is_connected(): if cast(Any, self).mqttc.is_connected():
try: try:
cast(Any, self).mqttc.disconnect() cast(Any, self).mqttc.disconnect()
self.logger.info("disconnected from MQTT broker") self.logger.info("disconnected from MQTT broker")
except Exception as err: except Exception as err:
self.logger.warning(f"error during MQTT disconnect: {err}") self.logger.warning(f"error during MQTT disconnect: {err!r}")
self.logger.info("exiting gracefully") self.logger.info("exiting gracefully")

@ -58,6 +58,7 @@ class AmcrestServiceProtocol(Protocol):
async def get_ip_address(self, string: str) -> str: ... async def get_ip_address(self, string: str) -> str: ...
async def get_motion_detection(self, device_id: str) -> bool: ... async def get_motion_detection(self, device_id: str) -> bool: ...
async def get_privacy_mode(self, device_id: str) -> bool: ... async def get_privacy_mode(self, device_id: str) -> bool: ...
async def get_recorded_file(self, device_id: str, file: str, encode: bool = True) -> str | None: ...
async def get_snapshot_from_device(self, device_id: str) -> str | None: ... 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 get_storage_stats(self, device_id: str) -> dict[str, str | float]: ...
async def handle_device_command(self, device_id: str, handler: str, message: Any) -> None: ... async def handle_device_command(self, device_id: str, handler: str, message: Any) -> None: ...
@ -108,7 +109,6 @@ class AmcrestServiceProtocol(Protocol):
def get_device_name_slug(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_state_topic(self, device_id: str, mode_name: str = "") -> str: ...
def get_next_event(self) -> dict[str, Any] | None: ... def get_next_event(self) -> dict[str, Any] | None: ...
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_snapshot(self, device_id: str) -> str | None: ...
def handle_signal(self, signum: int, _: FrameType | None) -> Any: ... def handle_signal(self, signum: int, _: FrameType | None) -> Any: ...
def heartbeat_ready(self) -> None: ... def heartbeat_ready(self) -> None: ...

@ -17,13 +17,20 @@ class AmcrestMixin:
seen_devices: set[str] = set() seen_devices: set[str] = set()
# Build all components concurrently # Build all components concurrently
tasks = [self.build_component(device) for device in amcrest_devices.values()] tasks = [(device, self.build_component(device)) for device in amcrest_devices.values()]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*[task[1] for task in tasks], return_exceptions=True)
# Collect successful device IDs # Collect successful device IDs
for result in results: for (device, _), result in zip(tasks, results):
if isinstance(result, Exception): if isinstance(result, Exception):
self.logger.error(f"error during build_component: {result}", exc_info=True) device_name = device.get("device_name", "unknown")
device_id = device.get("serial_number", "unknown")
exception_type = type(result).__name__
self.logger.error(
f"error during build_component for device '{device_name}' ({device_id}): "
f"{exception_type}: {result}",
exc_info=True
)
elif result and isinstance(result, str): elif result and isinstance(result, str):
seen_devices.add(result) seen_devices.add(result)
@ -72,7 +79,7 @@ class AmcrestMixin:
return "" return ""
async def build_camera(self: Amcrest2Mqtt, camera: dict) -> str: async def build_camera(self: Amcrest2Mqtt, camera: dict) -> str:
raw_id = cast(str, camera["serial_number"]) raw_id = cast(str, camera["serial_number"]).strip()
device_id = raw_id device_id = raw_id
rtc_url = "" rtc_url = ""
@ -94,7 +101,7 @@ class AmcrestMixin:
"name": camera["device_name"], "name": camera["device_name"],
"identifiers": [ "identifiers": [
self.mqtt_helper.device_slug(device_id), self.mqtt_helper.device_slug(device_id),
camera["serial_number"], raw_id,
], ],
"manufacturer": camera["vendor"], "manufacturer": camera["vendor"],
"model": camera["device_type"], "model": camera["device_type"],
@ -288,34 +295,14 @@ class AmcrestMixin:
"icon": "mdi:person", "icon": "mdi:person",
} }
# defaults - which build_device_states doesn't update (events do) self.upsert_state(device_id, internal={})
self.upsert_state(
device_id,
internal={"discovered": False},
camera={"video": None},
image={"snapshot": None, "motion_snapshot": None},
switch={"save_recordings": "ON" if "path" in self.config["media"] else "OFF"},
binary_sensor={
"motion": False,
"doorbell": False,
"human": False,
},
sensor={
"motion_detection": "ON",
"privacy": "OFF",
"storage_used": 0,
"storage_total": 0,
"storage_used_pct": 0,
"motion_region": "n/a",
},
)
self.upsert_device(device_id, component=device, cmps={k: v for k, v in device["cmps"].items()}) self.upsert_device(device_id, component=device, cmps={k: v for k, v in device["cmps"].items()})
await self.build_device_states(device_id) await self.build_device_states(device_id)
if not self.is_discovered(device_id): if not self.is_discovered(device_id):
self.logger.info(f'added new camera: "{camera["device_name"]}" {camera["vendor"]} {camera["device_type"]}] ({device_id})') self.logger.info(f'added new camera: "{camera["device_name"]}" {camera["vendor"]} {camera["device_type"]}] ({device_id})')
await self.publish_device_discovery(device_id) await self.publish_device_discovery(device_id)
await self.publish_device_availability(device_id, online=True) await self.publish_device_availability(device_id, online=True)
await self.publish_device_state(device_id) await self.publish_device_state(device_id)

@ -1,5 +1,6 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Jeff Culverhouse # Copyright (c) 2025 Jeff Culverhouse
import re
from amcrest import AmcrestCamera, ApiWrapper from amcrest import AmcrestCamera, ApiWrapper
from amcrest.exceptions import LoginError, AmcrestError, CommError from amcrest.exceptions import LoginError, AmcrestError, CommError
import asyncio import asyncio
@ -12,11 +13,6 @@ from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING: if TYPE_CHECKING:
from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt
SNAPSHOT_TIMEOUT_S = 20
SNAPSHOT_MAX_TRIES = 3
SNAPSHOT_BASE_BACKOFF_S = 8
class AmcrestAPIMixin: class AmcrestAPIMixin:
def increase_api_calls(self: Amcrest2Mqtt) -> None: def increase_api_calls(self: Amcrest2Mqtt) -> None:
if not self.last_call_date or self.last_call_date.date() != datetime.now().date(): if not self.last_call_date or self.last_call_date.date() != datetime.now().date():
@ -40,7 +36,7 @@ class AmcrestAPIMixin:
index += 1 index += 1
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
self.logger.info("connecting to hosts done") self.logger.info("connecting to Amcrest hosts done")
return {d: self.amcrest_devices[d]["config"] for d in self.amcrest_devices.keys()} return {d: self.amcrest_devices[d]["config"] for d in self.amcrest_devices.keys()}
async def get_camera(self: Amcrest2Mqtt, host: str) -> ApiWrapper: async def get_camera(self: Amcrest2Mqtt, host: str) -> ApiWrapper:
@ -51,22 +47,15 @@ class AmcrestAPIMixin:
config["username"], config["username"],
config["password"], config["password"],
verbose=False, verbose=False,
retries_connection=0, # dont multiply wall time at startup retries_connection=0,
timeout_protocol=(4.0, 4.0), # (connect, read) in seconds timeout_protocol=(4.0, 4.0), # (connect, read) in seconds
).camera ).camera
async def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None: async def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None:
def clean_value(value: str | Sequence[str], prefix: str = "") -> str: def clean_value(value: str | Sequence[str], prefix: str = "") -> str:
# Normalize to a string first
if not isinstance(value, str): if not isinstance(value, str):
# Handle list/tuple cases value = value[0] if isinstance(value, Sequence) else ""
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): if prefix and value.startswith(prefix):
value = value[len(prefix) :] value = value[len(prefix) :]
return value.strip() return value.strip()
@ -81,10 +70,10 @@ class AmcrestAPIMixin:
self.logger.error(f'invalid username/password to connect to device "{host}", fix in config.yaml') self.logger.error(f'invalid username/password to connect to device "{host}", fix in config.yaml')
return return
except AmcrestError as err: except AmcrestError as err:
self.logger.error(f'unexpected error connecting to device "{host}", check config.yaml: {err}') self.logger.error(f'unexpected error connecting to device "{host}", check config.yaml: {err!r}')
return return
except Exception as err: except Exception as err:
self.logger.error(f"error connecting to {host}: {err}") self.logger.error(f"error connecting to {host}: {err!r}")
return return
( (
@ -161,51 +150,65 @@ class AmcrestAPIMixin:
} }
def reboot_device(self: Amcrest2Mqtt, device_id: str) -> None: def reboot_device(self: Amcrest2Mqtt, device_id: str) -> None:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return None return None
response = device["camera"].reboot().strip() response = device["camera"].reboot().strip()
self.logger.info(f"sent REBOOT signal to {self.get_device_name(device_id)}, {response}") self.logger.debug(f"sent reboot signal to {self.get_device_name(device_id)}, {response}")
if response == "OK": if response == "OK":
self.upsert_state(device_id, internal={"reboot": datetime.now()}) self.upsert_state(device_id, internal={"reboot": datetime.now()})
self.logger.info(f"rebooted {self.get_device_name(device_id)}")
return
self.logger.error(f"failed to reboot {self.get_device_name(device_id)}: {response}")
def is_rebooting(self: Amcrest2Mqtt, device_id: str) -> bool: def is_rebooting(self: Amcrest2Mqtt, device_id: str) -> bool:
states = self.states[device_id] states = self.states[device_id]
if "reboot" not in states["internal"]: if "reboot" not in states["internal"]:
return False return False
reboot_time = states["internal"]["reboot"] reboot_time = states["internal"]["reboot"]
if reboot_time + timedelta(minutes=2) > datetime.now(): if reboot_time + timedelta(minutes=2) > datetime.now():
return True return True
states["internal"].pop("reboot") states["internal"].pop("reboot")
if states["sensor"].get("event_text", "").startswith("Reboot"): if states["sensor"].get("event_text", "").startswith("Reboot"):
self.upsert_state(device_id, sensor={"event_text": ""}) self.upsert_state(device_id, sensor={"event_text": ""})
return False return False
# Storage stats ------------------------------------------------------------------------------- # Storage stats -------------------------------------------------------------------------------
async 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]:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return {}
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
states = self.states[device_id] states = self.states[device_id]
# return our last known state if we fail to get new stats # return our last known state if something fails
current: dict[str, str | float] = { current: dict[str, str | float] = {
"used_percent": states["sensor"]["storage_used_pct"], "used_percent": states["sensor"]["storage_used_pct"],
"used": states["sensor"]["storage_used"], "used": states["sensor"]["storage_used"],
"total": states["sensor"]["storage_total"], "total": states["sensor"]["storage_total"],
} } if "sensor" in states else {}
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return current return current
try: try:
storage = await device["camera"].async_storage_all storage = cast(dict, await device["camera"].async_storage_all)
except CommError as err: except CommError as err:
self.logger.error(f"failed to get storage stats from ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to get storage stats from ({self.get_device_name(device_id)}): {err!r}")
return current return current
except LoginError as err: except LoginError as err:
self.logger.error(f"failed to auth to ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to auth to ({self.get_device_name(device_id)}): {err!r}")
return current return current
self.increase_api_calls() self.increase_api_calls()
@ -219,59 +222,91 @@ class AmcrestAPIMixin:
# Privacy config ------------------------------------------------------------------------------ # Privacy config ------------------------------------------------------------------------------
async def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool: async def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return False
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
states = self.states[device_id] states = self.states[device_id]
# return our last known state if something fails
# return our last known state if we fail to get new stats current = True if "sensor" in states and states["sensor"].get("privacy", "OFF") == "ON" else False
current = True if states["sensor"]["privacy"] == "ON" else False
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return current return current
try: try:
privacy = await device["camera"].async_privacy_config() response = await device["camera"].async_privacy_config()
except CommError as err: except CommError as err:
self.logger.error(f"failed to get privacy mode from ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to get privacy mode from ({self.get_device_name(device_id)}): {err!r}")
return current return current
except LoginError as err: except LoginError as err:
self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err!r}")
return current return current
self.increase_api_calls() self.increase_api_calls()
if not privacy or not isinstance(privacy, list) or len(privacy) < 1:
privacy = response if isinstance(response, dict) else {}
if isinstance(response, str):
for line in response.strip().split("\n"):
line = line.strip()
if "=" in line:
key, value = line.split("=", 1)
privacy[key] = value
# Extract Enable value from the parsed dictionary
enable_value = None
for key in privacy:
if key.endswith(".Enable") or key == "Enable":
enable_value = privacy[key]
break
if enable_value is None:
self.logger.warning(f"failed to get privacy mode from ({self.get_device_name(device_id)}), got: {type(privacy)} with value: {privacy}")
return current return current
privacy_mode = True if privacy[0].split("=")[1] == "true" else False
privacy_mode = enable_value.lower() == "true"
return privacy_mode return privacy_mode
async 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:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return None return None
camera = device["camera"]
try: try:
response = str(await device["camera"].async_set_privacy(switch)).strip() response = str(await camera.async_set_privacy(switch)).strip()
except CommError as err:
self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {err!r}")
except LoginError as err:
self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err!r}")
self.increase_api_calls() self.increase_api_calls()
self.logger.debug(f"set privacy_mode on {self.get_device_name(device_id)} to {switch}, got back: {response}") self.logger.debug(f"set privacy_mode on {self.get_device_name(device_id)} to {switch}, got back: {response}")
if response == "OK": if response == "OK":
self.upsert_state(device_id, switch={"privacy": "ON" if switch else "OFF"}) self.upsert_state(device_id, switch={"privacy": "ON" if switch else "OFF"})
await self.publish_device_state(device_id) await self.publish_device_state(device_id)
except CommError as err: return None
self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {response}")
except LoginError as err:
self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}")
return None return None
# Motion detection config --------------------------------------------------------------------- # Motion detection config ---------------------------------------------------------------------
async def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool: async def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return False
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
states = self.states[device_id] states = self.states[device_id]
# return our last known state if we fail to get new stats # return our last known state if something fails
current = True if states["sensor"]["motion_detection"] == "ON" else False current = True if "sensor" in states and states["sensor"].get("motion_detection", "OFF") == "ON" else False
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
@ -280,51 +315,51 @@ class AmcrestAPIMixin:
try: try:
motion_detection = bool(await device["camera"].async_is_motion_detector_on()) motion_detection = bool(await device["camera"].async_is_motion_detector_on())
except CommError as err: except CommError as err:
self.logger.error(f"failed to get motion detection switch on ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to get motion detection switch on ({self.get_device_name(device_id)}): {err!r}")
return current return current
except LoginError as err: except LoginError as err:
self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err!r}")
return current return current
self.increase_api_calls() self.increase_api_calls()
return motion_detection return motion_detection
async 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 device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None
device = self.amcrest_devices[device_id]
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return None return None
camera = device["camera"]
try: try:
response = bool(await device["camera"].async_set_motion_detection(switch)) response = bool(await 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"})
await self.publish_device_state(device_id)
except CommError: except CommError:
self.logger.error(f"failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections") self.logger.error(f"failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections")
except LoginError: except LoginError:
self.logger.error(f"failed to authenticate with device ({self.get_device_name(device_id)}) to set motion detections") self.logger.error(f"failed to authenticate with device ({self.get_device_name(device_id)}) to set motion detections")
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"})
await self.publish_device_state(device_id)
return None return None
# Snapshots ----------------------------------------------------------------------------------- # Snapshots -----------------------------------------------------------------------------------
async def collect_all_device_snapshots(self: Amcrest2Mqtt) -> None:
tasks = []
for device_id in self.amcrest_devices:
if self.is_rebooting(device_id):
self.logger.debug(f"skipping snapshot for {self.get_device_name(device_id)}, still rebooting")
continue
tasks.append(self.get_snapshot_from_device(device_id))
if tasks:
await asyncio.gather(*tasks)
async def get_snapshot_from_device(self: Amcrest2Mqtt, device_id: str) -> str | None: async def get_snapshot_from_device(self: Amcrest2Mqtt, device_id: str) -> str | None:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
timeout = 20
max_tries = 3
base_backoff = 15
# Respect privacy mode (default False if missing) # Respect privacy mode (default False if missing)
if device.get("privacy_mode", False): if device.get("privacy_mode", False):
@ -334,16 +369,19 @@ class AmcrestAPIMixin:
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return None return None
camera = device["camera"] camera = device["camera"]
for attempt in range(1, SNAPSHOT_MAX_TRIES + 1): for attempt in range(1, max_tries + 1):
try: try:
if self.is_rebooting(device_id): if self.is_rebooting(device_id):
return None return None
image_bytes = await asyncio.wait_for(camera.async_snapshot(), timeout=SNAPSHOT_TIMEOUT_S)
self.logger.debug(f"getting snapshot from {self.get_device_name(device_id)}")
image_bytes = await asyncio.wait_for(camera.async_snapshot(), timeout=timeout)
self.increase_api_calls() self.increase_api_calls()
if not image_bytes: if not image_bytes:
self.logger.warning(f"snapshot: empty image from {self.get_device_name(device_id)}") self.logger.warning(f"snapshot: empty image from {self.get_device_name(device_id)}, ignoring")
return None return None
encoded_b = base64.b64encode(image_bytes) encoded_b = base64.b64encode(image_bytes)
@ -357,42 +395,42 @@ class AmcrestAPIMixin:
self.logger.debug(f"got snapshot from {self.get_device_name(device_id)} {len(image_bytes)} raw bytes -> {len(encoded)} b64 chars") self.logger.debug(f"got snapshot from {self.get_device_name(device_id)} {len(image_bytes)} raw bytes -> {len(encoded)} b64 chars")
return encoded return encoded
except (CommError, LoginError, asyncio.TimeoutError, Exception) as err:
self.logger.debug(f"snapshot attempt {attempt}/{max_tries} failed for {self.get_device_name(device_id)}: {err!r}")
except asyncio.CancelledError: except asyncio.CancelledError:
# Let shutdown propagate self.logger.debug(f"snapshot cancelled for {self.get_device_name(device_id)}, letting shutdown propagate")
raise raise
except (CommError, LoginError, asyncio.TimeoutError) as err: delay = base_backoff * (2 ** (attempt - 1))
# Backoff with jitter before retrying delay += random.uniform(0, 5)
if attempt == SNAPSHOT_MAX_TRIES:
break
delay = SNAPSHOT_BASE_BACKOFF_S * (2 ** (attempt - 1))
delay += random.uniform(0, 0.25)
self.logger.debug(
f"snapshot attempt {attempt}/{SNAPSHOT_MAX_TRIES} failed for {self.get_device_name(device_id)}: {err!r}; retrying in {delay:.2f}s"
)
await asyncio.sleep(delay) await asyncio.sleep(delay)
# Any other unexpected exception: log and stop self.logger.info(f"getting snapshot failed after {max_tries} tries for {self.get_device_name(device_id)}")
except Exception as err: # noqa: BLE001 (log-and-drop is intentional here)
self.logger.exception(f"snapshot: unexpected error for {self.get_device_name(device_id)}: {err!r}")
return None return None
self.logger.info(f"getting snapshot failed after {SNAPSHOT_MAX_TRIES} tries for {self.get_device_name(device_id)}") def get_snapshot(self: Amcrest2Mqtt, device_id: str) -> str | None:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None return None
def get_snapshot(self: Amcrest2Mqtt, device_id: str) -> str | None:
return self.amcrest_devices[device_id]["snapshot"] if "snapshot" in self.devices[device_id] else None return self.amcrest_devices[device_id]["snapshot"] if "snapshot" in self.devices[device_id] else None
# Recorded file ------------------------------------------------------------------------------- # Recorded file -------------------------------------------------------------------------------
def get_recorded_file(self: Amcrest2Mqtt, device_id: str, file: str, encode: bool = True) -> str | None: async def get_recorded_file(self: Amcrest2Mqtt, device_id: str, file: str, encode: bool = True) -> str | None:
if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
max_attempts = 3
tries = 0 for attempt in range(1, max_attempts + 1):
while tries < 3:
try: try:
if self.is_rebooting(device_id): if self.is_rebooting(device_id):
return None return None
data_raw = cast(bytes, device["camera"].download_file(file)) data_raw = cast(bytes, device["camera"].download_file(file))
self.increase_api_calls() self.increase_api_calls()
if data_raw: if data_raw:
@ -400,7 +438,7 @@ class AmcrestAPIMixin:
if len(data_raw) < self.mb_to_b(100): if len(data_raw) < self.mb_to_b(100):
return data_raw.decode("latin-1") return data_raw.decode("latin-1")
else: else:
self.logger.error(f"skipping raw recording, too large: {self.b_to_mb(len(data_raw))} MB") self.logger.warning(f"skipping raw recording, too large: {self.b_to_mb(len(data_raw))} MB")
return None return None
data_base64 = base64.b64encode(data_raw) data_base64 = base64.b64encode(data_raw)
self.logger.debug( self.logger.debug(
@ -411,54 +449,54 @@ class AmcrestAPIMixin:
else: else:
self.logger.error(f"skipping recording, too large: {self.b_to_mb(len(data_base64))} MB") self.logger.error(f"skipping recording, too large: {self.b_to_mb(len(data_base64))} MB")
return None return None
except CommError: except CommError as err:
tries += 1 self.logger.debug(f"failed to get recording from ({self.get_device_name(device_id)}) on attempt {attempt}: {err!r}")
except LoginError: except LoginError as err:
tries += 1 self.logger.debug(f"failed to get recording from ({self.get_device_name(device_id)}) on attempt {attempt}: {err!r}")
except Exception as err: # noqa: BLE001 (log-and-drop is intentional here)
self.logger.debug(f"failed to get recording from ({self.get_device_name(device_id)}) on attempt {attempt}: {err!r}")
self.logger.error(f"failed to get recording from ({self.get_device_name(device_id)})") self.logger.error(f"failed to get recording from ({self.get_device_name(device_id)}) after {max_attempts} attempts")
return None return None
# Events -------------------------------------------------------------------------------------- # Events --------------------------------------------------------------------------------------
async def collect_all_device_events(self: Amcrest2Mqtt) -> None:
tasks = []
for device_id in self.amcrest_devices:
if self.is_rebooting(device_id):
self.logger.debug(f"skipping collecting events for {self.get_device_name(device_id)}, still rebooting")
continue
tasks.append(self.get_events_from_device(device_id))
if tasks:
await asyncio.gather(*tasks)
async def get_events_from_device(self: Amcrest2Mqtt, device_id: str) -> None: async def get_events_from_device(self: Amcrest2Mqtt, device_id: str) -> None:
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return None return None
camera = device["camera"]
max_attempts = 3
tries = 0 for attempt in range(1, max_attempts + 1):
while tries < 3:
try: try:
if self.is_rebooting(device_id): if self.is_rebooting(device_id):
return None return None
async for code, payload in device["camera"].async_event_actions("All"):
async for code, payload in camera.async_event_actions("All"):
await self.process_device_event(device_id, code, payload) await self.process_device_event(device_id, code, payload)
self.increase_api_calls() self.increase_api_calls()
return return
except CommError: except CommError as err:
tries += 1 self.logger.debug(f"failed to get events from ({self.get_device_name(device_id)}) on attempt {attempt}: {err!r}")
except LoginError: except LoginError as err:
tries += 1 self.logger.debug(f"failed to get events from ({self.get_device_name(device_id)}) on attempt {attempt}: {err!r}")
except Exception as err: # noqa: BLE001 (log-and-drop is intentional here)
self.logger.debug(f"failed to get events from ({self.get_device_name(device_id)}) on attempt {attempt}: {err!r}")
self.logger.error(f"failed to check for events on ({self.get_device_name(device_id)})") self.logger.error(f"failed to check for events on ({self.get_device_name(device_id)}) after {max_attempts} attempts ")
async def process_device_event(self: Amcrest2Mqtt, device_id: str, code: str, payload: Any) -> None: async def process_device_event(self: Amcrest2Mqtt, device_id: str, code: str, payload: Any) -> None:
try: if device_id not in self.amcrest_devices:
self.logger.warning(f"device not found for {device_id}")
return None
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
config = device["config"] config = device["config"]
try:
if (code == "ProfileAlarmTransmit" and config["is_ad110"]) or (code == "VideoMotion" and not config["is_ad110"]): if (code == "ProfileAlarmTransmit" and config["is_ad110"]) or (code == "VideoMotion" and not config["is_ad110"]):
motion_payload = {"state": "on" if payload["action"] == "Start" else "off", "region": ", ".join(payload["data"]["RegionName"])} motion_payload = {"state": "on" if payload["action"] == "Start" else "off", "region": ", ".join(payload["data"]["RegionName"])}
self.events.append({"device_id": device_id, "event": "motion", "payload": motion_payload}) self.events.append({"device_id": device_id, "event": "motion", "payload": motion_payload})
@ -484,17 +522,11 @@ class AmcrestAPIMixin:
self.events.append({"device_id": device_id, "event": "privacy_mode", "payload": "off"}) self.events.append({"device_id": device_id, "event": "privacy_mode", "payload": "off"})
# lets send these but not bother logging them here # lets send these but not bother logging them here
elif code == "TimeChange": elif code in ["TimeChange", "NTPAdjustTime", "RtspSessionDisconnect"]:
self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]})
elif code == "NTPAdjustTime":
self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]})
elif code == "RtspSessionDisconnect":
self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]}) self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]})
# lets just ignore these # lets just ignore these completely
elif code == "InterVideoAccess": # I think this is US, accessing the API of the camera, lets not inception! elif code in ["InterVideoAccess", "VideoMotionInfo"]:
pass
elif code == "VideoMotionInfo":
pass pass
# save everything else as a 'generic' event # save everything else as a 'generic' event
@ -502,7 +534,7 @@ class AmcrestAPIMixin:
self.logger.info(f"logged event on {self.get_device_name(device_id)} - {code}: {payload}") self.logger.info(f"logged event on {self.get_device_name(device_id)} - {code}: {payload}")
self.events.append({"device_id": device_id, "event": code, "payload": payload}) self.events.append({"device_id": device_id, "event": code, "payload": payload})
except Exception as err: except Exception as err:
self.logger.error(f"failed to process event from {self.get_device_name(device_id)}: {err}", exc_info=True) self.logger.error(f"failed to process event from {self.get_device_name(device_id)}: {err!r}")
def get_next_event(self: Amcrest2Mqtt) -> dict[str, Any] | None: def get_next_event(self: Amcrest2Mqtt) -> dict[str, Any] | None:
return self.events.pop(0) if len(self.events) > 0 else None return self.events.pop(0) if len(self.events) > 0 else None

@ -19,35 +19,47 @@ class EventsMixin:
device_id = str(device_event["device_id"]) device_id = str(device_event["device_id"])
event = str(device_event["event"]) event = str(device_event["event"])
payload = device_event["payload"] payload = device_event["payload"]
states = self.states[device_id] states = self.states[device_id]
# if one of our known sensors # if one of our known sensors
if event in ["motion", "human", "doorbell", "recording", "privacy_mode", "Reboot"]: if event in ["motion", "human", "doorbell", "recording", "privacy_mode", "Reboot"]:
if event == "recording": if event == "recording":
if payload["file"].endswith(".jpg"): if payload["file"].endswith(".jpg"):
image = self.get_recorded_file(device_id, payload["file"]) image = await self.get_recorded_file(device_id, payload["file"])
if image: if image:
self.upsert_state( if self.upsert_state(
device_id, device_id,
camera={"eventshot": image}, camera={"eventshot": image},
sensor={"event_time": datetime.now(timezone.utc).isoformat()}, sensor={"event_time": datetime.now(timezone.utc).isoformat()},
) ):
needs_publish.add(device_id)
event += ": snapshot" event += ": snapshot"
elif payload["file"].endswith(".mp4"): elif payload["file"].endswith(".mp4"):
if "path" in self.config["media"] and self.states[device_id]["switch"]["save_recordings"] == "ON": if "path" in self.config["media"] and self.states[device_id]["switch"].get("save_recordings", "OFF") == "ON":
await self.store_recording_in_media(device_id, payload["file"]) await self.store_recording_in_media(device_id, payload["file"])
event += ": video" event += ": video"
elif event == "motion": elif event == "motion":
region = payload["region"] if payload["state"] != "off" else "n/a" region = payload["region"] if payload["state"] != "off" else "n/a"
self.upsert_state( if payload["file"].endswith(".jpg"):
image = await self.get_recorded_file(device_id, payload["file"])
if image:
if self.upsert_state(
device_id,
camera={"eventshot": image},
sensor={"event_time": datetime.now(timezone.utc).isoformat()},
):
needs_publish.add(device_id)
event += ": snapshot"
elif payload["file"].endswith(".mp4"):
if "path" in self.config["media"] and self.states[device_id]["switch"].get("save_recordings", "OFF") == "ON":
await self.store_recording_in_media(device_id, payload["file"])
event += ": video"
if self.upsert_state(
device_id, device_id,
binary_sensor={"motion": payload["state"]}, binary_sensor={"motion": payload["state"]},
sensor={ sensor={"motion_region": region, "event_time": datetime.now(timezone.utc).isoformat()},
"motion_region": region, ):
"event_time": datetime.now(timezone.utc).isoformat(), needs_publish.add(device_id)
},
)
event += f": ({region}) - {payload["state"]}" event += f": ({region}) - {payload["state"]}"
else: else:
if isinstance(payload, str): if isinstance(payload, str):
@ -60,21 +72,22 @@ class EventsMixin:
# other ways to infer "privacy mode" has been turned off and we need to update # other ways to infer "privacy mode" has been turned off and we need to update
if event in ["motion", "human", "doorbell"] and states["switch"]["privacy"] != "OFF": if event in ["motion", "human", "doorbell"] and states["switch"]["privacy"] != "OFF":
self.upsert_state(device_id, switch={"privacy_mode": "OFF"}) if self.upsert_state(device_id, switch={"privacy_mode": "OFF"}):
needs_publish.add(device_id)
# record just these "events": text and time # record just these "events": text and time
self.upsert_state( if self.upsert_state(
device_id, device_id,
sensor={ sensor={
"event_text": event, "event_text": event,
"event_time": datetime.now(timezone.utc).isoformat(), "event_time": datetime.now(timezone.utc).isoformat(),
}, },
) ):
self.logger.debug(f'processed event for "{self.get_device_name(device_id)}": {event} with {payload}')
needs_publish.add(device_id) needs_publish.add(device_id)
self.logger.debug(f'processed event for "{self.get_device_name(device_id)}": {event} with {payload}')
else: else:
self.logger.debug(f'ignored event for "{self.get_device_name(device_id)}": {event} with {payload}') self.logger.debug(f'ignored event for "{self.get_device_name(device_id)}": {event} with {payload}')
tasks = [self.publish_device_state(id) for id in needs_publish] tasks = [self.publish_device_state(device_id) for device_id in needs_publish]
if tasks: if tasks:
await asyncio.gather(*tasks) await asyncio.gather(*tasks)

@ -250,7 +250,7 @@ class HelpersMixin:
return config return config
async def store_recording_in_media(self: Amcrest2Mqtt, device_id: str, amcrest_file: str) -> str | None: async def store_recording_in_media(self: Amcrest2Mqtt, device_id: str, amcrest_file: str) -> str | None:
recording = self.get_recorded_file(device_id, amcrest_file, encode=False) recording = await self.get_recorded_file(device_id, amcrest_file, encode=False)
if recording: if recording:
name = self.get_device_name_slug(device_id) name = self.get_device_name_slug(device_id)
time = datetime.now().strftime("%Y%m%d-%H%M%S") time = datetime.now().strftime("%Y%m%d-%H%M%S")
@ -266,7 +266,7 @@ class HelpersMixin:
try: try:
file_path.write_bytes(recording.encode("latin-1")) file_path.write_bytes(recording.encode("latin-1"))
except IOError as err: except IOError as err:
self.logger.error(f"failed to save recording to {file_path}: {err}") self.logger.error(f"failed to save recording to {file_path}: {err!r}")
return None return None
self.upsert_state( self.upsert_state(
@ -282,7 +282,7 @@ class HelpersMixin:
latest_link.unlink() latest_link.unlink()
latest_link.symlink_to(local_file) latest_link.symlink_to(local_file)
except IOError as err: except IOError as err:
self.logger.error(f"failed to save symlink {latest_link} -> {local_file}: {err}") self.logger.error(f"failed to save symlink {latest_link} -> {local_file}: {err!r}")
pass pass
if "media_source" in self.config["media"]: if "media_source" in self.config["media"]:

@ -79,7 +79,7 @@ class LoopsMixin:
except asyncio.CancelledError: except asyncio.CancelledError:
self.logger.warning("main loop cancelled — shutting down...") self.logger.warning("main loop cancelled — shutting down...")
except Exception as err: except Exception as err:
self.logger.exception(f"unhandled exception in main loop: {err}") self.logger.exception(f"unhandled exception in main loop: {err!r}")
self.running = False self.running = False
finally: finally:
self.logger.info("all loops terminated — cleanup complete.") self.logger.info("all loops terminated — cleanup complete.")

@ -83,12 +83,12 @@ class MqttMixin(BaseMqttMixin):
return [vendor, device_id, attribute] return [vendor, device_id, attribute]
except Exception as err: except Exception as err:
self.logger.warning(f"malformed device topic with {components}: {err}") self.logger.warning(f"malformed device topic with {components}: {err!r}")
return [] return []
def safe_split_device(self: Amcrest2Mqtt, topic: str, segment: str) -> list[str]: def safe_split_device(self: Amcrest2Mqtt, topic: str, segment: str) -> list[str]:
try: try:
return segment.split("-", 1) return segment.split("-", 1)
except ValueError as err: except ValueError as err:
self.logger.warning(f"ignoring malformed topic {topic}: {err}") self.logger.warning(f"ignoring malformed topic {topic}: {err!r}")
return [] return []

@ -27,3 +27,26 @@ class RefreshMixin:
tasks.append(_refresh(device_id)) tasks.append(_refresh(device_id))
if tasks: if tasks:
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
async def collect_all_device_events(self: Amcrest2Mqtt) -> None:
tasks = []
for device_id in self.amcrest_devices:
if self.is_rebooting(device_id):
self.logger.debug(f"skipping collecting events for {self.get_device_name(device_id)}, still rebooting")
continue
tasks.append(self.get_events_from_device(device_id))
if tasks:
await asyncio.gather(*tasks)
async def collect_all_device_snapshots(self: Amcrest2Mqtt) -> None:
tasks = []
for device_id in self.amcrest_devices:
if self.is_rebooting(device_id):
self.logger.debug(f"skipping snapshot for {self.get_device_name(device_id)}, still rebooting")
continue
tasks.append(self.get_snapshot_from_device(device_id))
if tasks:
await asyncio.gather(*tasks)

@ -372,7 +372,7 @@ wheels = [
[[package]] [[package]]
name = "json-logging-graystorm" name = "json-logging-graystorm"
version = "0.1.3" version = "0.1.3"
source = { git = "https://github.com/weirdtangent/json_logging.git?branch=main#68eaa4b57c9126154e297618c7722e97b00ee41f" } source = { git = "https://github.com/weirdtangent/json_logging.git?branch=main#5e245601ad2da7676a52281345e4040bb0281ffb" }
[[package]] [[package]]
name = "jsonschema" name = "jsonschema"
@ -649,83 +649,83 @@ wheels = [
[[package]] [[package]]
name = "rpds-py" name = "rpds-py"
version = "0.28.0" version = "0.29.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" }, { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" },
{ url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" }, { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" },
{ url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" }, { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" },
{ url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" }, { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" },
{ url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" }, { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" },
{ url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" }, { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" },
{ url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" }, { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" },
{ url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" }, { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" },
{ url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" }, { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" }, { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" },
{ url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" }, { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" }, { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" },
{ url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" }, { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" },
{ url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" }, { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" },
{ url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" },
{ url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" },
{ url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" },
{ url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" },
{ url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" },
{ url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" },
{ url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" },
{ url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" },
{ url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" },
{ url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" },
{ url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" },
{ url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" },
{ url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" },
{ url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" },
{ url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" },
{ url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" },
{ url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" }, { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" },
{ url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" }, { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" },
{ url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" }, { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" }, { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" }, { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" },
{ url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" }, { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" }, { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" },
{ url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" }, { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" },
{ url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" }, { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" },
{ url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" }, { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" },
{ url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" }, { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" }, { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" },
{ url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" },
{ url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" }, { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" },
{ url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" }, { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" },
{ url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" }, { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" },
{ url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" }, { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" },
{ url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" }, { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" },
{ url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" }, { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" }, { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" },
{ url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" }, { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" }, { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" }, { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" }, { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" }, { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" },
{ url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" }, { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" },
] ]
[[package]] [[package]]

Loading…
Cancel
Save