Plugin: device 'shadows' to bridge device online/offline states (#282)

* states, calculations and model for shadow plugin
* adding tests for shadow states and correcting use cases
* adding get/update message broadcast, adding json schema tests for shadow messages being received
* adding shadow plugin documentation
pull/287/head^2
Andrew Mirsky 2025-08-09 15:01:10 -04:00 zatwierdzone przez GitHub
rodzic 2fa0604547
commit f50e3b48f6
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
16 zmienionych plików z 1890 dodań i 4 usunięć

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,6 @@
"""Module for the shadow state plugin."""
from .plugin import ShadowPlugin, ShadowTopicAuthPlugin
from .states import ShadowOperation
__all__ = ["ShadowOperation", "ShadowPlugin", "ShadowTopicAuthPlugin"]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<client_id>[a-zA-Z0-9_-]+?)/(?P<shadow_name>[a-zA-Z0-9_-]+?)/(?P<request>get|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/<username>/<shadow_name>/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."""

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -30,3 +30,7 @@ h2.doc-heading-parameter {
.md-nav__link--active {
color: #f15581 !important;
}
.admonition {
font-size: 16px !important;
}

Wyświetl plik

@ -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.<br/>
`amqtt.contrib.http.HttpAuthTopicPlugin`
- [Shadows](shadows.md)<br/>
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.<br/>
`amqtt.contrib.shadows.ShadowPlugin`
- [Certificate Auth](cert.md)<br/>
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.<br/>
`amqtt.contrib.cert.CertificateAuthPlugin.Config`

Wyświetl plik

@ -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/<device_id>/<shadow name>`
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):

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

191
uv.lock
Wyświetl plik

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