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.
Files changed (65) hide show
  1. {mousetools-6.0.0rc2 → mousetools-6.1.0}/PKG-INFO +5 -2
  2. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/__init__.py +32 -2
  3. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/public.py +7 -1
  4. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/config.py +1 -1
  5. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/loaders.py +16 -0
  6. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/couchbase.py +17 -4
  7. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/ids.py +86 -0
  8. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/codec.py +8 -0
  9. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/connection.py +33 -6
  10. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/protocol.py +36 -1
  11. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/transport.py +11 -0
  12. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/client.py +147 -34
  13. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/__init__.py +10 -0
  14. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/base.py +32 -17
  15. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/character.py +3 -6
  16. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/destination.py +46 -6
  17. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/facility.py +2 -0
  18. mousetools-6.1.0/mousetools/entities/names.py +155 -0
  19. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/protocols.py +28 -5
  20. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/types.py +3 -0
  21. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/values.py +155 -6
  22. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/enums.py +7 -0
  23. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/errors.py +37 -0
  24. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/ext/passes.py +1 -1
  25. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/ext/weather.py +11 -1
  26. mousetools-6.1.0/mousetools/stream/__init__.py +33 -0
  27. mousetools-6.1.0/mousetools/stream/events.py +274 -0
  28. mousetools-6.1.0/mousetools/stream/filters.py +308 -0
  29. mousetools-6.1.0/mousetools/stream/subscription.py +248 -0
  30. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/PKG-INFO +5 -2
  31. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/SOURCES.txt +2 -0
  32. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/requires.txt +4 -1
  33. {mousetools-6.0.0rc2 → mousetools-6.1.0}/pyproject.toml +26 -5
  34. mousetools-6.0.0rc2/mousetools/stream/__init__.py +0 -5
  35. mousetools-6.0.0rc2/mousetools/stream/events.py +0 -158
  36. mousetools-6.0.0rc2/mousetools/stream/subscription.py +0 -165
  37. {mousetools-6.0.0rc2 → mousetools-6.1.0}/LICENSE +0 -0
  38. {mousetools-6.0.0rc2 → mousetools-6.1.0}/README.md +0 -0
  39. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/__init__.py +0 -0
  40. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/__init__.py +0 -0
  41. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/base.py +0 -0
  42. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/auth/syncgateway.py +0 -0
  43. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/client_registry.py +0 -0
  44. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/__init__.py +0 -0
  45. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/http.py +0 -0
  46. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/menu.py +0 -0
  47. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/services/search.py +0 -0
  48. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/_internal/stream/__init__.py +0 -0
  49. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/attraction.py +0 -0
  50. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/basic.py +0 -0
  51. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/container.py +0 -0
  52. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/entertainment.py +0 -0
  53. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/menu.py +0 -0
  54. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/mixins.py +0 -0
  55. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/poi.py +0 -0
  56. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/restaurant.py +0 -0
  57. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/search.py +0 -0
  58. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/entities/themepark.py +0 -0
  59. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/ext/__init__.py +0 -0
  60. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/py.typed +0 -0
  61. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/resources/environments.bin +0 -0
  62. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools/timezones.py +0 -0
  63. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/dependency_links.txt +0 -0
  64. {mousetools-6.0.0rc2 → mousetools-6.1.0}/mousetools.egg-info/top_level.txt +0 -0
  65. {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.0rc2
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: pycryptodome>=3.22.0
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 CalendarEvent, ChangeEvent, FacilityStatusEvent, ForecastEvent, RawChange
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(__name__)
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() + int(body["expires_in"])
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 Crypto.Cipher import AES
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(__name__)
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
- "_bulk_get: %d requested id(s) resolved to neither a doc nor a not_found: %s",
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) -> "BlipConnection":
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
- with contextlib.suppress(Exception):
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
- await asyncio.sleep(min(2**attempt, 30))
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
- await asyncio.sleep(min(2**attempt, 30))
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
- with contextlib.suppress(Exception):
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.parse_doc_id`, or use
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: