diff --git a/amqtt/broker.py b/amqtt/broker.py index ee0cbbc..5354391 100644 --- a/amqtt/broker.py +++ b/amqtt/broker.py @@ -737,6 +737,13 @@ class Broker: self.logger.debug(f"{client_session.client_id} handling message delivery") app_message = wait_deliver.result() + # notify of a message's receipt, even if a client isn't necessarily allowed to send it + await self.plugins_manager.fire_event( + BrokerEvents.MESSAGE_RECEIVED, + client_id=client_session.client_id, + message=app_message, + ) + if app_message is None: self.logger.debug("app_message was empty!") return True @@ -760,8 +767,9 @@ class Broker: if not permitted: self.logger.info(f"{client_session.client_id} not allowed to publish to TOPIC {app_message.topic}.") else: + # notify that a received message is valid and is allowed to be distributed to other clients await self.plugins_manager.fire_event( - BrokerEvents.MESSAGE_RECEIVED, + BrokerEvents.MESSAGE_BROADCAST, client_id=client_session.client_id, message=app_message, ) diff --git a/amqtt/contrib/shadows/__init__.py b/amqtt/contrib/shadows/__init__.py new file mode 100644 index 0000000..c1bdd8f --- /dev/null +++ b/amqtt/contrib/shadows/__init__.py @@ -0,0 +1,6 @@ +"""Module for the shadow state plugin.""" + +from .plugin import ShadowPlugin, ShadowTopicAuthPlugin +from .states import ShadowOperation + +__all__ = ["ShadowOperation", "ShadowPlugin", "ShadowTopicAuthPlugin"] diff --git a/amqtt/contrib/shadows/messages.py b/amqtt/contrib/shadows/messages.py new file mode 100644 index 0000000..65e94c6 --- /dev/null +++ b/amqtt/contrib/shadows/messages.py @@ -0,0 +1,107 @@ +from collections.abc import MutableMapping +from dataclasses import dataclass, fields, is_dataclass +import json +from typing import Any + +from amqtt.contrib.shadows.states import MetaTimestamp, ShadowOperation, State, StateDocument + + +def asdict_no_none(obj: Any) -> Any: + """Create dictionary from dataclass, but eliminate any key set to `None`.""" + if is_dataclass(obj): + result = {} + for f in fields(obj): + value = getattr(obj, f.name) + if value is not None: + result[f.name] = asdict_no_none(value) + return result + if isinstance(obj, list): + return [asdict_no_none(item) for item in obj if item is not None] + if isinstance(obj, dict): + return { + key: asdict_no_none(value) + for key, value in obj.items() + if value is not None + } + return obj + + +def create_shadow_topic(device_id: str, shadow_name: str, message_op: "ShadowOperation") -> str: + """Create a shadow topic for message type.""" + return f"$shadow/{device_id}/{shadow_name}/{message_op}" + + +class ShadowMessage: + def to_message(self) -> bytes: + return json.dumps(asdict_no_none(self)).encode("utf-8") + +@dataclass +class GetAcceptedMessage(ShadowMessage): + state: State[dict[str, Any]] + metadata: State[MetaTimestamp] + timestamp: int + version: int + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.GET_ACCEPT) + +@dataclass +class GetRejectedMessage(ShadowMessage): + code: int + message: str + timestamp: int | None = None + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.GET_REJECT) + +@dataclass +class UpdateAcceptedMessage(ShadowMessage): + state: State[dict[str, Any]] + metadata: State[MetaTimestamp] + timestamp: int + version: int + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.UPDATE_ACCEPT) + + +@dataclass +class UpdateRejectedMessage(ShadowMessage): + code: int + message: str + timestamp: int + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.UPDATE_REJECT) + +@dataclass +class UpdateDeltaMessage(ShadowMessage): + state: MutableMapping[str, Any] + metadata: MutableMapping[str, Any] + timestamp: int + version: int + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.UPDATE_DELTA) + +class UpdateIotaMessage(UpdateDeltaMessage): + """Same format, corollary name.""" + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.UPDATE_IOTA) + +@dataclass +class UpdateDocumentMessage(ShadowMessage): + previous: StateDocument + current: StateDocument + timestamp: int + + @staticmethod + def topic(device_id: str, shadow_name: str) -> str: + return create_shadow_topic(device_id, shadow_name, ShadowOperation.UPDATE_DOCUMENTS) diff --git a/amqtt/contrib/shadows/models.py b/amqtt/contrib/shadows/models.py new file mode 100644 index 0000000..70eb86b --- /dev/null +++ b/amqtt/contrib/shadows/models.py @@ -0,0 +1,139 @@ +from collections.abc import Sequence +from dataclasses import asdict +import logging +import time +from typing import Any, Optional +import uuid + +from sqlalchemy import JSON, CheckConstraint, Integer, String, UniqueConstraint, desc, event, func, select +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession +from sqlalchemy.orm import DeclarativeBase, Mapped, Mapper, Session, make_transient, mapped_column + +from amqtt.contrib.shadows.states import StateDocument + +logger = logging.getLogger(__name__) + + +class ShadowUpdateError(Exception): + def __init__(self, message: str = "updating an existing Shadow is not allowed") -> None: + super().__init__(message) + + +class ShadowBase(DeclarativeBase): + pass + + +async def sync_shadow_base(connection: AsyncConnection) -> None: + """Create tables and table schemas.""" + await connection.run_sync(ShadowBase.metadata.create_all) + + +def default_state_document() -> dict[str, Any]: + """Create a default (empty) state document, factory for model field.""" + return asdict(StateDocument()) + + +class Shadow(ShadowBase): + __tablename__ = "shadows_shadow" + + id: Mapped[str | None] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + device_id: Mapped[str] = mapped_column(String(128), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + version: Mapped[int] =mapped_column(Integer, nullable=False) + + _state: Mapped[dict[str, Any]] = mapped_column("state", JSON, nullable=False, default=dict) + + created_at: Mapped[int] = mapped_column(Integer, default=lambda: int(time.time()), nullable=False) + + __table_args__ = ( + CheckConstraint("version > 0", name="check_quantity_positive"), + UniqueConstraint("device_id", "name", "version", name="uq_device_id_name_version"), + ) + + @property + def state(self) -> StateDocument: + if not self._state: + return StateDocument() + return StateDocument.from_dict(self._state) + + @state.setter + def state(self, value: StateDocument) -> None: + self._state = asdict(value) + + @classmethod + async def latest_version(cls, session: AsyncSession, device_id: str, name: str) -> Optional["Shadow"]: + """Get the latest version of the shadow associated with the device and name.""" + stmt = ( + select(cls).where( + cls.device_id == device_id, + cls.name == name + ).order_by(desc(cls.version)).limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + @classmethod + async def all(cls, session: AsyncSession, device_id: str, name: str) -> Sequence["Shadow"]: + """Return a list of all shadows associated with the device and name.""" + stmt = ( + select(cls).where( + cls.device_id == device_id, + cls.name == name + ).order_by(desc(cls.version))) + result = await session.execute(stmt) + return result.scalars().all() + + +@event.listens_for(Shadow, "before_insert") +def assign_incremental_version(_: Mapper[Any], connection: Session, target: "Shadow") -> None: + """Get the latest version of the state document.""" + stmt = ( + select(func.max(Shadow.version)) + .where( + Shadow.device_id == target.device_id, + Shadow.name == target.name + ) + ) + result = connection.execute(stmt).scalar_one_or_none() + target.version = (result or 0) + 1 + + +@event.listens_for(Shadow, "before_update") +def prevent_update(_mapper: Mapper[Any], _session: Session, _instance: "Shadow") -> None: + """Prevent existing shadow from being updated.""" + raise ShadowUpdateError + + +@event.listens_for(Session, "before_flush") +def convert_update_to_insert(session: Session, _flush_context: object, _instances:object | None) -> None: + """Force a shadow to insert a new version, instead of updating an existing.""" + # Make a copy of the dirty set so we can safely mutate the session + dirty = list(session.dirty) + + for obj in dirty: + if not session.is_modified(obj, include_collections=False): + continue # skip unchanged + + # You can scope this to a particular class + if not isinstance(obj, Shadow): + continue + + # Clone logic: convert update into insert + session.expunge(obj) # remove from session + make_transient(obj) # remove identity and history + obj.id = "" # clear primary key + obj.version += 1 # bump version or modify fields + + session.add(obj) # re-add as new object + +_listener_example = '''# +# @event.listens_for(Shadow, "before_insert") +# def convert_state_document_to_json(_1: Mapper[Any], _2: Session, target: "Shadow") -> None: +# """Listen for insertion and convert state document to json.""" +# if not isinstance(target.state, StateDocument): +# msg = "'state' field needs to be a StateDocument" +# raise TypeError(msg) +# +# target.state = target.state.to_dict() +''' diff --git a/amqtt/contrib/shadows/plugin.py b/amqtt/contrib/shadows/plugin.py new file mode 100644 index 0000000..7271c20 --- /dev/null +++ b/amqtt/contrib/shadows/plugin.py @@ -0,0 +1,198 @@ +from collections import defaultdict +from dataclasses import dataclass, field +import json +import re +from typing import Any + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from amqtt.broker import BrokerContext +from amqtt.contexts import Action +from amqtt.contrib.shadows.messages import ( + GetAcceptedMessage, + GetRejectedMessage, + UpdateAcceptedMessage, + UpdateDeltaMessage, + UpdateDocumentMessage, + UpdateIotaMessage, +) +from amqtt.contrib.shadows.models import Shadow, sync_shadow_base +from amqtt.contrib.shadows.states import ( + ShadowOperation, + StateDocument, + calculate_delta_update, + calculate_iota_update, +) +from amqtt.plugins.base import BasePlugin, BaseTopicPlugin +from amqtt.session import ApplicationMessage, Session + +shadow_topic_re = re.compile(r"^\$shadow/(?P[a-zA-Z0-9_-]+?)/(?P[a-zA-Z0-9_-]+?)/(?Pget|update)") + +DeviceID= str +ShadowName = str + + +@dataclass +class ShadowTopic: + device_id: DeviceID + name: ShadowName + message_op: ShadowOperation + + +def shadow_dict() -> dict[DeviceID, dict[ShadowName, StateDocument]]: + """Nested defaultdict for shadow cache.""" + return defaultdict(shadow_dict) # type: ignore[arg-type] + +class ShadowPlugin(BasePlugin[BrokerContext]): + + def __init__(self, context: BrokerContext) -> None: + super().__init__(context) + self._shadows: dict[DeviceID, dict[ShadowName, StateDocument]] = defaultdict(dict) + + self._engine = create_async_engine(self.config.connection) + self._db_session_maker = async_sessionmaker(self._engine, expire_on_commit=False) + + + async def on_broker_pre_start(self) -> None: + """Sync the schema.""" + async with self._engine.begin() as conn: + await sync_shadow_base(conn) + + @staticmethod + def shadow_topic_match(topic: str) -> ShadowTopic | None: + """Check if topic matches the shadow topic format.""" + # pattern is "$shadow///get, update, etc + match = shadow_topic_re.search(topic) + if match: + groups = match.groupdict() + return ShadowTopic(groups["client_id"], groups["shadow_name"], ShadowOperation(groups["request"])) + return None + + async def _handle_get(self, st: ShadowTopic) -> None: + """Send 'accepted.""" + async with self._db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(db_session, st.device_id, st.name) + if not shadow: + reject_msg = GetRejectedMessage( + code=404, + message="shadow not found", + ) + await self.context.broadcast_message(reject_msg.topic(st.device_id, st.name), reject_msg.to_message()) + return + + accept_msg = GetAcceptedMessage( + state=shadow.state.state, + metadata=shadow.state.metadata, + timestamp= shadow.created_at, + version= shadow.version + ) + await self.context.broadcast_message(accept_msg.topic(st.device_id, st.name), accept_msg.to_message()) + + async def _handle_update(self, st: ShadowTopic, update: dict[str, Any]) -> None: + async with self._db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(db_session, st.device_id, st.name) + if not shadow: + shadow = Shadow(device_id=st.device_id, name=st.name) + + state_update = StateDocument.from_dict(update) + + prev_state = shadow.state or StateDocument() + prev_state.version = shadow.version or 0 # only required when generating shadow messages + prev_state.timestamp = shadow.created_at or 0 # only required when generating shadow messages + + next_state = prev_state + state_update + + shadow.state = next_state + db_session.add(shadow) + await db_session.commit() + + next_state.version = shadow.version + next_state.timestamp = shadow.created_at + + accept_msg = UpdateAcceptedMessage( + state=next_state.state, + metadata=next_state.metadata, + timestamp=123, + version=1 + ) + + await self.context.broadcast_message(accept_msg.topic(st.device_id, st.name), accept_msg.to_message()) + + delta_msg = UpdateDeltaMessage( + state=calculate_delta_update(next_state.state.desired, next_state.state.reported), + metadata=calculate_delta_update(next_state.metadata.desired, next_state.metadata.reported), + version=shadow.version, + timestamp=shadow.created_at + ) + await self.context.broadcast_message(delta_msg.topic(st.device_id, st.name), delta_msg.to_message()) + + iota_msg = UpdateIotaMessage( + state=calculate_iota_update(next_state.state.desired, next_state.state.reported), + metadata=calculate_delta_update(next_state.metadata.desired, next_state.metadata.reported), + version=shadow.version, + timestamp=shadow.created_at + ) + await self.context.broadcast_message(iota_msg.topic(st.device_id, st.name), iota_msg.to_message()) + + doc_msg = UpdateDocumentMessage( + previous=prev_state, + current=next_state, + timestamp=shadow.created_at + ) + + await self.context.broadcast_message(doc_msg.topic(st.device_id, st.name), doc_msg.to_message()) + + async def on_broker_message_received(self, *, client_id: str, message: ApplicationMessage) -> None: + """Process a message that was received from a client.""" + topic = message.topic + if not topic.startswith("$shadow"): # this is less overhead than do the full regular expression match + return + + if not (shadow_topic := self.shadow_topic_match(topic)): + return + + match shadow_topic.message_op: + + case ShadowOperation.GET: + await self._handle_get(shadow_topic) + case ShadowOperation.UPDATE: + await self._handle_update(shadow_topic, json.loads(message.data.decode("utf-8"))) + + @dataclass + class Config: + """Configuration for shadow plugin.""" + + connection: str + """SQLAlchemy connection string for the asyncio version of the database connector: + + - `mysql+aiomysql://user:password@host:port/dbname` + - `postgresql+asyncpg://user:password@host:port/dbname` + - `sqlite+aiosqlite:///dbfilename.db` + """ + + +class ShadowTopicAuthPlugin(BaseTopicPlugin): + + async def topic_filtering(self, *, + session: Session | None = None, + topic: str | None = None, + action: Action | None = None) -> bool | None: + + session = session or Session() + if not topic: + return False + + shadow_topic = ShadowPlugin.shadow_topic_match(topic) + + if not shadow_topic: + return False + + return shadow_topic.device_id == session.username or session.username in self.config.superusers + + @dataclass + class Config: + """Configuration for only allowing devices access to their own shadow topics.""" + + superusers: list[str] = field(default_factory=list) + """A list of one or more usernames that can write to any device topic, + primarily for the central app sending updates to devices.""" diff --git a/amqtt/contrib/shadows/states.py b/amqtt/contrib/shadows/states.py new file mode 100644 index 0000000..d05083c --- /dev/null +++ b/amqtt/contrib/shadows/states.py @@ -0,0 +1,203 @@ +from collections import Counter +from collections.abc import MutableMapping +from dataclasses import dataclass, field + +try: + from enum import StrEnum +except ImportError: + # support for python 3.10 + from enum import Enum + class StrEnum(str, Enum): #type: ignore[no-redef] + pass +import time +from typing import Any, Generic, TypeVar + +from mergedeep import merge + +C = TypeVar("C", bound=Any) + +class StateError(Exception): + def __init__(self, msg: str = "'state' field is required") -> None: + super().__init__(msg) + +@dataclass +class MetaTimestamp: + timestamp: int = 0 + + def __eq__(self, other: object) -> bool: + """Compare timestamps.""" + if isinstance(other, int): + return self.timestamp == other + if isinstance(other, self.__class__): + return self.timestamp == other.timestamp + msg = "needs to be int or MetaTimestamp" + raise ValueError(msg) + + # numeric operations to make this dataclass transparent + def __abs__(self) -> int: + """Absolute timestamp.""" + return self.timestamp + + def __add__(self, other: int) -> int: + """Add to a timestamp.""" + return self.timestamp + other + + def __sub__(self, other: int) -> int: + """Subtract from a timestamp.""" + return self.timestamp - other + + def __mul__(self, other: int) -> int: + """Multiply a timestamp.""" + return self.timestamp * other + + def __float__(self) -> float: + """Convert timestamp to float.""" + return float(self.timestamp) + + def __int__(self) -> int: + """Convert timestamp to int.""" + return int(self.timestamp) + + def __lt__(self, other:int ) -> bool: + """Compare timestamp.""" + return self.timestamp < other + + def __le__(self, other: int) -> bool: + """Compare timestamp.""" + return self.timestamp <= other + + def __gt__(self, other: int) -> bool: + """Compare timestamp.""" + return self.timestamp > other + + def __ge__(self, other: int) -> bool: + """Compare timestamp.""" + return self.timestamp >= other + + +def create_metadata(state: MutableMapping[str, Any], timestamp: int) -> dict[str, Any]: + """Create metadata (timestamps) for each of the keys in 'state'.""" + metadata: dict[str, Any] = {} + for key, value in state.items(): + if isinstance(value, dict): + metadata[key] = create_metadata(value, timestamp) + elif value is None: + metadata[key] = None + else: + metadata[key] = MetaTimestamp(timestamp) + + return metadata + + +def calculate_delta_update(desired: MutableMapping[str, Any], + reported: MutableMapping[str, Any], + depth: bool = True, + exclude_nones: bool = True, + ordered_lists: bool = True) -> dict[str, Any]: + """Calculate state differences between desired and reported.""" + diff_dict = {} + for key, value in desired.items(): + if value is None and exclude_nones: + continue + # if the desired has an element that the reported does not... + if key not in reported: + diff_dict[key] = value + # if the desired has an element that's a list, but the list is + elif isinstance(value, list) and not ordered_lists: + if Counter(value) != Counter(reported[key]): + diff_dict[key] = value + elif isinstance(value, dict) and depth: + # recurse, report when there is a difference + obj_diff = calculate_delta_update(value, reported[key]) + if obj_diff: + diff_dict[key] = obj_diff + elif value != reported[key]: + diff_dict[key] = value + + return diff_dict + + +def calculate_iota_update(desired: MutableMapping[str, Any], reported: MutableMapping[str, Any]) -> MutableMapping[str, Any]: + """Calculate state differences between desired and reported (including missing keys).""" + delta = calculate_delta_update(desired, reported, depth=False, exclude_nones=False) + + for key in reported: + if key not in desired: + delta[key] = None + + return delta + +@dataclass +class State(Generic[C]): + desired: MutableMapping[str, C] = field(default_factory=dict) + reported: MutableMapping[str, C] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, C]) -> "State[C]": + """Create state from dictionary.""" + return cls( + desired=data.get("desired", {}), + reported=data.get("reported", {}) + ) + + def __bool__(self) -> bool: + """Determine if state is empty.""" + return bool(self.desired) or bool(self.reported) + + def __add__(self, other: "State[C]") -> "State[C]": + """Merge states together.""" + return State( + desired=merge({}, self.desired, other.desired), + reported=merge({}, self.reported, other.reported) + ) + + +@dataclass +class StateDocument: + state: State[dict[str, Any]] = field(default_factory=State) + metadata: State[MetaTimestamp] = field(default_factory=State) + version: int | None = None # only required when generating shadow messages + timestamp: int | None = None # only required when generating shadow messages + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "StateDocument": + """Create state document from dictionary.""" + now = int(time.time()) + if data and "state" not in data: + raise StateError + + state = State.from_dict(data.get("state", {})) + metadata = State( + desired=create_metadata(state.desired, now), + reported=create_metadata(state.reported, now) + ) + + return cls(state=state, metadata=metadata) + + def __post_init__(self) -> None: + """Initialize meta data if not provided.""" + now = int(time.time()) + if not self.metadata: + self.metadata = State( + desired=create_metadata(self.state.desired, now), + reported=create_metadata(self.state.reported, now), + ) + + def __add__(self, other: "StateDocument") -> "StateDocument": + """Merge two state documents together.""" + return StateDocument( + state=self.state + other.state, + metadata=self.metadata + other.metadata + ) + + +class ShadowOperation(StrEnum): + GET = "get" + UPDATE = "update" + GET_ACCEPT = "get/accepted" + GET_REJECT = "get/rejected" + UPDATE_ACCEPT = "update/accepted" + UPDATE_REJECT = "update/rejected" + UPDATE_DOCUMENTS = "update/documents" + UPDATE_DELTA = "update/delta" + UPDATE_IOTA = "update/iota" diff --git a/amqtt/events.py b/amqtt/events.py index 96cdd39..5696dbf 100644 --- a/amqtt/events.py +++ b/amqtt/events.py @@ -33,3 +33,4 @@ class BrokerEvents(Events): CLIENT_UNSUBSCRIBED = "broker_client_unsubscribed" RETAINED_MESSAGE = "broker_retained_message" MESSAGE_RECEIVED = "broker_message_received" + MESSAGE_BROADCAST = "broker_message_broadcast" diff --git a/docs/assets/extra.css b/docs/assets/extra.css index be8f419..1d2cea7 100644 --- a/docs/assets/extra.css +++ b/docs/assets/extra.css @@ -30,3 +30,7 @@ h2.doc-heading-parameter { .md-nav__link--active { color: #f15581 !important; } + +.admonition { + font-size: 16px !important; +} \ No newline at end of file diff --git a/docs/plugins/contrib.md b/docs/plugins/contrib.md index 7a536c1..d85859f 100644 --- a/docs/plugins/contrib.md +++ b/docs/plugins/contrib.md @@ -18,11 +18,15 @@ These are fully supported plugins but require additional dependencies to be inst Determine client authentication and authorization based on response from a separate HTTP server.
`amqtt.contrib.http.HttpAuthTopicPlugin` +- [Shadows](shadows.md)
+ Device shadows provide a persistent, cloud-based representation of the state of a device, + even when the device is offline. This plugin tracks the desired and reported state of a client + and provides MQTT topic-based communication channels to retrieve and update a shadow.
+ `amqtt.contrib.shadows.ShadowPlugin` + - [Certificate Auth](cert.md)
Using client-specific certificates, signed by a common authority (even if self-signed) provides a highly secure way of authenticating mqtt clients. Often used with IoT devices where a unique certificate can be initialized on initial provisioning. Includes command line utilities to generate - root, broker and device certificates with the correct X509 attributes to enable authenticity. - + root, broker and device certificates with the correct X509 attributes to enable authenticity.
`amqtt.contrib.cert.CertificateAuthPlugin.Config` - diff --git a/docs/plugins/shadows.md b/docs/plugins/shadows.md new file mode 100644 index 0000000..c1e6bb1 --- /dev/null +++ b/docs/plugins/shadows.md @@ -0,0 +1,202 @@ +# Device Shadows Plugin + +Device shadows provide a persistent, cloud-based representation of the state of a device, +even when the device is offline. This plugin tracks the desired and reported state of a client +and provides MQTT topic-based communication channels to retrieve and update a shadow. + +Typically, this structure is used for MQTT IoT devices to communicate with a central application. + +This plugin is patterned after [AWS's IoT Shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html) service. + +## How it works + +All shadow states are associated with a `device id` and `name` and have the following structure: + +```json +{ + "state": { + "desired": { + "property1": "value1" + }, + "reported": { + "property1": "value1" + } + }, + "metadata": { + "desired": { + "property1": { + "timestamp": 1623855600 + } + }, + "reported": { + "property1": { + "timestamp": 1623855602 + } + } + }, + "version": 10, + "timestamp": 1623855602 +} +``` + +The `state` is updated by messages to shadow topics and includes key/value pairs, where the value can be any valid +json object (int, string, dictionary, list, etc). `metadata` is automatically updated by the plugin based on when +the key/values were most recently updated. Both `state` and `metadata` are split between: + +- desired: the intended state of a device +- reported: the actual state of a device + +A client can update a part or all of the desired or reported state. On any update, the plugin: + +- updates the 'state' portion of the shadow with any key/values provided in the update +- stores a version of the update +- tracks the timestamp of each key/value pair change +- sends messages that the shadow was updated + +## Typical usage + +As mentioned above, this plugin is often used for MQTT IoT devices to communicate with a central application. The +app pushes updates to a device's 'desired' shadow state and the device can confirm the change was made by updating +the 'reported' state. With this sequence the 'desired' state matches the 'reported' state and the delta message is empty. + +In most situations, the app only updates the 'desired' state and the device only updates the 'reported' state. + +If online, the IoT device will receive and can act on that information immediately. If offline, the app doesn't need +to republish or retry a change 'command', waiting for an acknowledgement from the device. If a device is offline, it +simply retrieves the configuration changes when it comes back online. + +Once a device receives its desired state, it should either (1) update its reported state to match the change in desired +or (2) if the desired state is invalid, clear that key/value from the desired state. The latter is the only case +when a device should update its own 'desired' state. + +For example, if the app sends a command to set the brightness of a device to 100 lumens, but the device only supports +a maximum of 80, it can send an update `'state': {'desired': {'lumens': null}}` to clear the invalid state. + +The reported state can (and most likely will) include key/values that will never show up in the desired state. For +example, the app might set the thermostat to 70 and the device reports both the configuration change of 70 to the +thermostat *and* the current temperature of the room. + +```json +{ + "state": { + "desired": { + "thermostat": 68 + }, + "reported": { + "thermostat": 68, + "temperature": 78 + } + } +} +``` + +!!! note "desired and reported state structure" + It is important that both the app and the device have the same understanding of the key/value + state structure and units. Creating [JSON schemas](https://json-schema.org/) for desired and + reported shadow states are very useful as it can provide a clear way of describing the schema. + These schemas can also be used to generate [dataclasses](https://pypi.org/project/datamodel-code-generator/), + [pojos](https://github.com/joelittlejohn/jsonschema2pojo) or [many other language constructs](https://json-schema.org/tools?query=&sortBy=name&sortOrder=ascending&groupBy=toolingTypes&licenses=&languages=&drafts=&toolingTypes=&environments=&showObsolete=false&supportsBowtie=false#schema-to-code) that + can be easily included by both app and device to make state encoding and decoding consistent. + +## Shadow state access + +All shadows are addressed by using specific topics, all of which have the following base: + +`$shadow//` + +Clients send either `get` or `update` messages: + +| Operation | Topic | Direction | Payload | +|-------------------------|-------------------------------------------------------|-----------|-----------------------------------------------------| +| **Update** | `$shadow/{device_id}/{shadow_name}/update` | → | `{ "state": { "desired" or "reported": ... } }` | +| **Get** | `$shadow/{device_id}/{shadow_name}/get` | → | Empty message triggers get accepted or rejected | + +Then clients can subscribe to any or all of these topics which receive messages issued by the plugin: + +| Operation | Topic | Direction | Payload | +|-------------------------|-------------------------------------------------------|-----------|-----------------------------------------------------| +| **Update Accepted** | `$shadow/{device_id}/{shadow_name}/update/accepted` | ← | Full updated document | +| **Update Rejected** | `$shadow/{device_id}/{shadow_name}/update/rejected` | ← | Error message | +| **Update Documents** | `$shadow/{device_id}/{shadow_name}/update/documents` | ← | Full current & previous shadow documents | +| **Get Accepted** | `$shadow/{device_id}/{shadow_name}/get/accepted` | ← | Full shadow document | +| **Get Rejected** | `$shadow/{device_id}/{shadow_name}/get/rejected` | ← | Error message | +| **Delta** | `$shadow/{device_id}/{shadow_name}/update/delta` | ← | Difference between desired and reported | +| **Iota** | `$shadow/{device_id}/{shadow_name}/update/iota` | ← | Difference between desired and reported, with nulls | + +## Delta messages + +While the 'accepted' and 'documents' messages carry the full desired and reported states, this plugin also generates +a 'delta' message - containing items in the desired state that are different from those items in the reported state. This +topic optimizes for IoT devices which typically have lower bandwidth and not as powerful processing by (1) to reducing the +amount of data transmitted and (2) simplifying device implementation as it only needs to respond to differences. + +While shadows are stateful since delta messages are only based on the desired and reported state and *not on the previous +and current state*. Therefore, it doesn't matter if an IoT device is offline and misses a delta message. When it comes +back online, the delta is identical. + +This is also an improvement over a connection without the clean flag and QoS > 0. When an IoT device comes back online, bandwidth +isn't consumed and the IoT device does not have to process a backlog of messages to understand how it should behave. +For a setting -- such as volume -- that goes from 80 then to 91 and then to 60 while the device is offline, it will +only receive a single change that its volume should now be 60. + + +| Reported Shadow State | Desired Shadow State | Resulting Delta Message (`delta`) | +|----------------------------------------|------------------------------------------|---------------------------------------| +| `{ "temperature": 70 }` | `{ "temperature": 72 }` | `{ "temperature": 72 }` | +| `{ "led": "off", "fan": "low" }` | `{ "led": "on", "fan": "low" }` | `{ "led": "on" }` | +| `{ "door": "closed" }` | `{ "door": "closed", "alarm": "armed" }` | `{ "alarm": "armed" }` | +| `{ "volume": 10 }` | `{ "volume": 10 }` | *(no delta; states match)* | +| `{ "brightness": 100 }` | `{ "brightness": 80, "mode": "eco" }` | `{ "brightness": 80, "mode": "eco" }` | +| `{ "levels": [1, 10, 4]}` | `{"levels": [1, 4, 10]}` | `{"levels": [1, 4, 10]}` | +| `{ "brightness": 100, "mode": "eco" }` | `{ "brightness": 80 }` | `{ "brightness": 80}` | + + +## Iota messages + +Typically, null values never show in any received update message as a null signals the removal of a key from the desired +or reported state. However, if the app removes a key from the desired state -- such as a piece of state that is no longer +needed or applicable -- the device won't receive any notification of this deletion in a delta messages. + +These messages are very similar to 'delta' messages as they also contain items in the desired state that are different from +those in the reported state; it *also* contains any items in the reported state that are *missing* from the desired +state (last row in table). + +| Reported Shadow State | Desired Shadow State | Resulting Delta Message (`delta`) | +|----------------------------------------|------------------------------------------|-----------------------------------------| +| `{ "temperature": 70 }` | `{ "temperature": 72 }` | `{ "temperature": 72 }` | +| `{ "led": "off", "fan": "low" }` | `{ "led": "on", "fan": "low" }` | `{ "led": "on" }` | +| `{ "door": "closed" }` | `{ "door": "closed", "alarm": "armed" }` | `{ "alarm": "armed" }` | +| `{ "volume": 10 }` | `{ "volume": 10 }` | *(no delta; states match)* | +| `{ "brightness": 100 }` | `{ "brightness": 80, "mode": "eco" }` | `{ "brightness": 80, "mode": "eco" }` | +| `{ "levels": [1, 10, 4]}` | `{"levels": [1, 4, 10]}` | `{"levels": [1, 4, 10]}` | +| `{ "brightness": 100, "mode": "eco" }` | `{ "brightness": 80 }` | `{ "brightness": 80, "mode": null }` | + +### Configuration + +::: amqtt.contrib.shadows.ShadowPlugin.Config + options: + show_source: false + heading_level: 4 + extra: + class_style: "simple" + + +## Security + +Often a device only needs access to get/update and receive changes in its own shadow state. In addition to the `ShadowPlugin`, +included is the `ShadowTopicAuthPlugin`. This allows (authorizes) a device to only subscribe, publish and receive its own topics. + + +::: amqtt.contrib.shadows.ShadowTopicAuthPlugin.Config + options: + show_source: false + heading_level: 4 + extra: + class_style: "simple" + +!!! warning + + `ShadowTopicAuthPlugin` only handles topic authorization. Another plugin should be used to authenticate client device + connections to the broker. See [file auth](packaged_plugins.md#password-file-auth-plugin), + [http auth](http.md), [db auth](auth_db.md) or [certificate auth](cert.md) plugins. Or create your own: + [auth plugins](custom_plugins.md#authentication-plugins): diff --git a/mkdocs.rtd.yml b/mkdocs.rtd.yml index bc0296e..9e5828f 100644 --- a/mkdocs.rtd.yml +++ b/mkdocs.rtd.yml @@ -47,6 +47,7 @@ nav: - plugins/contrib.md - Database Auth: plugins/auth_db.md - HTTP Auth: plugins/http.md + - Shadows: plugins/shadows.md - Certificate Auth: plugins/cert.md - Reference: - Containerization: docker.md diff --git a/pyproject.toml b/pyproject.toml index 45daf9d..3b347e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "poethepoet>=0.34.0", "pre-commit>=4.2.0", # https://pypi.org/project/pre-commit "psutil>=7.0.0", # https://pypi.org/project/psutil + "pyhamcrest>=2.1.0", "pylint>=3.3.6", # https://pypi.org/project/pylint "pyopenssl>=25.1.0", "pytest-asyncio>=0.26.0", # https://pypi.org/project/pytest-asyncio @@ -95,6 +96,8 @@ contrib = [ "sqlalchemy[asyncio]>=2.0.41", "argon2-cffi>=25.1.0", "aiohttp>=3.12.13", + "mergedeep>=1.3.4", + "jsonschema>=4.25.0", ] diff --git a/tests/contrib/test_shadows.py b/tests/contrib/test_shadows.py new file mode 100644 index 0000000..82e78ae --- /dev/null +++ b/tests/contrib/test_shadows.py @@ -0,0 +1,291 @@ +import json +import sqlite3 +import tempfile +from pathlib import Path +from unittest.mock import patch, call, ANY + +import aiosqlite +import pytest +from jsonschema import validate +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from amqtt.broker import BrokerContext, Broker +from amqtt.contrib.shadows import ShadowPlugin +from amqtt.contrib.shadows.models import Shadow, ShadowUpdateError +from amqtt.contrib.shadows.states import StateDocument, State +from amqtt.mqtt.constants import QOS_0 +from amqtt.session import IncomingApplicationMessage +from tests.contrib.test_shadows_schema import * + + +@pytest.fixture +def db_file(): + with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.NamedTemporaryFile(mode='wb', delete=True) as tmp: + yield Path(temp_dir) / f"{tmp.name}.db" + + +@pytest.fixture +def db_connection(db_file): + test_db_connect = f"sqlite+aiosqlite:///{db_file}" + yield test_db_connect + + +@pytest.fixture +@pytest.mark.asyncio +async def db_session_maker(db_connection): + engine = create_async_engine(f"{db_connection}") + db_session_maker = async_sessionmaker(engine, expire_on_commit=False) + + yield db_session_maker + + +@pytest.fixture +@pytest.mark.asyncio +async def shadow_plugin(db_connection): + + cfg = ShadowPlugin.Config(connection=db_connection) + ctx = BrokerContext(broker=Broker()) + ctx.config = cfg + + shadow_plugin = ShadowPlugin(ctx) + await shadow_plugin.on_broker_pre_start() + yield shadow_plugin + + +@pytest.mark.asyncio +async def test_shadow_find_latest_empty(db_session_maker, shadow_plugin): + async with db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(session=db_session, device_id='device123', name="myShadowName") + assert shadow is None + + +@pytest.mark.asyncio +async def test_shadow_create_new(db_file, db_connection, db_session_maker, shadow_plugin): + async with db_session_maker() as db_session, db_session.begin(): + shadow = Shadow(device_id='device123', name="myShadowName") + db_session.add(shadow) + await db_session.commit() + + async with aiosqlite.connect(db_file) as db_conn: + db_conn.row_factory = sqlite3.Row # Set the row_factory + + has_shadow = False + async with await db_conn.execute("SELECT * FROM shadows_shadow") as cursor: + for row in await cursor.fetchall(): + assert row['name'] == 'myShadowName' + assert row['device_id'] == 'device123' + assert row['state'] == '{}' + has_shadow = True + + assert has_shadow, "Shadow was not created." + +@pytest.mark.asyncio +async def test_shadow_create_find_empty_state(db_connection, db_session_maker, shadow_plugin): + async with db_session_maker() as db_session, db_session.begin(): + shadow = Shadow(device_id='device123', name="myShadowName") + db_session.add(shadow) + await db_session.commit() + await db_session.flush() + + async with db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(session=db_session, device_id='device123', name="myShadowName") + assert shadow is not None + assert shadow.version == 1 + assert shadow.state == StateDocument() + + +@pytest.mark.asyncio +async def test_shadow_create_find_state_doc(db_connection, db_session_maker, shadow_plugin): + state_doc = StateDocument( + state=State( + desired={'item1': 'value1', 'item2': 'value2'}, + reported={'item3': 'value3', 'item4': 'value4'}, + ) + ) + async with db_session_maker() as db_session, db_session.begin(): + shadow = Shadow(device_id='device123', name="myShadowName") + shadow.state = state_doc + db_session.add(shadow) + await db_session.commit() + await db_session.flush() + + async with db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(session=db_session, device_id='device123', name="myShadowName") + assert shadow is not None + assert shadow.version == 1 + assert shadow.state == state_doc + +@pytest.mark.asyncio +async def test_shadow_update_state(db_connection, db_session_maker, shadow_plugin): + state_doc = StateDocument( + state=State( + desired={'item1': 'value1', 'item2': 'value2'}, + reported={'item3': 'value3', 'item4': 'value4'}, + ) + ) + async with db_session_maker() as db_session, db_session.begin(): + shadow = Shadow(device_id='device123', name="myShadowName") + shadow.state = state_doc + db_session.add(shadow) + await db_session.commit() + await db_session.flush() + + async with db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(session=db_session, device_id='device123', name="myShadowName") + assert shadow is not None + shadow.state = StateDocument( + state=State( + desired={'item5': 'value5', 'item6': 'value6'}, + reported={'item7': 'value7', 'item8': 'value8'}, + ) + ) + with pytest.raises(ShadowUpdateError): + await db_session.commit() + + +@pytest.mark.asyncio +async def test_shadow_update_state(db_connection, db_session_maker, shadow_plugin): + state_doc = StateDocument( + state=State( + desired={'item1': 'value1', 'item2': 'value2'}, + reported={'item3': 'value3', 'item4': 'value4'}, + ) + ) + async with db_session_maker() as db_session, db_session.begin(): + shadow = Shadow(device_id='device123', name="myShadowName") + shadow.state = state_doc + db_session.add(shadow) + await db_session.commit() + + async with db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(session=db_session, device_id='device123', name="myShadowName") + assert shadow is not None + shadow.state += StateDocument( + state=State( + desired={'item1': 'value1a', 'item6': 'value6'} + ) + ) + await db_session.commit() + + async with db_session_maker() as db_session, db_session.begin(): + shadow_list = await Shadow.all(db_session, "device123", "myShadowName") + assert len(shadow_list) == 2 + + async with db_session_maker() as db_session, db_session.begin(): + shadow = await Shadow.latest_version(session=db_session, device_id='device123', name="myShadowName") + assert shadow is not None + assert shadow.version == 2 + assert shadow.state.state.desired == {'item1': 'value1a', 'item2': 'value2', 'item6': 'value6'} + assert shadow.state.state.reported == {'item3': 'value3', 'item4': 'value4'} + + +@pytest.mark.asyncio +async def test_shadow_plugin_get_rejected(shadow_plugin): + """test """ + + with patch.object(BrokerContext, 'broadcast_message', return_value=None) as mock_method: + msg = IncomingApplicationMessage(packet_id=1, + topic='$shadow/myClient123/myShadow/get', + qos=QOS_0, + data=json.dumps({}).encode('utf-8'), + retain=False) + await shadow_plugin.on_broker_message_received(client_id="myClient123", message=msg) + + mock_method.assert_called() + topic, message = mock_method.call_args[0] + assert topic == '$shadow/myClient123/myShadow/get/rejected' + validate(instance=json.loads(message.decode('utf-8')), schema=get_rejected_schema) + +@pytest.mark.asyncio +async def test_shadow_plugin_update_accepted(shadow_plugin): + with patch.object(BrokerContext, 'broadcast_message', return_value=None) as mock_method: + + update_msg = { + 'state': { + 'desired': { + 'item1': 'value1', + 'item2': 'value2' + } + } + } + + validate(instance=update_msg, schema=update_schema) + + + msg = IncomingApplicationMessage(packet_id=1, + topic='$shadow/myClient123/myShadow/update', + qos=QOS_0, + data=json.dumps(update_msg).encode('utf-8'), + retain=False) + await shadow_plugin.on_broker_message_received(client_id="myClient123", message=msg) + + accepted_call = call('$shadow/myClient123/myShadow/update/accepted', ANY) + document_call = call('$shadow/myClient123/myShadow/update/documents', ANY) + delta_call = call('$shadow/myClient123/myShadow/update/delta', ANY) + iota_call = call('$shadow/myClient123/myShadow/update/iota', ANY) + + mock_method.assert_has_calls( + [ + accepted_call, + document_call, + delta_call, + iota_call, + ], + any_order=True, + ) + + for actual in mock_method.call_args_list: + if actual == accepted_call: + validate(instance=json.loads(actual.args[1].decode('utf-8')), schema=update_accepted_schema) + elif actual == document_call: + validate(instance=json.loads(actual.args[1].decode('utf-8')), schema=update_documents_schema) + elif actual == delta_call: + validate(instance=json.loads(actual.args[1].decode('utf-8')), schema=delta_schema) + elif actual == iota_call: + validate(instance=json.loads(actual.args[1].decode('utf-8')), schema=delta_schema) + else: + assert False, "unknown call made to broadcast" + + +@pytest.mark.asyncio +async def test_shadow_plugin_get_accepted(shadow_plugin): + with patch.object(BrokerContext, 'broadcast_message', return_value=None) as mock_method: + + update_msg = { + 'state': { + 'desired': { + 'item1': 'value1', + 'item2': 'value2' + } + } + } + + update_msg = IncomingApplicationMessage(packet_id=1, + topic='$shadow/myClient123/myShadow/update', + qos=QOS_0, + data=json.dumps(update_msg).encode('utf-8'), + retain=False) + await shadow_plugin.on_broker_message_received(client_id="myClient123", message=update_msg) + + mock_method.reset_mock() + + get_msg = IncomingApplicationMessage(packet_id=1, + topic='$shadow/myClient123/myShadow/get', + qos=QOS_0, + data=json.dumps({}).encode('utf-8'), + retain=False) + await shadow_plugin.on_broker_message_received(client_id="myClient123", message=get_msg) + + get_accepted = call('$shadow/myClient123/myShadow/get/accepted', ANY) + + mock_method.assert_has_calls( + [get_accepted] + ) + + has_msg = False + for actual in mock_method.call_args_list: + if actual == get_accepted: + validate(instance=json.loads(actual.args[1].decode('utf-8')), schema=get_accepted_schema) + has_msg = True + assert has_msg, "could not find the broadcast call for get accepted" diff --git a/tests/contrib/test_shadows_schema.py b/tests/contrib/test_shadows_schema.py new file mode 100644 index 0000000..eca0e28 --- /dev/null +++ b/tests/contrib/test_shadows_schema.py @@ -0,0 +1,179 @@ +get_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Get Request", + "type": "object", + "properties": { + "clientToken": { "type": "string" } + }, + "additionalProperties": False +} + +get_accepted_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Get Accepted", + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + } + }, + "metadata": { + "type": "object" + }, + "version": { "type": "integer" }, + "timestamp": { "type": "integer" }, + "clientToken": { "type": "string" } + }, + "required": ["state", "version", "timestamp"], + "additionalProperties": False +} + +get_rejected_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Get Rejected", + "type": "object", + "properties": { + "code": { "type": "integer" }, + "message": { "type": "string" }, + "clientToken": { "type": "string" } + }, + "required": ["code", "message"], + "additionalProperties": False +} + +update_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Update", + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + }, + "additionalProperties": False + }, + "clientToken": { "type": "string" }, + "version": { "type": "integer" } + }, + "additionalProperties": False +} + +update_accepted_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Update Accepted", + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + } + }, + "metadata": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + } + }, + "version": { "type": "integer" }, + "timestamp": { "type": "integer" }, + "clientToken": { "type": "string" } + }, + "required": ["version", "timestamp"], + "additionalProperties": False +} + +update_rejected_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Update Rejected", + "type": "object", + "properties": { + "code": { "type": "integer" }, + "message": { "type": "string" }, + "clientToken": { "type": "string" } + }, + "required": ["code", "message"], + "additionalProperties": False +} + +delta_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Delta", + "type": "object", + "properties": { + "state": { "type": "object" }, + "metadata": { "type": "object" }, + "version": { "type": "integer" }, + "timestamp": { "type": "integer" } + }, + "required": ["state", "version", "timestamp"], + "additionalProperties": False +} + +update_documents_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AWS IoT Shadow Update Documents", + "type": "object", + "properties": { + "previous": { + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + }, + "additionalProperties": True + }, + "metadata": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + }, + "additionalProperties": True + }, + "version": { "type": "integer" }, + "timestamp": { "type": "integer" } + }, + "required": ["state", "metadata", "version", "timestamp"], + "additionalProperties": False + }, + "current": { + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + }, + "additionalProperties": True + }, + "metadata": { + "type": "object", + "properties": { + "desired": { "type": "object" }, + "reported": { "type": "object" } + }, + "additionalProperties": True + }, + "version": { "type": "integer" }, + "timestamp": { "type": "integer" } + }, + "required": ["state", "metadata", "version", "timestamp"], + "additionalProperties": False + }, + "timestamp": { "type": "integer" } + }, + "required": ["previous", "current", "timestamp"], + "additionalProperties": False +} diff --git a/tests/contrib/test_state.py b/tests/contrib/test_state.py new file mode 100644 index 0000000..1f23d2d --- /dev/null +++ b/tests/contrib/test_state.py @@ -0,0 +1,349 @@ +import asyncio +import math + +import pytest +import time +from hamcrest import equal_to, assert_that, close_to, has_key, instance_of, has_entry + + + +from amqtt.contrib.shadows import ShadowPlugin, ShadowOperation +from amqtt.contrib.shadows.states import State, StateDocument, calculate_delta_update, calculate_iota_update, \ + MetaTimestamp + + +@pytest.mark.parametrize("topic,client_id,shadow_name,message_type,is_match", [ + ('$shadow/myclientid/myshadow/get', 'myclientid', 'myshadow', ShadowOperation.GET, True), + ('$shadow/myshadow/get', '', '', '', False) +]) +def test_shadow_topic_match(topic, client_id, shadow_name, message_type, is_match): + + # broker_context = BrokerContext(broker=Broker()) + # shadow_plugin = ShadowPlugin(context=broker_context) + shadow_topic = ShadowPlugin.shadow_topic_match(topic) + if is_match: + assert shadow_topic.device_id == client_id + assert shadow_topic.name == shadow_name + assert shadow_topic.message_op in ShadowOperation + assert shadow_topic.message_op == message_type + else: + assert shadow_topic is None + + +@pytest.mark.asyncio +async def test_state_add(): + + cur_time = math.floor(time.time()) + + data = { + 'state':{ + 'desired': { + 'item1': 'value1a', + 'item2': 'value2a' + }, + 'reported': { + 'item1': 'value1a', + 'item2': 'value2b' + } + } + } + + meta = { + 'metadata': { + 'desired': { + 'item1': 10, + 'item2': 20 + }, + 'reported': { + 'item1': 11, + 'item2': 21 + } + } + } + + data_state = State.from_dict(data['state']) + meta_state = State.from_dict(meta['metadata']) + + state_document_one = StateDocument(state=data_state, metadata=meta_state) + await asyncio.sleep(2) + + data_update = { + 'state':{ + 'desired': { + 'item2': 'value2a' + }, + 'reported': { + 'item1': 'value1c', + 'item2': 'value2c' + } + } + } + + state_document_two = StateDocument.from_dict(data_update) + + final_doc = state_document_one + state_document_two + + assert final_doc.state.desired['item1'] == 'value1a' + assert final_doc.metadata.desired['item1'] == 10 + + assert final_doc.state.desired['item2'] == 'value2a' + assert final_doc.metadata.desired['item2'] > cur_time + + assert final_doc.state.reported['item1'] == 'value1c' + assert final_doc.metadata.reported['item1'] > cur_time + assert final_doc.state.reported['item1'] == 'value1c' + assert final_doc.metadata.reported['item1'] > cur_time + + +def test_state_from_dict() -> None: + + state_dict = { + 'desired': {'keyA': 'valueA', 'keyB': 'valueB'}, + 'reported': {'keyC': 'valueC', 'keyD': 'valueD'} + } + + state = State.from_dict(state_dict) + + assert_that(state.desired['keyA'], equal_to('valueA')) + assert_that(state.desired['keyB'], equal_to('valueB')) + assert_that(state.reported['keyC'], equal_to('valueC')) + assert_that(state.reported['keyD'], equal_to('valueD')) + + +def test_state_doc_from_dict() -> None: + now = int(time.time()) + + state_dict = { + 'state': { + 'desired': {'keyA': 'valueA', 'keyB': 'valueB'}, + 'reported': {'keyC': 'valueC', 'keyD': 'valueD'} + } + } + + state_doc = StateDocument.from_dict(state_dict) + + assert_that(state_doc.state.desired['keyA'], equal_to('valueA')) + assert_that(state_doc.state.desired['keyB'], equal_to('valueB')) + + assert_that(state_doc.metadata.desired, has_key('keyA')) # noqa + assert_that(state_doc.metadata.desired, has_key('keyB')) # noqa + + assert_that(state_doc.metadata.desired['keyA'], instance_of(MetaTimestamp)) # noqa + assert_that(state_doc.metadata.desired['keyB'], instance_of(MetaTimestamp)) # noqa + assert_that(state_doc.metadata.desired['keyA'], close_to(now, 0)) # noqa + assert_that(state_doc.metadata.desired['keyA'], equal_to(state_doc.metadata.desired['keyB'])) + + +def test_state_doc_including_meta() -> None: + now = int(time.time()) + state1 = State( + desired={'keyA': 'valueA', 'keyB': 'valueB'}, + reported={'keyC': 'valueC', 'keyD': 'valueD'} + ) + meta1 = State( + desired={'keyA': now - 100, 'keyB': now - 110}, + reported={'keyC': now - 90, 'keyD': now - 120} + ) + + state_doc1 = StateDocument( + state=state1, + metadata=meta1 + ) + + state2 = State( + desired={'keyA': 'valueA', 'keyB': 'valueB'}, + reported={'keyC': 'valueC', 'keyD': 'valueD'} + ) + meta2 = State( + desired={'keyA': now - 5, 'keyB': now - 5}, + reported={'keyC': now - 5, 'keyD': now - 5} + ) + + state_doc2 = StateDocument( + state=state2, + metadata=meta2 + ) + + new_doc = state_doc1 + state_doc2 + + assert_that(new_doc.metadata.desired['keyA'], equal_to(now - 5)) + assert_that(new_doc.metadata.reported['keyC'], equal_to(now - 5)) + + +def test_state_doc_plus_new_key_update() -> None: + now = int(time.time()) + state = State( + desired={'keyA': 'valueA', 'keyB': 'valueB'}, + reported={'keyC': 'valueC', 'keyD': 'valueD'} + ) + meta = State( + desired={'keyA': now - 100, 'keyB': now - 110}, + reported={'keyC': now - 90, 'keyD': now - 120} + ) + + state_doc = StateDocument( + state=state, + metadata=meta + ) + + update_dict = {'state': {'reported': {'keyE': 'valueE', 'keyF': 'valueF'}}} + update = StateDocument.from_dict(update_dict) + + next_doc = state_doc + update + + assert_that(next_doc.state.reported, has_key('keyC')) + assert_that(next_doc.metadata.reported['keyC'], equal_to(now - 90)) + assert_that(next_doc.metadata.reported['keyD'], equal_to(now - 120)) + + assert_that(next_doc.state.reported, has_key('keyE')) + assert_that(next_doc.state.reported, has_key('keyF')) + assert_that(next_doc.metadata.reported['keyE'], close_to(now, 1)) + assert_that(next_doc.metadata.reported['keyF'], close_to(now, 1)) + + +def test_state_with_updated_keys() -> None: + + now = int(time.time()) + state = State( + desired={'keyA': 'valueA', 'keyB': 'valueB'}, + reported={'keyC': 'valueC', 'keyD': 'valueD'} + ) + meta = State( + desired={'keyA': now - 100, 'keyB': now - 110}, + reported={'keyC': now - 90, 'keyD': now - 120} + ) + + state_doc = StateDocument( + state=state, + metadata=meta + ) + + update_dict = {'state': {'reported': {'keyD': 'valueD'}}} + update = StateDocument.from_dict(update_dict) + + next_doc = state_doc + update + + assert_that(next_doc.state.reported, has_key('keyC')) + assert_that(next_doc.state.reported, has_key('keyD')) + assert_that(next_doc.metadata.reported['keyC'], equal_to(now - 90)) + assert_that(next_doc.metadata.reported['keyD'], close_to(now, 1)) + + +def test_update_with_empty_initial_state() -> None: + now = int(time.time()) + + prev_doc = StateDocument.from_dict({}) + + state = State( + desired={'keyA': 'valueA', 'keyB': 'valueB'}, + reported={'keyC': 'valueC', 'keyD': 'valueD'} + ) + + state_doc = StateDocument(state=state) + new_doc = prev_doc + state_doc + + assert_that(state_doc.state.desired, equal_to(new_doc.state.desired)) + assert_that(state_doc.state.reported, equal_to(new_doc.state.reported)) + assert_that(state_doc.metadata.reported, has_key('keyC')) + assert_that(state_doc.metadata.reported['keyC'], close_to(now, 1)) + + +def test_update_with_clearing_key() -> None: + state = State( + desired={'keyA': 'valueA', 'keyB': 'valueB'}, + reported={'keyC': 'valueC', 'keyD': 'valueD'} + ) + + state_doc = StateDocument(state=state) + + update_doc = StateDocument.from_dict({'state': {'reported': {'keyC': None}}}) + + new_doc = state_doc + update_doc + + assert_that(new_doc.state.reported, has_entry(equal_to('keyC'), equal_to(None))) + + +def test_empty_desired_state() -> None: + + state_doc = StateDocument.from_dict({ + 'state': { + 'reported': { + 'items': ['value1', 'value2', 'value3'] + } + } + }) + + diff = calculate_delta_update(state_doc.state.desired, state_doc.state.reported) + assert diff == {} + + +def test_empty_reported_state() -> None: + + state_doc = StateDocument.from_dict({ + 'state': { + 'desired': { + 'items': ['value1', 'value2', 'value3'] + } + } + }) + + diff = calculate_delta_update(state_doc.state.desired, state_doc.state.reported) + assert diff == {'items': ['value1', 'value2', 'value3']} + + +def test_matching_desired_reported_state() -> None: + + state_doc = StateDocument.from_dict({ + 'state': { + 'desired': { + 'items': ['value1', 'value2', 'value3'] + }, + 'reported': { + 'items': ['value1', 'value2', 'value3'] + } + } + }) + + diff = calculate_delta_update(state_doc.state.desired, state_doc.state.reported) + assert diff == {} + + +def test_out_of_order_list() -> None: + + state_doc = StateDocument.from_dict({ + 'state': { + 'desired': { + 'items': ['value1', 'value2', 'value3'] + }, + 'reported': { + 'items': ['value2', 'value1', 'value3'] + } + } + }) + + diff = calculate_delta_update(state_doc.state.desired, state_doc.state.reported) + assert diff == { 'items': ['value1', 'value2', 'value3'] } + +def test_states_with_connector_transaction() -> None: + state_doc = StateDocument.from_dict({ + 'state': { + 'desired': {}, + 'reported': {"transaction": False, "transactionId": "5678", "tag": "ghijk"} + } + }) + + diff = calculate_iota_update(state_doc.state.desired, state_doc.state.reported) + assert diff == {"transaction": None, "transactionId": None, "tag": None} + + +def test_extra_reported_into_desired_with_overlap() -> None: + state_doc = StateDocument.from_dict({ + 'state': { + 'desired': {"connectors": [1, 2, 3]}, + 'reported': {"status": None, "heartbeat": "2025-02-07T04:16:51.431Z", "connectors": [1, 2, 3], + "module_version": "2.0.1", "restartTime": "2025-02-07T03:16:51.431Z"} + }}) + + diff = calculate_iota_update(state_doc.state.desired, state_doc.state.reported) + assert diff == {"status": None, "heartbeat": None, "module_version": None, "restartTime": None} + diff --git a/uv.lock b/uv.lock index b5a8720..3272b0c 100644 --- a/uv.lock +++ b/uv.lock @@ -152,6 +152,8 @@ contrib = [ { name = "argon2-cffi" }, { name = "cryptography" }, { name = "greenlet" }, + { name = "jsonschema" }, + { name = "mergedeep" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] @@ -169,6 +171,7 @@ dev = [ { name = "poethepoet" }, { name = "pre-commit" }, { name = "psutil" }, + { name = "pyhamcrest" }, { name = "pylint" }, { name = "pyopenssl" }, { name = "pytest" }, @@ -211,6 +214,8 @@ requires-dist = [ { name = "cryptography", marker = "extra == 'contrib'", specifier = ">=45.0.3" }, { name = "dacite", specifier = ">=1.9.2" }, { name = "greenlet", marker = "extra == 'contrib'", specifier = ">=3.2.3" }, + { name = "jsonschema", marker = "extra == 'contrib'", specifier = ">=4.25.0" }, + { name = "mergedeep", marker = "extra == 'contrib'", specifier = ">=1.3.4" }, { name = "passlib", specifier = "==1.7.4" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pyyaml", specifier = "==6.0.2" }, @@ -233,6 +238,7 @@ dev = [ { name = "poethepoet", specifier = ">=0.34.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "psutil", specifier = ">=7.0.0" }, + { name = "pyhamcrest", specifier = ">=2.1.0" }, { name = "pylint", specifier = ">=3.3.6" }, { name = "pyopenssl", specifier = ">=25.1.0" }, { name = "pytest", specifier = ">=8.3.5" }, @@ -1161,6 +1167,33 @@ version = "3.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } +[[package]] +name = "jsonschema" +version = "4.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/00/a297a868e9d0784450faa7365c2172a7d6110c763e30ba861867c32ae6a9/jsonschema-4.25.0.tar.gz", hash = "sha256:e63acf5c11762c0e6672ffb61482bdf57f0876684d8d249c0fe2d730d48bc55f", size = 356830, upload-time = "2025-07-18T15:39:45.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/54/c86cd8e011fe98803d7e382fd67c0df5ceab8d2b7ad8c5a81524f791551c/jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716", size = 89184, upload-time = "2025-07-18T15:39:42.956Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" }, +] + [[package]] name = "keyring" version = "25.6.0" @@ -2080,6 +2113,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pyhamcrest" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/3f/f286caba4e64391a8dc9200e6de6ce0d07471e3f718248c3276843b7793b/pyhamcrest-2.1.0.tar.gz", hash = "sha256:c6acbec0923d0cb7e72c22af1926f3e7c97b8e8d69fc7498eabacaf7c975bd9c", size = 60538, upload-time = "2023-10-22T15:47:28.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/71/1b25d3797a24add00f6f8c1bb0ac03a38616e2ec6606f598c1d50b0b0ffb/pyhamcrest-2.1.0-py3-none-any.whl", hash = "sha256:f6913d2f392e30e0375b3ecbd7aee79e5d1faa25d345c8f4ff597665dcac2587", size = 54555, upload-time = "2023-10-22T15:47:25.08Z" }, +] + [[package]] name = "pylint" version = "3.3.7" @@ -2277,6 +2319,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + [[package]] name = "requests" version = "2.32.3" @@ -2306,6 +2362,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "rpds-py" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/d9/991a0dee12d9fc53ed027e26a26a64b151d77252ac477e22666b9688bc16/rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f", size = 27420, upload-time = "2025-08-07T08:26:39.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2d/ad2e37dee3f45580f7fa0066c412a521f9bee53d2718b0e9436d308a1ecd/rpds_py-0.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:130c1ffa5039a333f5926b09e346ab335f0d4ec393b030a18549a7c7e7c2cea4", size = 371511, upload-time = "2025-08-07T08:23:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/67/57b4b2479193fde9dd6983a13c2550b5f9c3bcdf8912dffac2068945eb14/rpds_py-0.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a4cf32a26fa744101b67bfd28c55d992cd19438aff611a46cac7f066afca8fd4", size = 354718, upload-time = "2025-08-07T08:23:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/a3/be/c2b95ec4b813eb11f3a3c3d22f22bda8d3a48a074a0519cde968c4d102cf/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64a0fe3f334a40b989812de70160de6b0ec7e3c9e4a04c0bbc48d97c5d3600ae", size = 381518, upload-time = "2025-08-07T08:23:09.696Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d2/5a7279bc2b93b20bd50865a2269016238cee45f7dc3cc33402a7f41bd447/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a0ff7ee28583ab30a52f371b40f54e7138c52ca67f8ca17ccb7ccf0b383cb5f", size = 396694, upload-time = "2025-08-07T08:23:11.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/e9/bac8b3714bd853c5bcb466e04acfb9a5da030d77e0ddf1dfad9afb791c31/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15ea4d2e182345dd1b4286593601d766411b43f868924afe297570658c31a62b", size = 514813, upload-time = "2025-08-07T08:23:12.215Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/293115e956d7d13b7d2a9e9a4121f74989a427aa125f00ce4426ca8b7b28/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36184b44bf60a480863e51021c26aca3dfe8dd2f5eeabb33622b132b9d8b8b54", size = 402246, upload-time = "2025-08-07T08:23:13.699Z" }, + { url = "https://files.pythonhosted.org/packages/88/59/2d6789bb898fb3e2f0f7b82b7bcf27f579ebcb6cc36c24f4e208f7f58a5b/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b78430703cfcf5f5e86eb74027a1ed03a93509273d7c705babb547f03e60016", size = 383661, upload-time = "2025-08-07T08:23:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/0c/55/add13a593a7a81243a9eed56d618d3d427be5dc1214931676e3f695dfdc1/rpds_py-0.27.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:dbd749cff1defbde270ca346b69b3baf5f1297213ef322254bf2a28537f0b046", size = 401691, upload-time = "2025-08-07T08:23:16.681Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/3e8b2aad494ffaca571e4e19611a12cc18fcfd756d9274f3871a2d822445/rpds_py-0.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bde37765564cd22a676dd8101b657839a1854cfaa9c382c5abf6ff7accfd4ae", size = 416529, upload-time = "2025-08-07T08:23:17.863Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6d/bd899234728f1d8f72c9610f50fdf1c140ecd0a141320e1f1d0f6b20595d/rpds_py-0.27.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1d66f45b9399036e890fb9c04e9f70c33857fd8f58ac8db9f3278cfa835440c3", size = 558673, upload-time = "2025-08-07T08:23:18.99Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/f3e02def5193fb899d797c232f90d6f8f0f2b9eca2faef6f0d34cbc89b2e/rpds_py-0.27.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d85d784c619370d9329bbd670f41ff5f2ae62ea4519761b679d0f57f0f0ee267", size = 588426, upload-time = "2025-08-07T08:23:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0c/88e716cd8fd760e5308835fe298255830de4a1c905fd51760b9bb40aa965/rpds_py-0.27.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5df559e9e7644d9042f626f2c3997b555f347d7a855a15f170b253f6c5bfe358", size = 554552, upload-time = "2025-08-07T08:23:21.714Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/0a8243c182e7ac59b901083dff7e671feba6676a131bfff3f8d301cd2b36/rpds_py-0.27.0-cp310-cp310-win32.whl", hash = "sha256:b8a4131698b6992b2a56015f51646711ec5d893a0b314a4b985477868e240c87", size = 218081, upload-time = "2025-08-07T08:23:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/202ff35852312760148be9e08fe2ba6900aa28e7a46940a313eae473c10c/rpds_py-0.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:cbc619e84a5e3ab2d452de831c88bdcad824414e9c2d28cd101f94dbdf26329c", size = 230077, upload-time = "2025-08-07T08:23:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/49d515434c1752e40f5e35b985260cf27af052593378580a2f139a5be6b8/rpds_py-0.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dbc2ab5d10544eb485baa76c63c501303b716a5c405ff2469a1d8ceffaabf622", size = 371577, upload-time = "2025-08-07T08:23:25.379Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6d/bf2715b2fee5087fa13b752b5fd573f1a93e4134c74d275f709e38e54fe7/rpds_py-0.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7ec85994f96a58cf7ed288caa344b7fe31fd1d503bdf13d7331ead5f70ab60d5", size = 354959, upload-time = "2025-08-07T08:23:26.767Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5c/e7762808c746dd19733a81373c10da43926f6a6adcf4920a21119697a60a/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:190d7285cd3bb6d31d37a0534d7359c1ee191eb194c511c301f32a4afa5a1dd4", size = 381485, upload-time = "2025-08-07T08:23:27.869Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/0d308eb0b558309ca0598bcba4243f52c4cd20e15fe991b5bd75824f2e61/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c10d92fb6d7fd827e44055fcd932ad93dac6a11e832d51534d77b97d1d85400f", size = 396816, upload-time = "2025-08-07T08:23:29.424Z" }, + { url = "https://files.pythonhosted.org/packages/5c/aa/2d585ec911d78f66458b2c91252134ca0c7c70f687a72c87283173dc0c96/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd2c1d27ebfe6a015cfa2005b7fe8c52d5019f7bbdd801bc6f7499aab9ae739e", size = 514950, upload-time = "2025-08-07T08:23:30.576Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ef/aced551cc1148179557aed84343073adadf252c91265263ee6203458a186/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4790c9d5dd565ddb3e9f656092f57268951398cef52e364c405ed3112dc7c7c1", size = 402132, upload-time = "2025-08-07T08:23:32.428Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/cf644803d8d417653fe2b3604186861d62ea6afaef1b2284045741baef17/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4300e15e7d03660f04be84a125d1bdd0e6b2f674bc0723bc0fd0122f1a4585dc", size = 383660, upload-time = "2025-08-07T08:23:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/caf47c55ce02b76cbaeeb2d3b36a73da9ca2e14324e3d75cf72b59dcdac5/rpds_py-0.27.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:59195dc244fc183209cf8a93406889cadde47dfd2f0a6b137783aa9c56d67c85", size = 401730, upload-time = "2025-08-07T08:23:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/0b/71/c1f355afdcd5b99ffc253422aa4bdcb04ccf1491dcd1bda3688a0c07fd61/rpds_py-0.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fae4a01ef8c4cb2bbe92ef2063149596907dc4a881a8d26743b3f6b304713171", size = 416122, upload-time = "2025-08-07T08:23:36.062Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/f4b5b1eda724ed0e04d2b26d8911cdc131451a7ee4c4c020a1387e5c6ded/rpds_py-0.27.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e3dc8d4ede2dbae6c0fc2b6c958bf51ce9fd7e9b40c0f5b8835c3fde44f5807d", size = 558771, upload-time = "2025-08-07T08:23:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/5f8b834db2289ab48d5cffbecbb75e35410103a77ac0b8da36bf9544ec1c/rpds_py-0.27.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3782fb753aa825b4ccabc04292e07897e2fd941448eabf666856c5530277626", size = 587876, upload-time = "2025-08-07T08:23:38.662Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dd/1a1df02ab8eb970115cff2ae31a6f73916609b900dc86961dc382b8c2e5e/rpds_py-0.27.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:887ab1f12b0d227e9260558a4a2320024b20102207ada65c43e1ffc4546df72e", size = 554359, upload-time = "2025-08-07T08:23:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/95a014ab0d51ab6e3bebbdb476a42d992d2bbf9c489d24cff9fda998e925/rpds_py-0.27.0-cp311-cp311-win32.whl", hash = "sha256:5d6790ff400254137b81b8053b34417e2c46921e302d655181d55ea46df58cf7", size = 218084, upload-time = "2025-08-07T08:23:41.086Z" }, + { url = "https://files.pythonhosted.org/packages/49/78/f8d5b71ec65a0376b0de31efcbb5528ce17a9b7fdd19c3763303ccfdedec/rpds_py-0.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:e24d8031a2c62f34853756d9208eeafa6b940a1efcbfe36e8f57d99d52bb7261", size = 230085, upload-time = "2025-08-07T08:23:42.143Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d3/84429745184091e06b4cc70f8597408e314c2d2f7f5e13249af9ffab9e3d/rpds_py-0.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:08680820d23df1df0a0260f714d12966bc6c42d02e8055a91d61e03f0c47dda0", size = 222112, upload-time = "2025-08-07T08:23:43.233Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/e67309ca1ac993fa1888a0d9b2f5ccc1f67196ace32e76c9f8e1dbbbd50c/rpds_py-0.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:19c990fdf5acecbf0623e906ae2e09ce1c58947197f9bced6bbd7482662231c4", size = 362611, upload-time = "2025-08-07T08:23:44.773Z" }, + { url = "https://files.pythonhosted.org/packages/93/2e/28c2fb84aa7aa5d75933d1862d0f7de6198ea22dfd9a0cca06e8a4e7509e/rpds_py-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6c27a7054b5224710fcfb1a626ec3ff4f28bcb89b899148c72873b18210e446b", size = 347680, upload-time = "2025-08-07T08:23:46.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/3e/9834b4c8f4f5fe936b479e623832468aa4bd6beb8d014fecaee9eac6cdb1/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09965b314091829b378b60607022048953e25f0b396c2b70e7c4c81bcecf932e", size = 384600, upload-time = "2025-08-07T08:23:48Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/744123c7b38865a965cd9e6f691fde7ef989a00a256fa8bf15b75240d12f/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f028eb47f59e9169bfdf9f7ceafd29dd64902141840633683d0bad5b04ff34", size = 400697, upload-time = "2025-08-07T08:23:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/32/97/3c3d32fe7daee0a1f1a678b6d4dfb8c4dcf88197fa2441f9da7cb54a8466/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6168af0be75bba990a39f9431cdfae5f0ad501f4af32ae62e8856307200517b8", size = 517781, upload-time = "2025-08-07T08:23:50.557Z" }, + { url = "https://files.pythonhosted.org/packages/b2/be/28f0e3e733680aa13ecec1212fc0f585928a206292f14f89c0b8a684cad1/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab47fe727c13c09d0e6f508e3a49e545008e23bf762a245b020391b621f5b726", size = 406449, upload-time = "2025-08-07T08:23:51.732Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/5d15c83e337c082d0367053baeb40bfba683f42459f6ebff63a2fd7e5518/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa01b3d5e3b7d97efab65bd3d88f164e289ec323a8c033c5c38e53ee25c007e", size = 386150, upload-time = "2025-08-07T08:23:52.822Z" }, + { url = "https://files.pythonhosted.org/packages/bf/65/944e95f95d5931112829e040912b25a77b2e7ed913ea5fe5746aa5c1ce75/rpds_py-0.27.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:6c135708e987f46053e0a1246a206f53717f9fadfba27174a9769ad4befba5c3", size = 406100, upload-time = "2025-08-07T08:23:54.339Z" }, + { url = "https://files.pythonhosted.org/packages/21/a4/1664b83fae02894533cd11dc0b9f91d673797c2185b7be0f7496107ed6c5/rpds_py-0.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc327f4497b7087d06204235199daf208fd01c82d80465dc5efa4ec9df1c5b4e", size = 421345, upload-time = "2025-08-07T08:23:55.832Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/b7303941c2b0823bfb34c71378249f8beedce57301f400acb04bb345d025/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e57906e38583a2cba67046a09c2637e23297618dc1f3caddbc493f2be97c93f", size = 561891, upload-time = "2025-08-07T08:23:56.951Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c8/48623d64d4a5a028fa99576c768a6159db49ab907230edddc0b8468b998b/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f4f69d7a4300fbf91efb1fb4916421bd57804c01ab938ab50ac9c4aa2212f03", size = 591756, upload-time = "2025-08-07T08:23:58.146Z" }, + { url = "https://files.pythonhosted.org/packages/b3/51/18f62617e8e61cc66334c9fb44b1ad7baae3438662098efbc55fb3fda453/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4c4fbbcff474e1e5f38be1bf04511c03d492d42eec0babda5d03af3b5589374", size = 557088, upload-time = "2025-08-07T08:23:59.6Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4c/e84c3a276e2496a93d245516be6b49e20499aa8ca1c94d59fada0d79addc/rpds_py-0.27.0-cp312-cp312-win32.whl", hash = "sha256:27bac29bbbf39601b2aab474daf99dbc8e7176ca3389237a23944b17f8913d97", size = 221926, upload-time = "2025-08-07T08:24:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/9d0fbcef64340db0605eb0a0044f258076f3ae0a3b108983b2c614d96212/rpds_py-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a06aa1197ec0281eb1d7daf6073e199eb832fe591ffa329b88bae28f25f5fe5", size = 233235, upload-time = "2025-08-07T08:24:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/e177aa9f39cbab060f96de4a09df77d494f0279604dc2f509263e21b05f9/rpds_py-0.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:e14aab02258cb776a108107bd15f5b5e4a1bbaa61ef33b36693dfab6f89d54f9", size = 223315, upload-time = "2025-08-07T08:24:03.337Z" }, + { url = "https://files.pythonhosted.org/packages/81/d2/dfdfd42565a923b9e5a29f93501664f5b984a802967d48d49200ad71be36/rpds_py-0.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:443d239d02d9ae55b74015234f2cd8eb09e59fbba30bf60baeb3123ad4c6d5ff", size = 362133, upload-time = "2025-08-07T08:24:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/0a2e2460c4b66021d349ce9f6331df1d6c75d7eea90df9785d333a49df04/rpds_py-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8a7acf04fda1f30f1007f3cc96d29d8cf0a53e626e4e1655fdf4eabc082d367", size = 347128, upload-time = "2025-08-07T08:24:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/35/8d/7d1e4390dfe09d4213b3175a3f5a817514355cb3524593380733204f20b9/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0f92b78cfc3b74a42239fdd8c1266f4715b573204c234d2f9fc3fc7a24f185", size = 384027, upload-time = "2025-08-07T08:24:06.841Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/78499d1a62172891c8cd45de737b2a4b84a414b6ad8315ab3ac4945a5b61/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce4ed8e0c7dbc5b19352b9c2c6131dd23b95fa8698b5cdd076307a33626b72dc", size = 399973, upload-time = "2025-08-07T08:24:08.143Z" }, + { url = "https://files.pythonhosted.org/packages/10/a1/1c67c1d8cc889107b19570bb01f75cf49852068e95e6aee80d22915406fc/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fde355b02934cc6b07200cc3b27ab0c15870a757d1a72fd401aa92e2ea3c6bfe", size = 515295, upload-time = "2025-08-07T08:24:09.711Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/700ec88e748436b6c7c4a2262d66e80f8c21ab585d5e98c45e02f13f21c0/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13bbc4846ae4c993f07c93feb21a24d8ec637573d567a924b1001e81c8ae80f9", size = 406737, upload-time = "2025-08-07T08:24:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/33/cc/6b0ee8f0ba3f2df2daac1beda17fde5cf10897a7d466f252bd184ef20162/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0744661afbc4099fef7f4e604e7f1ea1be1dd7284f357924af12a705cc7d5c", size = 385898, upload-time = "2025-08-07T08:24:12.798Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/c927b37d7d33c0a0ebf249cc268dc2fcec52864c1b6309ecb960497f2285/rpds_py-0.27.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:069e0384a54f427bd65d7fda83b68a90606a3835901aaff42185fcd94f5a9295", size = 405785, upload-time = "2025-08-07T08:24:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/8ed50746d909dcf402af3fa58b83d5a590ed43e07251d6b08fad1a535ba6/rpds_py-0.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4bc262ace5a1a7dc3e2eac2fa97b8257ae795389f688b5adf22c5db1e2431c43", size = 419760, upload-time = "2025-08-07T08:24:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/d3/60/2b2071aee781cb3bd49f94d5d35686990b925e9b9f3e3d149235a6f5d5c1/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fe6e18e5c8581f0361b35ae575043c7029d0a92cb3429e6e596c2cdde251432", size = 561201, upload-time = "2025-08-07T08:24:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/98/1f/27b67304272521aaea02be293fecedce13fa351a4e41cdb9290576fc6d81/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d93ebdb82363d2e7bec64eecdc3632b59e84bd270d74fe5be1659f7787052f9b", size = 591021, upload-time = "2025-08-07T08:24:18.999Z" }, + { url = "https://files.pythonhosted.org/packages/db/9b/a2fadf823164dd085b1f894be6443b0762a54a7af6f36e98e8fcda69ee50/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0954e3a92e1d62e83a54ea7b3fdc9efa5d61acef8488a8a3d31fdafbfb00460d", size = 556368, upload-time = "2025-08-07T08:24:20.54Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/6d135d46a129cda2e3e6d4c5e91e2cc26ea0428c6cf152763f3f10b6dd05/rpds_py-0.27.0-cp313-cp313-win32.whl", hash = "sha256:2cff9bdd6c7b906cc562a505c04a57d92e82d37200027e8d362518df427f96cd", size = 221236, upload-time = "2025-08-07T08:24:22.144Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/65d7494f5448ecc755b545d78b188440f81da98b50ea0447ab5ebfdf9bd6/rpds_py-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc79d192fb76fc0c84f2c58672c17bbbc383fd26c3cdc29daae16ce3d927e8b2", size = 232634, upload-time = "2025-08-07T08:24:23.642Z" }, + { url = "https://files.pythonhosted.org/packages/70/d9/23852410fadab2abb611733933401de42a1964ce6600a3badae35fbd573e/rpds_py-0.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b3a5c8089eed498a3af23ce87a80805ff98f6ef8f7bdb70bd1b7dae5105f6ac", size = 222783, upload-time = "2025-08-07T08:24:25.098Z" }, + { url = "https://files.pythonhosted.org/packages/15/75/03447917f78512b34463f4ef11066516067099a0c466545655503bed0c77/rpds_py-0.27.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:90fb790138c1a89a2e58c9282fe1089638401f2f3b8dddd758499041bc6e0774", size = 359154, upload-time = "2025-08-07T08:24:26.249Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fc/4dac4fa756451f2122ddaf136e2c6aeb758dc6fdbe9ccc4bc95c98451d50/rpds_py-0.27.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010c4843a3b92b54373e3d2291a7447d6c3fc29f591772cc2ea0e9f5c1da434b", size = 343909, upload-time = "2025-08-07T08:24:27.405Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/723c1ed8e6f57ed9d8c0c07578747a2d3d554aaefc1ab89f4e42cfeefa07/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9ce7a9e967afc0a2af7caa0d15a3e9c1054815f73d6a8cb9225b61921b419bd", size = 379340, upload-time = "2025-08-07T08:24:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/98/16/7e3740413de71818ce1997df82ba5f94bae9fff90c0a578c0e24658e6201/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa0bf113d15e8abdfee92aa4db86761b709a09954083afcb5bf0f952d6065fdb", size = 391655, upload-time = "2025-08-07T08:24:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/e0/63/2a9f510e124d80660f60ecce07953f3f2d5f0b96192c1365443859b9c87f/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb91d252b35004a84670dfeafadb042528b19842a0080d8b53e5ec1128e8f433", size = 513017, upload-time = "2025-08-07T08:24:31.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4e/cf6ff311d09776c53ea1b4f2e6700b9d43bb4e99551006817ade4bbd6f78/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db8a6313dbac934193fc17fe7610f70cd8181c542a91382531bef5ed785e5615", size = 402058, upload-time = "2025-08-07T08:24:32.613Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/5e36096d474cb10f2a2d68b22af60a3bc4164fd8db15078769a568d9d3ac/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce96ab0bdfcef1b8c371ada2100767ace6804ea35aacce0aef3aeb4f3f499ca8", size = 383474, upload-time = "2025-08-07T08:24:33.767Z" }, + { url = "https://files.pythonhosted.org/packages/db/a2/3dff02805b06058760b5eaa6d8cb8db3eb3e46c9e452453ad5fc5b5ad9fe/rpds_py-0.27.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:7451ede3560086abe1aa27dcdcf55cd15c96b56f543fb12e5826eee6f721f858", size = 400067, upload-time = "2025-08-07T08:24:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/67/87/eed7369b0b265518e21ea836456a4ed4a6744c8c12422ce05bce760bb3cf/rpds_py-0.27.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:32196b5a99821476537b3f7732432d64d93a58d680a52c5e12a190ee0135d8b5", size = 412085, upload-time = "2025-08-07T08:24:36.267Z" }, + { url = "https://files.pythonhosted.org/packages/8b/48/f50b2ab2fbb422fbb389fe296e70b7a6b5ea31b263ada5c61377e710a924/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a029be818059870664157194e46ce0e995082ac49926f1423c1f058534d2aaa9", size = 555928, upload-time = "2025-08-07T08:24:37.573Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/b18eb51045d06887666c3560cd4bbb6819127b43d758f5adb82b5f56f7d1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3841f66c1ffdc6cebce8aed64e36db71466f1dc23c0d9a5592e2a782a3042c79", size = 585527, upload-time = "2025-08-07T08:24:39.391Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/a3dd6470fc76499959b00ae56295b76b4bdf7c6ffc60d62006b1217567e1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:42894616da0fc0dcb2ec08a77896c3f56e9cb2f4b66acd76fc8992c3557ceb1c", size = 554211, upload-time = "2025-08-07T08:24:40.6Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d1/ee5fd1be395a07423ac4ca0bcc05280bf95db2b155d03adefeb47d5ebf7e/rpds_py-0.27.0-cp313-cp313t-win32.whl", hash = "sha256:b1fef1f13c842a39a03409e30ca0bf87b39a1e2a305a9924deadb75a43105d23", size = 216624, upload-time = "2025-08-07T08:24:42.204Z" }, + { url = "https://files.pythonhosted.org/packages/1c/94/4814c4c858833bf46706f87349c37ca45e154da7dbbec9ff09f1abeb08cc/rpds_py-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:183f5e221ba3e283cd36fdfbe311d95cd87699a083330b4f792543987167eff1", size = 230007, upload-time = "2025-08-07T08:24:43.329Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a5/8fffe1c7dc7c055aa02df310f9fb71cfc693a4d5ccc5de2d3456ea5fb022/rpds_py-0.27.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f3cd110e02c5bf17d8fb562f6c9df5c20e73029d587cf8602a2da6c5ef1e32cb", size = 362595, upload-time = "2025-08-07T08:24:44.478Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c7/4e4253fd2d4bb0edbc0b0b10d9f280612ca4f0f990e3c04c599000fe7d71/rpds_py-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d0e09cf4863c74106b5265c2c310f36146e2b445ff7b3018a56799f28f39f6f", size = 347252, upload-time = "2025-08-07T08:24:45.678Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/3d1a954d30f0174dd6baf18b57c215da03cf7846a9d6e0143304e784cddc/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f689ab822f9b5eb6dfc69893b4b9366db1d2420f7db1f6a2adf2a9ca15ad64", size = 384886, upload-time = "2025-08-07T08:24:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/e0/52/3c5835f2df389832b28f9276dd5395b5a965cea34226e7c88c8fbec2093c/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e36c80c49853b3ffda7aa1831bf175c13356b210c73128c861f3aa93c3cc4015", size = 399716, upload-time = "2025-08-07T08:24:48.174Z" }, + { url = "https://files.pythonhosted.org/packages/40/73/176e46992461a1749686a2a441e24df51ff86b99c2d34bf39f2a5273b987/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6de6a7f622860af0146cb9ee148682ff4d0cea0b8fd3ad51ce4d40efb2f061d0", size = 517030, upload-time = "2025-08-07T08:24:49.52Z" }, + { url = "https://files.pythonhosted.org/packages/79/2a/7266c75840e8c6e70effeb0d38922a45720904f2cd695e68a0150e5407e2/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4045e2fc4b37ec4b48e8907a5819bdd3380708c139d7cc358f03a3653abedb89", size = 408448, upload-time = "2025-08-07T08:24:50.727Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/a7efc572b8e235093dc6cf39f4dbc8a7f08e65fdbcec7ff4daeb3585eef1/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da162b718b12c4219eeeeb68a5b7552fbc7aadedf2efee440f88b9c0e54b45d", size = 387320, upload-time = "2025-08-07T08:24:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/a2/eb/9ff6bc92efe57cf5a2cb74dee20453ba444b6fdc85275d8c99e0d27239d1/rpds_py-0.27.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:0665be515767dc727ffa5f74bd2ef60b0ff85dad6bb8f50d91eaa6b5fb226f51", size = 407414, upload-time = "2025-08-07T08:24:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bd/3b9b19b00d5c6e1bd0f418c229ab0f8d3b110ddf7ec5d9d689ef783d0268/rpds_py-0.27.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203f581accef67300a942e49a37d74c12ceeef4514874c7cede21b012613ca2c", size = 420766, upload-time = "2025-08-07T08:24:55.917Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/521a7b1079ce16258c70805166e3ac6ec4ee2139d023fe07954dc9b2d568/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7873b65686a6471c0037139aa000d23fe94628e0daaa27b6e40607c90e3f5ec4", size = 562409, upload-time = "2025-08-07T08:24:57.17Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/65db5bfb14ccc55e39de8419a659d05a2a9cd232f0a699a516bb0991da7b/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:249ab91ceaa6b41abc5f19513cb95b45c6f956f6b89f1fe3d99c81255a849f9e", size = 590793, upload-time = "2025-08-07T08:24:58.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/b8/82d368b378325191ba7aae8f40f009b78057b598d4394d1f2cdabaf67b3f/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2f184336bc1d6abfaaa1262ed42739c3789b1e3a65a29916a615307d22ffd2e", size = 558178, upload-time = "2025-08-07T08:24:59.756Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/f270bddbfbc3812500f8131b1ebbd97afd014cd554b604a3f73f03133a36/rpds_py-0.27.0-cp314-cp314-win32.whl", hash = "sha256:d3c622c39f04d5751408f5b801ecb527e6e0a471b367f420a877f7a660d583f6", size = 222355, upload-time = "2025-08-07T08:25:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/fdab055b1460c02ed356a0e0b0a78c1dd32dc64e82a544f7b31c9ac643dc/rpds_py-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf824aceaeffff029ccfba0da637d432ca71ab21f13e7f6f5179cd88ebc77a8a", size = 234007, upload-time = "2025-08-07T08:25:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a8/694c060005421797a3be4943dab8347c76c2b429a9bef68fb2c87c9e70c7/rpds_py-0.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:86aca1616922b40d8ac1b3073a1ead4255a2f13405e5700c01f7c8d29a03972d", size = 223527, upload-time = "2025-08-07T08:25:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f9/77f4c90f79d2c5ca8ce6ec6a76cb4734ee247de6b3a4f337e289e1f00372/rpds_py-0.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:341d8acb6724c0c17bdf714319c393bb27f6d23d39bc74f94221b3e59fc31828", size = 359469, upload-time = "2025-08-07T08:25:04.648Z" }, + { url = "https://files.pythonhosted.org/packages/c0/22/b97878d2f1284286fef4172069e84b0b42b546ea7d053e5fb7adb9ac6494/rpds_py-0.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b96b0b784fe5fd03beffff2b1533dc0d85e92bab8d1b2c24ef3a5dc8fac5669", size = 343960, upload-time = "2025-08-07T08:25:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b0/dfd55b5bb480eda0578ae94ef256d3061d20b19a0f5e18c482f03e65464f/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c431bfb91478d7cbe368d0a699978050d3b112d7f1d440a41e90faa325557fd", size = 380201, upload-time = "2025-08-07T08:25:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/28/22/e1fa64e50d58ad2b2053077e3ec81a979147c43428de9e6de68ddf6aff4e/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e222a44ae9f507d0f2678ee3dd0c45ec1e930f6875d99b8459631c24058aec", size = 392111, upload-time = "2025-08-07T08:25:09.149Z" }, + { url = "https://files.pythonhosted.org/packages/49/f9/43ab7a43e97aedf6cea6af70fdcbe18abbbc41d4ae6cdec1bfc23bbad403/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:184f0d7b342967f6cda94a07d0e1fae177d11d0b8f17d73e06e36ac02889f303", size = 515863, upload-time = "2025-08-07T08:25:10.431Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9bd59dcc636cd04d86a2d20ad967770bf348f5eb5922a8f29b547c074243/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a00c91104c173c9043bc46f7b30ee5e6d2f6b1149f11f545580f5d6fdff42c0b", size = 402398, upload-time = "2025-08-07T08:25:11.819Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/f099328c6c85667aba6b66fa5c35a8882db06dcd462ea214be72813a0dd2/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a37dd208f0d658e0487522078b1ed68cd6bce20ef4b5a915d2809b9094b410", size = 384665, upload-time = "2025-08-07T08:25:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c5/9c1f03121ece6634818490bd3c8be2c82a70928a19de03467fb25a3ae2a8/rpds_py-0.27.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92f3b3ec3e6008a1fe00b7c0946a170f161ac00645cde35e3c9a68c2475e8156", size = 400405, upload-time = "2025-08-07T08:25:14.417Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b8/e25d54af3e63ac94f0c16d8fe143779fe71ff209445a0c00d0f6984b6b2c/rpds_py-0.27.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b3db5fae5cbce2131b7420a3f83553d4d89514c03d67804ced36161fe8b6b2", size = 413179, upload-time = "2025-08-07T08:25:15.664Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d1/406b3316433fe49c3021546293a04bc33f1478e3ec7950215a7fce1a1208/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5355527adaa713ab693cbce7c1e0ec71682f599f61b128cf19d07e5c13c9b1f1", size = 556895, upload-time = "2025-08-07T08:25:17.061Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bc/3697c0c21fcb9a54d46ae3b735eb2365eea0c2be076b8f770f98e07998de/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fcc01c57ce6e70b728af02b2401c5bc853a9e14eb07deda30624374f0aebfe42", size = 585464, upload-time = "2025-08-07T08:25:18.406Z" }, + { url = "https://files.pythonhosted.org/packages/63/09/ee1bb5536f99f42c839b177d552f6114aa3142d82f49cef49261ed28dbe0/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3001013dae10f806380ba739d40dee11db1ecb91684febb8406a87c2ded23dae", size = 555090, upload-time = "2025-08-07T08:25:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2c/363eada9e89f7059199d3724135a86c47082cbf72790d6ba2f336d146ddb/rpds_py-0.27.0-cp314-cp314t-win32.whl", hash = "sha256:0f401c369186a5743694dd9fc08cba66cf70908757552e1f714bfc5219c655b5", size = 218001, upload-time = "2025-08-07T08:25:21.761Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3f/d6c216ed5199c9ef79e2a33955601f454ed1e7420a93b89670133bca5ace/rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391", size = 230993, upload-time = "2025-08-07T08:25:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/47/55/287068956f9ba1cb40896d291213f09fdd4527630709058b45a592bc09dc/rpds_py-0.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:46f48482c1a4748ab2773f75fffbdd1951eb59794e32788834b945da857c47a8", size = 371566, upload-time = "2025-08-07T08:25:43.95Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/443af59cbe552e89680bb0f1d1ba47f6387b92083e28a45b8c8863b86c5a/rpds_py-0.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:419dd9c98bcc9fb0242be89e0c6e922df333b975d4268faa90d58499fd9c9ebe", size = 355781, upload-time = "2025-08-07T08:25:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f0/35f48bb073b5ca42b1dcc55cb148f4a3bd4411a3e584f6a18d26f0ea8832/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d42a0ef2bdf6bc81e1cc2d49d12460f63c6ae1423c4f4851b828e454ccf6f1", size = 382575, upload-time = "2025-08-07T08:25:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/51/e1/5f5296a21d1189f0f116a938af2e346d83172bf814d373695e54004a936f/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e39169ac6aae06dd79c07c8a69d9da867cef6a6d7883a0186b46bb46ccfb0c3", size = 397435, upload-time = "2025-08-07T08:25:48.204Z" }, + { url = "https://files.pythonhosted.org/packages/97/79/3af99b7852b2b55cad8a08863725cbe9dc14781bcf7dc6ecead0c3e1dc54/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:935afcdea4751b0ac918047a2df3f720212892347767aea28f5b3bf7be4f27c0", size = 514861, upload-time = "2025-08-07T08:25:49.814Z" }, + { url = "https://files.pythonhosted.org/packages/df/3e/11fd6033708ed3ae0e6947bb94f762f56bb46bf59a1b16eef6944e8a62ee/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de567dec6d451649a781633d36f5c7501711adee329d76c095be2178855b042", size = 402776, upload-time = "2025-08-07T08:25:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/b7/89/f9375ceaa996116de9cbc949874804c7874d42fb258c384c037a46d730b8/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:555ed147cbe8c8f76e72a4c6cd3b7b761cbf9987891b9448808148204aed74a5", size = 384665, upload-time = "2025-08-07T08:25:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/48/bf/0061e55c6f1f573a63c0f82306b8984ed3b394adafc66854a936d5db3522/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:d2cc2b34f9e1d31ce255174da82902ad75bd7c0d88a33df54a77a22f2ef421ee", size = 402518, upload-time = "2025-08-07T08:25:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/ae/dc/8d506676bfe87b3b683332ec8e6ab2b0be118a3d3595ed021e3274a63191/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb0702c12983be3b2fab98ead349ac63a98216d28dda6f518f52da5498a27a1b", size = 416247, upload-time = "2025-08-07T08:25:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/2e/02/9a89eea1b75c69e81632de7963076e455b1e00e1cfb46dfdabb055fa03e3/rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ba783541be46f27c8faea5a6645e193943c17ea2f0ffe593639d906a327a9bcc", size = 559456, upload-time = "2025-08-07T08:25:56.866Z" }, + { url = "https://files.pythonhosted.org/packages/38/4a/0f3ac4351957847c0d322be6ec72f916e43804a2c1d04e9672ea4a67c315/rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:2406d034635d1497c596c40c85f86ecf2bf9611c1df73d14078af8444fe48031", size = 587778, upload-time = "2025-08-07T08:25:58.202Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8e/39d0d7401095bed5a5ad5ef304fae96383f9bef40ca3f3a0807ff5b68d9d/rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dea0808153f1fbbad772669d906cddd92100277533a03845de6893cadeffc8be", size = 555247, upload-time = "2025-08-07T08:25:59.707Z" }, + { url = "https://files.pythonhosted.org/packages/e0/04/6b8311e811e620b9eaca67cd80a118ff9159558a719201052a7b2abb88bf/rpds_py-0.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d2a81bdcfde4245468f7030a75a37d50400ac2455c3a4819d9d550c937f90ab5", size = 230256, upload-time = "2025-08-07T08:26:01.07Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/72ab5b911fdcc48058359b0e786e5363e3fde885156116026f1a2ba9a5b5/rpds_py-0.27.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e6491658dd2569f05860bad645569145c8626ac231877b0fb2d5f9bcb7054089", size = 371658, upload-time = "2025-08-07T08:26:02.369Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4b/90ff04b4da055db53d8fea57640d8d5d55456343a1ec9a866c0ecfe10fd1/rpds_py-0.27.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:bec77545d188f8bdd29d42bccb9191682a46fb2e655e3d1fb446d47c55ac3b8d", size = 355529, upload-time = "2025-08-07T08:26:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/527491fb1afcd86fc5ce5812eb37bc70428ee017d77fee20de18155c3937/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a4aebf8ca02bbb90a9b3e7a463bbf3bee02ab1c446840ca07b1695a68ce424", size = 382822, upload-time = "2025-08-07T08:26:05.52Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a5/dcdb8725ce11e6d0913e6fcf782a13f4b8a517e8acc70946031830b98441/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44524b96481a4c9b8e6c46d6afe43fa1fb485c261e359fbe32b63ff60e3884d8", size = 397233, upload-time = "2025-08-07T08:26:07.179Z" }, + { url = "https://files.pythonhosted.org/packages/33/f9/0947920d1927e9f144660590cc38cadb0795d78fe0d9aae0ef71c1513b7c/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45d04a73c54b6a5fd2bab91a4b5bc8b426949586e61340e212a8484919183859", size = 514892, upload-time = "2025-08-07T08:26:08.622Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ed/d1343398c1417c68f8daa1afce56ef6ce5cc587daaf98e29347b00a80ff2/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:343cf24de9ed6c728abefc5d5c851d5de06497caa7ac37e5e65dd572921ed1b5", size = 402733, upload-time = "2025-08-07T08:26:10.433Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0b/646f55442cd14014fb64d143428f25667a100f82092c90087b9ea7101c74/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aed8118ae20515974650d08eb724150dc2e20c2814bcc307089569995e88a14", size = 384447, upload-time = "2025-08-07T08:26:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/4b/15/0596ef7529828e33a6c81ecf5013d1dd33a511a3e0be0561f83079cda227/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:af9d4fd79ee1cc8e7caf693ee02737daabfc0fcf2773ca0a4735b356c8ad6f7c", size = 402502, upload-time = "2025-08-07T08:26:13.537Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8d/986af3c42f8454a6cafff8729d99fb178ae9b08a9816325ac7a8fa57c0c0/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f0396e894bd1e66c74ecbc08b4f6a03dc331140942c4b1d345dd131b68574a60", size = 416651, upload-time = "2025-08-07T08:26:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9a/b4ec3629b7b447e896eec574469159b5b60b7781d3711c914748bf32de05/rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:59714ab0a5af25d723d8e9816638faf7f4254234decb7d212715c1aa71eee7be", size = 559460, upload-time = "2025-08-07T08:26:16.295Z" }, + { url = "https://files.pythonhosted.org/packages/61/63/d1e127b40c3e4733b3a6f26ae7a063cdf2bc1caa5272c89075425c7d397a/rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:88051c3b7d5325409f433c5a40328fcb0685fc04e5db49ff936e910901d10114", size = 588072, upload-time = "2025-08-07T08:26:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/04/7e/8ffc71a8f6833d9c9fb999f5b0ee736b8b159fd66968e05c7afc2dbcd57e/rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:181bc29e59e5e5e6e9d63b143ff4d5191224d355e246b5a48c88ce6b35c4e466", size = 555083, upload-time = "2025-08-07T08:26:19.301Z" }, +] + [[package]] name = "ruff" version = "0.11.12"