petsafe-api 3.1.2__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/PKG-INFO +5 -2
  2. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/README.md +4 -1
  3. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/local.py +97 -0
  4. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/PKG-INFO +5 -2
  5. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/setup.py +1 -1
  6. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_local.py +76 -1
  7. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/LICENSE +0 -0
  8. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/__init__.py +0 -0
  9. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/__main__.py +0 -0
  10. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/client.py +0 -0
  11. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/const.py +0 -0
  12. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/devices.py +0 -0
  13. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/general.py +0 -0
  14. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/pets.py +0 -0
  15. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/provisioning.py +0 -0
  16. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/setup.py +0 -0
  17. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/SOURCES.txt +0 -0
  18. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/dependency_links.txt +0 -0
  19. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/requires.txt +0 -0
  20. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/top_level.txt +0 -0
  21. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/setup.cfg +0 -0
  22. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_provisioning.py +0 -0
  23. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_setup.py +0 -0
  24. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_smartdoor_modes.py +0 -0
  25. {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_smartfeed_last_feeding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: petsafe-api
3
- Version: 3.1.2
3
+ Version: 3.2.0
4
4
  Summary: Provides ability to connect and control a PetSafe Smart Feed, Scoopfree, and Smart Door devices using the PetSafe API.
5
5
  Home-page: https://github.com/ThomasHFWright/petsafe-api
6
6
  Author: Jordan Stremming & Dominick Meglio & Thomas Wright
@@ -57,7 +57,7 @@ Thank you to both projects for building the API integrations for existing feeder
57
57
  If installing from source code,
58
58
  `python setup.py install`
59
59
 
60
- ## Local SmartDoor control and provisioning (3.1.0)
60
+ ## Local SmartDoor control and provisioning (3.2.0)
61
61
 
62
62
  `LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
63
63
  and API token. It does not use a PetSafe vendor login, and returns the same
@@ -77,6 +77,9 @@ async def main() -> None:
77
77
  await door.unlock()
78
78
  await door.smart_mode()
79
79
 
80
+ async for snapshot in client.iter_events():
81
+ print(snapshot["revision"], snapshot["door"])
82
+
80
83
 
81
84
  asyncio.run(main())
82
85
  ```
@@ -24,7 +24,7 @@ Thank you to both projects for building the API integrations for existing feeder
24
24
  If installing from source code,
25
25
  `python setup.py install`
26
26
 
27
- ## Local SmartDoor control and provisioning (3.1.0)
27
+ ## Local SmartDoor control and provisioning (3.2.0)
28
28
 
29
29
  `LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
30
30
  and API token. It does not use a PetSafe vendor login, and returns the same
@@ -44,6 +44,9 @@ async def main() -> None:
44
44
  await door.unlock()
45
45
  await door.smart_mode()
46
46
 
47
+ async for snapshot in client.iter_events():
48
+ print(snapshot["revision"], snapshot["door"])
49
+
47
50
 
48
51
  asyncio.run(main())
49
52
  ```
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import json
6
+ import math
6
7
  from typing import Any, Optional
7
8
  from urllib.parse import unquote, urlsplit
8
9
 
@@ -12,6 +13,7 @@ from .devices import DeviceSmartDoor
12
13
  from .setup import _TOKEN
13
14
 
14
15
  MAX_RESPONSE_BYTES = 1024 * 1024
16
+ MAX_EVENT_BYTES = 4 * 1024 * 1024
15
17
 
16
18
 
17
19
  class LocalPetSafeClient:
@@ -97,6 +99,101 @@ class LocalPetSafeClient:
97
99
  async def api_delete(self, path: str = "") -> httpx.Response:
98
100
  return await self._request("DELETE", path)
99
101
 
102
+ async def iter_events(self):
103
+ """Yield validated full snapshots from the local server's SSE stream."""
104
+ request = self._client.build_request(
105
+ "GET", self._request_url("events"),
106
+ headers={"Authorization": f"Bearer {self._api_token}",
107
+ "Accept": "text/event-stream"},
108
+ timeout=httpx.Timeout(60, read=45),
109
+ )
110
+ response = await self._client.send(request, stream=True, follow_redirects=False)
111
+ event_type, event_data, event_bytes = None, [], 0
112
+ try:
113
+ response.raise_for_status()
114
+ if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "text/event-stream":
115
+ raise ValueError("Unexpected local events content type")
116
+ buffer = bytearray()
117
+ async for chunk in response.aiter_bytes():
118
+ buffer.extend(chunk)
119
+ while b"\n" in buffer:
120
+ line, _, remainder = buffer.partition(b"\n")
121
+ buffer = bytearray(remainder)
122
+ event_bytes += len(line) + 1
123
+ if event_bytes > MAX_EVENT_BYTES:
124
+ raise ValueError("SSE event is too large")
125
+ line = line.removesuffix(b"\r")
126
+ if line:
127
+ if line.startswith(b":"):
128
+ continue
129
+ try:
130
+ field, separator, value = line.decode("UTF-8").partition(":")
131
+ except UnicodeDecodeError as error:
132
+ raise ValueError("Invalid UTF-8 in SSE event") from error
133
+ if separator and value.startswith(" "):
134
+ value = value[1:]
135
+ if field == "event":
136
+ event_type = value
137
+ elif field == "data":
138
+ event_data.append(value)
139
+ continue
140
+ event = self._snapshot(event_type, event_data)
141
+ event_type, event_data, event_bytes = None, [], 0
142
+ if event is not None:
143
+ yield event
144
+ if event_bytes + len(buffer) > MAX_EVENT_BYTES:
145
+ raise ValueError("SSE event is too large")
146
+ # A disconnected peer may leave a partial frame; reconnect policy belongs to the caller.
147
+ finally:
148
+ await response.aclose()
149
+
150
+ @classmethod
151
+ def _snapshot(cls, event_type: Optional[str], data: list[str]):
152
+ if event_type != "snapshot":
153
+ return None
154
+ if not data:
155
+ raise ValueError("Snapshot event has no data")
156
+ try:
157
+ payload = json.loads("\n".join(data), parse_constant=lambda value: (_ for _ in ()).throw(
158
+ ValueError("Invalid JSON number")))
159
+ except (TypeError, ValueError, RecursionError) as error:
160
+ raise ValueError("Invalid snapshot JSON") from error
161
+ keys = {"version", "revision", "brokerConnected", "door", "pets",
162
+ "links", "activity", "override"}
163
+ try:
164
+ finite = cls._finite(payload)
165
+ except RecursionError as error:
166
+ raise ValueError("Invalid snapshot schema") from error
167
+ if (not isinstance(payload, dict) or set(payload) != keys
168
+ or type(payload["version"]) is not int or payload["version"] != 1
169
+ or type(payload["revision"]) is not int
170
+ or payload["revision"] < 0
171
+ or type(payload["brokerConnected"]) is not bool
172
+ or not isinstance(payload["door"], dict)
173
+ or not isinstance(payload["pets"], list)
174
+ or any(not isinstance(item, dict) for item in payload["pets"])
175
+ or not isinstance(payload["links"], dict)
176
+ or any(not isinstance(key, str) or not isinstance(items, list)
177
+ or any(not isinstance(item, dict) for item in items)
178
+ for key, items in payload["links"].items())
179
+ or not isinstance(payload["activity"], list)
180
+ or any(not isinstance(item, dict) for item in payload["activity"])
181
+ or payload["override"] is not None and not isinstance(payload["override"], dict)
182
+ or not finite):
183
+ raise ValueError("Invalid snapshot schema")
184
+ return payload
185
+
186
+ @staticmethod
187
+ def _finite(value: Any) -> bool:
188
+ if isinstance(value, float):
189
+ return math.isfinite(value)
190
+ if isinstance(value, dict):
191
+ return all(isinstance(key, str) and LocalPetSafeClient._finite(item)
192
+ for key, item in value.items())
193
+ if isinstance(value, list):
194
+ return all(LocalPetSafeClient._finite(item) for item in value)
195
+ return True
196
+
100
197
  async def get_smartdoors(self) -> list[DeviceSmartDoor]:
101
198
  response = await self.api_get("smartdoor/product/product")
102
199
  payload = json.loads(response.content.decode("UTF-8"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: petsafe-api
3
- Version: 3.1.2
3
+ Version: 3.2.0
4
4
  Summary: Provides ability to connect and control a PetSafe Smart Feed, Scoopfree, and Smart Door devices using the PetSafe API.
5
5
  Home-page: https://github.com/ThomasHFWright/petsafe-api
6
6
  Author: Jordan Stremming & Dominick Meglio & Thomas Wright
@@ -57,7 +57,7 @@ Thank you to both projects for building the API integrations for existing feeder
57
57
  If installing from source code,
58
58
  `python setup.py install`
59
59
 
60
- ## Local SmartDoor control and provisioning (3.1.0)
60
+ ## Local SmartDoor control and provisioning (3.2.0)
61
61
 
62
62
  `LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
63
63
  and API token. It does not use a PetSafe vendor login, and returns the same
@@ -77,6 +77,9 @@ async def main() -> None:
77
77
  await door.unlock()
78
78
  await door.smart_mode()
79
79
 
80
+ async for snapshot in client.iter_events():
81
+ print(snapshot["revision"], snapshot["door"])
82
+
80
83
 
81
84
  asyncio.run(main())
82
85
  ```
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="petsafe-api",
8
- version="3.1.2",
8
+ version="3.2.0",
9
9
  author="Jordan Stremming & Dominick Meglio & Thomas Wright",
10
10
  license="MIT",
11
11
  author_email="thomas.h.f.wright@gmail.com",
@@ -8,7 +8,7 @@ import pytest
8
8
  import petsafe
9
9
  from petsafe import pets
10
10
  from petsafe.devices import DeviceSmartDoor
11
- from petsafe.local import MAX_RESPONSE_BYTES
11
+ from petsafe.local import MAX_EVENT_BYTES, MAX_RESPONSE_BYTES
12
12
 
13
13
 
14
14
  BASE = "https://local.example.test"
@@ -166,3 +166,78 @@ def test_smartdoor_collection_shape_is_validated(payload):
166
166
  await petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).get_smartdoors()
167
167
 
168
168
  run(check())
169
+
170
+
171
+ def test_iter_events_parses_snapshots_and_closes_stream():
172
+ snapshot = {"version": 1, "revision": 7, "brokerConnected": True,
173
+ "door": {"thingName": "door-test"}, "pets": [], "links": {},
174
+ "activity": [], "override": None}
175
+
176
+ class Stream(httpx.AsyncByteStream):
177
+ closed = False
178
+
179
+ async def __aiter__(self):
180
+ encoded = json.dumps(snapshot)
181
+ split = encoded.index('"door"')
182
+ yield (": heartbeat\n\nevent: snapshot\nid: 7\ndata: " + encoded[:split]
183
+ + "\ndata: " + encoded[split:] + "\n\n").encode()
184
+
185
+ async def aclose(self):
186
+ self.closed = True
187
+
188
+ stream = Stream()
189
+
190
+ async def handler(request):
191
+ assert request.url == BASE + "/local/events"
192
+ assert request.headers["authorization"] == f"Bearer {TOKEN}"
193
+ assert request.headers["accept"] == "text/event-stream"
194
+ assert request.extensions["timeout"]["read"] == 45
195
+ return httpx.Response(200, headers={"content-type": "text/event-stream; charset=utf-8"},
196
+ stream=stream)
197
+
198
+ async def check():
199
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
200
+ events = petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).iter_events()
201
+ assert await events.__anext__() == snapshot
202
+ await events.aclose()
203
+ assert stream.closed
204
+
205
+ run(check())
206
+
207
+
208
+ @pytest.mark.parametrize("body, message", [
209
+ (b"event: snapshot\ndata: {}\n\n", "schema"),
210
+ (b"event: snapshot\ndata: {\"version\":NaN}\n\n", "JSON"),
211
+ (b":" + b"x" * MAX_EVENT_BYTES + b"\n", "too large"),
212
+ ])
213
+ def test_iter_events_rejects_invalid_or_oversized_frames(body, message):
214
+ async def check():
215
+ async with httpx.AsyncClient(transport=httpx.MockTransport(
216
+ lambda request: httpx.Response(200, headers={"content-type": "text/event-stream"},
217
+ content=body))) as http:
218
+ with pytest.raises(ValueError, match=message):
219
+ await petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).iter_events().__anext__()
220
+
221
+ run(check())
222
+
223
+
224
+ def test_iter_events_preserves_http_status_errors():
225
+ async def check():
226
+ async with httpx.AsyncClient(transport=httpx.MockTransport(
227
+ lambda request: httpx.Response(401))) as http:
228
+ with pytest.raises(httpx.HTTPStatusError) as raised:
229
+ await petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).iter_events().__anext__()
230
+ assert raised.value.response.status_code == 401
231
+
232
+ run(check())
233
+
234
+
235
+ def test_iter_events_sanitizes_deep_schema_recursion():
236
+ nested = "[]"
237
+ for _ in range(500):
238
+ nested = "[" + nested + "]"
239
+ payload = ('{"version":1,"revision":0,"brokerConnected":true,'
240
+ '"door":{"nested":' + nested
241
+ + '},"pets":[],"links":{},"activity":[],"override":null}')
242
+ with pytest.raises(ValueError, match="snapshot schema"):
243
+ petsafe.LocalPetSafeClient._snapshot("snapshot", [payload])
File without changes
File without changes
File without changes