kopia lustrzana https://github.com/Yakifo/amqtt
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 documentationpull/287/head^2
rodzic
2fa0604547
commit
f50e3b48f6
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
"""Module for the shadow state plugin."""
|
||||
|
||||
from .plugin import ShadowPlugin, ShadowTopicAuthPlugin
|
||||
from .states import ShadowOperation
|
||||
|
||||
__all__ = ["ShadowOperation", "ShadowPlugin", "ShadowTopicAuthPlugin"]
|
|
@ -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)
|
|
@ -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()
|
||||
'''
|
|
@ -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."""
|
|
@ -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"
|
|
@ -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"
|
||||
|
|
|
@ -30,3 +30,7 @@ h2.doc-heading-parameter {
|
|||
.md-nav__link--active {
|
||||
color: #f15581 !important;
|
||||
}
|
||||
|
||||
.admonition {
|
||||
font-size: 16px !important;
|
||||
}
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -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):
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
|
@ -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
|
||||
}
|
|
@ -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
191
uv.lock
|
@ -152,6 +152,8 @@ contrib = [
|
|||
{ name = "argon2-cffi" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mergedeep" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
]
|
||||
|
||||
|
@ -169,6 +171,7 @@ dev = [
|
|||
{ name = "poethepoet" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyhamcrest" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "pytest" },
|
||||
|
@ -211,6 +214,8 @@ requires-dist = [
|
|||
{ name = "cryptography", marker = "extra == 'contrib'", specifier = ">=45.0.3" },
|
||||
{ name = "dacite", specifier = ">=1.9.2" },
|
||||
{ name = "greenlet", marker = "extra == 'contrib'", specifier = ">=3.2.3" },
|
||||
{ name = "jsonschema", marker = "extra == 'contrib'", specifier = ">=4.25.0" },
|
||||
{ name = "mergedeep", marker = "extra == 'contrib'", specifier = ">=1.3.4" },
|
||||
{ name = "passlib", specifier = "==1.7.4" },
|
||||
{ name = "psutil", specifier = ">=7.0.0" },
|
||||
{ name = "pyyaml", specifier = "==6.0.2" },
|
||||
|
@ -233,6 +238,7 @@ dev = [
|
|||
{ name = "poethepoet", specifier = ">=0.34.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||
{ name = "psutil", specifier = ">=7.0.0" },
|
||||
{ name = "pyhamcrest", specifier = ">=2.1.0" },
|
||||
{ name = "pylint", specifier = ">=3.3.6" },
|
||||
{ name = "pyopenssl", specifier = ">=25.1.0" },
|
||||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
|
@ -1161,6 +1167,33 @@ version = "3.0.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" }
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/00/a297a868e9d0784450faa7365c2172a7d6110c763e30ba861867c32ae6a9/jsonschema-4.25.0.tar.gz", hash = "sha256:e63acf5c11762c0e6672ffb61482bdf57f0876684d8d249c0fe2d730d48bc55f", size = 356830, upload-time = "2025-07-18T15:39:45.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/54/c86cd8e011fe98803d7e382fd67c0df5ceab8d2b7ad8c5a81524f791551c/jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716", size = 89184, upload-time = "2025-07-18T15:39:42.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.6.0"
|
||||
|
@ -2080,6 +2113,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyhamcrest"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/3f/f286caba4e64391a8dc9200e6de6ce0d07471e3f718248c3276843b7793b/pyhamcrest-2.1.0.tar.gz", hash = "sha256:c6acbec0923d0cb7e72c22af1926f3e7c97b8e8d69fc7498eabacaf7c975bd9c", size = 60538, upload-time = "2023-10-22T15:47:28.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/71/1b25d3797a24add00f6f8c1bb0ac03a38616e2ec6606f598c1d50b0b0ffb/pyhamcrest-2.1.0-py3-none-any.whl", hash = "sha256:f6913d2f392e30e0375b3ecbd7aee79e5d1faa25d345c8f4ff597665dcac2587", size = 54555, upload-time = "2023-10-22T15:47:25.08Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "3.3.7"
|
||||
|
@ -2277,6 +2319,20 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
|
@ -2306,6 +2362,141 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/d9/991a0dee12d9fc53ed027e26a26a64b151d77252ac477e22666b9688bc16/rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f", size = 27420, upload-time = "2025-08-07T08:26:39.624Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2d/ad2e37dee3f45580f7fa0066c412a521f9bee53d2718b0e9436d308a1ecd/rpds_py-0.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:130c1ffa5039a333f5926b09e346ab335f0d4ec393b030a18549a7c7e7c2cea4", size = 371511, upload-time = "2025-08-07T08:23:06.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/67/57b4b2479193fde9dd6983a13c2550b5f9c3bcdf8912dffac2068945eb14/rpds_py-0.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a4cf32a26fa744101b67bfd28c55d992cd19438aff611a46cac7f066afca8fd4", size = 354718, upload-time = "2025-08-07T08:23:08.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/be/c2b95ec4b813eb11f3a3c3d22f22bda8d3a48a074a0519cde968c4d102cf/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64a0fe3f334a40b989812de70160de6b0ec7e3c9e4a04c0bbc48d97c5d3600ae", size = 381518, upload-time = "2025-08-07T08:23:09.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d2/5a7279bc2b93b20bd50865a2269016238cee45f7dc3cc33402a7f41bd447/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a0ff7ee28583ab30a52f371b40f54e7138c52ca67f8ca17ccb7ccf0b383cb5f", size = 396694, upload-time = "2025-08-07T08:23:11.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/e9/bac8b3714bd853c5bcb466e04acfb9a5da030d77e0ddf1dfad9afb791c31/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15ea4d2e182345dd1b4286593601d766411b43f868924afe297570658c31a62b", size = 514813, upload-time = "2025-08-07T08:23:12.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/aa/293115e956d7d13b7d2a9e9a4121f74989a427aa125f00ce4426ca8b7b28/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36184b44bf60a480863e51021c26aca3dfe8dd2f5eeabb33622b132b9d8b8b54", size = 402246, upload-time = "2025-08-07T08:23:13.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/59/2d6789bb898fb3e2f0f7b82b7bcf27f579ebcb6cc36c24f4e208f7f58a5b/rpds_py-0.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b78430703cfcf5f5e86eb74027a1ed03a93509273d7c705babb547f03e60016", size = 383661, upload-time = "2025-08-07T08:23:15.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/55/add13a593a7a81243a9eed56d618d3d427be5dc1214931676e3f695dfdc1/rpds_py-0.27.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:dbd749cff1defbde270ca346b69b3baf5f1297213ef322254bf2a28537f0b046", size = 401691, upload-time = "2025-08-07T08:23:16.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/09/3e8b2aad494ffaca571e4e19611a12cc18fcfd756d9274f3871a2d822445/rpds_py-0.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bde37765564cd22a676dd8101b657839a1854cfaa9c382c5abf6ff7accfd4ae", size = 416529, upload-time = "2025-08-07T08:23:17.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/6d/bd899234728f1d8f72c9610f50fdf1c140ecd0a141320e1f1d0f6b20595d/rpds_py-0.27.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1d66f45b9399036e890fb9c04e9f70c33857fd8f58ac8db9f3278cfa835440c3", size = 558673, upload-time = "2025-08-07T08:23:18.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/f4/f3e02def5193fb899d797c232f90d6f8f0f2b9eca2faef6f0d34cbc89b2e/rpds_py-0.27.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d85d784c619370d9329bbd670f41ff5f2ae62ea4519761b679d0f57f0f0ee267", size = 588426, upload-time = "2025-08-07T08:23:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/0c/88e716cd8fd760e5308835fe298255830de4a1c905fd51760b9bb40aa965/rpds_py-0.27.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5df559e9e7644d9042f626f2c3997b555f347d7a855a15f170b253f6c5bfe358", size = 554552, upload-time = "2025-08-07T08:23:21.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/a9/0a8243c182e7ac59b901083dff7e671feba6676a131bfff3f8d301cd2b36/rpds_py-0.27.0-cp310-cp310-win32.whl", hash = "sha256:b8a4131698b6992b2a56015f51646711ec5d893a0b314a4b985477868e240c87", size = 218081, upload-time = "2025-08-07T08:23:23.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e7/202ff35852312760148be9e08fe2ba6900aa28e7a46940a313eae473c10c/rpds_py-0.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:cbc619e84a5e3ab2d452de831c88bdcad824414e9c2d28cd101f94dbdf26329c", size = 230077, upload-time = "2025-08-07T08:23:24.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c1/49d515434c1752e40f5e35b985260cf27af052593378580a2f139a5be6b8/rpds_py-0.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dbc2ab5d10544eb485baa76c63c501303b716a5c405ff2469a1d8ceffaabf622", size = 371577, upload-time = "2025-08-07T08:23:25.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6d/bf2715b2fee5087fa13b752b5fd573f1a93e4134c74d275f709e38e54fe7/rpds_py-0.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7ec85994f96a58cf7ed288caa344b7fe31fd1d503bdf13d7331ead5f70ab60d5", size = 354959, upload-time = "2025-08-07T08:23:26.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5c/e7762808c746dd19733a81373c10da43926f6a6adcf4920a21119697a60a/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:190d7285cd3bb6d31d37a0534d7359c1ee191eb194c511c301f32a4afa5a1dd4", size = 381485, upload-time = "2025-08-07T08:23:27.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/51/0d308eb0b558309ca0598bcba4243f52c4cd20e15fe991b5bd75824f2e61/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c10d92fb6d7fd827e44055fcd932ad93dac6a11e832d51534d77b97d1d85400f", size = 396816, upload-time = "2025-08-07T08:23:29.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/aa/2d585ec911d78f66458b2c91252134ca0c7c70f687a72c87283173dc0c96/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd2c1d27ebfe6a015cfa2005b7fe8c52d5019f7bbdd801bc6f7499aab9ae739e", size = 514950, upload-time = "2025-08-07T08:23:30.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ef/aced551cc1148179557aed84343073adadf252c91265263ee6203458a186/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4790c9d5dd565ddb3e9f656092f57268951398cef52e364c405ed3112dc7c7c1", size = 402132, upload-time = "2025-08-07T08:23:32.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ac/cf644803d8d417653fe2b3604186861d62ea6afaef1b2284045741baef17/rpds_py-0.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4300e15e7d03660f04be84a125d1bdd0e6b2f674bc0723bc0fd0122f1a4585dc", size = 383660, upload-time = "2025-08-07T08:23:33.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ec/caf47c55ce02b76cbaeeb2d3b36a73da9ca2e14324e3d75cf72b59dcdac5/rpds_py-0.27.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:59195dc244fc183209cf8a93406889cadde47dfd2f0a6b137783aa9c56d67c85", size = 401730, upload-time = "2025-08-07T08:23:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/71/c1f355afdcd5b99ffc253422aa4bdcb04ccf1491dcd1bda3688a0c07fd61/rpds_py-0.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fae4a01ef8c4cb2bbe92ef2063149596907dc4a881a8d26743b3f6b304713171", size = 416122, upload-time = "2025-08-07T08:23:36.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0f/f4b5b1eda724ed0e04d2b26d8911cdc131451a7ee4c4c020a1387e5c6ded/rpds_py-0.27.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e3dc8d4ede2dbae6c0fc2b6c958bf51ce9fd7e9b40c0f5b8835c3fde44f5807d", size = 558771, upload-time = "2025-08-07T08:23:37.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c0/5f8b834db2289ab48d5cffbecbb75e35410103a77ac0b8da36bf9544ec1c/rpds_py-0.27.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3782fb753aa825b4ccabc04292e07897e2fd941448eabf666856c5530277626", size = 587876, upload-time = "2025-08-07T08:23:38.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/dd/1a1df02ab8eb970115cff2ae31a6f73916609b900dc86961dc382b8c2e5e/rpds_py-0.27.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:887ab1f12b0d227e9260558a4a2320024b20102207ada65c43e1ffc4546df72e", size = 554359, upload-time = "2025-08-07T08:23:39.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e4/95a014ab0d51ab6e3bebbdb476a42d992d2bbf9c489d24cff9fda998e925/rpds_py-0.27.0-cp311-cp311-win32.whl", hash = "sha256:5d6790ff400254137b81b8053b34417e2c46921e302d655181d55ea46df58cf7", size = 218084, upload-time = "2025-08-07T08:23:41.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/78/f8d5b71ec65a0376b0de31efcbb5528ce17a9b7fdd19c3763303ccfdedec/rpds_py-0.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:e24d8031a2c62f34853756d9208eeafa6b940a1efcbfe36e8f57d99d52bb7261", size = 230085, upload-time = "2025-08-07T08:23:42.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d3/84429745184091e06b4cc70f8597408e314c2d2f7f5e13249af9ffab9e3d/rpds_py-0.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:08680820d23df1df0a0260f714d12966bc6c42d02e8055a91d61e03f0c47dda0", size = 222112, upload-time = "2025-08-07T08:23:43.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/17/e67309ca1ac993fa1888a0d9b2f5ccc1f67196ace32e76c9f8e1dbbbd50c/rpds_py-0.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:19c990fdf5acecbf0623e906ae2e09ce1c58947197f9bced6bbd7482662231c4", size = 362611, upload-time = "2025-08-07T08:23:44.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/2e/28c2fb84aa7aa5d75933d1862d0f7de6198ea22dfd9a0cca06e8a4e7509e/rpds_py-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6c27a7054b5224710fcfb1a626ec3ff4f28bcb89b899148c72873b18210e446b", size = 347680, upload-time = "2025-08-07T08:23:46.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/3e/9834b4c8f4f5fe936b479e623832468aa4bd6beb8d014fecaee9eac6cdb1/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09965b314091829b378b60607022048953e25f0b396c2b70e7c4c81bcecf932e", size = 384600, upload-time = "2025-08-07T08:23:48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/78/744123c7b38865a965cd9e6f691fde7ef989a00a256fa8bf15b75240d12f/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f028eb47f59e9169bfdf9f7ceafd29dd64902141840633683d0bad5b04ff34", size = 400697, upload-time = "2025-08-07T08:23:49.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/97/3c3d32fe7daee0a1f1a678b6d4dfb8c4dcf88197fa2441f9da7cb54a8466/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6168af0be75bba990a39f9431cdfae5f0ad501f4af32ae62e8856307200517b8", size = 517781, upload-time = "2025-08-07T08:23:50.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/be/28f0e3e733680aa13ecec1212fc0f585928a206292f14f89c0b8a684cad1/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab47fe727c13c09d0e6f508e3a49e545008e23bf762a245b020391b621f5b726", size = 406449, upload-time = "2025-08-07T08:23:51.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/ae/5d15c83e337c082d0367053baeb40bfba683f42459f6ebff63a2fd7e5518/rpds_py-0.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa01b3d5e3b7d97efab65bd3d88f164e289ec323a8c033c5c38e53ee25c007e", size = 386150, upload-time = "2025-08-07T08:23:52.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/65/944e95f95d5931112829e040912b25a77b2e7ed913ea5fe5746aa5c1ce75/rpds_py-0.27.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:6c135708e987f46053e0a1246a206f53717f9fadfba27174a9769ad4befba5c3", size = 406100, upload-time = "2025-08-07T08:23:54.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/a4/1664b83fae02894533cd11dc0b9f91d673797c2185b7be0f7496107ed6c5/rpds_py-0.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc327f4497b7087d06204235199daf208fd01c82d80465dc5efa4ec9df1c5b4e", size = 421345, upload-time = "2025-08-07T08:23:55.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/26/b7303941c2b0823bfb34c71378249f8beedce57301f400acb04bb345d025/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e57906e38583a2cba67046a09c2637e23297618dc1f3caddbc493f2be97c93f", size = 561891, upload-time = "2025-08-07T08:23:56.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/c8/48623d64d4a5a028fa99576c768a6159db49ab907230edddc0b8468b998b/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f4f69d7a4300fbf91efb1fb4916421bd57804c01ab938ab50ac9c4aa2212f03", size = 591756, upload-time = "2025-08-07T08:23:58.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/51/18f62617e8e61cc66334c9fb44b1ad7baae3438662098efbc55fb3fda453/rpds_py-0.27.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4c4fbbcff474e1e5f38be1bf04511c03d492d42eec0babda5d03af3b5589374", size = 557088, upload-time = "2025-08-07T08:23:59.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/4c/e84c3a276e2496a93d245516be6b49e20499aa8ca1c94d59fada0d79addc/rpds_py-0.27.0-cp312-cp312-win32.whl", hash = "sha256:27bac29bbbf39601b2aab474daf99dbc8e7176ca3389237a23944b17f8913d97", size = 221926, upload-time = "2025-08-07T08:24:00.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/89/9d0fbcef64340db0605eb0a0044f258076f3ae0a3b108983b2c614d96212/rpds_py-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a06aa1197ec0281eb1d7daf6073e199eb832fe591ffa329b88bae28f25f5fe5", size = 233235, upload-time = "2025-08-07T08:24:01.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/b0/e177aa9f39cbab060f96de4a09df77d494f0279604dc2f509263e21b05f9/rpds_py-0.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:e14aab02258cb776a108107bd15f5b5e4a1bbaa61ef33b36693dfab6f89d54f9", size = 223315, upload-time = "2025-08-07T08:24:03.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d2/dfdfd42565a923b9e5a29f93501664f5b984a802967d48d49200ad71be36/rpds_py-0.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:443d239d02d9ae55b74015234f2cd8eb09e59fbba30bf60baeb3123ad4c6d5ff", size = 362133, upload-time = "2025-08-07T08:24:04.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4a/0a2e2460c4b66021d349ce9f6331df1d6c75d7eea90df9785d333a49df04/rpds_py-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8a7acf04fda1f30f1007f3cc96d29d8cf0a53e626e4e1655fdf4eabc082d367", size = 347128, upload-time = "2025-08-07T08:24:05.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8d/7d1e4390dfe09d4213b3175a3f5a817514355cb3524593380733204f20b9/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0f92b78cfc3b74a42239fdd8c1266f4715b573204c234d2f9fc3fc7a24f185", size = 384027, upload-time = "2025-08-07T08:24:06.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/65/78499d1a62172891c8cd45de737b2a4b84a414b6ad8315ab3ac4945a5b61/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce4ed8e0c7dbc5b19352b9c2c6131dd23b95fa8698b5cdd076307a33626b72dc", size = 399973, upload-time = "2025-08-07T08:24:08.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a1/1c67c1d8cc889107b19570bb01f75cf49852068e95e6aee80d22915406fc/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fde355b02934cc6b07200cc3b27ab0c15870a757d1a72fd401aa92e2ea3c6bfe", size = 515295, upload-time = "2025-08-07T08:24:09.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/27/700ec88e748436b6c7c4a2262d66e80f8c21ab585d5e98c45e02f13f21c0/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13bbc4846ae4c993f07c93feb21a24d8ec637573d567a924b1001e81c8ae80f9", size = 406737, upload-time = "2025-08-07T08:24:11.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/cc/6b0ee8f0ba3f2df2daac1beda17fde5cf10897a7d466f252bd184ef20162/rpds_py-0.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0744661afbc4099fef7f4e604e7f1ea1be1dd7284f357924af12a705cc7d5c", size = 385898, upload-time = "2025-08-07T08:24:12.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/7e/c927b37d7d33c0a0ebf249cc268dc2fcec52864c1b6309ecb960497f2285/rpds_py-0.27.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:069e0384a54f427bd65d7fda83b68a90606a3835901aaff42185fcd94f5a9295", size = 405785, upload-time = "2025-08-07T08:24:14.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/d2/8ed50746d909dcf402af3fa58b83d5a590ed43e07251d6b08fad1a535ba6/rpds_py-0.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4bc262ace5a1a7dc3e2eac2fa97b8257ae795389f688b5adf22c5db1e2431c43", size = 419760, upload-time = "2025-08-07T08:24:16.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/60/2b2071aee781cb3bd49f94d5d35686990b925e9b9f3e3d149235a6f5d5c1/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fe6e18e5c8581f0361b35ae575043c7029d0a92cb3429e6e596c2cdde251432", size = 561201, upload-time = "2025-08-07T08:24:17.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/1f/27b67304272521aaea02be293fecedce13fa351a4e41cdb9290576fc6d81/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d93ebdb82363d2e7bec64eecdc3632b59e84bd270d74fe5be1659f7787052f9b", size = 591021, upload-time = "2025-08-07T08:24:18.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/9b/a2fadf823164dd085b1f894be6443b0762a54a7af6f36e98e8fcda69ee50/rpds_py-0.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0954e3a92e1d62e83a54ea7b3fdc9efa5d61acef8488a8a3d31fdafbfb00460d", size = 556368, upload-time = "2025-08-07T08:24:20.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/f3/6d135d46a129cda2e3e6d4c5e91e2cc26ea0428c6cf152763f3f10b6dd05/rpds_py-0.27.0-cp313-cp313-win32.whl", hash = "sha256:2cff9bdd6c7b906cc562a505c04a57d92e82d37200027e8d362518df427f96cd", size = 221236, upload-time = "2025-08-07T08:24:22.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/44/65d7494f5448ecc755b545d78b188440f81da98b50ea0447ab5ebfdf9bd6/rpds_py-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc79d192fb76fc0c84f2c58672c17bbbc383fd26c3cdc29daae16ce3d927e8b2", size = 232634, upload-time = "2025-08-07T08:24:23.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d9/23852410fadab2abb611733933401de42a1964ce6600a3badae35fbd573e/rpds_py-0.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b3a5c8089eed498a3af23ce87a80805ff98f6ef8f7bdb70bd1b7dae5105f6ac", size = 222783, upload-time = "2025-08-07T08:24:25.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/75/03447917f78512b34463f4ef11066516067099a0c466545655503bed0c77/rpds_py-0.27.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:90fb790138c1a89a2e58c9282fe1089638401f2f3b8dddd758499041bc6e0774", size = 359154, upload-time = "2025-08-07T08:24:26.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fc/4dac4fa756451f2122ddaf136e2c6aeb758dc6fdbe9ccc4bc95c98451d50/rpds_py-0.27.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010c4843a3b92b54373e3d2291a7447d6c3fc29f591772cc2ea0e9f5c1da434b", size = 343909, upload-time = "2025-08-07T08:24:27.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/81/723c1ed8e6f57ed9d8c0c07578747a2d3d554aaefc1ab89f4e42cfeefa07/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9ce7a9e967afc0a2af7caa0d15a3e9c1054815f73d6a8cb9225b61921b419bd", size = 379340, upload-time = "2025-08-07T08:24:28.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/16/7e3740413de71818ce1997df82ba5f94bae9fff90c0a578c0e24658e6201/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa0bf113d15e8abdfee92aa4db86761b709a09954083afcb5bf0f952d6065fdb", size = 391655, upload-time = "2025-08-07T08:24:30.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/63/2a9f510e124d80660f60ecce07953f3f2d5f0b96192c1365443859b9c87f/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb91d252b35004a84670dfeafadb042528b19842a0080d8b53e5ec1128e8f433", size = 513017, upload-time = "2025-08-07T08:24:31.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/4e/cf6ff311d09776c53ea1b4f2e6700b9d43bb4e99551006817ade4bbd6f78/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db8a6313dbac934193fc17fe7610f70cd8181c542a91382531bef5ed785e5615", size = 402058, upload-time = "2025-08-07T08:24:32.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/11/5e36096d474cb10f2a2d68b22af60a3bc4164fd8db15078769a568d9d3ac/rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce96ab0bdfcef1b8c371ada2100767ace6804ea35aacce0aef3aeb4f3f499ca8", size = 383474, upload-time = "2025-08-07T08:24:33.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a2/3dff02805b06058760b5eaa6d8cb8db3eb3e46c9e452453ad5fc5b5ad9fe/rpds_py-0.27.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:7451ede3560086abe1aa27dcdcf55cd15c96b56f543fb12e5826eee6f721f858", size = 400067, upload-time = "2025-08-07T08:24:35.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/87/eed7369b0b265518e21ea836456a4ed4a6744c8c12422ce05bce760bb3cf/rpds_py-0.27.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:32196b5a99821476537b3f7732432d64d93a58d680a52c5e12a190ee0135d8b5", size = 412085, upload-time = "2025-08-07T08:24:36.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/48/f50b2ab2fbb422fbb389fe296e70b7a6b5ea31b263ada5c61377e710a924/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a029be818059870664157194e46ce0e995082ac49926f1423c1f058534d2aaa9", size = 555928, upload-time = "2025-08-07T08:24:37.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/b18eb51045d06887666c3560cd4bbb6819127b43d758f5adb82b5f56f7d1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3841f66c1ffdc6cebce8aed64e36db71466f1dc23c0d9a5592e2a782a3042c79", size = 585527, upload-time = "2025-08-07T08:24:39.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/03/a3dd6470fc76499959b00ae56295b76b4bdf7c6ffc60d62006b1217567e1/rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:42894616da0fc0dcb2ec08a77896c3f56e9cb2f4b66acd76fc8992c3557ceb1c", size = 554211, upload-time = "2025-08-07T08:24:40.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/d1/ee5fd1be395a07423ac4ca0bcc05280bf95db2b155d03adefeb47d5ebf7e/rpds_py-0.27.0-cp313-cp313t-win32.whl", hash = "sha256:b1fef1f13c842a39a03409e30ca0bf87b39a1e2a305a9924deadb75a43105d23", size = 216624, upload-time = "2025-08-07T08:24:42.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/94/4814c4c858833bf46706f87349c37ca45e154da7dbbec9ff09f1abeb08cc/rpds_py-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:183f5e221ba3e283cd36fdfbe311d95cd87699a083330b4f792543987167eff1", size = 230007, upload-time = "2025-08-07T08:24:43.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/a5/8fffe1c7dc7c055aa02df310f9fb71cfc693a4d5ccc5de2d3456ea5fb022/rpds_py-0.27.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f3cd110e02c5bf17d8fb562f6c9df5c20e73029d587cf8602a2da6c5ef1e32cb", size = 362595, upload-time = "2025-08-07T08:24:44.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/c7/4e4253fd2d4bb0edbc0b0b10d9f280612ca4f0f990e3c04c599000fe7d71/rpds_py-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d0e09cf4863c74106b5265c2c310f36146e2b445ff7b3018a56799f28f39f6f", size = 347252, upload-time = "2025-08-07T08:24:45.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/3d1a954d30f0174dd6baf18b57c215da03cf7846a9d6e0143304e784cddc/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f689ab822f9b5eb6dfc69893b4b9366db1d2420f7db1f6a2adf2a9ca15ad64", size = 384886, upload-time = "2025-08-07T08:24:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/52/3c5835f2df389832b28f9276dd5395b5a965cea34226e7c88c8fbec2093c/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e36c80c49853b3ffda7aa1831bf175c13356b210c73128c861f3aa93c3cc4015", size = 399716, upload-time = "2025-08-07T08:24:48.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/73/176e46992461a1749686a2a441e24df51ff86b99c2d34bf39f2a5273b987/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6de6a7f622860af0146cb9ee148682ff4d0cea0b8fd3ad51ce4d40efb2f061d0", size = 517030, upload-time = "2025-08-07T08:24:49.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/2a/7266c75840e8c6e70effeb0d38922a45720904f2cd695e68a0150e5407e2/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4045e2fc4b37ec4b48e8907a5819bdd3380708c139d7cc358f03a3653abedb89", size = 408448, upload-time = "2025-08-07T08:24:50.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/5f/a7efc572b8e235093dc6cf39f4dbc8a7f08e65fdbcec7ff4daeb3585eef1/rpds_py-0.27.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da162b718b12c4219eeeeb68a5b7552fbc7aadedf2efee440f88b9c0e54b45d", size = 387320, upload-time = "2025-08-07T08:24:52.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/eb/9ff6bc92efe57cf5a2cb74dee20453ba444b6fdc85275d8c99e0d27239d1/rpds_py-0.27.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:0665be515767dc727ffa5f74bd2ef60b0ff85dad6bb8f50d91eaa6b5fb226f51", size = 407414, upload-time = "2025-08-07T08:24:53.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/bd/3b9b19b00d5c6e1bd0f418c229ab0f8d3b110ddf7ec5d9d689ef783d0268/rpds_py-0.27.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203f581accef67300a942e49a37d74c12ceeef4514874c7cede21b012613ca2c", size = 420766, upload-time = "2025-08-07T08:24:55.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/6b/521a7b1079ce16258c70805166e3ac6ec4ee2139d023fe07954dc9b2d568/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7873b65686a6471c0037139aa000d23fe94628e0daaa27b6e40607c90e3f5ec4", size = 562409, upload-time = "2025-08-07T08:24:57.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/bf/65db5bfb14ccc55e39de8419a659d05a2a9cd232f0a699a516bb0991da7b/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:249ab91ceaa6b41abc5f19513cb95b45c6f956f6b89f1fe3d99c81255a849f9e", size = 590793, upload-time = "2025-08-07T08:24:58.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b8/82d368b378325191ba7aae8f40f009b78057b598d4394d1f2cdabaf67b3f/rpds_py-0.27.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2f184336bc1d6abfaaa1262ed42739c3789b1e3a65a29916a615307d22ffd2e", size = 558178, upload-time = "2025-08-07T08:24:59.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ff/f270bddbfbc3812500f8131b1ebbd97afd014cd554b604a3f73f03133a36/rpds_py-0.27.0-cp314-cp314-win32.whl", hash = "sha256:d3c622c39f04d5751408f5b801ecb527e6e0a471b367f420a877f7a660d583f6", size = 222355, upload-time = "2025-08-07T08:25:01.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/fdab055b1460c02ed356a0e0b0a78c1dd32dc64e82a544f7b31c9ac643dc/rpds_py-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf824aceaeffff029ccfba0da637d432ca71ab21f13e7f6f5179cd88ebc77a8a", size = 234007, upload-time = "2025-08-07T08:25:02.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a8/694c060005421797a3be4943dab8347c76c2b429a9bef68fb2c87c9e70c7/rpds_py-0.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:86aca1616922b40d8ac1b3073a1ead4255a2f13405e5700c01f7c8d29a03972d", size = 223527, upload-time = "2025-08-07T08:25:03.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f9/77f4c90f79d2c5ca8ce6ec6a76cb4734ee247de6b3a4f337e289e1f00372/rpds_py-0.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:341d8acb6724c0c17bdf714319c393bb27f6d23d39bc74f94221b3e59fc31828", size = 359469, upload-time = "2025-08-07T08:25:04.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/22/b97878d2f1284286fef4172069e84b0b42b546ea7d053e5fb7adb9ac6494/rpds_py-0.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b96b0b784fe5fd03beffff2b1533dc0d85e92bab8d1b2c24ef3a5dc8fac5669", size = 343960, upload-time = "2025-08-07T08:25:05.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b0/dfd55b5bb480eda0578ae94ef256d3061d20b19a0f5e18c482f03e65464f/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c431bfb91478d7cbe368d0a699978050d3b112d7f1d440a41e90faa325557fd", size = 380201, upload-time = "2025-08-07T08:25:07.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/22/e1fa64e50d58ad2b2053077e3ec81a979147c43428de9e6de68ddf6aff4e/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e222a44ae9f507d0f2678ee3dd0c45ec1e930f6875d99b8459631c24058aec", size = 392111, upload-time = "2025-08-07T08:25:09.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/f9/43ab7a43e97aedf6cea6af70fdcbe18abbbc41d4ae6cdec1bfc23bbad403/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:184f0d7b342967f6cda94a07d0e1fae177d11d0b8f17d73e06e36ac02889f303", size = 515863, upload-time = "2025-08-07T08:25:10.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/9b/9bd59dcc636cd04d86a2d20ad967770bf348f5eb5922a8f29b547c074243/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a00c91104c173c9043bc46f7b30ee5e6d2f6b1149f11f545580f5d6fdff42c0b", size = 402398, upload-time = "2025-08-07T08:25:11.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/bf/f099328c6c85667aba6b66fa5c35a8882db06dcd462ea214be72813a0dd2/rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a37dd208f0d658e0487522078b1ed68cd6bce20ef4b5a915d2809b9094b410", size = 384665, upload-time = "2025-08-07T08:25:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c5/9c1f03121ece6634818490bd3c8be2c82a70928a19de03467fb25a3ae2a8/rpds_py-0.27.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92f3b3ec3e6008a1fe00b7c0946a170f161ac00645cde35e3c9a68c2475e8156", size = 400405, upload-time = "2025-08-07T08:25:14.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/b8/e25d54af3e63ac94f0c16d8fe143779fe71ff209445a0c00d0f6984b6b2c/rpds_py-0.27.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b3db5fae5cbce2131b7420a3f83553d4d89514c03d67804ced36161fe8b6b2", size = 413179, upload-time = "2025-08-07T08:25:15.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/d1/406b3316433fe49c3021546293a04bc33f1478e3ec7950215a7fce1a1208/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5355527adaa713ab693cbce7c1e0ec71682f599f61b128cf19d07e5c13c9b1f1", size = 556895, upload-time = "2025-08-07T08:25:17.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/bc/3697c0c21fcb9a54d46ae3b735eb2365eea0c2be076b8f770f98e07998de/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fcc01c57ce6e70b728af02b2401c5bc853a9e14eb07deda30624374f0aebfe42", size = 585464, upload-time = "2025-08-07T08:25:18.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/09/ee1bb5536f99f42c839b177d552f6114aa3142d82f49cef49261ed28dbe0/rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3001013dae10f806380ba739d40dee11db1ecb91684febb8406a87c2ded23dae", size = 555090, upload-time = "2025-08-07T08:25:20.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2c/363eada9e89f7059199d3724135a86c47082cbf72790d6ba2f336d146ddb/rpds_py-0.27.0-cp314-cp314t-win32.whl", hash = "sha256:0f401c369186a5743694dd9fc08cba66cf70908757552e1f714bfc5219c655b5", size = 218001, upload-time = "2025-08-07T08:25:21.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3f/d6c216ed5199c9ef79e2a33955601f454ed1e7420a93b89670133bca5ace/rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391", size = 230993, upload-time = "2025-08-07T08:25:23.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/55/287068956f9ba1cb40896d291213f09fdd4527630709058b45a592bc09dc/rpds_py-0.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:46f48482c1a4748ab2773f75fffbdd1951eb59794e32788834b945da857c47a8", size = 371566, upload-time = "2025-08-07T08:25:43.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/fb/443af59cbe552e89680bb0f1d1ba47f6387b92083e28a45b8c8863b86c5a/rpds_py-0.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:419dd9c98bcc9fb0242be89e0c6e922df333b975d4268faa90d58499fd9c9ebe", size = 355781, upload-time = "2025-08-07T08:25:45.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/f0/35f48bb073b5ca42b1dcc55cb148f4a3bd4411a3e584f6a18d26f0ea8832/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d42a0ef2bdf6bc81e1cc2d49d12460f63c6ae1423c4f4851b828e454ccf6f1", size = 382575, upload-time = "2025-08-07T08:25:46.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e1/5f5296a21d1189f0f116a938af2e346d83172bf814d373695e54004a936f/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e39169ac6aae06dd79c07c8a69d9da867cef6a6d7883a0186b46bb46ccfb0c3", size = 397435, upload-time = "2025-08-07T08:25:48.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/79/3af99b7852b2b55cad8a08863725cbe9dc14781bcf7dc6ecead0c3e1dc54/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:935afcdea4751b0ac918047a2df3f720212892347767aea28f5b3bf7be4f27c0", size = 514861, upload-time = "2025-08-07T08:25:49.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3e/11fd6033708ed3ae0e6947bb94f762f56bb46bf59a1b16eef6944e8a62ee/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de567dec6d451649a781633d36f5c7501711adee329d76c095be2178855b042", size = 402776, upload-time = "2025-08-07T08:25:51.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/89/f9375ceaa996116de9cbc949874804c7874d42fb258c384c037a46d730b8/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:555ed147cbe8c8f76e72a4c6cd3b7b761cbf9987891b9448808148204aed74a5", size = 384665, upload-time = "2025-08-07T08:25:52.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/bf/0061e55c6f1f573a63c0f82306b8984ed3b394adafc66854a936d5db3522/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:d2cc2b34f9e1d31ce255174da82902ad75bd7c0d88a33df54a77a22f2ef421ee", size = 402518, upload-time = "2025-08-07T08:25:54.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/dc/8d506676bfe87b3b683332ec8e6ab2b0be118a3d3595ed021e3274a63191/rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb0702c12983be3b2fab98ead349ac63a98216d28dda6f518f52da5498a27a1b", size = 416247, upload-time = "2025-08-07T08:25:55.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/02/9a89eea1b75c69e81632de7963076e455b1e00e1cfb46dfdabb055fa03e3/rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ba783541be46f27c8faea5a6645e193943c17ea2f0ffe593639d906a327a9bcc", size = 559456, upload-time = "2025-08-07T08:25:56.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/4a/0f3ac4351957847c0d322be6ec72f916e43804a2c1d04e9672ea4a67c315/rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:2406d034635d1497c596c40c85f86ecf2bf9611c1df73d14078af8444fe48031", size = 587778, upload-time = "2025-08-07T08:25:58.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8e/39d0d7401095bed5a5ad5ef304fae96383f9bef40ca3f3a0807ff5b68d9d/rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dea0808153f1fbbad772669d906cddd92100277533a03845de6893cadeffc8be", size = 555247, upload-time = "2025-08-07T08:25:59.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/04/6b8311e811e620b9eaca67cd80a118ff9159558a719201052a7b2abb88bf/rpds_py-0.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d2a81bdcfde4245468f7030a75a37d50400ac2455c3a4819d9d550c937f90ab5", size = 230256, upload-time = "2025-08-07T08:26:01.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/64/72ab5b911fdcc48058359b0e786e5363e3fde885156116026f1a2ba9a5b5/rpds_py-0.27.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e6491658dd2569f05860bad645569145c8626ac231877b0fb2d5f9bcb7054089", size = 371658, upload-time = "2025-08-07T08:26:02.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4b/90ff04b4da055db53d8fea57640d8d5d55456343a1ec9a866c0ecfe10fd1/rpds_py-0.27.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:bec77545d188f8bdd29d42bccb9191682a46fb2e655e3d1fb446d47c55ac3b8d", size = 355529, upload-time = "2025-08-07T08:26:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/be/527491fb1afcd86fc5ce5812eb37bc70428ee017d77fee20de18155c3937/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a4aebf8ca02bbb90a9b3e7a463bbf3bee02ab1c446840ca07b1695a68ce424", size = 382822, upload-time = "2025-08-07T08:26:05.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a5/dcdb8725ce11e6d0913e6fcf782a13f4b8a517e8acc70946031830b98441/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44524b96481a4c9b8e6c46d6afe43fa1fb485c261e359fbe32b63ff60e3884d8", size = 397233, upload-time = "2025-08-07T08:26:07.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f9/0947920d1927e9f144660590cc38cadb0795d78fe0d9aae0ef71c1513b7c/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45d04a73c54b6a5fd2bab91a4b5bc8b426949586e61340e212a8484919183859", size = 514892, upload-time = "2025-08-07T08:26:08.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ed/d1343398c1417c68f8daa1afce56ef6ce5cc587daaf98e29347b00a80ff2/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:343cf24de9ed6c728abefc5d5c851d5de06497caa7ac37e5e65dd572921ed1b5", size = 402733, upload-time = "2025-08-07T08:26:10.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/0b/646f55442cd14014fb64d143428f25667a100f82092c90087b9ea7101c74/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aed8118ae20515974650d08eb724150dc2e20c2814bcc307089569995e88a14", size = 384447, upload-time = "2025-08-07T08:26:11.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/15/0596ef7529828e33a6c81ecf5013d1dd33a511a3e0be0561f83079cda227/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:af9d4fd79ee1cc8e7caf693ee02737daabfc0fcf2773ca0a4735b356c8ad6f7c", size = 402502, upload-time = "2025-08-07T08:26:13.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/8d/986af3c42f8454a6cafff8729d99fb178ae9b08a9816325ac7a8fa57c0c0/rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f0396e894bd1e66c74ecbc08b4f6a03dc331140942c4b1d345dd131b68574a60", size = 416651, upload-time = "2025-08-07T08:26:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9a/b4ec3629b7b447e896eec574469159b5b60b7781d3711c914748bf32de05/rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:59714ab0a5af25d723d8e9816638faf7f4254234decb7d212715c1aa71eee7be", size = 559460, upload-time = "2025-08-07T08:26:16.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/63/d1e127b40c3e4733b3a6f26ae7a063cdf2bc1caa5272c89075425c7d397a/rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:88051c3b7d5325409f433c5a40328fcb0685fc04e5db49ff936e910901d10114", size = 588072, upload-time = "2025-08-07T08:26:17.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/7e/8ffc71a8f6833d9c9fb999f5b0ee736b8b159fd66968e05c7afc2dbcd57e/rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:181bc29e59e5e5e6e9d63b143ff4d5191224d355e246b5a48c88ce6b35c4e466", size = 555083, upload-time = "2025-08-07T08:26:19.301Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.12"
|
||||
|
|
Ładowanie…
Reference in New Issue