MouseTools 5.0.0__tar.gz → 6.0.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 (93) hide show
  1. {mousetools-5.0.0 → mousetools-6.0.0}/LICENSE +1 -1
  2. mousetools-6.0.0/PKG-INFO +87 -0
  3. mousetools-6.0.0/README.md +62 -0
  4. mousetools-6.0.0/mousetools/__init__.py +132 -0
  5. mousetools-6.0.0/mousetools/_internal/auth/__init__.py +5 -0
  6. mousetools-6.0.0/mousetools/_internal/auth/base.py +39 -0
  7. mousetools-6.0.0/mousetools/_internal/auth/public.py +104 -0
  8. mousetools-6.0.0/mousetools/_internal/auth/syncgateway.py +55 -0
  9. mousetools-6.0.0/mousetools/_internal/client_registry.py +59 -0
  10. mousetools-6.0.0/mousetools/_internal/config.py +93 -0
  11. mousetools-6.0.0/mousetools/_internal/loaders.py +126 -0
  12. mousetools-6.0.0/mousetools/_internal/services/couchbase.py +355 -0
  13. mousetools-6.0.0/mousetools/_internal/services/http.py +24 -0
  14. mousetools-6.0.0/mousetools/_internal/services/ids.py +182 -0
  15. mousetools-6.0.0/mousetools/_internal/services/menu.py +49 -0
  16. mousetools-6.0.0/mousetools/_internal/services/search.py +87 -0
  17. mousetools-6.0.0/mousetools/_internal/stream/codec.py +207 -0
  18. mousetools-6.0.0/mousetools/_internal/stream/connection.py +292 -0
  19. mousetools-6.0.0/mousetools/_internal/stream/protocol.py +193 -0
  20. mousetools-6.0.0/mousetools/_internal/transport.py +77 -0
  21. mousetools-6.0.0/mousetools/client.py +1273 -0
  22. mousetools-6.0.0/mousetools/entities/__init__.py +101 -0
  23. mousetools-6.0.0/mousetools/entities/attraction.py +38 -0
  24. mousetools-6.0.0/mousetools/entities/base.py +506 -0
  25. mousetools-6.0.0/mousetools/entities/basic.py +60 -0
  26. mousetools-6.0.0/mousetools/entities/character.py +44 -0
  27. mousetools-6.0.0/mousetools/entities/container.py +111 -0
  28. mousetools-6.0.0/mousetools/entities/destination.py +266 -0
  29. mousetools-6.0.0/mousetools/entities/entertainment.py +42 -0
  30. mousetools-6.0.0/mousetools/entities/facility.py +295 -0
  31. mousetools-6.0.0/mousetools/entities/menu.py +409 -0
  32. mousetools-6.0.0/mousetools/entities/mixins.py +46 -0
  33. mousetools-6.0.0/mousetools/entities/poi.py +41 -0
  34. mousetools-6.0.0/mousetools/entities/protocols.py +222 -0
  35. mousetools-6.0.0/mousetools/entities/restaurant.py +86 -0
  36. mousetools-6.0.0/mousetools/entities/search.py +179 -0
  37. mousetools-6.0.0/mousetools/entities/themepark.py +43 -0
  38. mousetools-6.0.0/mousetools/entities/types.py +115 -0
  39. mousetools-6.0.0/mousetools/entities/values.py +1009 -0
  40. mousetools-6.0.0/mousetools/enums.py +101 -0
  41. mousetools-6.0.0/mousetools/errors.py +134 -0
  42. mousetools-6.0.0/mousetools/ext/__init__.py +4 -0
  43. mousetools-6.0.0/mousetools/ext/passes.py +260 -0
  44. mousetools-6.0.0/mousetools/ext/weather.py +180 -0
  45. mousetools-6.0.0/mousetools/stream/__init__.py +5 -0
  46. mousetools-6.0.0/mousetools/stream/events.py +158 -0
  47. mousetools-6.0.0/mousetools/stream/subscription.py +165 -0
  48. mousetools-6.0.0/mousetools/timezones.py +93 -0
  49. mousetools-6.0.0/mousetools.egg-info/PKG-INFO +87 -0
  50. mousetools-6.0.0/mousetools.egg-info/SOURCES.txt +58 -0
  51. {mousetools-5.0.0 → mousetools-6.0.0}/mousetools.egg-info/requires.txt +2 -1
  52. mousetools-6.0.0/pyproject.toml +292 -0
  53. mousetools-5.0.0/PKG-INFO +0 -140
  54. mousetools-5.0.0/README.md +0 -114
  55. mousetools-5.0.0/mousetools/__init__.py +0 -43
  56. mousetools-5.0.0/mousetools/_internal/auth.py +0 -117
  57. mousetools-5.0.0/mousetools/_internal/decorators.py +0 -60
  58. mousetools-5.0.0/mousetools/_internal/http/couchbase.py +0 -118
  59. mousetools-5.0.0/mousetools/_internal/http/disney.py +0 -34
  60. mousetools-5.0.0/mousetools/api/menu.py +0 -420
  61. mousetools-5.0.0/mousetools/api/search.py +0 -58
  62. mousetools-5.0.0/mousetools/auth.py +0 -14
  63. mousetools-5.0.0/mousetools/channels/__init__.py +0 -146
  64. mousetools-5.0.0/mousetools/channels/calendar/__init__.py +0 -246
  65. mousetools-5.0.0/mousetools/channels/characters/__init__.py +0 -122
  66. mousetools-5.0.0/mousetools/channels/enums.py +0 -80
  67. mousetools-5.0.0/mousetools/channels/facilities/__init__.py +0 -770
  68. mousetools-5.0.0/mousetools/channels/facilities/enums.py +0 -32
  69. mousetools-5.0.0/mousetools/channels/facilitystatus/__init__.py +0 -210
  70. mousetools-5.0.0/mousetools/channels/forecastedwaittimes/__init__.py +0 -71
  71. mousetools-5.0.0/mousetools/channels/manager.py +0 -113
  72. mousetools-5.0.0/mousetools/channels/today/__init__.py +0 -121
  73. mousetools-5.0.0/mousetools/decorators.py +0 -13
  74. mousetools-5.0.0/mousetools/enums.py +0 -34
  75. mousetools-5.0.0/mousetools/mixins/__init__.py +0 -1
  76. mousetools-5.0.0/mousetools/mixins/couchbase.py +0 -13
  77. mousetools-5.0.0/mousetools/mixins/disney.py +0 -13
  78. mousetools-5.0.0/mousetools.egg-info/PKG-INFO +0 -140
  79. mousetools-5.0.0/mousetools.egg-info/SOURCES.txt +0 -41
  80. mousetools-5.0.0/pyproject.toml +0 -146
  81. mousetools-5.0.0/tests/test_deprecation_shims.py +0 -30
  82. mousetools-5.0.0/tests/test_internal_layout.py +0 -12
  83. mousetools-5.0.0/tests/test_manager.py +0 -28
  84. mousetools-5.0.0/tests/test_mixins.py +0 -73
  85. mousetools-5.0.0/tests/test_public_api.py +0 -37
  86. {mousetools-5.0.0 → mousetools-6.0.0}/mousetools/_internal/__init__.py +0 -0
  87. {mousetools-5.0.0/mousetools/_internal/http → mousetools-6.0.0/mousetools/_internal/services}/__init__.py +0 -0
  88. {mousetools-5.0.0/mousetools/api → mousetools-6.0.0/mousetools/_internal/stream}/__init__.py +0 -0
  89. {mousetools-5.0.0 → mousetools-6.0.0}/mousetools/py.typed +0 -0
  90. {mousetools-5.0.0 → mousetools-6.0.0}/mousetools/resources/environments.bin +0 -0
  91. {mousetools-5.0.0 → mousetools-6.0.0}/mousetools.egg-info/dependency_links.txt +0 -0
  92. {mousetools-5.0.0 → mousetools-6.0.0}/mousetools.egg-info/top_level.txt +0 -0
  93. {mousetools-5.0.0 → mousetools-6.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2018-2025 Scott Caratozzolo
3
+ Copyright (c) 2018-2026 Scott Caratozzolo
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: mousetools
3
+ Version: 6.0.0
4
+ Summary: An unofficial Python wrapper for Disney's API
5
+ Author: Scott Caratozzolo
6
+ License-Expression: MIT
7
+ Project-URL: repository, https://gitlab.com/caratozzoloxyz/public/MouseTools
8
+ Project-URL: documentation, https://caratozzoloxyz.gitlab.io/public/MouseTools/
9
+ Project-URL: issues, https://gitlab.com/caratozzoloxyz/public/MouseTools/-/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: <4.0,>=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: more-itertools>=10.7.0
19
+ Requires-Dist: pycryptodome>=3.22.0
20
+ Requires-Dist: python-dateutil>=2.9.0.post0
21
+ Requires-Dist: niquests[http3,utls]>=3.20.0
22
+ Requires-Dist: tzdata>=2025.2
23
+ Requires-Dist: websockets>=13.0
24
+ Dynamic: license-file
25
+
26
+ # MouseTools
27
+
28
+ ![PyPI - Version](https://img.shields.io/pypi/v/mousetools?style=flat-square&logo=python&color=00C106&link=https%3A%2F%2Fpypi.org%2Fproject%2Fmousetools%2F) ![PyPI - Downloads](https://img.shields.io/pypi/dm/mousetools?style=flat-square&color=blue&link=https%3A%2F%2Fpypi.org%2Fproject%2Fmousetools%2F)
29
+
30
+
31
+ An unofficial, **async** Python wrapper for Disney's undocumented API. Data is pulled directly from Disney, and this package makes no attempt to access data that would require individual user authentication — public data only. Supports Walt Disney World and Disneyland Resort.
32
+
33
+ ## Install
34
+ You can install using pip:
35
+ ```bash
36
+ pip install mousetools
37
+ ```
38
+ You can also install directly from this repo in case of any changes not uploaded to Pypi.
39
+ ```bash
40
+ pip install git+https://gitlab.com/caratozzoloxyz/public/MouseTools
41
+ ```
42
+
43
+ ## Quick example
44
+
45
+ ```python
46
+ import asyncio
47
+ import mousetools
48
+ from mousetools import WALT_DISNEY_WORLD
49
+
50
+ async def main() -> None:
51
+ # Build an unloaded handle via the destination front-door (no I/O yet).
52
+ attraction = WALT_DISNEY_WORLD.get_attraction("80010177") # Pirates of the Caribbean
53
+
54
+ # Loading is EXPLICIT — attribute access does NOT auto-load.
55
+ await attraction.load()
56
+
57
+ print(attraction.name) # str
58
+ wait = attraction.wait_time # WaitTime | None
59
+ if wait is not None:
60
+ print("Standby (min):", wait.standby_minutes)
61
+ print("Status:", wait.status)
62
+
63
+ park = await attraction.theme_park() # ThemePark | None
64
+ if park is not None:
65
+ print("In park:", park.name)
66
+
67
+ await mousetools.aclose() # close the shared default client(s)
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ 📖 **[Full documentation](https://caratozzoloxyz.gitlab.io/public/MouseTools/)**
73
+
74
+ ### License
75
+ This project is distributed under the MIT license. For more information see [LICENSE](https://gitlab.com/caratozzoloxyz/public/MouseTools/-/blob/master/LICENSE?ref_type=heads)
76
+
77
+ ### Disclaimer
78
+ This project is in no way affiliated with The Walt Disney Company and all use of Disney Services is subject to the [Disney Terms of Use](https://disneytermsofuse.com/).
79
+
80
+
81
+ ### About Project
82
+
83
+ <a href='https://ko-fi.com/scaratozzolo' target='_blank'>
84
+ <img src='https://img.shields.io/badge/sponsor-30363D?style=for-the-badge&logo=kofi&logoColor=white&link=https%3A%2F%2Fko-fi.com%2Fscaratozzolo border='0' alt='Support my projects' />
85
+ </a>
86
+
87
+ MouseTools is a passion project I've been working on since 2018 when I was first learning Python. The package has evolved and been rewritten multiple times as I've become a better developer and as the Disney API has changed. There is a lot of time exploring Disney's API and figuring out how all the pieces work before any code is even written. I work on this project when I have time or when I have an idea for another project that could use this data.
@@ -0,0 +1,62 @@
1
+ # MouseTools
2
+
3
+ ![PyPI - Version](https://img.shields.io/pypi/v/mousetools?style=flat-square&logo=python&color=00C106&link=https%3A%2F%2Fpypi.org%2Fproject%2Fmousetools%2F) ![PyPI - Downloads](https://img.shields.io/pypi/dm/mousetools?style=flat-square&color=blue&link=https%3A%2F%2Fpypi.org%2Fproject%2Fmousetools%2F)
4
+
5
+
6
+ An unofficial, **async** Python wrapper for Disney's undocumented API. Data is pulled directly from Disney, and this package makes no attempt to access data that would require individual user authentication — public data only. Supports Walt Disney World and Disneyland Resort.
7
+
8
+ ## Install
9
+ You can install using pip:
10
+ ```bash
11
+ pip install mousetools
12
+ ```
13
+ You can also install directly from this repo in case of any changes not uploaded to Pypi.
14
+ ```bash
15
+ pip install git+https://gitlab.com/caratozzoloxyz/public/MouseTools
16
+ ```
17
+
18
+ ## Quick example
19
+
20
+ ```python
21
+ import asyncio
22
+ import mousetools
23
+ from mousetools import WALT_DISNEY_WORLD
24
+
25
+ async def main() -> None:
26
+ # Build an unloaded handle via the destination front-door (no I/O yet).
27
+ attraction = WALT_DISNEY_WORLD.get_attraction("80010177") # Pirates of the Caribbean
28
+
29
+ # Loading is EXPLICIT — attribute access does NOT auto-load.
30
+ await attraction.load()
31
+
32
+ print(attraction.name) # str
33
+ wait = attraction.wait_time # WaitTime | None
34
+ if wait is not None:
35
+ print("Standby (min):", wait.standby_minutes)
36
+ print("Status:", wait.status)
37
+
38
+ park = await attraction.theme_park() # ThemePark | None
39
+ if park is not None:
40
+ print("In park:", park.name)
41
+
42
+ await mousetools.aclose() # close the shared default client(s)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ 📖 **[Full documentation](https://caratozzoloxyz.gitlab.io/public/MouseTools/)**
48
+
49
+ ### License
50
+ This project is distributed under the MIT license. For more information see [LICENSE](https://gitlab.com/caratozzoloxyz/public/MouseTools/-/blob/master/LICENSE?ref_type=heads)
51
+
52
+ ### Disclaimer
53
+ This project is in no way affiliated with The Walt Disney Company and all use of Disney Services is subject to the [Disney Terms of Use](https://disneytermsofuse.com/).
54
+
55
+
56
+ ### About Project
57
+
58
+ <a href='https://ko-fi.com/scaratozzolo' target='_blank'>
59
+ <img src='https://img.shields.io/badge/sponsor-30363D?style=for-the-badge&logo=kofi&logoColor=white&link=https%3A%2F%2Fko-fi.com%2Fscaratozzolo border='0' alt='Support my projects' />
60
+ </a>
61
+
62
+ MouseTools is a passion project I've been working on since 2018 when I was first learning Python. The package has evolved and been rewritten multiple times as I've become a better developer and as the Disney API has changed. There is a lot of time exploring Disney's API and figuring out how all the pieces work before any code is even written. I work on this project when I have time or when I have an idea for another project that could use this data.
@@ -0,0 +1,132 @@
1
+ """MouseTools — an async, domain-first Python wrapper for Disney's API (v6)."""
2
+
3
+ import logging
4
+
5
+ from mousetools import errors
6
+ from mousetools.client import Client, aclose
7
+ from mousetools.entities import (
8
+ DISNEYLAND_RESORT,
9
+ WALT_DISNEY_WORLD,
10
+ Appearance,
11
+ Attraction,
12
+ AudioTour,
13
+ Avatar,
14
+ BusStop,
15
+ CalendarSchedule,
16
+ Character,
17
+ ContainerEntity,
18
+ Coordinates,
19
+ DailySchedule,
20
+ Destination,
21
+ DiningEvent,
22
+ DinnerShow,
23
+ Entertainment,
24
+ EntertainmentVenue,
25
+ Entity,
26
+ Event,
27
+ FacilityEntity,
28
+ Forecast,
29
+ ForecastPoint,
30
+ GuestService,
31
+ Land,
32
+ Menu,
33
+ MenuCollection,
34
+ MenuGroup,
35
+ MenuItem,
36
+ MenuPrice,
37
+ MerchandiseFacility,
38
+ PartySizeStatus,
39
+ PhotoPass,
40
+ PointOfInterest,
41
+ PriceRange,
42
+ Recreation,
43
+ RecreationActivity,
44
+ Resort,
45
+ ResortArea,
46
+ Restaurant,
47
+ ScheduleSegment,
48
+ SearchMode,
49
+ SearchResponse,
50
+ SearchResult,
51
+ Spa,
52
+ ThemePark,
53
+ Tour,
54
+ Transportation,
55
+ WaitTime,
56
+ WalkUpAvailability,
57
+ WaterPark,
58
+ entity_for,
59
+ load_many,
60
+ )
61
+ from mousetools.enums import ChannelSource, DestinationShort, EntityType, ScheduleSource
62
+ from mousetools.stream import CalendarEvent, ChangeEvent, FacilityStatusEvent, ForecastEvent, RawChange
63
+
64
+ logger = logging.getLogger(__name__)
65
+ logger.addHandler(logging.NullHandler())
66
+
67
+ __all__ = [
68
+ "DISNEYLAND_RESORT",
69
+ "WALT_DISNEY_WORLD",
70
+ "Appearance",
71
+ "Attraction",
72
+ "AudioTour",
73
+ "Avatar",
74
+ "BusStop",
75
+ "CalendarEvent",
76
+ "CalendarSchedule",
77
+ "ChangeEvent",
78
+ "ChannelSource",
79
+ "Character",
80
+ "Client",
81
+ "ContainerEntity",
82
+ "Coordinates",
83
+ "DailySchedule",
84
+ "Destination",
85
+ "DestinationShort",
86
+ "DiningEvent",
87
+ "DinnerShow",
88
+ "Entertainment",
89
+ "EntertainmentVenue",
90
+ "Entity",
91
+ "EntityType",
92
+ "Event",
93
+ "FacilityEntity",
94
+ "FacilityStatusEvent",
95
+ "Forecast",
96
+ "ForecastEvent",
97
+ "ForecastPoint",
98
+ "GuestService",
99
+ "Land",
100
+ "Menu",
101
+ "MenuCollection",
102
+ "MenuGroup",
103
+ "MenuItem",
104
+ "MenuPrice",
105
+ "MerchandiseFacility",
106
+ "PartySizeStatus",
107
+ "PhotoPass",
108
+ "PointOfInterest",
109
+ "PriceRange",
110
+ "RawChange",
111
+ "Recreation",
112
+ "RecreationActivity",
113
+ "Resort",
114
+ "ResortArea",
115
+ "Restaurant",
116
+ "ScheduleSegment",
117
+ "ScheduleSource",
118
+ "SearchMode",
119
+ "SearchResponse",
120
+ "SearchResult",
121
+ "Spa",
122
+ "ThemePark",
123
+ "Tour",
124
+ "Transportation",
125
+ "WaitTime",
126
+ "WalkUpAvailability",
127
+ "WaterPark",
128
+ "aclose",
129
+ "entity_for",
130
+ "errors",
131
+ "load_many",
132
+ ]
@@ -0,0 +1,5 @@
1
+ from mousetools._internal.auth.base import Auth
2
+ from mousetools._internal.auth.public import PublicAuth
3
+ from mousetools._internal.auth.syncgateway import SyncGatewayAuth
4
+
5
+ __all__ = ["Auth", "PublicAuth", "SyncGatewayAuth"]
@@ -0,0 +1,39 @@
1
+ import time
2
+ from typing import Protocol, runtime_checkable
3
+
4
+ from mousetools._internal.transport import Transport
5
+
6
+ LEEWAY_SECONDS = 10 # refresh this many seconds before actual expiry
7
+
8
+
9
+ @runtime_checkable
10
+ class Auth(Protocol):
11
+ def bind(self, transport: Transport) -> None:
12
+ """Wire the shared `Transport` in, for implementations that need it to
13
+ fetch/refresh tokens.
14
+
15
+ Args:
16
+ transport: The Client's shared transport.
17
+ """
18
+ ...
19
+
20
+ async def headers(self) -> dict[str, str]:
21
+ """Return the auth headers to send on the next request, refreshing first if needed.
22
+
23
+ Returns:
24
+ The headers to merge into the outgoing request.
25
+ """
26
+ ...
27
+
28
+ async def refresh(self) -> None:
29
+ """Force-refresh the credential, discarding any cached one."""
30
+ ...
31
+
32
+
33
+ def epoch_now() -> float:
34
+ """Return the current time in epoch seconds (the default auth clock).
35
+
36
+ Returns:
37
+ The current epoch time.
38
+ """
39
+ return time.time()
@@ -0,0 +1,104 @@
1
+ import asyncio
2
+ from collections.abc import Callable
3
+
4
+ from mousetools._internal.auth.base import LEEWAY_SECONDS, epoch_now
5
+ from mousetools._internal.config import load_config
6
+ from mousetools._internal.transport import Transport
7
+ from mousetools.errors import AuthError
8
+
9
+
10
+ class PublicAuth:
11
+ """Zero-config guest-token auth (assertion/public grant). The Client binds the
12
+ shared Transport in; token_url/client_id default to the decrypted config."""
13
+
14
+ def __init__(
15
+ self,
16
+ *,
17
+ now: Callable[[], float] = epoch_now,
18
+ token_url: str | None = None,
19
+ client_id: str | None = None,
20
+ ) -> None:
21
+ """Create a guest-token auth, defaulting the token endpoint/client id from config.
22
+
23
+ Args:
24
+ now: The clock used for expiry checks; overridable for tests.
25
+ token_url: The assertion-grant token endpoint. Falls back to the
26
+ decrypted config's `token_url`.
27
+ client_id: The OAuth client id to grant against. Falls back to
28
+ the decrypted config's `client_id`.
29
+ """
30
+ self._now = now
31
+ if token_url is None or client_id is None:
32
+ cfg = load_config()
33
+ token_url = token_url or cfg.token_url
34
+ client_id = client_id or cfg.client_id
35
+ self._token_url = token_url
36
+ self._client_id = client_id
37
+ self._transport: Transport | None = None
38
+ self._token: str | None = None
39
+ self._expires_at: float = 0.0
40
+ # Serialises the grant so N concurrent callers on a cold/just-expired token
41
+ # issue ONE request instead of each firing their own at Disney's endpoint.
42
+ self._grant_lock = asyncio.Lock()
43
+
44
+ def bind(self, transport: Transport) -> None:
45
+ """Wire the shared `Transport` in; must be called before any token grant.
46
+
47
+ Args:
48
+ transport: The Client's shared transport, used for the token POST.
49
+ """
50
+ self._transport = transport
51
+
52
+ async def _token_value(self) -> str:
53
+ """Return a valid access token, granting/re-granting one if needed.
54
+
55
+ Returns:
56
+ The current (possibly freshly-granted) access token.
57
+
58
+ Raises:
59
+ AuthError: `bind(transport)` has not been called yet, or the
60
+ token grant request failed (no response, or status >= 400).
61
+ """
62
+ if self._transport is None:
63
+ raise AuthError("PublicAuth used before bind(transport)")
64
+ if self._token is not None and self._now() < self._expires_at - LEEWAY_SECONDS:
65
+ return self._token
66
+ async with self._grant_lock:
67
+ # Re-check inside the lock: a concurrent caller may have granted while
68
+ # we were waiting, in which case we reuse its token rather than minting
69
+ # a redundant one.
70
+ if self._token is None or self._now() >= self._expires_at - LEEWAY_SECONDS:
71
+ resp = await self._transport.request(
72
+ "POST",
73
+ self._token_url,
74
+ data={"grant_type": "assertion", "assertion_type": "public", "client_id": self._client_id},
75
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
76
+ )
77
+ if resp.status_code is None or resp.status_code >= 400:
78
+ raise AuthError(f"public token grant failed: {resp.status_code} from {self._token_url}")
79
+ body = resp.json()
80
+ self._token = body["access_token"]
81
+ self._expires_at = self._now() + int(body["expires_in"])
82
+ return self._token
83
+
84
+ async def headers(self) -> dict[str, str]:
85
+ """Return the bearer-auth header, granting a token first if needed.
86
+
87
+ Returns:
88
+ The `Authorization` header to send.
89
+
90
+ Raises:
91
+ AuthError: `bind(transport)` has not been called yet, or the
92
+ token grant request failed.
93
+ """
94
+ return {"Authorization": f"BEARER {await self._token_value()}"}
95
+
96
+ async def refresh(self) -> None:
97
+ """Discard the cached token and grant a fresh one immediately.
98
+
99
+ Raises:
100
+ AuthError: `bind(transport)` has not been called yet, or the
101
+ token grant request failed.
102
+ """
103
+ self._token = None
104
+ await self._token_value()
@@ -0,0 +1,55 @@
1
+ import base64
2
+
3
+ from mousetools._internal.config import load_config
4
+ from mousetools._internal.transport import Transport
5
+
6
+
7
+ class SyncGatewayAuth:
8
+ """Static HTTP Basic auth for the Couchbase sync gateway (public couchbase data).
9
+ No token dance, no swid; creds come from the decrypted config."""
10
+
11
+ def __init__(self, *, sync_user: str | None = None, sync_pass: str | None = None) -> None:
12
+ """Create a Basic auth, defaulting the credentials from config.
13
+
14
+ Args:
15
+ sync_user: The sync-gateway Basic-auth username. Falls back to
16
+ the decrypted config's `sync_user`.
17
+ sync_pass: The sync-gateway Basic-auth password. Falls back to
18
+ the decrypted config's `sync_pass`.
19
+ """
20
+ if sync_user is None or sync_pass is None:
21
+ cfg = load_config()
22
+ sync_user = sync_user or cfg.sync_user
23
+ sync_pass = sync_pass or cfg.sync_pass
24
+ token = base64.b64encode(f"{sync_user}:{sync_pass}".encode()).decode("ascii")
25
+ self._auth_header = f"Basic {token}"
26
+
27
+ def bind(self, transport: Transport) -> None: # noqa: ARG002 -- static creds need no transport
28
+ """No-op: static Basic creds need no transport to fetch/refresh anything.
29
+
30
+ Args:
31
+ transport: Unused; accepted only to satisfy the `Auth` protocol.
32
+ """
33
+ return
34
+
35
+ async def headers(self) -> dict[str, str]:
36
+ """Return the static Basic-auth header.
37
+
38
+ Returns:
39
+ The `Authorization` and `Accept` headers to send.
40
+ """
41
+ return {"Authorization": self._auth_header, "Accept": "multipart/related"}
42
+
43
+ @property
44
+ def auth_header(self) -> str:
45
+ """The prebuilt `Authorization: Basic ...` header value.
46
+
47
+ Returns:
48
+ The `Basic <token>` header value, for callers (e.g. the WebSocket
49
+ stream connection) that need the raw value rather than a header dict.
50
+ """
51
+ return self._auth_header
52
+
53
+ async def refresh(self) -> None:
54
+ """No-op: the static Basic credential never expires."""
55
+ return
@@ -0,0 +1,59 @@
1
+ """Indirection that lets `mousetools.entities` resolve a default `Client`
2
+ without importing `mousetools.client` at module level.
3
+
4
+ `mousetools.client` needs to import `mousetools.entities.search` (for
5
+ `Client.search()`'s `SearchResponse`/`build_search_response`), and
6
+ `mousetools.entities.base`/`mousetools.entities.destination` already need a
7
+ default `Client` when none is passed explicitly. A direct top-level
8
+ `entities -> client` import would make that a genuine circular import
9
+ (`client -> entities -> client`), which `tach check` forbids outright
10
+ (`forbid_circular_dependencies = true`) -- this is not a layering nuance to
11
+ suppress, it's a real cycle Python's import system can't resolve.
12
+
13
+ `mousetools.client` calls `register()` with `get_default_client` once, at the
14
+ bottom of its own module body (still a top-level statement, not a
15
+ function-local import -- PLC0415 is about deferred *imports*, not about when
16
+ a plain function call happens to run). Entities call `resolve()` at runtime,
17
+ never importing `mousetools.client` themselves.
18
+ """
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ from mousetools.enums import DestinationShort
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+ from mousetools.client import Client
28
+
29
+ # A single-entry dict, not a module-level `Callable | None` + `global`: item
30
+ # assignment mutates in place, so `register()` needs no `global` statement.
31
+ _state: dict[str, "Callable[[DestinationShort], Client]"] = {}
32
+
33
+
34
+ def register(provider: "Callable[[DestinationShort], Client]") -> None:
35
+ """Called once by `mousetools.client`, at the bottom of its module body.
36
+
37
+ Args:
38
+ provider: Returns the shared default `Client` for a given destination.
39
+ """
40
+ _state["provider"] = provider
41
+
42
+
43
+ def resolve(destination: DestinationShort = DestinationShort.WALT_DISNEY_WORLD) -> "Client":
44
+ """Return the shared default `Client` for `destination` (one per resort).
45
+
46
+ Args:
47
+ destination: The resort to resolve the default client for.
48
+
49
+ Returns:
50
+ The shared default `Client` for `destination`.
51
+
52
+ Raises:
53
+ RuntimeError: `mousetools.client` has not been imported yet, so no
54
+ provider has been registered.
55
+ """
56
+ provider = _state.get("provider")
57
+ if provider is None:
58
+ raise RuntimeError("default client provider not registered -- import mousetools.client first")
59
+ return provider(destination)
@@ -0,0 +1,93 @@
1
+ """Decrypts the bundled `resources/environments.bin` app config.
2
+
3
+ INTENTIONALLY OBFUSCATED, NOT SECRET. `environments.bin` holds Disney's own
4
+ client-side config -- the sync-gateway Basic-auth pair for its PUBLIC read-only
5
+ Couchbase channels, plus the OneID token endpoint and client id -- obfuscated
6
+ the same way the official Android app ships them. The key below is derived from
7
+ constants in this file, so anyone can reproduce it; that is by design and
8
+ mirrors the app, not an attempt at protection. These are NOT user credentials
9
+ and grant no access beyond what any APK teardown already exposes. The file
10
+ predates v6 (it ships in v5 on master too). Do not mistake it for an
11
+ accidentally-committed secret -- and do not put a real secret here.
12
+ """
13
+
14
+ import json
15
+ import math
16
+ from dataclasses import dataclass
17
+ from functools import lru_cache
18
+ from importlib.resources import files
19
+
20
+ from Crypto.Cipher import AES
21
+
22
+ # AES-GCM appends a 16-byte authentication tag to the ciphertext.
23
+ _GCM_TAG_BYTES = 16
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Config:
28
+ token_url: str
29
+ client_id: str
30
+ sync_gateway_url: str
31
+ sync_user: str
32
+ sync_pass: str
33
+ menu_url: str
34
+ search_url: str
35
+
36
+
37
+ def _key() -> bytes:
38
+ """Derive the AES-GCM key used to decrypt the bundled `environments.bin`.
39
+
40
+ Returns:
41
+ The 16-byte key.
42
+ """
43
+ s = ""
44
+ for i in range(1, 17):
45
+ s += chr(i + 65) if (i % 2 != 0 or i >= 16) else str(math.pi)[i]
46
+ return s.encode("utf-8")
47
+
48
+
49
+ def _nonce() -> bytes:
50
+ """Derive the AES-GCM nonce (the key's bytes, reversed).
51
+
52
+ Returns:
53
+ The 16-byte nonce.
54
+ """
55
+ return bytes(reversed(_key()))
56
+
57
+
58
+ def _decrypt_environments() -> dict:
59
+ """Decrypt the bundled `resources/environments.bin` and return its `production` block.
60
+
61
+ Returns:
62
+ The production environment's config values (token/sync-gateway/menu/
63
+ search URLs and sync-gateway creds).
64
+ """
65
+ cipher = AES.new(_key(), AES.MODE_GCM, nonce=_nonce())
66
+ raw = (files("mousetools") / "resources/environments.bin").read_bytes()
67
+ # Verify the GCM tag instead of decrypting blind and slicing the trailing 16
68
+ # bytes off the decoded string. The old form only worked because cp1252 is a
69
+ # single-byte codec that never raises, so a corrupted resource (bit rot, a bad
70
+ # merge, a packaging error) surfaced as a confusing KeyError/JSONDecodeError
71
+ # further down rather than a clear integrity failure here.
72
+ ciphertext, tag = raw[:-_GCM_TAG_BYTES], raw[-_GCM_TAG_BYTES:]
73
+ decrypted = cipher.decrypt_and_verify(ciphertext, tag)
74
+ return json.loads(decrypted.decode("Windows-1252"))["production"]
75
+
76
+
77
+ @lru_cache(maxsize=1)
78
+ def load_config() -> Config:
79
+ """Return the decrypted app config, decrypting once and caching the result.
80
+
81
+ Returns:
82
+ The parsed, process-lifetime-cached `Config`.
83
+ """
84
+ env = _decrypt_environments()
85
+ return Config(
86
+ token_url=f"{env['authzServiceUrl'].rstrip('/')}/token",
87
+ client_id=env["authzClientId"],
88
+ sync_gateway_url="https://" + env["syncGatewayUrl"].split("//")[1].rstrip("/"),
89
+ sync_user=env["syncGatewayUser"],
90
+ sync_pass=env["syncGatewayPass"],
91
+ menu_url=env["serviceMenuUrl"].rstrip("/"),
92
+ search_url=env["searchServiceUrl"].rstrip("/"),
93
+ )