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.
- {mousetools-6.1.0 → mousetools-6.2.0}/PKG-INFO +2 -1
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/__init__.py +6 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/ids.py +146 -1
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/protocol.py +1 -5
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/client.py +5 -14
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/__init__.py +5 -4
- mousetools-6.2.0/mousetools/entities/_parse.py +177 -0
- mousetools-6.2.0/mousetools/entities/arrivals.py +143 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/base.py +90 -18
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/entertainment.py +0 -1
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/facility.py +1 -7
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/mixins.py +2 -1
- mousetools-6.2.0/mousetools/entities/offline.py +204 -0
- mousetools-6.2.0/mousetools/entities/related.py +151 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/restaurant.py +2 -1
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/values.py +26 -303
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/errors.py +12 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/events.py +3 -84
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/PKG-INFO +2 -1
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/SOURCES.txt +4 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/pyproject.toml +38 -13
- {mousetools-6.1.0 → mousetools-6.2.0}/LICENSE +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/README.md +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/__init__.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/__init__.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/base.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/public.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/auth/syncgateway.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/client_registry.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/config.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/loaders.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/__init__.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/couchbase.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/http.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/menu.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/services/search.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/__init__.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/codec.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/stream/connection.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/_internal/transport.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/attraction.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/basic.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/character.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/container.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/destination.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/menu.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/names.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/poi.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/protocols.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/search.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/themepark.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/entities/types.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/enums.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/ext/__init__.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/ext/passes.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/ext/weather.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/py.typed +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/resources/environments.bin +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/__init__.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/filters.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/stream/subscription.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools/timezones.py +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/dependency_links.txt +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/requires.txt +0 -0
- {mousetools-6.1.0 → mousetools-6.2.0}/mousetools.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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=
|
|
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
|
|
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.
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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)
|