dlpwait 1.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.
@@ -0,0 +1,49 @@
1
+ name: Linting
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ ruff:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+
21
+ - name: Install dependencies
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install -e .
25
+ pip install ruff
26
+
27
+ - name: Run ruff
28
+ run: ruff check src
29
+
30
+ pylint:
31
+ runs-on: ubuntu-latest
32
+
33
+ steps:
34
+ - name: Checkout code
35
+ uses: actions/checkout@v4
36
+
37
+ - name: Set up Python
38
+ uses: actions/setup-python@v5
39
+ with:
40
+ python-version: "3.11"
41
+
42
+ - name: Install dependencies
43
+ run: |
44
+ python -m pip install --upgrade pip
45
+ pip install -e .
46
+ pip install pylint
47
+
48
+ - name: Run pylint
49
+ run: pylint src
@@ -0,0 +1,32 @@
1
+ name: Publish Python package
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+ contents: read
10
+
11
+ jobs:
12
+ build-and-publish:
13
+ name: Build and publish package
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout source
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.11"
24
+
25
+ - name: Install build tools
26
+ run: pip install build
27
+
28
+ - name: Build distribution
29
+ run: python -m build
30
+
31
+ - name: Publish to PyPI via Trusted Publisher
32
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,39 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ pytest:
10
+ runs-on: ubuntu-latest
11
+
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Install dependencies
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install -e .
29
+ pip install pytest pytest-asyncio pytest-cov aioresponses
30
+
31
+ - name: Run tests with coverage
32
+ run: |
33
+ pytest
34
+
35
+ - name: Upload coverage artifact
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: coverage-${{ matrix.python-version }}
39
+ path: coverage.xml
@@ -0,0 +1,28 @@
1
+ name: Typing
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ mypy:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+
21
+ - name: Install dependencies
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install -e .
25
+ pip install mypy
26
+
27
+ - name: Run mypy
28
+ run: mypy src/dlpwait
@@ -0,0 +1,13 @@
1
+ # PhpStorm
2
+ .idea
3
+
4
+ # General files
5
+ *~
6
+ *.DS_STORE
7
+
8
+ # python
9
+ .venv
10
+
11
+ # pytest
12
+ .coverage
13
+ coverage.xml
dlpwait-1.0.0/LICENCE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Glenn de Haan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dlpwait-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: dlpwait
3
+ Version: 1.0.0
4
+ Summary: Asynchronous Python client for Disneyland Paris park data
5
+ Project-URL: Homepage, https://github.com/glenndehaan/python-dlpwait
6
+ Project-URL: Issues, https://github.com/glenndehaan/python-dlpwait/issues
7
+ Author-email: Glenn de Haan <glenn@dehaan.cloud>
8
+ License: MIT
9
+ License-File: LICENCE
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: aiohttp>=3.0.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # DLPWait API Client
15
+
16
+ An **asynchronous Python client** for fetching real-time **Disneyland Paris park and attraction data** via the DLPWait API.
17
+ This lightweight library provides methods for retrieving park hours, attractions, and standby wait times.
18
+
19
+ ## Features
20
+
21
+ * Asynchronous communication using `aiohttp`
22
+ * Fetch park hours and operating schedules
23
+ * Fetch attractions
24
+ * Get real-time standby wait times
25
+ * Built-in error handling for connection and parsing failures
26
+ * Designed for easy integration into automation tools or async workflows
27
+
28
+ ## Requirements
29
+
30
+ * Python **3.11+**
31
+ * `aiohttp` library
32
+
33
+ ## Usage Example
34
+
35
+ ```python
36
+ import asyncio
37
+ from dlpwait import DLPWaitAPI, DLPWaitConnectionError, Parks
38
+
39
+ async def main():
40
+ client = DLPWaitAPI()
41
+
42
+ try:
43
+ await client.update() # Fetch all park data
44
+
45
+ for park in Parks:
46
+ park_data = client.parks[Parks(park)]
47
+ print(f"{park_data.slug} is open from {park_data.opening_time} to {park_data.closing_time}")
48
+ print("Attractions:")
49
+ for attraction_id, name in park_data.attractions.items():
50
+ wait_time = park_data.standby_wait_times.get(attraction_id, "N/A")
51
+ print(f" {name}: {wait_time} min")
52
+
53
+ except DLPWaitConnectionError as err:
54
+ print(f"Error fetching park data: {err}")
55
+
56
+ finally:
57
+ await client.close()
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ ## API Reference
63
+
64
+ ### Class: `DLPWaitAPI`
65
+
66
+ #### Initialization
67
+
68
+ ```python
69
+ DLPWaitAPI(session: aiohttp.ClientSession | None = None)
70
+ ```
71
+
72
+ * **session** *(optional)* – existing `aiohttp.ClientSession` to reuse.
73
+
74
+ #### Fetch & Update Methods
75
+
76
+ | Method | Description |
77
+ |------------|------------------------------------------|
78
+ | `update()` | Fetch and parse all park data |
79
+ | `close()` | Close the HTTP session to free resources |
80
+
81
+ ### Models
82
+
83
+ #### `Parks` Enum
84
+
85
+ | Member | Description |
86
+ |-----------------------|--------------------------|
87
+ | `DISNEYLAND` | Disneyland Park |
88
+ | `WALT_DISNEY_STUDIOS` | Walt Disney Studios Park |
89
+
90
+ #### `Park` Dataclass
91
+
92
+ | Field | Type | Description |
93
+ |----------------------|------------------|-----------------------------------------------|
94
+ | `slug` | `Parks` | Park identifier |
95
+ | `opening_time` | `datetime` | Park opening time |
96
+ | `closing_time` | `datetime` | Park closing time |
97
+ | `attractions` | `dict[str, str]` | Attraction IDs mapped to names |
98
+ | `standby_wait_times` | `dict[str, int]` | Attraction IDs mapped to wait times (minutes) |
99
+
100
+ ## Exception Handling
101
+
102
+ All exceptions inherit from `DLPWaitError`.
103
+
104
+ | Exception | Description |
105
+ |--------------------------|-----------------------------------------------------|
106
+ | `DLPWaitError` | Base exception for DLPWait client |
107
+ | `DLPWaitConnectionError` | Connection-related errors (timeouts, bad responses) |
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,98 @@
1
+ # DLPWait API Client
2
+
3
+ An **asynchronous Python client** for fetching real-time **Disneyland Paris park and attraction data** via the DLPWait API.
4
+ This lightweight library provides methods for retrieving park hours, attractions, and standby wait times.
5
+
6
+ ## Features
7
+
8
+ * Asynchronous communication using `aiohttp`
9
+ * Fetch park hours and operating schedules
10
+ * Fetch attractions
11
+ * Get real-time standby wait times
12
+ * Built-in error handling for connection and parsing failures
13
+ * Designed for easy integration into automation tools or async workflows
14
+
15
+ ## Requirements
16
+
17
+ * Python **3.11+**
18
+ * `aiohttp` library
19
+
20
+ ## Usage Example
21
+
22
+ ```python
23
+ import asyncio
24
+ from dlpwait import DLPWaitAPI, DLPWaitConnectionError, Parks
25
+
26
+ async def main():
27
+ client = DLPWaitAPI()
28
+
29
+ try:
30
+ await client.update() # Fetch all park data
31
+
32
+ for park in Parks:
33
+ park_data = client.parks[Parks(park)]
34
+ print(f"{park_data.slug} is open from {park_data.opening_time} to {park_data.closing_time}")
35
+ print("Attractions:")
36
+ for attraction_id, name in park_data.attractions.items():
37
+ wait_time = park_data.standby_wait_times.get(attraction_id, "N/A")
38
+ print(f" {name}: {wait_time} min")
39
+
40
+ except DLPWaitConnectionError as err:
41
+ print(f"Error fetching park data: {err}")
42
+
43
+ finally:
44
+ await client.close()
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ ## API Reference
50
+
51
+ ### Class: `DLPWaitAPI`
52
+
53
+ #### Initialization
54
+
55
+ ```python
56
+ DLPWaitAPI(session: aiohttp.ClientSession | None = None)
57
+ ```
58
+
59
+ * **session** *(optional)* – existing `aiohttp.ClientSession` to reuse.
60
+
61
+ #### Fetch & Update Methods
62
+
63
+ | Method | Description |
64
+ |------------|------------------------------------------|
65
+ | `update()` | Fetch and parse all park data |
66
+ | `close()` | Close the HTTP session to free resources |
67
+
68
+ ### Models
69
+
70
+ #### `Parks` Enum
71
+
72
+ | Member | Description |
73
+ |-----------------------|--------------------------|
74
+ | `DISNEYLAND` | Disneyland Park |
75
+ | `WALT_DISNEY_STUDIOS` | Walt Disney Studios Park |
76
+
77
+ #### `Park` Dataclass
78
+
79
+ | Field | Type | Description |
80
+ |----------------------|------------------|-----------------------------------------------|
81
+ | `slug` | `Parks` | Park identifier |
82
+ | `opening_time` | `datetime` | Park opening time |
83
+ | `closing_time` | `datetime` | Park closing time |
84
+ | `attractions` | `dict[str, str]` | Attraction IDs mapped to names |
85
+ | `standby_wait_times` | `dict[str, int]` | Attraction IDs mapped to wait times (minutes) |
86
+
87
+ ## Exception Handling
88
+
89
+ All exceptions inherit from `DLPWaitError`.
90
+
91
+ | Exception | Description |
92
+ |--------------------------|-----------------------------------------------------|
93
+ | `DLPWaitError` | Base exception for DLPWait client |
94
+ | `DLPWaitConnectionError` | Connection-related errors (timeouts, bad responses) |
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "dlpwait"
3
+ version = "1.0.0"
4
+ description = "Asynchronous Python client for Disneyland Paris park data"
5
+ authors = [
6
+ { name = "Glenn de Haan", email = "glenn@dehaan.cloud" }
7
+ ]
8
+ license = { text = "MIT" }
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "aiohttp>=3.0.0",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/glenndehaan/python-dlpwait"
17
+ Issues = "https://github.com/glenndehaan/python-dlpwait/issues"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/dlpwait"]
25
+
26
+ [tool.hatch.build.targets.wheel.package-data]
27
+ dlpwait = ["py.typed"]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+ testpaths = ["tests"]
32
+ addopts = [
33
+ "--strict-markers",
34
+ "--cov=dlpwait",
35
+ "--cov-report=term-missing",
36
+ "--cov-report=xml",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ line-length = 120
41
+ exclude = ["build", ".venv"]
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "W", "I"]
45
+ extend-select = ["B", "D"]
46
+ ignore = [
47
+ "D203", # incompatible with D211
48
+ "D213", # incompatible with D212
49
+ ]
50
+ extend-ignore = []
51
+
52
+ [tool.pylint."BASIC"]
53
+ good-names = ["JSON"]
54
+
55
+ [tool.pylint."MESSAGES CONTROL"]
56
+ disable = [
57
+ "redefined-builtin",
58
+ "too-many-public-methods",
59
+ ]
60
+
61
+ [tool.mypy]
62
+ python_version = 3.11
63
+ files = "src,dlpwait"
64
+ ignore_missing_imports = true
65
+ strict = true
66
+ disallow_untyped_defs = true
67
+ warn_unused_ignores = true
68
+ warn_return_any = true
69
+ pretty = true
70
+ show_error_codes = true
@@ -0,0 +1,13 @@
1
+ """DLPWait Client."""
2
+
3
+ from .api import DLPWaitAPI
4
+ from .exceptions import DLPWaitConnectionError, DLPWaitError
5
+ from .models import Park, Parks
6
+
7
+ __all__ = [
8
+ "DLPWaitAPI",
9
+ "DLPWaitConnectionError",
10
+ "DLPWaitError",
11
+ "Park",
12
+ "Parks",
13
+ ]
@@ -0,0 +1,184 @@
1
+ """DLPWait Client API."""
2
+
3
+ from asyncio import TimeoutError
4
+ from datetime import datetime
5
+ from typing import Any, TypeAlias
6
+ from zoneinfo import ZoneInfo
7
+
8
+ import aiohttp
9
+ from aiohttp import ClientError, ClientResponseError, ClientTimeout
10
+
11
+ from .exceptions import DLPWaitConnectionError
12
+ from .models import Park, Parks
13
+
14
+ JSON: TypeAlias = dict[str, Any]
15
+
16
+ GRAPHQL_QUERY = """
17
+ query {
18
+ parks {
19
+ slug
20
+ schedules {
21
+ status
22
+ startTime
23
+ endTime
24
+ date
25
+ }
26
+ }
27
+ attractions {
28
+ id
29
+ active
30
+ hide
31
+ status
32
+ name
33
+ park {
34
+ slug
35
+ }
36
+ waitTime {
37
+ standby {
38
+ minutes
39
+ }
40
+ }
41
+ }
42
+ }
43
+ """
44
+
45
+
46
+ class DLPWaitAPI:
47
+ """Asynchronous API client for DLPWait."""
48
+
49
+ def __init__(self, session: aiohttp.ClientSession | None = None) -> None:
50
+ """DLPWait API Client."""
51
+ self._session: aiohttp.ClientSession = session or aiohttp.ClientSession()
52
+
53
+ self.parks: dict[Parks, Park] = {}
54
+
55
+ async def _request(self) -> JSON:
56
+ """Handle a request to the DLPWait api."""
57
+ try:
58
+ async with self._session.post(
59
+ "https://api.dlpwait.com",
60
+ json={"query": GRAPHQL_QUERY},
61
+ timeout=ClientTimeout(total=10)
62
+ ) as response:
63
+ if response.status != 200:
64
+ raise DLPWaitConnectionError(
65
+ f"Unexpected response (Status: {response.status})"
66
+ )
67
+
68
+ payload: JSON = await response.json()
69
+ data = payload.get("data")
70
+ if not isinstance(data, dict):
71
+ raise DLPWaitConnectionError("Invalid API response")
72
+
73
+ return data
74
+ except TimeoutError as err:
75
+ raise DLPWaitConnectionError("Timeout while fetching") from err
76
+ except (ClientError, ClientResponseError) as err:
77
+ raise DLPWaitConnectionError(f"Request failed: {err}") from err
78
+ except Exception as err:
79
+ raise DLPWaitConnectionError(f"Unexpected error: {err}") from err
80
+
81
+ @staticmethod
82
+ def _parse_park_hours(parks: list[JSON]) -> dict[Parks, tuple[datetime, datetime]]:
83
+ """Return park hours from the API data."""
84
+ result: dict[Parks, tuple[datetime, datetime]] = {}
85
+
86
+ for park in parks:
87
+ try:
88
+ slug = Parks(park["slug"])
89
+ except ValueError:
90
+ continue
91
+
92
+ for schedule in park["schedules"]:
93
+ if schedule["status"] == "OPERATING":
94
+ result[slug] = (datetime.strptime(
95
+ f"{schedule['date']} {schedule['startTime']}",
96
+ "%Y-%m-%d %H:%M:%S"
97
+ ).replace(tzinfo=ZoneInfo("Europe/Paris")), datetime.strptime(
98
+ f"{schedule['date']} {schedule['endTime']}",
99
+ "%Y-%m-%d %H:%M:%S"
100
+ ).replace(tzinfo=ZoneInfo("Europe/Paris")))
101
+
102
+ return result
103
+
104
+ @staticmethod
105
+ def _parse_attractions(attractions: list[JSON]) -> dict[Parks, dict[str, str]]:
106
+ """Return park attractions from the API data."""
107
+ result: dict[Parks, dict[str, str]] = {}
108
+
109
+ for attraction in attractions:
110
+ if not attraction["active"]:
111
+ continue
112
+
113
+ if attraction["hide"]:
114
+ continue
115
+
116
+ if attraction["status"] != "OPERATING":
117
+ continue
118
+
119
+ try:
120
+ slug = Parks(attraction["park"]["slug"])
121
+ except ValueError:
122
+ continue
123
+
124
+ result.setdefault(slug, {})
125
+ result[slug][attraction["id"]] = attraction["name"]
126
+
127
+ return result
128
+
129
+ @staticmethod
130
+ def _parse_standby_wait_times(attractions: list[JSON]) -> dict[Parks, dict[str, int]]:
131
+ """Return park wait times from the API data."""
132
+ result: dict[Parks, dict[str, int]] = {}
133
+
134
+ for attraction in attractions:
135
+ if not attraction["active"]:
136
+ continue
137
+
138
+ if attraction["hide"]:
139
+ continue
140
+
141
+ if attraction["status"] != "OPERATING":
142
+ continue
143
+
144
+ try:
145
+ slug = Parks(attraction["park"]["slug"])
146
+ except ValueError:
147
+ continue
148
+
149
+ standby = attraction.get("waitTime", {}).get("standby")
150
+ if not standby:
151
+ continue
152
+
153
+ minutes = standby.get("minutes")
154
+ if minutes is None:
155
+ continue
156
+
157
+ result.setdefault(slug, {})
158
+ result[slug][attraction["id"]] = minutes
159
+
160
+ return result
161
+
162
+ async def update(self) -> None:
163
+ """Fetch and parse all park data."""
164
+ data = await self._request()
165
+
166
+ park_hours = self._parse_park_hours(data["parks"])
167
+ attractions = self._parse_attractions(data["attractions"])
168
+ standby_wait_times = self._parse_standby_wait_times(data["attractions"])
169
+
170
+ self.parks = {}
171
+
172
+ for park in Parks:
173
+ self.parks[park] = Park(
174
+ slug=Parks(park),
175
+ opening_time=park_hours[Parks(park)][0],
176
+ closing_time=park_hours[Parks(park)][1],
177
+ attractions=attractions[Parks(park)],
178
+ standby_wait_times=standby_wait_times[Parks(park)],
179
+ )
180
+
181
+ async def close(self) -> None:
182
+ """Close open client session."""
183
+ if self._session:
184
+ await self._session.close()
@@ -0,0 +1,8 @@
1
+ """DLPWait Client Exceptions."""
2
+
3
+ class DLPWaitError(Exception):
4
+ """Base exception for DLPWait client."""
5
+
6
+
7
+ class DLPWaitConnectionError(DLPWaitError):
8
+ """DLPWait connection exception."""
@@ -0,0 +1,23 @@
1
+ """DLPWait Models."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+
8
+ class Parks(StrEnum):
9
+ """Parks available within the API."""
10
+
11
+ DISNEYLAND = "disneyland-park"
12
+ WALT_DISNEY_STUDIOS = "walt-disney-studios-park"
13
+
14
+
15
+ @dataclass(kw_only=True, frozen=True)
16
+ class Park:
17
+ """Park data returned from the API."""
18
+
19
+ slug: Parks
20
+ opening_time: datetime
21
+ closing_time: datetime
22
+ attractions: dict[str, str]
23
+ standby_wait_times: dict[str, int]
File without changes
@@ -0,0 +1 @@
1
+ """Tests for the DLPWait library."""
@@ -0,0 +1,472 @@
1
+ """Tests for the DLPWait api."""
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from unittest.mock import AsyncMock, MagicMock
6
+ from zoneinfo import ZoneInfo
7
+
8
+ import pytest
9
+ from aiohttp import ClientError
10
+
11
+ from dlpwait.api import DLPWaitAPI
12
+ from dlpwait.exceptions import DLPWaitConnectionError
13
+ from dlpwait.models import Park, Parks
14
+
15
+ tz = ZoneInfo("Europe/Paris")
16
+
17
+ # -------------------------
18
+ # Helpers
19
+ # -------------------------
20
+
21
+ class MockResponse:
22
+ """Mock aiohttp response object supporting async context management."""
23
+
24
+ def __init__(self, status=200, payload=None, json_side_effect=None):
25
+ """Initialize the mock response."""
26
+ self.status = status
27
+ self._payload = payload or {}
28
+ self._json_side_effect = json_side_effect
29
+
30
+ async def json(self):
31
+ """Return JSON payload or raise configured exception."""
32
+ if self._json_side_effect:
33
+ raise self._json_side_effect
34
+ return self._payload
35
+
36
+ async def __aenter__(self):
37
+ """Enter async context manager."""
38
+ return self
39
+
40
+ async def __aexit__(self, exc_type, exc, tb):
41
+ """Exit async context manager."""
42
+ pass
43
+
44
+
45
+ def make_session(response: MockResponse = None, side_effect=None):
46
+ """Create a mocked aiohttp session with configurable behavior."""
47
+ session = MagicMock()
48
+ if side_effect:
49
+ session.post.side_effect = side_effect
50
+ else:
51
+ session.post.return_value = response
52
+ return session
53
+
54
+
55
+ # -------------------------
56
+ # _request tests
57
+ # -------------------------
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_request_success():
61
+ """Ensure _request returns parsed data on successful response."""
62
+ payload = {"data": {"parks": [], "attractions": []}}
63
+ response = MockResponse(status=200, payload=payload)
64
+ session = make_session(response=response)
65
+
66
+ api = DLPWaitAPI(session=session)
67
+ result = await api._request()
68
+
69
+ assert result == payload["data"]
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_request_non_200_status():
74
+ """Ensure _request raises on non-200 HTTP status."""
75
+ response = MockResponse(status=500)
76
+ session = make_session(response=response)
77
+
78
+ api = DLPWaitAPI(session=session)
79
+
80
+ with pytest.raises(DLPWaitConnectionError, match="Unexpected response"):
81
+ await api._request()
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_request_invalid_payload():
86
+ """Ensure _request raises when API payload format is invalid."""
87
+ payload = {"data": "invalid"}
88
+ response = MockResponse(status=200, payload=payload)
89
+ session = make_session(response=response)
90
+
91
+ api = DLPWaitAPI(session=session)
92
+
93
+ with pytest.raises(DLPWaitConnectionError, match="Invalid API response"):
94
+ await api._request()
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_request_timeout():
99
+ """Ensure _request raises connection error on timeout."""
100
+ session = make_session(side_effect=asyncio.TimeoutError())
101
+
102
+ api = DLPWaitAPI(session=session)
103
+
104
+ with pytest.raises(DLPWaitConnectionError, match="Timeout"):
105
+ await api._request()
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_request_client_error():
110
+ """Ensure _request raises connection error on aiohttp client error."""
111
+ session = make_session(side_effect=ClientError("boom"))
112
+
113
+ api = DLPWaitAPI(session=session)
114
+
115
+ with pytest.raises(DLPWaitConnectionError, match="Request failed"):
116
+ await api._request()
117
+
118
+
119
+ # -------------------------
120
+ # Static parsing methods
121
+ # -------------------------
122
+
123
+ def test_parse_park_hours():
124
+ """Ensure park hours are parsed correctly and invalid parks are ignored."""
125
+ parks = [
126
+ {
127
+ "slug": "disneyland-park",
128
+ "schedules": [
129
+ {
130
+ "status": "OPERATING",
131
+ "startTime": "09:00:00",
132
+ "endTime": "22:00:00",
133
+ "date": "2026-01-01"
134
+ }
135
+ ],
136
+ },
137
+ {
138
+ "slug": "invalid-park",
139
+ "schedules": [
140
+ {
141
+ "status": "OPERATING",
142
+ "startTime": "09:00:00",
143
+ "endTime": "22:00:00",
144
+ "date": "2026-01-01"
145
+ }
146
+ ],
147
+ },
148
+ ]
149
+
150
+ result = DLPWaitAPI._parse_park_hours(parks)
151
+
152
+ assert result == {
153
+ Parks.DISNEYLAND: (
154
+ datetime(2026, 1, 1, 9, 0, tzinfo=tz),
155
+ datetime(2026, 1, 1, 22, 0, tzinfo=tz),
156
+ )
157
+ }
158
+
159
+
160
+ def test_parse_attractions_filters_correctly():
161
+ """Ensure attractions are filtered by active, visible, and operating status."""
162
+ attractions = [
163
+ {
164
+ "id": "1",
165
+ "name": "Big Thunder Mountain",
166
+ "active": True,
167
+ "hide": False,
168
+ "status": "OPERATING",
169
+ "park": {"slug": "disneyland-park"},
170
+ },
171
+ {
172
+ "id": "2",
173
+ "name": "Hidden Ride",
174
+ "active": True,
175
+ "hide": True,
176
+ "status": "OPERATING",
177
+ "park": {"slug": "disneyland-park"},
178
+ },
179
+ ]
180
+
181
+ result = DLPWaitAPI._parse_attractions(attractions)
182
+
183
+ assert result == {
184
+ Parks.DISNEYLAND: {
185
+ "1": "Big Thunder Mountain"
186
+ }
187
+ }
188
+
189
+
190
+ def test_parse_standby_wait_times_filters_correctly():
191
+ """Ensure standby wait times are parsed and filtered correctly."""
192
+ attractions = [
193
+ {
194
+ "id": "1",
195
+ "active": True,
196
+ "hide": False,
197
+ "status": "OPERATING",
198
+ "park": {"slug": "disneyland-park"},
199
+ "waitTime": {"standby": {"minutes": 35}},
200
+ },
201
+ {
202
+ "id": "2",
203
+ "active": True,
204
+ "hide": False,
205
+ "status": "OPERATING",
206
+ "park": {"slug": "disneyland-park"},
207
+ "waitTime": {"standby": None},
208
+ },
209
+ ]
210
+
211
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
212
+
213
+ assert result == {
214
+ Parks.DISNEYLAND: {
215
+ "1": 35
216
+ }
217
+ }
218
+
219
+
220
+ # -------------------------
221
+ # Additional branch coverage
222
+ # -------------------------
223
+
224
+ def test_parse_attractions_skips_non_operating():
225
+ """Ensure non-operating attractions are ignored."""
226
+ attractions = [
227
+ {
228
+ "id": "1",
229
+ "name": "Closed Ride",
230
+ "active": True,
231
+ "hide": False,
232
+ "status": "DOWN",
233
+ "park": {"slug": "disneyland-park"},
234
+ }
235
+ ]
236
+
237
+ result = DLPWaitAPI._parse_attractions(attractions)
238
+ assert result == {}
239
+
240
+
241
+ def test_parse_attractions_skips_inactive():
242
+ """Ensure inactive attractions are ignored."""
243
+ attractions = [
244
+ {
245
+ "id": "1",
246
+ "name": "Inactive Ride",
247
+ "active": False,
248
+ "hide": False,
249
+ "status": "OPERATING",
250
+ "park": {"slug": "disneyland-park"},
251
+ }
252
+ ]
253
+
254
+ result = DLPWaitAPI._parse_attractions(attractions)
255
+ assert result == {}
256
+
257
+
258
+ def test_parse_attractions_skips_invalid_slug():
259
+ """Ensure attractions with invalid park slugs are ignored."""
260
+ attractions = [
261
+ {
262
+ "id": "1",
263
+ "name": "Invalid Park Ride",
264
+ "active": True,
265
+ "hide": False,
266
+ "status": "OPERATING",
267
+ "park": {"slug": "not-a-real-park"},
268
+ }
269
+ ]
270
+
271
+ result = DLPWaitAPI._parse_attractions(attractions)
272
+ assert result == {}
273
+
274
+
275
+ def test_parse_standby_wait_times_skips_inactive():
276
+ """Ensure inactive attractions are ignored in standby wait times."""
277
+ attractions = [
278
+ {
279
+ "id": "1",
280
+ "active": False,
281
+ "hide": False,
282
+ "status": "OPERATING",
283
+ "park": {"slug": "disneyland-park"},
284
+ "waitTime": {"standby": {"minutes": 10}},
285
+ }
286
+ ]
287
+
288
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
289
+ assert result == {}
290
+
291
+
292
+ def test_parse_standby_wait_times_skips_hidden():
293
+ """Ensure hidden attractions are ignored in standby wait times."""
294
+ attractions = [
295
+ {
296
+ "id": "1",
297
+ "active": True,
298
+ "hide": True,
299
+ "status": "OPERATING",
300
+ "park": {"slug": "disneyland-park"},
301
+ "waitTime": {"standby": {"minutes": 10}},
302
+ }
303
+ ]
304
+
305
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
306
+ assert result == {}
307
+
308
+
309
+ def test_parse_standby_wait_times_skips_non_operating():
310
+ """Ensure non-operating attractions are ignored in standby wait times."""
311
+ attractions = [
312
+ {
313
+ "id": "1",
314
+ "active": True,
315
+ "hide": False,
316
+ "status": "DOWN",
317
+ "park": {"slug": "disneyland-park"},
318
+ "waitTime": {"standby": {"minutes": 10}},
319
+ }
320
+ ]
321
+
322
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
323
+ assert result == {}
324
+
325
+
326
+ def test_parse_standby_wait_times_skips_invalid_slug():
327
+ """Ensure attractions with invalid park slugs are ignored in standby wait times."""
328
+ attractions = [
329
+ {
330
+ "id": "1",
331
+ "active": True,
332
+ "hide": False,
333
+ "status": "OPERATING",
334
+ "park": {"slug": "invalid-park"},
335
+ "waitTime": {"standby": {"minutes": 10}},
336
+ }
337
+ ]
338
+
339
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
340
+ assert result == {}
341
+
342
+
343
+ def test_parse_standby_wait_times_skips_missing_wait_time():
344
+ """Ensure attractions missing waitTime are ignored."""
345
+ attractions = [
346
+ {
347
+ "id": "1",
348
+ "active": True,
349
+ "hide": False,
350
+ "status": "OPERATING",
351
+ "park": {"slug": "disneyland-park"},
352
+ }
353
+ ]
354
+
355
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
356
+ assert result == {}
357
+
358
+
359
+ def test_parse_standby_wait_times_skips_missing_standby():
360
+ """Ensure attractions missing standby data are ignored."""
361
+ attractions = [
362
+ {
363
+ "id": "1",
364
+ "active": True,
365
+ "hide": False,
366
+ "status": "OPERATING",
367
+ "park": {"slug": "disneyland-park"},
368
+ "waitTime": {},
369
+ }
370
+ ]
371
+
372
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
373
+ assert result == {}
374
+
375
+
376
+ def test_parse_standby_wait_times_skips_none_minutes():
377
+ """Ensure attractions with None standby minutes are ignored."""
378
+ attractions = [
379
+ {
380
+ "id": "1",
381
+ "active": True,
382
+ "hide": False,
383
+ "status": "OPERATING",
384
+ "park": {"slug": "disneyland-park"},
385
+ "waitTime": {"standby": {"minutes": None}},
386
+ }
387
+ ]
388
+
389
+ result = DLPWaitAPI._parse_standby_wait_times(attractions)
390
+ assert result == {}
391
+
392
+
393
+ # -------------------------
394
+ # update() integration
395
+ # -------------------------
396
+
397
+ @pytest.mark.asyncio
398
+ async def test_update_populates_parks():
399
+ """Ensure update() populates parks with hours and standby wait times."""
400
+ payload = {
401
+ "data": {
402
+ "parks": [
403
+ {
404
+ "slug": "disneyland-park",
405
+ "schedules": [
406
+ {
407
+ "status": "OPERATING",
408
+ "startTime": "09:00:00",
409
+ "endTime": "22:00:00",
410
+ "date": "2026-01-01"
411
+ }
412
+ ],
413
+ },
414
+ {
415
+ "slug": "walt-disney-studios-park",
416
+ "schedules": [
417
+ {
418
+ "status": "OPERATING",
419
+ "startTime": "09:30:00",
420
+ "endTime": "21:00:00",
421
+ "date": "2026-01-01"
422
+ }
423
+ ],
424
+ },
425
+ ],
426
+ "attractions": [
427
+ {
428
+ "id": "1",
429
+ "name": "Ride A",
430
+ "active": True,
431
+ "hide": False,
432
+ "status": "OPERATING",
433
+ "park": {"slug": "disneyland-park"},
434
+ "waitTime": {"standby": {"minutes": 20}},
435
+ },
436
+ {
437
+ "id": "2",
438
+ "name": "Ride B",
439
+ "active": True,
440
+ "hide": False,
441
+ "status": "OPERATING",
442
+ "park": {"slug": "walt-disney-studios-park"},
443
+ "waitTime": {"standby": {"minutes": 15}},
444
+ },
445
+ ],
446
+ }
447
+ }
448
+
449
+ response = MockResponse(status=200, payload=payload)
450
+ session = make_session(response=response)
451
+
452
+ api = DLPWaitAPI(session=session)
453
+ await api.update()
454
+
455
+ assert isinstance(api.parks[Parks.DISNEYLAND], Park)
456
+ assert api.parks[Parks.DISNEYLAND].opening_time == datetime(2026, 1, 1, 9, 0, tzinfo=tz)
457
+ assert api.parks[Parks.DISNEYLAND].standby_wait_times["1"] == 20
458
+
459
+ assert api.parks[Parks.WALT_DISNEY_STUDIOS].opening_time == datetime(2026, 1, 1, 9, 30, tzinfo=tz)
460
+ assert api.parks[Parks.WALT_DISNEY_STUDIOS].standby_wait_times["2"] == 15
461
+
462
+
463
+ @pytest.mark.asyncio
464
+ async def test_close_closes_session():
465
+ """Ensure close() properly closes the aiohttp session."""
466
+ session = MagicMock()
467
+ session.close = AsyncMock()
468
+
469
+ api = DLPWaitAPI(session=session)
470
+ await api.close()
471
+
472
+ session.close.assert_awaited_once()