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.
- {mousetools-5.0.0 → mousetools-6.0.0}/LICENSE +1 -1
- mousetools-6.0.0/PKG-INFO +87 -0
- mousetools-6.0.0/README.md +62 -0
- mousetools-6.0.0/mousetools/__init__.py +132 -0
- mousetools-6.0.0/mousetools/_internal/auth/__init__.py +5 -0
- mousetools-6.0.0/mousetools/_internal/auth/base.py +39 -0
- mousetools-6.0.0/mousetools/_internal/auth/public.py +104 -0
- mousetools-6.0.0/mousetools/_internal/auth/syncgateway.py +55 -0
- mousetools-6.0.0/mousetools/_internal/client_registry.py +59 -0
- mousetools-6.0.0/mousetools/_internal/config.py +93 -0
- mousetools-6.0.0/mousetools/_internal/loaders.py +126 -0
- mousetools-6.0.0/mousetools/_internal/services/couchbase.py +355 -0
- mousetools-6.0.0/mousetools/_internal/services/http.py +24 -0
- mousetools-6.0.0/mousetools/_internal/services/ids.py +182 -0
- mousetools-6.0.0/mousetools/_internal/services/menu.py +49 -0
- mousetools-6.0.0/mousetools/_internal/services/search.py +87 -0
- mousetools-6.0.0/mousetools/_internal/stream/codec.py +207 -0
- mousetools-6.0.0/mousetools/_internal/stream/connection.py +292 -0
- mousetools-6.0.0/mousetools/_internal/stream/protocol.py +193 -0
- mousetools-6.0.0/mousetools/_internal/transport.py +77 -0
- mousetools-6.0.0/mousetools/client.py +1273 -0
- mousetools-6.0.0/mousetools/entities/__init__.py +101 -0
- mousetools-6.0.0/mousetools/entities/attraction.py +38 -0
- mousetools-6.0.0/mousetools/entities/base.py +506 -0
- mousetools-6.0.0/mousetools/entities/basic.py +60 -0
- mousetools-6.0.0/mousetools/entities/character.py +44 -0
- mousetools-6.0.0/mousetools/entities/container.py +111 -0
- mousetools-6.0.0/mousetools/entities/destination.py +266 -0
- mousetools-6.0.0/mousetools/entities/entertainment.py +42 -0
- mousetools-6.0.0/mousetools/entities/facility.py +295 -0
- mousetools-6.0.0/mousetools/entities/menu.py +409 -0
- mousetools-6.0.0/mousetools/entities/mixins.py +46 -0
- mousetools-6.0.0/mousetools/entities/poi.py +41 -0
- mousetools-6.0.0/mousetools/entities/protocols.py +222 -0
- mousetools-6.0.0/mousetools/entities/restaurant.py +86 -0
- mousetools-6.0.0/mousetools/entities/search.py +179 -0
- mousetools-6.0.0/mousetools/entities/themepark.py +43 -0
- mousetools-6.0.0/mousetools/entities/types.py +115 -0
- mousetools-6.0.0/mousetools/entities/values.py +1009 -0
- mousetools-6.0.0/mousetools/enums.py +101 -0
- mousetools-6.0.0/mousetools/errors.py +134 -0
- mousetools-6.0.0/mousetools/ext/__init__.py +4 -0
- mousetools-6.0.0/mousetools/ext/passes.py +260 -0
- mousetools-6.0.0/mousetools/ext/weather.py +180 -0
- mousetools-6.0.0/mousetools/stream/__init__.py +5 -0
- mousetools-6.0.0/mousetools/stream/events.py +158 -0
- mousetools-6.0.0/mousetools/stream/subscription.py +165 -0
- mousetools-6.0.0/mousetools/timezones.py +93 -0
- mousetools-6.0.0/mousetools.egg-info/PKG-INFO +87 -0
- mousetools-6.0.0/mousetools.egg-info/SOURCES.txt +58 -0
- {mousetools-5.0.0 → mousetools-6.0.0}/mousetools.egg-info/requires.txt +2 -1
- mousetools-6.0.0/pyproject.toml +292 -0
- mousetools-5.0.0/PKG-INFO +0 -140
- mousetools-5.0.0/README.md +0 -114
- mousetools-5.0.0/mousetools/__init__.py +0 -43
- mousetools-5.0.0/mousetools/_internal/auth.py +0 -117
- mousetools-5.0.0/mousetools/_internal/decorators.py +0 -60
- mousetools-5.0.0/mousetools/_internal/http/couchbase.py +0 -118
- mousetools-5.0.0/mousetools/_internal/http/disney.py +0 -34
- mousetools-5.0.0/mousetools/api/menu.py +0 -420
- mousetools-5.0.0/mousetools/api/search.py +0 -58
- mousetools-5.0.0/mousetools/auth.py +0 -14
- mousetools-5.0.0/mousetools/channels/__init__.py +0 -146
- mousetools-5.0.0/mousetools/channels/calendar/__init__.py +0 -246
- mousetools-5.0.0/mousetools/channels/characters/__init__.py +0 -122
- mousetools-5.0.0/mousetools/channels/enums.py +0 -80
- mousetools-5.0.0/mousetools/channels/facilities/__init__.py +0 -770
- mousetools-5.0.0/mousetools/channels/facilities/enums.py +0 -32
- mousetools-5.0.0/mousetools/channels/facilitystatus/__init__.py +0 -210
- mousetools-5.0.0/mousetools/channels/forecastedwaittimes/__init__.py +0 -71
- mousetools-5.0.0/mousetools/channels/manager.py +0 -113
- mousetools-5.0.0/mousetools/channels/today/__init__.py +0 -121
- mousetools-5.0.0/mousetools/decorators.py +0 -13
- mousetools-5.0.0/mousetools/enums.py +0 -34
- mousetools-5.0.0/mousetools/mixins/__init__.py +0 -1
- mousetools-5.0.0/mousetools/mixins/couchbase.py +0 -13
- mousetools-5.0.0/mousetools/mixins/disney.py +0 -13
- mousetools-5.0.0/mousetools.egg-info/PKG-INFO +0 -140
- mousetools-5.0.0/mousetools.egg-info/SOURCES.txt +0 -41
- mousetools-5.0.0/pyproject.toml +0 -146
- mousetools-5.0.0/tests/test_deprecation_shims.py +0 -30
- mousetools-5.0.0/tests/test_internal_layout.py +0 -12
- mousetools-5.0.0/tests/test_manager.py +0 -28
- mousetools-5.0.0/tests/test_mixins.py +0 -73
- mousetools-5.0.0/tests/test_public_api.py +0 -37
- {mousetools-5.0.0 → mousetools-6.0.0}/mousetools/_internal/__init__.py +0 -0
- {mousetools-5.0.0/mousetools/_internal/http → mousetools-6.0.0/mousetools/_internal/services}/__init__.py +0 -0
- {mousetools-5.0.0/mousetools/api → mousetools-6.0.0/mousetools/_internal/stream}/__init__.py +0 -0
- {mousetools-5.0.0 → mousetools-6.0.0}/mousetools/py.typed +0 -0
- {mousetools-5.0.0 → mousetools-6.0.0}/mousetools/resources/environments.bin +0 -0
- {mousetools-5.0.0 → mousetools-6.0.0}/mousetools.egg-info/dependency_links.txt +0 -0
- {mousetools-5.0.0 → mousetools-6.0.0}/mousetools.egg-info/top_level.txt +0 -0
- {mousetools-5.0.0 → mousetools-6.0.0}/setup.cfg +0 -0
|
@@ -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
|
+
 
|
|
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
|
+
 
|
|
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,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
|
+
)
|