MouseTools 6.0.0rc2__tar.gz → 6.1.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.
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/PKG-INFO +5 -2
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/__init__.py +32 -2
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/public.py +7 -1
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/config.py +1 -1
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/loaders.py +16 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/couchbase.py +17 -4
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/ids.py +86 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/codec.py +8 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/connection.py +33 -6
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/protocol.py +36 -1
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/transport.py +11 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/client.py +147 -34
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/__init__.py +10 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/base.py +32 -17
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/character.py +3 -6
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/destination.py +46 -6
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/facility.py +2 -0
- mousetools-6.1.0/mousetools/entities/names.py +155 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/protocols.py +28 -5
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/types.py +3 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/values.py +155 -6
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/enums.py +7 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/errors.py +37 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/ext/passes.py +1 -1
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/ext/weather.py +11 -1
- mousetools-6.1.0/mousetools/stream/__init__.py +33 -0
- mousetools-6.1.0/mousetools/stream/events.py +274 -0
- mousetools-6.1.0/mousetools/stream/filters.py +308 -0
- mousetools-6.1.0/mousetools/stream/subscription.py +248 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/PKG-INFO +5 -2
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/SOURCES.txt +2 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/requires.txt +4 -1
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/pyproject.toml +26 -5
- mousetools-6.0.0rc2/mousetools/stream/__init__.py +0 -5
- mousetools-6.0.0rc2/mousetools/stream/events.py +0 -158
- mousetools-6.0.0rc2/mousetools/stream/subscription.py +0 -165
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/LICENSE +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/README.md +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/__init__.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/__init__.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/base.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/syncgateway.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/client_registry.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/__init__.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/http.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/menu.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/search.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/__init__.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/attraction.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/basic.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/container.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/entertainment.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/menu.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/mixins.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/poi.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/restaurant.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/search.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/themepark.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/ext/__init__.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/py.typed +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/resources/environments.bin +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/timezones.py +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/dependency_links.txt +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/top_level.txt +0 -0
- {mousetools-6.0.0rc2 → mousetools-6.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mousetools
|
|
3
|
-
Version: 6.0
|
|
3
|
+
Version: 6.1.0
|
|
4
4
|
Summary: An unofficial Python wrapper for Disney's API
|
|
5
5
|
Author: Scott Caratozzolo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -11,15 +11,18 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
15
|
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Typing :: Typed
|
|
15
17
|
Requires-Python: <4.0,>=3.11
|
|
16
18
|
Description-Content-Type: text/markdown
|
|
17
19
|
License-File: LICENSE
|
|
18
20
|
Requires-Dist: more-itertools>=10.7.0
|
|
19
|
-
Requires-Dist:
|
|
21
|
+
Requires-Dist: pycryptodomex>=3.22.0
|
|
20
22
|
Requires-Dist: python-dateutil>=2.9.0.post0
|
|
21
23
|
Requires-Dist: niquests[http3,utls]>=3.20.0
|
|
22
24
|
Requires-Dist: tzdata>=2025.2
|
|
25
|
+
Requires-Dist: typing-extensions>=4.5; python_version < "3.13"
|
|
23
26
|
Requires-Dist: websockets>=13.0
|
|
24
27
|
Dynamic: license-file
|
|
25
28
|
|
|
@@ -8,6 +8,7 @@ from mousetools.entities import (
|
|
|
8
8
|
DISNEYLAND_RESORT,
|
|
9
9
|
WALT_DISNEY_WORLD,
|
|
10
10
|
Appearance,
|
|
11
|
+
ArrivalPrediction,
|
|
11
12
|
Attraction,
|
|
12
13
|
AudioTour,
|
|
13
14
|
Avatar,
|
|
@@ -18,6 +19,7 @@ from mousetools.entities import (
|
|
|
18
19
|
Coordinates,
|
|
19
20
|
DailySchedule,
|
|
20
21
|
Destination,
|
|
22
|
+
DestinationArrival,
|
|
21
23
|
DiningEvent,
|
|
22
24
|
DinnerShow,
|
|
23
25
|
Entertainment,
|
|
@@ -35,6 +37,7 @@ from mousetools.entities import (
|
|
|
35
37
|
MenuItem,
|
|
36
38
|
MenuPrice,
|
|
37
39
|
MerchandiseFacility,
|
|
40
|
+
NameParts,
|
|
38
41
|
PartySizeStatus,
|
|
39
42
|
PhotoPass,
|
|
40
43
|
PointOfInterest,
|
|
@@ -51,7 +54,9 @@ from mousetools.entities import (
|
|
|
51
54
|
Spa,
|
|
52
55
|
ThemePark,
|
|
53
56
|
Tour,
|
|
57
|
+
TransportArrivals,
|
|
54
58
|
Transportation,
|
|
59
|
+
TransportService,
|
|
55
60
|
WaitTime,
|
|
56
61
|
WalkUpAvailability,
|
|
57
62
|
WaterPark,
|
|
@@ -59,15 +64,31 @@ from mousetools.entities import (
|
|
|
59
64
|
load_many,
|
|
60
65
|
)
|
|
61
66
|
from mousetools.enums import ChannelSource, DestinationShort, EntityType, ScheduleSource
|
|
62
|
-
from mousetools.stream import
|
|
67
|
+
from mousetools.stream import (
|
|
68
|
+
ArrivalsEvent,
|
|
69
|
+
ArrivalsFilter,
|
|
70
|
+
CalendarEvent,
|
|
71
|
+
ChangeEvent,
|
|
72
|
+
CharacterEvent,
|
|
73
|
+
DiningFacilityStatusFilter,
|
|
74
|
+
DocId,
|
|
75
|
+
FacilityStatusEvent,
|
|
76
|
+
ForecastEvent,
|
|
77
|
+
NonDiningFacilityStatusFilter,
|
|
78
|
+
RawChange,
|
|
79
|
+
SubscriptionFilter,
|
|
80
|
+
)
|
|
63
81
|
|
|
64
|
-
logger = logging.getLogger(
|
|
82
|
+
logger = logging.getLogger("mousetools")
|
|
65
83
|
logger.addHandler(logging.NullHandler())
|
|
66
84
|
|
|
67
85
|
__all__ = [
|
|
68
86
|
"DISNEYLAND_RESORT",
|
|
69
87
|
"WALT_DISNEY_WORLD",
|
|
70
88
|
"Appearance",
|
|
89
|
+
"ArrivalPrediction",
|
|
90
|
+
"ArrivalsEvent",
|
|
91
|
+
"ArrivalsFilter",
|
|
71
92
|
"Attraction",
|
|
72
93
|
"AudioTour",
|
|
73
94
|
"Avatar",
|
|
@@ -77,14 +98,18 @@ __all__ = [
|
|
|
77
98
|
"ChangeEvent",
|
|
78
99
|
"ChannelSource",
|
|
79
100
|
"Character",
|
|
101
|
+
"CharacterEvent",
|
|
80
102
|
"Client",
|
|
81
103
|
"ContainerEntity",
|
|
82
104
|
"Coordinates",
|
|
83
105
|
"DailySchedule",
|
|
84
106
|
"Destination",
|
|
107
|
+
"DestinationArrival",
|
|
85
108
|
"DestinationShort",
|
|
86
109
|
"DiningEvent",
|
|
110
|
+
"DiningFacilityStatusFilter",
|
|
87
111
|
"DinnerShow",
|
|
112
|
+
"DocId",
|
|
88
113
|
"Entertainment",
|
|
89
114
|
"EntertainmentVenue",
|
|
90
115
|
"Entity",
|
|
@@ -103,6 +128,8 @@ __all__ = [
|
|
|
103
128
|
"MenuItem",
|
|
104
129
|
"MenuPrice",
|
|
105
130
|
"MerchandiseFacility",
|
|
131
|
+
"NameParts",
|
|
132
|
+
"NonDiningFacilityStatusFilter",
|
|
106
133
|
"PartySizeStatus",
|
|
107
134
|
"PhotoPass",
|
|
108
135
|
"PointOfInterest",
|
|
@@ -119,8 +146,11 @@ __all__ = [
|
|
|
119
146
|
"SearchResponse",
|
|
120
147
|
"SearchResult",
|
|
121
148
|
"Spa",
|
|
149
|
+
"SubscriptionFilter",
|
|
122
150
|
"ThemePark",
|
|
123
151
|
"Tour",
|
|
152
|
+
"TransportArrivals",
|
|
153
|
+
"TransportService",
|
|
124
154
|
"Transportation",
|
|
125
155
|
"WaitTime",
|
|
126
156
|
"WalkUpAvailability",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import logging
|
|
2
3
|
from collections.abc import Callable
|
|
3
4
|
|
|
4
5
|
from mousetools._internal.auth.base import LEEWAY_SECONDS, epoch_now
|
|
@@ -6,6 +7,8 @@ from mousetools._internal.config import load_config
|
|
|
6
7
|
from mousetools._internal.transport import Transport
|
|
7
8
|
from mousetools.errors import AuthError
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger("mousetools.network.auth")
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class PublicAuth:
|
|
11
14
|
"""Zero-config guest-token auth (assertion/public grant). The Client binds the
|
|
@@ -62,6 +65,7 @@ class PublicAuth:
|
|
|
62
65
|
if self._transport is None:
|
|
63
66
|
raise AuthError("PublicAuth used before bind(transport)")
|
|
64
67
|
if self._token is not None and self._now() < self._expires_at - LEEWAY_SECONDS:
|
|
68
|
+
logger.debug("reusing cached public token, %.0fs of life left", self._expires_at - self._now())
|
|
65
69
|
return self._token
|
|
66
70
|
async with self._grant_lock:
|
|
67
71
|
# Re-check inside the lock: a concurrent caller may have granted while
|
|
@@ -77,8 +81,10 @@ class PublicAuth:
|
|
|
77
81
|
if resp.status_code is None or resp.status_code >= 400:
|
|
78
82
|
raise AuthError(f"public token grant failed: {resp.status_code} from {self._token_url}")
|
|
79
83
|
body = resp.json()
|
|
84
|
+
expires_in = int(body["expires_in"])
|
|
80
85
|
self._token = body["access_token"]
|
|
81
|
-
self._expires_at = self._now() +
|
|
86
|
+
self._expires_at = self._now() + expires_in
|
|
87
|
+
logger.info("public token refreshed, expires in %.0fs", expires_in)
|
|
82
88
|
return self._token
|
|
83
89
|
|
|
84
90
|
async def headers(self) -> dict[str, str]:
|
|
@@ -17,7 +17,7 @@ from dataclasses import dataclass
|
|
|
17
17
|
from functools import lru_cache
|
|
18
18
|
from importlib.resources import files
|
|
19
19
|
|
|
20
|
-
from
|
|
20
|
+
from Cryptodome.Cipher import AES
|
|
21
21
|
|
|
22
22
|
# AES-GCM appends a 16-byte authentication tag to the ciphertext.
|
|
23
23
|
_GCM_TAG_BYTES = 16
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""Generic DataLoader: coalesce per-tick key loads into batched fetches."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
4
5
|
from collections.abc import Awaitable, Callable
|
|
5
6
|
from typing import Generic, TypeVar
|
|
6
7
|
|
|
7
8
|
from mousetools.errors import NotFound
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger("mousetools.network.loaders")
|
|
11
|
+
|
|
9
12
|
T = TypeVar("T")
|
|
10
13
|
|
|
11
14
|
|
|
@@ -27,6 +30,7 @@ class DataLoader(Generic[T]):
|
|
|
27
30
|
batch_fetch: Callable[[list[str]], Awaitable[dict[str, T]]],
|
|
28
31
|
*,
|
|
29
32
|
max_batch: int = 100,
|
|
33
|
+
name: str | None = None,
|
|
30
34
|
) -> None:
|
|
31
35
|
"""Create a loader around a batch-fetch callable.
|
|
32
36
|
|
|
@@ -36,9 +40,13 @@ class DataLoader(Generic[T]):
|
|
|
36
40
|
max_batch: The largest chunk of keys passed to `batch_fetch` in
|
|
37
41
|
one call; a tick's coalesced keys are split into chunks of
|
|
38
42
|
this size.
|
|
43
|
+
name: Identifies this loader in its log records. Without it,
|
|
44
|
+
several loaders batching concurrently emit indistinguishable
|
|
45
|
+
lines; optional so any construction site still works.
|
|
39
46
|
"""
|
|
40
47
|
self._batch_fetch = batch_fetch
|
|
41
48
|
self._max_batch = max_batch
|
|
49
|
+
self._name = name or "unnamed"
|
|
42
50
|
self._pending: dict[str, asyncio.Future[T]] = {}
|
|
43
51
|
self._flush_scheduled = False
|
|
44
52
|
self._tasks: set[asyncio.Task[None]] = set()
|
|
@@ -83,6 +91,8 @@ class DataLoader(Generic[T]):
|
|
|
83
91
|
"""
|
|
84
92
|
try:
|
|
85
93
|
keys = list(batch)
|
|
94
|
+
chunks = (len(keys) + self._max_batch - 1) // self._max_batch
|
|
95
|
+
logger.debug("batch[%s]: %d key(s) in %d chunk(s)", self._name, len(keys), chunks)
|
|
86
96
|
await asyncio.gather(
|
|
87
97
|
*(
|
|
88
98
|
self._resolve_chunk(keys[start : start + self._max_batch], batch)
|
|
@@ -112,6 +122,12 @@ class DataLoader(Generic[T]):
|
|
|
112
122
|
# settled with the error (its awaiters re-raise it) -- narrowing would
|
|
113
123
|
# leave futures hung forever on an unexpected type. `Exception` (not
|
|
114
124
|
# BaseException) deliberately lets CancelledError propagate.
|
|
125
|
+
logger.debug(
|
|
126
|
+
"batch[%s]: chunk fetch failed (%s); settling %d future(s) with it",
|
|
127
|
+
self._name,
|
|
128
|
+
type(exc).__name__,
|
|
129
|
+
len(chunk),
|
|
130
|
+
)
|
|
115
131
|
for k in chunk:
|
|
116
132
|
if not batch[k].done():
|
|
117
133
|
batch[k].set_exception(exc)
|
|
@@ -15,7 +15,7 @@ from mousetools._internal.services.http import looks_like_bot_block
|
|
|
15
15
|
from mousetools._internal.transport import Transport
|
|
16
16
|
from mousetools.errors import AuthError, BotChallengeError, DisneyServerError, HTTPStatusError, RateLimited
|
|
17
17
|
|
|
18
|
-
logger = logging.getLogger(
|
|
18
|
+
logger = logging.getLogger("mousetools.network.couchbase")
|
|
19
19
|
|
|
20
20
|
_JSON_OBJECT = re.compile(r"\{.*\}", re.DOTALL)
|
|
21
21
|
# Pull the boundary token out of a `multipart/related; boundary=...` Content-Type,
|
|
@@ -43,7 +43,7 @@ def _raise_for_status_code(code: int | None, url: str) -> None:
|
|
|
43
43
|
HTTPStatusError: Any other status code of 400 or higher.
|
|
44
44
|
"""
|
|
45
45
|
if code is None:
|
|
46
|
-
raise DisneyServerError(f"no response from {url}")
|
|
46
|
+
raise DisneyServerError(f"no response from {url}", url=url)
|
|
47
47
|
if code < 400:
|
|
48
48
|
return
|
|
49
49
|
if code == 401:
|
|
@@ -54,7 +54,7 @@ def _raise_for_status_code(code: int | None, url: str) -> None:
|
|
|
54
54
|
if code == 429:
|
|
55
55
|
raise RateLimited(url)
|
|
56
56
|
if code >= 500:
|
|
57
|
-
raise DisneyServerError(f"{code} from {url}")
|
|
57
|
+
raise DisneyServerError(f"{code} from {url}", status=code, url=url)
|
|
58
58
|
raise HTTPStatusError(f"{code} from {url}", status=code, url=url)
|
|
59
59
|
|
|
60
60
|
|
|
@@ -278,6 +278,9 @@ async def bulk_get(
|
|
|
278
278
|
(response-level or per-doc), including a per-doc error other than
|
|
279
279
|
`"not_found"` (e.g. `"forbidden"`/`"unauthorized"`).
|
|
280
280
|
"""
|
|
281
|
+
# `base_url` is constant for the client's life, so it cannot tell two concurrent
|
|
282
|
+
# bulk_gets apart under HTTP/2 multiplexing -- name the ids instead, bounded.
|
|
283
|
+
logger.debug("bulk_get: requesting %d id(s): %s", len(channel_ids), channel_ids[:5])
|
|
281
284
|
resp = await transport.request(
|
|
282
285
|
"POST",
|
|
283
286
|
f"{base_url}/_bulk_get",
|
|
@@ -305,10 +308,20 @@ async def bulk_get(
|
|
|
305
308
|
unexplained = [cid for cid in channel_ids if cid not in out and cid not in reported_missing]
|
|
306
309
|
if unexplained:
|
|
307
310
|
logger.debug(
|
|
308
|
-
"
|
|
311
|
+
"bulk_get: %d requested id(s) resolved to neither a doc nor a not_found: %s",
|
|
309
312
|
len(unexplained),
|
|
310
313
|
unexplained[:5],
|
|
311
314
|
)
|
|
315
|
+
absent = [cid for cid in channel_ids if cid not in out]
|
|
316
|
+
# Names the requested ids AND the absent ones: the former is what pairs this
|
|
317
|
+
# record with its opener under H2 multiplexing, the latter is the diagnosis.
|
|
318
|
+
logger.debug(
|
|
319
|
+
"bulk_get: %d of %d id(s) returned for %s; absent: %s",
|
|
320
|
+
len(out),
|
|
321
|
+
len(channel_ids),
|
|
322
|
+
channel_ids[:5],
|
|
323
|
+
absent[:5],
|
|
324
|
+
)
|
|
312
325
|
return out
|
|
313
326
|
|
|
314
327
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""Couchbase channel-id construction and entity-id parsing (pure string ops)."""
|
|
2
2
|
|
|
3
3
|
from mousetools.enums import ChannelSource
|
|
4
|
+
from mousetools.errors import InvalidFilterError
|
|
5
|
+
|
|
6
|
+
DOCS_URL = "https://caratozzoloxyz.gitlab.io/public/MouseTools/guides/streaming/"
|
|
4
7
|
|
|
5
8
|
_VERSION = "1_0"
|
|
6
9
|
|
|
@@ -11,6 +14,7 @@ SOURCE_MULTI_LANG: dict[str, bool] = {
|
|
|
11
14
|
ChannelSource.TODAY: False,
|
|
12
15
|
ChannelSource.CALENDAR: False,
|
|
13
16
|
ChannelSource.CHARACTERS: False,
|
|
17
|
+
ChannelSource.ARRIVALS: False,
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
# The type-slug segment (e.g. `.attraction.`) is a SEPARATE axis from
|
|
@@ -35,6 +39,13 @@ SOURCE_HAS_TYPE_SLUG: dict[str, bool] = {
|
|
|
35
39
|
# source is currently in both; keep it that way.
|
|
36
40
|
SOURCE_ID_SUFFIX: dict[str, str] = {ChannelSource.CHARACTERS: ".en.us"}
|
|
37
41
|
|
|
42
|
+
# Sources whose channel embeds a compound parent clause (`<id>;entityType=<T>`) BEFORE the
|
|
43
|
+
# version token, shifting `1_0` from parts[2] to parts[3]. arrivals is keyed by resort:
|
|
44
|
+
# wdw.arrivals.<resortId>;entityType=resort.1_0.<busStopId>;entityType=bus-stop
|
|
45
|
+
# DocId.parse skips one extra segment for these. Typed dict[str, bool] like its siblings --
|
|
46
|
+
# DocId.parse looks it up with the raw parsed source string.
|
|
47
|
+
SOURCE_PARENT_KEYED: dict[str, bool] = {ChannelSource.ARRIVALS: True}
|
|
48
|
+
|
|
38
49
|
|
|
39
50
|
_TYPE_CLAUSE = ";entityType="
|
|
40
51
|
|
|
@@ -180,3 +191,78 @@ def parent_channel_id(dest: str, source: ChannelSource, *, multi_language: bool)
|
|
|
180
191
|
if multi_language:
|
|
181
192
|
parts.append("en_us")
|
|
182
193
|
return ".".join(parts)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# facilitystatus is the only source observed to fan out across two channels
|
|
197
|
+
# (corpus-wide check, 2026-08-02); deliberately not a general mechanism.
|
|
198
|
+
def dining_facility_status_channel_id(dest: str) -> str:
|
|
199
|
+
"""The dining-only facilitystatus split channel id.
|
|
200
|
+
|
|
201
|
+
Every facilitystatus document declares the universal channel (`parent_channel_id`)
|
|
202
|
+
PLUS exactly one of the two split channels, so subscribing to this one is a strict
|
|
203
|
+
narrowing of it.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
dest: The destination code (e.g. `"wdw"`).
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The channel id, e.g. `"wdw.diningfacilitystatus.1_0"`.
|
|
210
|
+
"""
|
|
211
|
+
return f"{dest}.diningfacilitystatus.{_VERSION}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def nondining_facility_status_channel_id(dest: str) -> str:
|
|
215
|
+
"""The non-dining facilitystatus split channel id.
|
|
216
|
+
|
|
217
|
+
Every facilitystatus document declares the universal channel (`parent_channel_id`)
|
|
218
|
+
PLUS exactly one of the two split channels, so subscribing to this one is a strict
|
|
219
|
+
narrowing of it.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
dest: The destination code (e.g. `"wdw"`).
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The channel id, e.g. `"wdw.nondiningfacilitystatus.1_0"`.
|
|
226
|
+
"""
|
|
227
|
+
return f"{dest}.nondiningfacilitystatus.{_VERSION}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def parent_keyed_channel_id(dest: str, source: ChannelSource, parent_id: str, parent_type: str) -> str:
|
|
231
|
+
"""Build a channel id whose parent clause precedes the version token.
|
|
232
|
+
|
|
233
|
+
Convention: `{dest}.{source}.{parent_id};entityType={parent_type}.1_0`. Used by
|
|
234
|
+
the `SOURCE_PARENT_KEYED` sources (arrivals, keyed by resort). `parent_id` may
|
|
235
|
+
be bare or already carry its `;entityType=` clause, which must then AGREE with
|
|
236
|
+
`parent_type` -- both a blank `parent_id` and a disagreeing clause would
|
|
237
|
+
otherwise build a syntactically valid channel the gateway silently ignores
|
|
238
|
+
forever, per `SubscriptionFilter`'s rejection principle (verified-wrong input
|
|
239
|
+
raises).
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
dest: The destination code (e.g. `"wdw"`).
|
|
243
|
+
source: The `ChannelSource` (e.g. `ChannelSource.ARRIVALS`).
|
|
244
|
+
parent_id: The parent entity's id, bare or composed.
|
|
245
|
+
parent_type: The parent's entity-type value (e.g. `"resort"`).
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The parent-keyed channel id.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
InvalidFilterError: `parent_id` is blank/whitespace-only, or its existing
|
|
252
|
+
`;entityType=` clause disagrees with `parent_type`.
|
|
253
|
+
"""
|
|
254
|
+
if not parent_id.strip():
|
|
255
|
+
msg = f"parent_id must not be blank. See {DOCS_URL}"
|
|
256
|
+
raise InvalidFilterError(msg)
|
|
257
|
+
if _TYPE_CLAUSE in parent_id:
|
|
258
|
+
clause_type = parent_id.split(_TYPE_CLAUSE, 1)[1]
|
|
259
|
+
if clause_type != parent_type:
|
|
260
|
+
msg = (
|
|
261
|
+
f"parent_id {parent_id!r} carries a conflicting entityType clause; "
|
|
262
|
+
f"expected {parent_type!r}. See {DOCS_URL}"
|
|
263
|
+
)
|
|
264
|
+
raise InvalidFilterError(msg)
|
|
265
|
+
composed = parent_id
|
|
266
|
+
else:
|
|
267
|
+
composed = f"{parent_id}{_TYPE_CLAUSE}{parent_type}"
|
|
268
|
+
return f"{dest}.{source}.{composed}.{_VERSION}"
|
|
@@ -6,10 +6,13 @@ uncompressed payload bytes sent in that direction (absent on ACK frames).
|
|
|
6
6
|
Compression is per-frame through one shared raw-deflate stream per connection.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import struct
|
|
10
11
|
import zlib
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger("mousetools.stream.codec")
|
|
15
|
+
|
|
13
16
|
MSG, RPY, ERR, ACKMSG, ACKRPY = 0, 1, 2, 4, 5
|
|
14
17
|
TYPE_MASK = 0x07
|
|
15
18
|
COMPRESSED = 0x08
|
|
@@ -185,8 +188,12 @@ class FrameDecoder:
|
|
|
185
188
|
flags, pos = read_varint(raw, pos)
|
|
186
189
|
mtype = flags & TYPE_MASK
|
|
187
190
|
if mtype in {ACKMSG, ACKRPY}:
|
|
191
|
+
logger.debug("blip flow-control ack (msg_no=%d, no payload)", msg_no)
|
|
188
192
|
return None # flow-control acks carry no payload we consume in v1
|
|
189
193
|
if len(raw) - pos < 4:
|
|
194
|
+
# R2: adds msg_no/byte-length the exception lacks. Still raises -- the running
|
|
195
|
+
# CRC/inflate state is cumulative, so only a full reconnect recovers from this.
|
|
196
|
+
logger.debug("undecodable blip frame (msg_no=%d, %d bytes): shorter than its CRC trailer", msg_no, len(raw))
|
|
190
197
|
raise CodecError("frame shorter than its CRC trailer")
|
|
191
198
|
chunk = raw[pos:-4]
|
|
192
199
|
if flags & COMPRESSED:
|
|
@@ -194,6 +201,7 @@ class FrameDecoder:
|
|
|
194
201
|
self._in_crc = zlib.crc32(chunk, self._in_crc)
|
|
195
202
|
(expected,) = struct.unpack(">I", raw[-4:])
|
|
196
203
|
if (self._in_crc & 0xFFFFFFFF) != expected:
|
|
204
|
+
logger.debug("undecodable blip frame (msg_no=%d, %d bytes): running CRC32 mismatch", msg_no, len(raw))
|
|
197
205
|
raise CodecError("running CRC32 mismatch: corrupt or desynchronized frame")
|
|
198
206
|
key = (mtype in {RPY, ERR}, msg_no)
|
|
199
207
|
buf = self._pending.setdefault(key, bytearray())
|
|
@@ -7,8 +7,9 @@ queued) sequence. Yields `RawChange`s.
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import contextlib
|
|
10
|
+
import logging
|
|
10
11
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
11
|
-
from typing import TYPE_CHECKING, Protocol, cast
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol, Self, cast
|
|
12
13
|
|
|
13
14
|
import websockets
|
|
14
15
|
|
|
@@ -17,6 +18,8 @@ from mousetools._internal.stream.protocol import BlipProtocol, RawChange
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from websockets.typing import Subprotocol
|
|
19
20
|
|
|
21
|
+
logger = logging.getLogger("mousetools.stream.connection")
|
|
22
|
+
|
|
20
23
|
_SUBPROTOCOLS = ["BLIP_3+CBMobile_3", "BLIP_3+CBMobile_2"]
|
|
21
24
|
_UA = "MouseTools/stream (CouchbaseLite-compatible)"
|
|
22
25
|
|
|
@@ -173,7 +176,7 @@ class BlipConnection:
|
|
|
173
176
|
self._seed_since = since
|
|
174
177
|
self._caught_up_fired = False
|
|
175
178
|
|
|
176
|
-
async def __aenter__(self) ->
|
|
179
|
+
async def __aenter__(self) -> Self:
|
|
177
180
|
"""Open the socket and start the receive loop.
|
|
178
181
|
|
|
179
182
|
Returns:
|
|
@@ -194,8 +197,10 @@ class BlipConnection:
|
|
|
194
197
|
with contextlib.suppress(asyncio.CancelledError, StreamError):
|
|
195
198
|
await self._task
|
|
196
199
|
if self._ws is not None:
|
|
197
|
-
|
|
200
|
+
try:
|
|
198
201
|
await self._ws.close()
|
|
202
|
+
except Exception as close_exc: # noqa: BLE001 -- teardown must not mask the real error
|
|
203
|
+
logger.debug("error closing stream socket for %s: %s", self._url, close_exc)
|
|
199
204
|
|
|
200
205
|
def __aiter__(self) -> AsyncIterator[RawChange]:
|
|
201
206
|
"""Iterate change records as they arrive.
|
|
@@ -239,7 +244,17 @@ class BlipConnection:
|
|
|
239
244
|
attempt = self._bump_attempt(attempt)
|
|
240
245
|
if attempt > self._max_retries:
|
|
241
246
|
raise StreamError(f"stream reconnect exhausted after {attempt} attempts") from exc
|
|
242
|
-
|
|
247
|
+
delay = min(2**attempt, 30)
|
|
248
|
+
logger.warning(
|
|
249
|
+
"stream session on %s failed (%s: %s); reconnect %d/%d in %.0fs",
|
|
250
|
+
self._url,
|
|
251
|
+
type(exc).__name__,
|
|
252
|
+
exc,
|
|
253
|
+
attempt,
|
|
254
|
+
self._max_retries,
|
|
255
|
+
delay,
|
|
256
|
+
)
|
|
257
|
+
await asyncio.sleep(delay)
|
|
243
258
|
continue
|
|
244
259
|
# `_session()` returned cleanly -- real `websockets` does this on a server
|
|
245
260
|
# 1000/1001 close. Must back off exactly like the except path above: without
|
|
@@ -248,7 +263,15 @@ class BlipConnection:
|
|
|
248
263
|
attempt = self._bump_attempt(attempt)
|
|
249
264
|
if attempt > self._max_retries:
|
|
250
265
|
raise StreamError(f"stream reconnect exhausted after {attempt} attempts")
|
|
251
|
-
|
|
266
|
+
delay = min(2**attempt, 30)
|
|
267
|
+
logger.warning(
|
|
268
|
+
"stream on %s closed by server; reconnect %d/%d in %.0fs",
|
|
269
|
+
self._url,
|
|
270
|
+
attempt,
|
|
271
|
+
self._max_retries,
|
|
272
|
+
delay,
|
|
273
|
+
)
|
|
274
|
+
await asyncio.sleep(delay)
|
|
252
275
|
|
|
253
276
|
def _bump_attempt(self, attempt: int) -> int:
|
|
254
277
|
"""Reset `attempt` on a healthy flap (this session delivered data), else bump it.
|
|
@@ -261,6 +284,7 @@ class BlipConnection:
|
|
|
261
284
|
flap), else `attempt + 1`.
|
|
262
285
|
"""
|
|
263
286
|
if self._progress: # delivered data this session: treat as a healthy flap
|
|
287
|
+
logger.debug("stream on %s flapped after delivering data; attempt counter reset", self._url)
|
|
264
288
|
return 1
|
|
265
289
|
return attempt + 1
|
|
266
290
|
|
|
@@ -269,6 +293,7 @@ class BlipConnection:
|
|
|
269
293
|
ws = await self._connector(self._url, headers, _SUBPROTOCOLS)
|
|
270
294
|
self._ws = ws
|
|
271
295
|
since = self._seed_since if (self._seed_since is not None and self._last_seq == "0") else self._last_seq
|
|
296
|
+
logger.debug("stream connected to %s (channels=%r, since=%s)", self._url, self._channels, since)
|
|
272
297
|
proto = BlipProtocol(channels=self._channels, since=since, live_forward=self._live_forward)
|
|
273
298
|
try:
|
|
274
299
|
for frame in proto.start():
|
|
@@ -288,5 +313,7 @@ class BlipConnection:
|
|
|
288
313
|
# would spin forever without ever yielding to the event loop.
|
|
289
314
|
await asyncio.sleep(0)
|
|
290
315
|
finally:
|
|
291
|
-
|
|
316
|
+
try:
|
|
292
317
|
await ws.close()
|
|
318
|
+
except Exception as exc: # noqa: BLE001 -- teardown must not mask the real error
|
|
319
|
+
logger.debug("error closing stream socket for %s: %s", self._url, exc)
|
|
@@ -6,11 +6,16 @@ NoReply frame makes Sync Gateway close the connection).
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import logging
|
|
9
10
|
from dataclasses import dataclass, field
|
|
10
11
|
|
|
11
12
|
from mousetools._internal.stream import codec
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger("mousetools.stream.protocol")
|
|
15
|
+
|
|
13
16
|
_CKPT_CLIENT = "mousetools-stream-v1"
|
|
17
|
+
# Longest channel CSV rendered whole in a log record before it is summarised.
|
|
18
|
+
_LABEL_MAX = 48
|
|
14
19
|
# go-blip default order of magnitude; tune down if a live replay backfill stalls (see Task-9).
|
|
15
20
|
_ACK_INTERVAL = 50_000
|
|
16
21
|
|
|
@@ -22,7 +27,7 @@ class RawChange:
|
|
|
22
27
|
This is what `Subscription` yields when opened with `raw=True` (see
|
|
23
28
|
`Client.stream`/`Client.subscribe`/`Destination.subscribe`/`Entity.stream`):
|
|
24
29
|
every field the wire protocol carries, with no `ChangeEvent` parsing applied.
|
|
25
|
-
Split `doc_id` yourself via `mousetools.stream.events.
|
|
30
|
+
Split `doc_id` yourself via `mousetools.stream.events.DocId.parse`, or use
|
|
26
31
|
the default `raw=False` stream if you want that (and value-object extraction)
|
|
27
32
|
done for you.
|
|
28
33
|
|
|
@@ -51,6 +56,29 @@ class ProtocolOutput:
|
|
|
51
56
|
caught_up: bool = False
|
|
52
57
|
|
|
53
58
|
|
|
59
|
+
def _label_for(channels: str) -> str:
|
|
60
|
+
"""Build the short subscription identifier carried by this protocol's log records.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
channels: The CSV of Sync Gateway channels this subscription filters to;
|
|
64
|
+
empty means the whole bucket.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A bounded identifier: the CSV itself when short, else its first channel
|
|
68
|
+
plus a count of the rest.
|
|
69
|
+
"""
|
|
70
|
+
if not channels:
|
|
71
|
+
return "all-channels"
|
|
72
|
+
if len(channels) <= _LABEL_MAX:
|
|
73
|
+
return channels
|
|
74
|
+
first, sep, rest = channels.partition(",")
|
|
75
|
+
# A single over-long channel has no comma, so `first` is the whole string and
|
|
76
|
+
# there is no "rest" to count -- truncate it rather than claiming a phantom peer.
|
|
77
|
+
if not sep:
|
|
78
|
+
return f"{first[:_LABEL_MAX]}..."
|
|
79
|
+
return f"{first}+{rest.count(',') + 1} more"
|
|
80
|
+
|
|
81
|
+
|
|
54
82
|
class BlipProtocol:
|
|
55
83
|
"""Drives one continuous, live-forward pull subscription."""
|
|
56
84
|
|
|
@@ -65,6 +93,7 @@ class BlipProtocol:
|
|
|
65
93
|
backfill from `since` is exhausted (replay-only).
|
|
66
94
|
"""
|
|
67
95
|
self._channels = channels
|
|
96
|
+
self._label = _label_for(channels)
|
|
68
97
|
self._since = since
|
|
69
98
|
self._live_forward = live_forward
|
|
70
99
|
self._enc = codec.FrameEncoder()
|
|
@@ -79,6 +108,7 @@ class BlipProtocol:
|
|
|
79
108
|
def _request(self, props: dict[str, str], body: bytes = b"") -> bytes:
|
|
80
109
|
self._out_no += 1
|
|
81
110
|
self._my_reqs[self._out_no] = props.get("Profile", "")
|
|
111
|
+
logger.debug("blip[%s] -> %s (msg_no=%d)", self._label, props.get("Profile", "?"), self._out_no)
|
|
82
112
|
return self._enc.frame(codec.MSG, self._out_no, props, body)
|
|
83
113
|
|
|
84
114
|
def start(self) -> list[bytes]:
|
|
@@ -141,6 +171,8 @@ class BlipProtocol:
|
|
|
141
171
|
self._on_changes(msg, out)
|
|
142
172
|
elif msg.mtype == codec.MSG and msg.props.get("Profile") == "rev":
|
|
143
173
|
self._on_rev(msg, out)
|
|
174
|
+
if out.caught_up:
|
|
175
|
+
logger.debug("blip[%s] caught up: replay backfill drained", self._label)
|
|
144
176
|
return out
|
|
145
177
|
|
|
146
178
|
def _on_response(self, req: str, msg: codec.Message, out: ProtocolOutput) -> None:
|
|
@@ -159,6 +191,9 @@ class BlipProtocol:
|
|
|
159
191
|
try:
|
|
160
192
|
rows = json.loads(msg.body) if msg.body.strip() else []
|
|
161
193
|
except ValueError:
|
|
194
|
+
logger.debug(
|
|
195
|
+
"blip[%s] dropping changes frame with an unparseable body (msg_no=%d)", self._label, msg.msg_no
|
|
196
|
+
)
|
|
162
197
|
rows = []
|
|
163
198
|
if not rows:
|
|
164
199
|
if not self._caught_up:
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
2
4
|
from collections.abc import Mapping
|
|
3
5
|
from typing import Any
|
|
4
6
|
|
|
@@ -6,6 +8,8 @@ import niquests
|
|
|
6
8
|
|
|
7
9
|
from mousetools.errors import TransportError
|
|
8
10
|
|
|
11
|
+
logger = logging.getLogger("mousetools.network.transport")
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
class Transport:
|
|
11
15
|
"""Async HTTP transport. The `utls` extra gives the session a Chrome
|
|
@@ -49,7 +53,13 @@ class Transport:
|
|
|
49
53
|
TransportError: If the underlying HTTP client cannot complete the
|
|
50
54
|
request at all (dropped connection, timeout, too many redirects).
|
|
51
55
|
"""
|
|
56
|
+
if self._sema.locked():
|
|
57
|
+
# A saturated semaphore looks exactly like a hung request from the
|
|
58
|
+
# outside; say so rather than leaving a silent gap in the trace.
|
|
59
|
+
logger.debug("transport at concurrency limit; %s %s queued", method, url)
|
|
52
60
|
async with self._sema:
|
|
61
|
+
logger.debug("-> %s %s", method, url)
|
|
62
|
+
started = time.monotonic()
|
|
53
63
|
try:
|
|
54
64
|
resp = await self._session.request(
|
|
55
65
|
method,
|
|
@@ -70,6 +80,7 @@ class Transport:
|
|
|
70
80
|
await self._session.gather(resp)
|
|
71
81
|
except niquests.exceptions.RequestException as exc:
|
|
72
82
|
raise TransportError(f"request to {url} failed: {exc}", url=url) from exc
|
|
83
|
+
logger.debug("<- %s %s %s (%.0f ms)", method, url, resp.status_code, (time.monotonic() - started) * 1000)
|
|
73
84
|
return resp
|
|
74
85
|
|
|
75
86
|
async def aclose(self) -> None:
|