MouseTools 6.1.0__tar.gz → 6.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 (66) hide show
  1. {mousetools-6.1.0 → mousetools-6.2.0}/PKG-INFO +2 -1
  2. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/__init__.py +6 -0
  3. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/ids.py +146 -1
  4. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/protocol.py +1 -5
  5. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/client.py +5 -14
  6. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/__init__.py +5 -4
  7. mousetools-6.2.0/mousetools/entities/_parse.py +177 -0
  8. mousetools-6.2.0/mousetools/entities/arrivals.py +143 -0
  9. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/base.py +90 -18
  10. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/entertainment.py +0 -1
  11. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/facility.py +1 -7
  12. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/mixins.py +2 -1
  13. mousetools-6.2.0/mousetools/entities/offline.py +204 -0
  14. mousetools-6.2.0/mousetools/entities/related.py +151 -0
  15. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/restaurant.py +2 -1
  16. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/values.py +26 -303
  17. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/errors.py +12 -0
  18. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/events.py +3 -84
  19. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/PKG-INFO +2 -1
  20. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/SOURCES.txt +4 -0
  21. {mousetools-6.1.0 → mousetools-6.2.0}/pyproject.toml +38 -13
  22. {mousetools-6.1.0 → mousetools-6.2.0}/LICENSE +0 -0
  23. {mousetools-6.1.0 → mousetools-6.2.0}/README.md +0 -0
  24. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/__init__.py +0 -0
  25. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/__init__.py +0 -0
  26. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/base.py +0 -0
  27. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/public.py +0 -0
  28. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/syncgateway.py +0 -0
  29. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/client_registry.py +0 -0
  30. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/config.py +0 -0
  31. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/loaders.py +0 -0
  32. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/__init__.py +0 -0
  33. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/couchbase.py +0 -0
  34. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/http.py +0 -0
  35. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/menu.py +0 -0
  36. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/search.py +0 -0
  37. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/__init__.py +0 -0
  38. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/codec.py +0 -0
  39. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/connection.py +0 -0
  40. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/transport.py +0 -0
  41. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/attraction.py +0 -0
  42. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/basic.py +0 -0
  43. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/character.py +0 -0
  44. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/container.py +0 -0
  45. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/destination.py +0 -0
  46. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/menu.py +0 -0
  47. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/names.py +0 -0
  48. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/poi.py +0 -0
  49. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/protocols.py +0 -0
  50. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/search.py +0 -0
  51. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/themepark.py +0 -0
  52. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/types.py +0 -0
  53. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/enums.py +0 -0
  54. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/ext/__init__.py +0 -0
  55. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/ext/passes.py +0 -0
  56. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/ext/weather.py +0 -0
  57. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/py.typed +0 -0
  58. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/resources/environments.bin +0 -0
  59. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/__init__.py +0 -0
  60. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/filters.py +0 -0
  61. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/subscription.py +0 -0
  62. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/timezones.py +0 -0
  63. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/dependency_links.txt +0 -0
  64. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/requires.txt +0 -0
  65. {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/top_level.txt +0 -0
  66. {mousetools-6.1.0 → mousetools-6.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mousetools
3
- Version: 6.1.0
3
+ Version: 6.2.0
4
4
  Summary: An unofficial Python wrapper for Disney's API
5
5
  Author: Scott Caratozzolo
6
6
  License-Expression: MIT
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
15
16
  Classifier: Operating System :: OS Independent
16
17
  Classifier: Typing :: Typed
17
18
  Requires-Python: <4.0,>=3.11
@@ -44,6 +44,9 @@ from mousetools.entities import (
44
44
  PriceRange,
45
45
  Recreation,
46
46
  RecreationActivity,
47
+ RelatedAncestor,
48
+ RelatedCoordinate,
49
+ RelatedLocation,
47
50
  Resort,
48
51
  ResortArea,
49
52
  Restaurant,
@@ -137,6 +140,9 @@ __all__ = [
137
140
  "RawChange",
138
141
  "Recreation",
139
142
  "RecreationActivity",
143
+ "RelatedAncestor",
144
+ "RelatedCoordinate",
145
+ "RelatedLocation",
140
146
  "Resort",
141
147
  "ResortArea",
142
148
  "Restaurant",
@@ -1,8 +1,14 @@
1
- """Couchbase channel-id construction and entity-id parsing (pure string ops)."""
1
+ """Couchbase channel-id construction, and entity-id and doc-id parsing."""
2
+
3
+ import logging
4
+ import re
5
+ from dataclasses import dataclass
2
6
 
3
7
  from mousetools.enums import ChannelSource
4
8
  from mousetools.errors import InvalidFilterError
5
9
 
10
+ logger = logging.getLogger("mousetools.stream.ids")
11
+
6
12
  DOCS_URL = "https://caratozzoloxyz.gitlab.io/public/MouseTools/guides/streaming/"
7
13
 
8
14
  _VERSION = "1_0"
@@ -266,3 +272,142 @@ def parent_keyed_channel_id(dest: str, source: ChannelSource, parent_id: str, pa
266
272
  else:
267
273
  composed = f"{parent_id}{_TYPE_CLAUSE}{parent_type}"
268
274
  return f"{dest}.{source}.{composed}.{_VERSION}"
275
+
276
+
277
+ _VERSION_SEGMENT = re.compile(r"^\d+_\d+$")
278
+ # Matched by shape, not a per-source table, so new versions need no table update.
279
+ # Case-insensitive because wire ids have been seen with inconsistent casing (#32).
280
+ _LANG_SEGMENT = re.compile(r"^[a-z]{2}_[a-z]{2}$", re.IGNORECASE)
281
+
282
+
283
+ @dataclass(frozen=True, slots=True)
284
+ class DocId:
285
+ """The parsed components of a sync-gateway document id.
286
+
287
+ Every field is the raw wire string, never a coerced enum -- `build_event` does
288
+ that conversion. A doc id is `{destination}.{source}.{version}[.{lang}][.{type-slug}].{entity}[{suffix}]`,
289
+ where the language may appear EITHER as a leading segment (`en_us`) or as a
290
+ trailing suffix (`.en.us`), depending on the source's schema version. A
291
+ parent-keyed source inserts a composed `{parent}` segment before `{version}`,
292
+ reported as `parent_id` with extra clauses dropped. `parent_id` is None for
293
+ every other source, and for a parent segment that isn't a complete
294
+ `<id>;entityType=<type>` clause or isn't followed by a `<major>_<minor>`
295
+ version (`version` itself is reported as found).
296
+ """
297
+
298
+ destination: str | None
299
+ source: str
300
+ version: str | None
301
+ language: str | None
302
+ entity_id: str | None
303
+ entity_type: str | None
304
+ parent_id: str | None = None
305
+
306
+ @classmethod
307
+ def parse(cls, doc_id: str) -> "DocId":
308
+ """Parse a sync-gateway document id into its components.
309
+
310
+ Compound ids (`A;entityType=X:B;entityType=Y`) describe the PRIMARY half
311
+ only; extra clauses such as `;destination=wdw` are ignored. A parent-keyed
312
+ source (`SOURCE_PARENT_KEYED`, e.g. `arrivals`) embeds a composed parent
313
+ id before the version token, shifting every later segment by one; it is
314
+ reported as `parent_id` (e.g. `"80010388;entityType=resort"`).
315
+
316
+ Args:
317
+ doc_id: e.g. `wdw.facilities.1_0.en_us.attraction.80010177;entityType=Attraction`.
318
+
319
+ Returns:
320
+ The parsed `DocId`; every component is `None` when absent.
321
+ """
322
+ parts = doc_id.split(".")
323
+ source = parts[1] if len(parts) > 1 else ""
324
+ parent_keyed = SOURCE_PARENT_KEYED.get(source, False)
325
+ version_idx = 3 if parent_keyed else 2
326
+ parent_id = _parent_id(parts) if parent_keyed else None
327
+ version = parts[version_idx] if len(parts) > version_idx else None
328
+ idx = version_idx + 1
329
+
330
+ language: str | None = None
331
+ if idx < len(parts) and _LANG_SEGMENT.match(parts[idx]):
332
+ language = parts[idx].casefold()
333
+ idx += 1
334
+ if SOURCE_HAS_TYPE_SLUG.get(source, False):
335
+ idx += 1
336
+
337
+ tail = ".".join(parts[idx:]) if len(parts) > idx else ""
338
+ suffix = SOURCE_ID_SUFFIX.get(source, "")
339
+ if suffix and tail.casefold().endswith(suffix.casefold()):
340
+ tail = strip_id_suffix(tail, suffix)
341
+ language = language or suffix.lstrip(".").replace(".", "_").casefold()
342
+
343
+ primary, _ = split_compound(tail)
344
+ segments = primary.split(";")
345
+ entity_type = _type_clause(primary)
346
+ return cls(
347
+ # `"".split(".")` is `[""]`, so parts[0] is always present but may be empty.
348
+ destination=parts[0] or None,
349
+ source=source,
350
+ version=version or None,
351
+ language=language,
352
+ entity_id=segments[0] or None,
353
+ entity_type=entity_type,
354
+ parent_id=parent_id,
355
+ )
356
+
357
+
358
+ def parent_from_channel_id(channel: str) -> str | None:
359
+ """Extract the composed parent id from a parent-keyed channel id.
360
+
361
+ The inverse of `parent_keyed_channel_id`:
362
+ `"wdw.arrivals.80010388;entityType=resort.1_0"` -> `"80010388;entityType=resort"`.
363
+ Also accepts a full parent-keyed doc id, which shares the channel's prefix.
364
+
365
+ Args:
366
+ channel: A sync-gateway channel id.
367
+
368
+ Returns:
369
+ The composed parent id, or None if `channel` is not a parent-keyed
370
+ channel, has no `<major>_<minor>` version segment, or its parent is not a complete
371
+ `<id>;entityType=<type>` clause.
372
+ """
373
+ return DocId.parse(channel).parent_id
374
+
375
+
376
+ def _parent_id(parts: list[str]) -> str | None:
377
+ """The composed parent id from a parent-keyed id's dot-split segments, or None.
378
+
379
+ Args:
380
+ parts: The id split on `.`: `[dest, source, parent, version, ...]`.
381
+
382
+ Returns:
383
+ `<id>;entityType=<type>` from the parent segment, with any extra clauses or
384
+ compound half dropped, when it is followed by a `<major>_<minor>` version
385
+ segment; else None, logged at DEBUG.
386
+ """
387
+ if len(parts) < 4 or not _VERSION_SEGMENT.match(parts[3]):
388
+ logger.debug("parent-keyed id %r has no version segment after its parent", ".".join(parts))
389
+ return None
390
+ primary, _ = split_compound(parts[2])
391
+ numeric = primary.split(";", 1)[0]
392
+ entity_type = _type_clause(primary)
393
+ if not numeric or not entity_type:
394
+ logger.debug("parent-keyed id %r has an incomplete parent %r", ".".join(parts), parts[2])
395
+ return None
396
+ return f"{numeric}{_TYPE_CLAUSE}{entity_type}"
397
+
398
+
399
+ def _type_clause(primary: str) -> str | None:
400
+ """The value of the `entityType=` clause in a simple id, wherever it appears.
401
+
402
+ Args:
403
+ primary: A simple (non-compound) id such as `"1;destination=wdw;entityType=resort"`.
404
+
405
+ Returns:
406
+ The last `entityType=` value among the id's clauses, or None if it has none
407
+ or it is empty.
408
+ """
409
+ entity_type: str | None = None
410
+ for segment in primary.split(";")[1:]:
411
+ if segment.startswith("entityType="):
412
+ entity_type = segment.split("=", 1)[1]
413
+ return entity_type or None
@@ -101,7 +101,6 @@ class BlipProtocol:
101
101
  self._out_no = 0
102
102
  self._my_reqs: dict[int, str] = {}
103
103
  self._caught_up = False
104
- self.last_seq = since
105
104
  self._recv_bytes: dict[tuple[bool, int], int] = {}
106
105
  self._acked: dict[tuple[bool, int], int] = {}
107
106
 
@@ -210,9 +209,6 @@ class BlipProtocol:
210
209
  def _on_rev(self, msg: codec.Message, out: ProtocolOutput) -> None:
211
210
  if not msg.noreply:
212
211
  out.frames.append(self._enc.frame(codec.RPY, msg.msg_no, {}, b""))
213
- seq = msg.props.get("sequence", "")
214
- if seq:
215
- self.last_seq = seq
216
212
  try:
217
213
  body = json.loads(msg.body) if msg.body else {}
218
214
  except ValueError:
@@ -221,7 +217,7 @@ class BlipProtocol:
221
217
  RawChange(
222
218
  doc_id=msg.props.get("id", ""),
223
219
  rev=msg.props.get("rev", ""),
224
- sequence=seq,
220
+ sequence=msg.props.get("sequence", ""),
225
221
  deleted=msg.props.get("deleted") == "1",
226
222
  body=body if isinstance(body, dict) else {},
227
223
  )
@@ -553,20 +553,17 @@ class Client:
553
553
  inflight.pop(key, None)
554
554
  return await fut
555
555
 
556
- async def _changes_feed(self, source: ChannelSource, *, refresh: bool = False) -> dict:
556
+ async def _changes_feed(self, source: ChannelSource) -> dict:
557
557
  """The `_changes` feed for this destination's `source` parent channel, cached.
558
558
 
559
559
  The feed is identical for every parent/type query in a destination
560
560
  (filtering is client-side), so it is cached per parent channel id with
561
561
  a `SOURCE_CADENCE[source]`-derived TTL and single-flighted through
562
562
  `_coalesce`: concurrent callers share one pull instead of each
563
- re-enumerating the full feed. `refresh=True` evicts the cached entry and
564
- falls through to that same shared pull -- so a refresh racing an in-flight
565
- pull joins it rather than starting a redundant second enumeration.
563
+ re-enumerating the full feed.
566
564
 
567
565
  Args:
568
566
  source: The `ChannelSource` whose parent-channel feed to fetch.
569
- refresh: Force a re-fetch even if the cached feed is still fresh.
570
567
 
571
568
  Returns:
572
569
  The parsed `_changes` feed (`{"results": [...], "last_seq": ...}`).
@@ -578,15 +575,9 @@ class Client:
578
575
  """
579
576
  parent_cid = ids.parent_channel_id(self.destination.value, source, multi_language=ids.SOURCE_MULTI_LANG[source])
580
577
  now = time.time()
581
- if refresh:
582
- # Drop the cached entry and fall through to the shared single-flight,
583
- # mirroring _evict_if_stale -> _fetch_shard. Deliberately NOT a second
584
- # pull: if one is already in flight it IS a fresh read, so join it.
585
- self._feed_cache.pop(parent_cid, None)
586
- else:
587
- cached = self._feed_cache.get(parent_cid)
588
- if cached is not None and cached[0] > now:
589
- return cached[1]
578
+ cached = self._feed_cache.get(parent_cid)
579
+ if cached is not None and cached[0] > now:
580
+ return cached[1]
590
581
 
591
582
  async def _pull() -> dict:
592
583
  headers = await self._sync_headers()
@@ -1,5 +1,6 @@
1
1
  """Domain entities and value objects."""
2
2
 
3
+ from mousetools.entities.arrivals import ArrivalPrediction, DestinationArrival, TransportArrivals, TransportService
3
4
  from mousetools.entities.attraction import Attraction
4
5
  from mousetools.entities.base import Entity, entity_for, load_many
5
6
  from mousetools.entities.basic import EntertainmentVenue, Land, Resort, ResortArea, WaterPark
@@ -11,6 +12,7 @@ from mousetools.entities.facility import FacilityEntity
11
12
  from mousetools.entities.menu import Menu, MenuCollection, MenuGroup, MenuItem, MenuPrice, PriceRange
12
13
  from mousetools.entities.names import NameParts
13
14
  from mousetools.entities.poi import PointOfInterest
15
+ from mousetools.entities.related import RelatedAncestor, RelatedCoordinate, RelatedLocation
14
16
  from mousetools.entities.restaurant import Restaurant
15
17
  from mousetools.entities.search import SearchResponse, SearchResult
16
18
  from mousetools.entities.themepark import ThemePark
@@ -33,17 +35,13 @@ from mousetools.entities.types import (
33
35
  )
34
36
  from mousetools.entities.values import (
35
37
  Appearance,
36
- ArrivalPrediction,
37
38
  CalendarSchedule,
38
39
  Coordinates,
39
40
  DailySchedule,
40
- DestinationArrival,
41
41
  Forecast,
42
42
  ForecastPoint,
43
43
  PartySizeStatus,
44
44
  ScheduleSegment,
45
- TransportArrivals,
46
- TransportService,
47
45
  WaitTime,
48
46
  WalkUpAvailability,
49
47
  )
@@ -90,6 +88,9 @@ __all__ = [
90
88
  "PriceRange",
91
89
  "Recreation",
92
90
  "RecreationActivity",
91
+ "RelatedAncestor",
92
+ "RelatedCoordinate",
93
+ "RelatedLocation",
93
94
  "Resort",
94
95
  "ResortArea",
95
96
  "Restaurant",
@@ -0,0 +1,177 @@
1
+ """Lenient coercers shared by the value objects: bad input degrades (to None, or [] for lists), never raises.
2
+
3
+ Present-but-unusable input logs at DEBUG; absent input (None) stays silent.
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ import reprlib
9
+ from datetime import datetime, time
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from dateutil.parser import isoparse
13
+
14
+ # Kept under the name these coercers logged as before moving out of values.py.
15
+ logger = logging.getLogger("mousetools.entities.values")
16
+
17
+ _PLAIN_INT = re.compile(r"^-?[0-9]+$")
18
+ _REPR = reprlib.Repr() # constructor keywords are 3.12+; attributes work on 3.11
19
+ _REPR.maxstring = _REPR.maxother = 80
20
+ _REPR.maxlist = _REPR.maxdict = 5
21
+
22
+
23
+ def clip(value: object) -> str:
24
+ """A length-capped repr of `value` for DEBUG lines about bad input.
25
+
26
+ Args:
27
+ value: The value to describe.
28
+
29
+ Returns:
30
+ `repr(value)`, shortened with `...` when long.
31
+ """
32
+ return _REPR.repr(value)
33
+
34
+
35
+ def parse_dt(value: object, tz: ZoneInfo) -> datetime | None:
36
+ """Parse an ISO-8601 string to a tz-aware datetime in `tz`.
37
+
38
+ Converts (astimezone), never relabels — fixes v5's `.replace(tzinfo=...)` bug.
39
+ Empty/invalid -> None.
40
+
41
+ Args:
42
+ value: The raw ISO-8601 string from a couchbase doc, or None/empty.
43
+ tz: Timezone the parsed datetime is converted into.
44
+
45
+ Returns:
46
+ The tz-aware datetime, or None if `value` is absent or unparseable.
47
+ """
48
+ if not value:
49
+ return None
50
+ if not isinstance(value, str):
51
+ logger.debug("unparseable datetime %r (not a string)", value)
52
+ return None
53
+ try:
54
+ return isoparse(value).astimezone(tz)
55
+ except (ValueError, TypeError) as exc:
56
+ logger.debug("unparseable datetime %r (%s)", value, exc)
57
+ return None
58
+
59
+
60
+ def parse_duration(value: object) -> time | None:
61
+ """Parse a facilities `duration` (`"HH:MM:SS"`) to a `time`, or None if absent/invalid.
62
+
63
+ Args:
64
+ value: The raw `"HH:MM:SS"` duration string, or None/empty.
65
+
66
+ Returns:
67
+ The parsed time, or None if `value` is absent or unparseable.
68
+ """
69
+ if not value:
70
+ return None
71
+ if not isinstance(value, str):
72
+ logger.debug("unparseable duration %r (not a string)", value)
73
+ return None
74
+ try:
75
+ return time.fromisoformat(value)
76
+ except (ValueError, TypeError) as exc:
77
+ logger.debug("unparseable duration %r (%s)", value, exc)
78
+ return None
79
+
80
+
81
+ def parse_status(value: object) -> str | None:
82
+ """A facility-status doc's `status` string, or None if absent/non-string.
83
+
84
+ Plain type coercion, not a leak guard: `couchbase.bulk_get` already drops a
85
+ not-found error part (`{"error": "not_found", "status": 404, ...}`) at the
86
+ fetch boundary, so a genuinely-absent `facilitystatus` doc reaches here as
87
+ `None` (via `WaitTime.from_doc`'s / the `Field` transform's `not doc` short
88
+ circuit), never as that error body's numeric `status: 404`. This still
89
+ guards defensively against any doc whose `status` key is present but
90
+ non-string for some other reason -- a genuine status is always a string, so
91
+ anything else is treated as absent.
92
+
93
+ Args:
94
+ value: The raw `status` value from a couchbase doc.
95
+
96
+ Returns:
97
+ `value` unchanged if it's a string, else None.
98
+ """
99
+ return as_str(value)
100
+
101
+
102
+ def as_float(value: object) -> float | None:
103
+ """Coerce a couchbase string/number to float, or None if absent/invalid.
104
+
105
+ Args:
106
+ value: The raw value to coerce.
107
+
108
+ Returns:
109
+ The coerced float, or None if `value` isn't an int/float/str or isn't
110
+ parseable as a float.
111
+ """
112
+ if value is None or value == "": # noqa: PLC1901 -- `not value` would also swallow 0/0.0
113
+ return None
114
+ if not isinstance(value, int | float | str):
115
+ logger.debug("uncoercible float %r (unsupported type %s)", value, type(value).__name__)
116
+ return None
117
+ try:
118
+ return float(value)
119
+ except (ValueError, OverflowError) as exc:
120
+ logger.debug("uncoercible float %r (%s)", value, exc)
121
+ return None
122
+
123
+
124
+ def as_int(value: object) -> int | None:
125
+ """Coerce a couchbase number or plain-digit string to int, or None if absent/invalid.
126
+
127
+ Args:
128
+ value: The raw value to coerce.
129
+
130
+ Returns:
131
+ The int, or None if `value` is a bool, a non-integral float, a string that
132
+ isn't plain ASCII digits (an optional leading `-`), or any other type.
133
+ """
134
+ if value is None or value == "": # noqa: PLC1901 -- `not value` would also swallow 0
135
+ return None
136
+ if isinstance(value, bool):
137
+ logger.debug("uncoercible int %s", clip(value))
138
+ return None
139
+ if isinstance(value, int):
140
+ return value
141
+ if isinstance(value, float) and value.is_integer():
142
+ return int(value)
143
+ if isinstance(value, str) and _PLAIN_INT.match(value):
144
+ return int(value)
145
+ logger.debug("uncoercible int %s", clip(value))
146
+ return None
147
+
148
+
149
+ def as_str(value: object) -> str | None:
150
+ """`value` if it is a string, else None.
151
+
152
+ Args:
153
+ value: The raw value expected to be a string.
154
+
155
+ Returns:
156
+ `value` unchanged if it's a string, else None.
157
+ """
158
+ if value is None or isinstance(value, str):
159
+ return value
160
+ logger.debug("non-string value %s; treating as absent", clip(value))
161
+ return None
162
+
163
+
164
+ def as_list(raw: object) -> list[object]:
165
+ """`raw` if it is a list, else an empty list, for iterating optional doc arrays.
166
+
167
+ Args:
168
+ raw: A raw couchbase value expected to be a list.
169
+
170
+ Returns:
171
+ `raw` unchanged if it is a list, else `[]`.
172
+ """
173
+ if isinstance(raw, list):
174
+ return raw
175
+ if raw is not None:
176
+ logger.debug("expected a list, got %s; treating as empty", clip(raw))
177
+ return []
@@ -0,0 +1,143 @@
1
+ """Live bus-stop arrivals from the `arrivals` source."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from zoneinfo import ZoneInfo
7
+
8
+ from mousetools._internal.services.ids import parent_from_channel_id, parse_entity_id
9
+ from mousetools.entities._parse import as_int, as_list, as_str, clip, parse_dt
10
+ from mousetools.enums import EntityType
11
+
12
+ logger = logging.getLogger("mousetools.entities.arrivals")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ArrivalPrediction:
17
+ """One predicted transport arrival: pickup at the stop and drop-off at the destination."""
18
+
19
+ at_stop: datetime | None
20
+ at_destination: datetime | None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TransportService:
25
+ """A continuous transport mode serving a destination (e.g. Skyliner, Watercraft, Monorail)."""
26
+
27
+ type: str | None
28
+ status: str | None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DestinationArrival:
33
+ """Arrivals + services for one destination reachable from a bus stop."""
34
+
35
+ destination_id: str | None
36
+ frequency_seconds: int | None
37
+ arrivals: tuple[ArrivalPrediction, ...]
38
+ services: tuple[TransportService, ...]
39
+
40
+ @classmethod
41
+ def from_list(cls, raw: object, tz: ZoneInfo) -> "tuple[DestinationArrival, ...]":
42
+ """Build the per-destination tuple from a doc's `destinations[]`.
43
+
44
+ Args:
45
+ raw: The raw `destinations` value (expected a list; anything else -> `()`).
46
+ tz: Timezone the parsed arrival datetimes are converted into.
47
+
48
+ Returns:
49
+ One `DestinationArrival` per dict entry (non-dict entries skipped); `()` if `raw`
50
+ is not a list. `arrivals`/`services` degrade to `()` when absent or malformed.
51
+ """
52
+ out: list[DestinationArrival] = []
53
+ for d in as_list(raw):
54
+ if not isinstance(d, dict):
55
+ logger.debug("arrivals destination: expected a mapping, got %r", d)
56
+ continue
57
+ arrivals = tuple(
58
+ ArrivalPrediction(
59
+ at_stop=parse_dt(a.get("atStop"), tz), at_destination=parse_dt(a.get("atDestination"), tz)
60
+ )
61
+ for a in as_list(d.get("arrivals"))
62
+ if isinstance(a, dict)
63
+ )
64
+ services = tuple(
65
+ TransportService(type=as_str(s.get("type")), status=as_str(s.get("status")))
66
+ for s in as_list(d.get("services"))
67
+ if isinstance(s, dict)
68
+ )
69
+ out.append(
70
+ cls(
71
+ destination_id=as_str(d.get("id")),
72
+ frequency_seconds=as_int(d.get("frequency")),
73
+ arrivals=arrivals,
74
+ services=services,
75
+ )
76
+ )
77
+ return tuple(out)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class TransportArrivals:
82
+ """A bus stop's live transportation arrivals to every reachable destination.
83
+
84
+ A stop serving several resorts publishes a separate copy per resort, each
85
+ updated on its own. `resort_ids` names the resorts a copy was published for:
86
+ one in every captured doc, kept in order with duplicates preserved.
87
+ """
88
+
89
+ stop_id: str | None
90
+ last_updated: datetime | None
91
+ destinations: tuple[DestinationArrival, ...]
92
+ # Default keeps the constructor shape from before resort_ids; from_doc always sets it.
93
+ resort_ids: tuple[str, ...] = ()
94
+
95
+ @classmethod
96
+ def from_doc(cls, doc: dict | None, tz: ZoneInfo) -> "TransportArrivals | None":
97
+ """Build from an `arrivals` couchbase doc body, or None if `doc` is falsy/not a dict.
98
+
99
+ Note: `stop_id` is the composed `"<n>;entityType=bus-stop"` form; a streamed
100
+ `ChangeEvent.entity_id` for the same stop is the bare `"<n>"`. Dedup the per-resort
101
+ stream duplication on one form consistently, not a mix.
102
+
103
+ `resort_ids` are the composed resort ids (`"<n>;entityType=resort"`) parsed
104
+ from the doc's `channels`, in order. The type match is case-insensitive
105
+ (#32); channels not keyed by a resort are skipped and logged at DEBUG.
106
+
107
+ Args:
108
+ doc: The raw `arrivals` doc body, or None/falsy.
109
+ tz: Timezone the parsed datetimes are converted into.
110
+
111
+ Returns:
112
+ The built `TransportArrivals`, or None if `doc` is falsy or not a dict.
113
+ """
114
+ if not doc or not isinstance(doc, dict):
115
+ if doc:
116
+ logger.debug("arrivals doc: expected a mapping, got %s", clip(doc))
117
+ return None
118
+ return cls(
119
+ stop_id=as_str(doc.get("id")),
120
+ resort_ids=_resort_ids(doc.get("channels")),
121
+ last_updated=parse_dt(doc.get("lastUpdate"), tz),
122
+ destinations=DestinationArrival.from_list(doc.get("destinations"), tz),
123
+ )
124
+
125
+
126
+ def _resort_ids(channels: object) -> tuple[str, ...]:
127
+ """The composed resort ids named by an arrivals doc's `channels`, in order.
128
+
129
+ Args:
130
+ channels: The raw `channels` value from an arrivals doc.
131
+
132
+ Returns:
133
+ One composed resort id per channel keyed by a resort; other entries are
134
+ skipped and logged at DEBUG.
135
+ """
136
+ out: list[str] = []
137
+ for channel in as_list(channels):
138
+ parent = parent_from_channel_id(channel) if isinstance(channel, str) else None
139
+ if parent and parse_entity_id(parent)[1].casefold() == EntityType.RESORT:
140
+ out.append(parent)
141
+ else:
142
+ logger.debug("arrivals channel %r names no resort; skipped", channel)
143
+ return tuple(out)