hydropeak-opendata 0.1.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.
- hydropeak_opendata-0.1.0/.github/workflows/ci.yml +22 -0
- hydropeak_opendata-0.1.0/.github/workflows/release.yml +33 -0
- hydropeak_opendata-0.1.0/.gitignore +9 -0
- hydropeak_opendata-0.1.0/LICENSE +21 -0
- hydropeak_opendata-0.1.0/PKG-INFO +74 -0
- hydropeak_opendata-0.1.0/README.md +48 -0
- hydropeak_opendata-0.1.0/pyproject.toml +54 -0
- hydropeak_opendata-0.1.0/src/hydropeak_opendata/__init__.py +31 -0
- hydropeak_opendata-0.1.0/src/hydropeak_opendata/client.py +158 -0
- hydropeak_opendata-0.1.0/src/hydropeak_opendata/exceptions.py +27 -0
- hydropeak_opendata-0.1.0/src/hydropeak_opendata/models.py +134 -0
- hydropeak_opendata-0.1.0/src/hydropeak_opendata/py.typed +0 -0
- hydropeak_opendata-0.1.0/tests/conftest.py +27 -0
- hydropeak_opendata-0.1.0/tests/fixtures/feed_summer_2026.json +103 -0
- hydropeak_opendata-0.1.0/tests/fixtures/feed_winter.json +47 -0
- hydropeak_opendata-0.1.0/tests/test_client.py +178 -0
- hydropeak_opendata-0.1.0/tests/test_models.py +101 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e .[dev]
|
|
20
|
+
- run: ruff check .
|
|
21
|
+
- run: mypy
|
|
22
|
+
- run: pytest -q
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.13"
|
|
15
|
+
- run: pip install build
|
|
16
|
+
- run: python -m build
|
|
17
|
+
- uses: actions/upload-artifact@v4
|
|
18
|
+
with:
|
|
19
|
+
name: dist
|
|
20
|
+
path: dist/
|
|
21
|
+
|
|
22
|
+
publish:
|
|
23
|
+
needs: build
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: pypi
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write # OIDC for PyPI Trusted Publishing — no token needed
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/download-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Beat YT
|
|
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.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hydropeak-opendata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for Hydro-Québec's public peak events (pointes hivernales) open data
|
|
5
|
+
Project-URL: Homepage, https://github.com/Beat-YT/hydropeak-opendata
|
|
6
|
+
Project-URL: Issues, https://github.com/Beat-YT/hydropeak-opendata/issues
|
|
7
|
+
Author: Beat-YT
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: aiohttp>=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-aiohttp>=1.1; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# hydropeak-opendata
|
|
28
|
+
|
|
29
|
+
Async Python client for Hydro-Québec's public **peak events** (pointes hivernales) open data. No account, no login — just the open data feed.
|
|
30
|
+
|
|
31
|
+
Extracted from the [HydroPeak](https://github.com/Beat-YT/hydropeak-ha) Home Assistant integration.
|
|
32
|
+
|
|
33
|
+
## Data sources
|
|
34
|
+
|
|
35
|
+
- **Peak events feed** — `pointeshivernales.json`: available offers (canonical, verbatim) and scheduled peak events.
|
|
36
|
+
- **Offer descriptions** — the `evenements-de-pointe-offres-disponibles` Opendatasoft dataset (rate limited; intended for occasional use such as setup flows).
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import aiohttp
|
|
42
|
+
from hydropeak_opendata import OpenDataClient
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
async with aiohttp.ClientSession() as session:
|
|
46
|
+
client = OpenDataClient(session)
|
|
47
|
+
|
|
48
|
+
offers = await client.get_available_offers()
|
|
49
|
+
# ('Credit hivernal Residentiel (CPC-D)', 'Flex Residentiel (TPC-DPC)', ...)
|
|
50
|
+
|
|
51
|
+
events = await client.get_events(offers[0])
|
|
52
|
+
for event in events:
|
|
53
|
+
print(event.start, event.end, event.period, event.duration)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Notes:
|
|
57
|
+
|
|
58
|
+
- Offer identifiers are the strings published in `offresDisponibles`, used verbatim. The library applies no transformation; `get_events(offer)` filters by exact match.
|
|
59
|
+
- The client sends conditional requests (`If-None-Match`) and serves its cached parse on `304 Not Modified`, so frequent polling is cheap for both sides. Concurrent refreshes are serialized on a lock.
|
|
60
|
+
- All datetimes from the feed are timezone-aware. `PeakEventsFeed.last_execution` is naive (the feed publishes it without an offset).
|
|
61
|
+
- Errors raise typed exceptions: `OpenDataConnectionError`, `OpenDataRateLimitError`, `OpenDataResponseError`, `OpenDataParseError` — all subclasses of `OpenDataError`. Failures never silently return empty data.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
pip install -e .[dev]
|
|
67
|
+
ruff check .
|
|
68
|
+
mypy
|
|
69
|
+
pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# hydropeak-opendata
|
|
2
|
+
|
|
3
|
+
Async Python client for Hydro-Québec's public **peak events** (pointes hivernales) open data. No account, no login — just the open data feed.
|
|
4
|
+
|
|
5
|
+
Extracted from the [HydroPeak](https://github.com/Beat-YT/hydropeak-ha) Home Assistant integration.
|
|
6
|
+
|
|
7
|
+
## Data sources
|
|
8
|
+
|
|
9
|
+
- **Peak events feed** — `pointeshivernales.json`: available offers (canonical, verbatim) and scheduled peak events.
|
|
10
|
+
- **Offer descriptions** — the `evenements-de-pointe-offres-disponibles` Opendatasoft dataset (rate limited; intended for occasional use such as setup flows).
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import aiohttp
|
|
16
|
+
from hydropeak_opendata import OpenDataClient
|
|
17
|
+
|
|
18
|
+
async def main():
|
|
19
|
+
async with aiohttp.ClientSession() as session:
|
|
20
|
+
client = OpenDataClient(session)
|
|
21
|
+
|
|
22
|
+
offers = await client.get_available_offers()
|
|
23
|
+
# ('Credit hivernal Residentiel (CPC-D)', 'Flex Residentiel (TPC-DPC)', ...)
|
|
24
|
+
|
|
25
|
+
events = await client.get_events(offers[0])
|
|
26
|
+
for event in events:
|
|
27
|
+
print(event.start, event.end, event.period, event.duration)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Notes:
|
|
31
|
+
|
|
32
|
+
- Offer identifiers are the strings published in `offresDisponibles`, used verbatim. The library applies no transformation; `get_events(offer)` filters by exact match.
|
|
33
|
+
- The client sends conditional requests (`If-None-Match`) and serves its cached parse on `304 Not Modified`, so frequent polling is cheap for both sides. Concurrent refreshes are serialized on a lock.
|
|
34
|
+
- All datetimes from the feed are timezone-aware. `PeakEventsFeed.last_execution` is naive (the feed publishes it without an offset).
|
|
35
|
+
- Errors raise typed exceptions: `OpenDataConnectionError`, `OpenDataRateLimitError`, `OpenDataResponseError`, `OpenDataParseError` — all subclasses of `OpenDataError`. Failures never silently return empty data.
|
|
36
|
+
|
|
37
|
+
## Development
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install -e .[dev]
|
|
41
|
+
ruff check .
|
|
42
|
+
mypy
|
|
43
|
+
pytest
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hydropeak-opendata"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client for Hydro-Québec's public peak events (pointes hivernales) open data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Beat-YT" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["aiohttp>=3.9"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Beat-YT/hydropeak-opendata"
|
|
26
|
+
Issues = "https://github.com/Beat-YT/hydropeak-opendata/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"pytest-asyncio>=0.23",
|
|
32
|
+
"pytest-aiohttp>=1.1",
|
|
33
|
+
"ruff>=0.6",
|
|
34
|
+
"mypy>=1.10",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/hydropeak_opendata"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py311"
|
|
46
|
+
line-length = 100
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
strict = true
|
|
53
|
+
packages = ["hydropeak_opendata"]
|
|
54
|
+
mypy_path = "src"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Async client for Hydro-Québec's peak events (pointes hivernales) open data."""
|
|
2
|
+
|
|
3
|
+
from .client import OFFER_DESCRIPTIONS_URL, PEAK_EVENTS_URL, OpenDataClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
OpenDataConnectionError,
|
|
6
|
+
OpenDataError,
|
|
7
|
+
OpenDataParseError,
|
|
8
|
+
OpenDataRateLimitError,
|
|
9
|
+
OpenDataResponseError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
OfferDescription,
|
|
13
|
+
PeakEvent,
|
|
14
|
+
PeakEventsFeed,
|
|
15
|
+
parse_feed,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"OFFER_DESCRIPTIONS_URL",
|
|
20
|
+
"PEAK_EVENTS_URL",
|
|
21
|
+
"OfferDescription",
|
|
22
|
+
"OpenDataClient",
|
|
23
|
+
"OpenDataConnectionError",
|
|
24
|
+
"OpenDataError",
|
|
25
|
+
"OpenDataParseError",
|
|
26
|
+
"OpenDataRateLimitError",
|
|
27
|
+
"OpenDataResponseError",
|
|
28
|
+
"PeakEvent",
|
|
29
|
+
"PeakEventsFeed",
|
|
30
|
+
"parse_feed",
|
|
31
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Async client for Hydro-Québec's peak events open data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from datetime import date
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
OpenDataConnectionError,
|
|
13
|
+
OpenDataParseError,
|
|
14
|
+
OpenDataRateLimitError,
|
|
15
|
+
OpenDataResponseError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
OfferDescription,
|
|
19
|
+
PeakEvent,
|
|
20
|
+
PeakEventsFeed,
|
|
21
|
+
parse_feed,
|
|
22
|
+
parse_offer_description,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PEAK_EVENTS_URL = (
|
|
26
|
+
"https://donnees.solutions.hydroquebec.com/donnees-ouvertes/data/json/pointeshivernales.json"
|
|
27
|
+
)
|
|
28
|
+
OFFER_DESCRIPTIONS_URL = (
|
|
29
|
+
"https://donnees.hydroquebec.com/api/explore/v2.1/catalog/datasets"
|
|
30
|
+
"/evenements-de-pointe-offres-disponibles/records"
|
|
31
|
+
)
|
|
32
|
+
_DESCRIPTIONS_PAGE_SIZE = 100
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenDataClient:
|
|
36
|
+
"""Client for the peak events feed and the offer descriptions dataset.
|
|
37
|
+
|
|
38
|
+
The aiohttp session is injected and never closed by the client, so one
|
|
39
|
+
session can be shared across the application. The client sends
|
|
40
|
+
conditional requests (``If-None-Match``) and serves the last parsed feed
|
|
41
|
+
on ``304 Not Modified``; concurrent callers are serialized on a lock so
|
|
42
|
+
a burst of refreshes results in one request followed by cheap 304s.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
session: aiohttp.ClientSession,
|
|
48
|
+
*,
|
|
49
|
+
request_timeout: float = 30.0,
|
|
50
|
+
peak_events_url: str = PEAK_EVENTS_URL,
|
|
51
|
+
offer_descriptions_url: str = OFFER_DESCRIPTIONS_URL,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._session = session
|
|
54
|
+
self._peak_events_url = peak_events_url
|
|
55
|
+
self._offer_descriptions_url = offer_descriptions_url
|
|
56
|
+
self._timeout = aiohttp.ClientTimeout(total=request_timeout)
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
self._etag: str | None = None
|
|
59
|
+
self._feed: PeakEventsFeed | None = None
|
|
60
|
+
|
|
61
|
+
async def get_feed(self) -> PeakEventsFeed:
|
|
62
|
+
"""Fetch (or revalidate) the peak events feed."""
|
|
63
|
+
async with self._lock:
|
|
64
|
+
headers: dict[str, str] = {}
|
|
65
|
+
if self._etag is not None and self._feed is not None:
|
|
66
|
+
headers["If-None-Match"] = self._etag
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
async with self._session.get(
|
|
70
|
+
self._peak_events_url, headers=headers, timeout=self._timeout
|
|
71
|
+
) as response:
|
|
72
|
+
if response.status == 304 and self._feed is not None:
|
|
73
|
+
return self._feed
|
|
74
|
+
_raise_for_status(response)
|
|
75
|
+
etag = response.headers.get("ETag")
|
|
76
|
+
# The endpoint mislabels the JSON payload as text/plain,
|
|
77
|
+
# so parse the body ourselves instead of response.json().
|
|
78
|
+
text = await response.text()
|
|
79
|
+
except OSError as err:
|
|
80
|
+
raise OpenDataConnectionError(f"Error fetching peak events: {err}") from err
|
|
81
|
+
except aiohttp.ClientError as err:
|
|
82
|
+
raise OpenDataConnectionError(f"Error fetching peak events: {err}") from err
|
|
83
|
+
except TimeoutError as err:
|
|
84
|
+
raise OpenDataConnectionError("Timeout fetching peak events") from err
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(text)
|
|
88
|
+
except ValueError as err:
|
|
89
|
+
raise OpenDataParseError("Peak events payload is not valid JSON") from err
|
|
90
|
+
|
|
91
|
+
feed = parse_feed(data)
|
|
92
|
+
self._etag = etag
|
|
93
|
+
self._feed = feed
|
|
94
|
+
return feed
|
|
95
|
+
|
|
96
|
+
async def get_events(self, offer: str | None = None) -> tuple[PeakEvent, ...]:
|
|
97
|
+
"""Fetch events, optionally filtered to one offer (exact match)."""
|
|
98
|
+
feed = await self.get_feed()
|
|
99
|
+
if offer is None:
|
|
100
|
+
return feed.events
|
|
101
|
+
return feed.events_for_offer(offer)
|
|
102
|
+
|
|
103
|
+
async def get_available_offers(self) -> tuple[str, ...]:
|
|
104
|
+
"""Fetch the canonical offer list, verbatim from ``offresDisponibles``."""
|
|
105
|
+
feed = await self.get_feed()
|
|
106
|
+
return feed.offers
|
|
107
|
+
|
|
108
|
+
async def get_offer_descriptions(
|
|
109
|
+
self, active_on: date | None = None
|
|
110
|
+
) -> tuple[OfferDescription, ...]:
|
|
111
|
+
"""Fetch offer descriptions from the Opendatasoft dataset.
|
|
112
|
+
|
|
113
|
+
This endpoint is rate limited (429 raises
|
|
114
|
+
:class:`OpenDataRateLimitError`), so it is intended for occasional
|
|
115
|
+
use such as a setup flow, not for polling. Results are paginated
|
|
116
|
+
transparently. ``active_on`` filters to descriptions whose validity
|
|
117
|
+
window covers that date.
|
|
118
|
+
"""
|
|
119
|
+
params: dict[str, str | int] = {"limit": _DESCRIPTIONS_PAGE_SIZE}
|
|
120
|
+
if active_on is not None:
|
|
121
|
+
day = active_on.isoformat()
|
|
122
|
+
params["where"] = f"debut<='{day}' AND fin>='{day}'"
|
|
123
|
+
|
|
124
|
+
descriptions: list[OfferDescription] = []
|
|
125
|
+
offset = 0
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
async with self._session.get(
|
|
129
|
+
self._offer_descriptions_url,
|
|
130
|
+
params={**params, "offset": offset},
|
|
131
|
+
timeout=self._timeout,
|
|
132
|
+
) as response:
|
|
133
|
+
_raise_for_status(response)
|
|
134
|
+
data = await response.json(content_type=None)
|
|
135
|
+
except OSError as err:
|
|
136
|
+
raise OpenDataConnectionError(f"Error fetching descriptions: {err}") from err
|
|
137
|
+
except aiohttp.ClientError as err:
|
|
138
|
+
raise OpenDataConnectionError(f"Error fetching descriptions: {err}") from err
|
|
139
|
+
except TimeoutError as err:
|
|
140
|
+
raise OpenDataConnectionError("Timeout fetching descriptions") from err
|
|
141
|
+
|
|
142
|
+
if not isinstance(data, dict) or not isinstance(data.get("results"), list):
|
|
143
|
+
raise OpenDataParseError("Unexpected offer descriptions payload")
|
|
144
|
+
|
|
145
|
+
results = data["results"]
|
|
146
|
+
descriptions.extend(parse_offer_description(item) for item in results)
|
|
147
|
+
|
|
148
|
+
total = data.get("total_count", 0)
|
|
149
|
+
offset += len(results)
|
|
150
|
+
if not results or offset >= total:
|
|
151
|
+
return tuple(descriptions)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _raise_for_status(response: aiohttp.ClientResponse) -> None:
|
|
155
|
+
if response.status == 429:
|
|
156
|
+
raise OpenDataRateLimitError("Rate limited by the open data endpoint")
|
|
157
|
+
if response.status != 200:
|
|
158
|
+
raise OpenDataResponseError(response.status)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exceptions raised by the hydropeak-opendata client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenDataError(Exception):
|
|
7
|
+
"""Base exception for all hydropeak-opendata errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenDataConnectionError(OpenDataError):
|
|
11
|
+
"""Communicating with the open data endpoint failed (network, timeout)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenDataRateLimitError(OpenDataError):
|
|
15
|
+
"""The endpoint answered 429 Too Many Requests."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenDataResponseError(OpenDataError):
|
|
19
|
+
"""The endpoint answered with an unexpected HTTP status."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status: int, message: str | None = None) -> None:
|
|
22
|
+
super().__init__(message or f"Unexpected HTTP status {status}")
|
|
23
|
+
self.status = status
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenDataParseError(OpenDataError):
|
|
27
|
+
"""The endpoint answered 200 but the payload could not be parsed."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Data models for the Hydro-Québec peak events open data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, datetime, timedelta
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .exceptions import OpenDataParseError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class PeakEvent:
|
|
14
|
+
"""A single peak event from the ``evenements`` array."""
|
|
15
|
+
|
|
16
|
+
offer: str
|
|
17
|
+
start: datetime
|
|
18
|
+
end: datetime
|
|
19
|
+
period: str | None
|
|
20
|
+
sector: str | None
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def duration(self) -> timedelta:
|
|
24
|
+
"""Length of the event."""
|
|
25
|
+
return self.end - self.start
|
|
26
|
+
|
|
27
|
+
def is_active(self, now: datetime) -> bool:
|
|
28
|
+
"""Whether the event is in progress at ``now``."""
|
|
29
|
+
return self.start <= now <= self.end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class PeakEventsFeed:
|
|
34
|
+
"""The parsed ``pointeshivernales.json`` document.
|
|
35
|
+
|
|
36
|
+
``offers`` is the canonical list of offer identifiers exactly as
|
|
37
|
+
published in ``offresDisponibles``; no transformation is applied.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
offers: tuple[str, ...]
|
|
41
|
+
events: tuple[PeakEvent, ...]
|
|
42
|
+
last_execution: datetime | None
|
|
43
|
+
|
|
44
|
+
def events_for_offer(self, offer: str) -> tuple[PeakEvent, ...]:
|
|
45
|
+
"""Events whose ``offre`` value equals ``offer``, sorted by start time."""
|
|
46
|
+
return tuple(event for event in self.events if event.offer == offer)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class OfferDescription:
|
|
51
|
+
"""A record from the ``evenements-de-pointe-offres-disponibles`` dataset."""
|
|
52
|
+
|
|
53
|
+
offer: str
|
|
54
|
+
client_type: str | None
|
|
55
|
+
description_fr: str | None
|
|
56
|
+
doc_url_fr: str | None
|
|
57
|
+
doc_url_en: str | None
|
|
58
|
+
start: date | None
|
|
59
|
+
end: date | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_event(item: dict[str, Any]) -> PeakEvent:
|
|
63
|
+
try:
|
|
64
|
+
return PeakEvent(
|
|
65
|
+
offer=item["offre"],
|
|
66
|
+
start=datetime.fromisoformat(item["dateDebut"]),
|
|
67
|
+
end=datetime.fromisoformat(item["dateFin"]),
|
|
68
|
+
period=item.get("plageHoraire"),
|
|
69
|
+
sector=item.get("secteurClient"),
|
|
70
|
+
)
|
|
71
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
72
|
+
raise OpenDataParseError(f"Malformed event entry: {item!r}") from err
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_feed(data: Any) -> PeakEventsFeed:
|
|
76
|
+
"""Parse the raw JSON document into a :class:`PeakEventsFeed`.
|
|
77
|
+
|
|
78
|
+
Raises :class:`OpenDataParseError` if required structure is missing.
|
|
79
|
+
"""
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
raise OpenDataParseError(f"Expected a JSON object, got {type(data).__name__}")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
raw_offers = data["offresDisponibles"]
|
|
85
|
+
raw_events = data["evenements"]
|
|
86
|
+
except KeyError as err:
|
|
87
|
+
raise OpenDataParseError(f"Missing key in feed: {err}") from err
|
|
88
|
+
|
|
89
|
+
if not isinstance(raw_offers, list) or not isinstance(raw_events, list):
|
|
90
|
+
raise OpenDataParseError("offresDisponibles and evenements must be arrays")
|
|
91
|
+
|
|
92
|
+
# "derniereExecution" is a single-element array of a naive local
|
|
93
|
+
# timestamp ("2026-08-15 19:21:22"); its timezone is unspecified upstream.
|
|
94
|
+
last_execution: datetime | None = None
|
|
95
|
+
raw_last = data.get("derniereExecution")
|
|
96
|
+
if isinstance(raw_last, list) and raw_last:
|
|
97
|
+
try:
|
|
98
|
+
last_execution = datetime.fromisoformat(raw_last[0])
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
last_execution = None
|
|
101
|
+
|
|
102
|
+
events = sorted((_parse_event(item) for item in raw_events), key=lambda e: e.start)
|
|
103
|
+
return PeakEventsFeed(
|
|
104
|
+
offers=tuple(str(raw) for raw in raw_offers),
|
|
105
|
+
events=tuple(events),
|
|
106
|
+
last_execution=last_execution,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_date(value: Any) -> date | None:
|
|
111
|
+
if not isinstance(value, str):
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return date.fromisoformat(value)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_offer_description(item: dict[str, Any]) -> OfferDescription:
|
|
120
|
+
"""Parse one record from the offer descriptions dataset."""
|
|
121
|
+
try:
|
|
122
|
+
offer = item["offresdisponibles"]
|
|
123
|
+
except KeyError as err:
|
|
124
|
+
raise OpenDataParseError(f"Malformed offer description: {item!r}") from err
|
|
125
|
+
|
|
126
|
+
return OfferDescription(
|
|
127
|
+
offer=offer,
|
|
128
|
+
client_type=item.get("type_clientele"),
|
|
129
|
+
description_fr=item.get("description_fr"),
|
|
130
|
+
doc_url_fr=item.get("doc_fr"),
|
|
131
|
+
doc_url_en=item.get("doc_eng"),
|
|
132
|
+
start=_parse_date(item.get("debut")),
|
|
133
|
+
end=_parse_date(item.get("fin")),
|
|
134
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared test fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_fixture(name: str) -> Any:
|
|
15
|
+
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def summer_feed() -> Any:
|
|
20
|
+
"""The real feed as served in August 2026 (business-only events)."""
|
|
21
|
+
return load_fixture("feed_summer_2026.json")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def winter_feed() -> Any:
|
|
26
|
+
"""A winter-shaped feed with residential (CPC-D / TPC-DPC) events."""
|
|
27
|
+
return load_fixture("feed_winter.json")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"offresDisponibles": [
|
|
3
|
+
"Chaine de bloc",
|
|
4
|
+
"OEA",
|
|
5
|
+
"OEA Serres",
|
|
6
|
+
"ENG_100H_2F_7J",
|
|
7
|
+
"ENG_100H_2F_5J",
|
|
8
|
+
"ENG_100H_1F_7J",
|
|
9
|
+
"ENG_100H_1F_5J",
|
|
10
|
+
"ENG_80H_2F_7J",
|
|
11
|
+
"ENG_80H_2F_5J",
|
|
12
|
+
"ENG_80H_1F_7J",
|
|
13
|
+
"ENG_80H_1F_5J",
|
|
14
|
+
"ENG_60H_2F_7J",
|
|
15
|
+
"ENG_60H_2F_5J",
|
|
16
|
+
"ENG_60H_1F_7J",
|
|
17
|
+
"ENG_60H_1F_5J",
|
|
18
|
+
"ENG_40H_2F_7J",
|
|
19
|
+
"ENG_40H_2F_5J",
|
|
20
|
+
"ENG_40H_1F_7J",
|
|
21
|
+
"ENG_40H_1F_5J",
|
|
22
|
+
"ENG_20H_2F_7J",
|
|
23
|
+
"ENG_20H_2F_5J",
|
|
24
|
+
"ENG_20H_1F_7J",
|
|
25
|
+
"ENG_20H_1F_5J",
|
|
26
|
+
"LAT_100H_5J",
|
|
27
|
+
"LAT_100H_7J",
|
|
28
|
+
"LAT_Signal_FDS",
|
|
29
|
+
"LAT_20H_5J",
|
|
30
|
+
"LAT_20H_7J",
|
|
31
|
+
"LAT_40H_5J",
|
|
32
|
+
"LAT_40H_7J",
|
|
33
|
+
"LAT_60H_5J",
|
|
34
|
+
"LAT_60H_7J",
|
|
35
|
+
"LAT_80H_5J",
|
|
36
|
+
"LAT_80H_7J",
|
|
37
|
+
"Credit hivernal Residentiel (CPC-D)",
|
|
38
|
+
"Credit hivernal Affaires (CPC-G)",
|
|
39
|
+
"Flex Residentiel (TPC-DPC)",
|
|
40
|
+
"Flex Affaires (TPC-GPC)"
|
|
41
|
+
],
|
|
42
|
+
"derniereExecution": [
|
|
43
|
+
"2026-08-15 19:21:22"
|
|
44
|
+
],
|
|
45
|
+
"evenements": [
|
|
46
|
+
{
|
|
47
|
+
"offre": "OEA",
|
|
48
|
+
"dateDebut": "2026-06-11T16:00:00-04:00",
|
|
49
|
+
"dateFin": "2026-06-11T19:00:00-04:00",
|
|
50
|
+
"plageHoraire": "PM",
|
|
51
|
+
"duree": "PT03H00MS",
|
|
52
|
+
"secteurClient": "Affaires"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"offre": "Chaine de bloc",
|
|
56
|
+
"dateDebut": "2026-06-11T16:00:00-04:00",
|
|
57
|
+
"dateFin": "2026-06-11T19:00:00-04:00",
|
|
58
|
+
"plageHoraire": "PM",
|
|
59
|
+
"duree": "PT03H00MS",
|
|
60
|
+
"secteurClient": "Affaires"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"offre": "OEA",
|
|
64
|
+
"dateDebut": "2026-06-12T17:00:00-04:00",
|
|
65
|
+
"dateFin": "2026-06-12T19:00:00-04:00",
|
|
66
|
+
"plageHoraire": "PM",
|
|
67
|
+
"duree": "PT02H00MS",
|
|
68
|
+
"secteurClient": "Affaires"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"offre": "Chaine de bloc",
|
|
72
|
+
"dateDebut": "2026-06-12T17:00:00-04:00",
|
|
73
|
+
"dateFin": "2026-06-12T19:00:00-04:00",
|
|
74
|
+
"plageHoraire": "PM",
|
|
75
|
+
"duree": "PT02H00MS",
|
|
76
|
+
"secteurClient": "Affaires"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"offre": "Chaine de bloc",
|
|
80
|
+
"dateDebut": "2026-07-02T13:00:00-04:00",
|
|
81
|
+
"dateFin": "2026-07-02T20:00:00-04:00",
|
|
82
|
+
"plageHoraire": "PM",
|
|
83
|
+
"duree": "PT07H00MS",
|
|
84
|
+
"secteurClient": "Affaires"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"offre": "OEA",
|
|
88
|
+
"dateDebut": "2026-07-02T13:00:00-04:00",
|
|
89
|
+
"dateFin": "2026-07-02T20:00:00-04:00",
|
|
90
|
+
"plageHoraire": "PM",
|
|
91
|
+
"duree": "PT07H00MS",
|
|
92
|
+
"secteurClient": "Affaires"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"offre": "Chaine de bloc",
|
|
96
|
+
"dateDebut": "2026-08-06T13:00:00-04:00",
|
|
97
|
+
"dateFin": "2026-08-06T19:00:00-04:00",
|
|
98
|
+
"plageHoraire": "PM",
|
|
99
|
+
"duree": "PT06H00MS",
|
|
100
|
+
"secteurClient": "Affaires"
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"offresDisponibles": [
|
|
3
|
+
"Credit hivernal Residentiel (CPC-D)",
|
|
4
|
+
"Credit hivernal Affaires (CPC-G)",
|
|
5
|
+
"Flex Residentiel (TPC-DPC)",
|
|
6
|
+
"Flex Affaires (TPC-GPC)",
|
|
7
|
+
"OEA",
|
|
8
|
+
"Chaine de bloc"
|
|
9
|
+
],
|
|
10
|
+
"derniereExecution": [
|
|
11
|
+
"2026-01-09 05:31:10"
|
|
12
|
+
],
|
|
13
|
+
"evenements": [
|
|
14
|
+
{
|
|
15
|
+
"offre": "Credit hivernal Residentiel (CPC-D)",
|
|
16
|
+
"dateDebut": "2026-01-09T06:00:00-05:00",
|
|
17
|
+
"dateFin": "2026-01-09T09:00:00-05:00",
|
|
18
|
+
"plageHoraire": "AM",
|
|
19
|
+
"duree": "PT03H00MS",
|
|
20
|
+
"secteurClient": "Residentiel"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"offre": "Flex Residentiel (TPC-DPC)",
|
|
24
|
+
"dateDebut": "2026-01-09T16:00:00-05:00",
|
|
25
|
+
"dateFin": "2026-01-09T20:00:00-05:00",
|
|
26
|
+
"plageHoraire": "PM",
|
|
27
|
+
"duree": "PT04H00MS",
|
|
28
|
+
"secteurClient": "Residentiel"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"offre": "Credit hivernal Residentiel (CPC-D)",
|
|
32
|
+
"dateDebut": "2026-01-09T16:00:00-05:00",
|
|
33
|
+
"dateFin": "2026-01-09T20:00:00-05:00",
|
|
34
|
+
"plageHoraire": "PM",
|
|
35
|
+
"duree": "PT04H00MS",
|
|
36
|
+
"secteurClient": "Residentiel"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"offre": "Credit hivernal Residentiel (CPC-D)",
|
|
40
|
+
"dateDebut": "2026-01-10T06:00:00-05:00",
|
|
41
|
+
"dateFin": "2026-01-10T09:00:00-05:00",
|
|
42
|
+
"plageHoraire": "AM",
|
|
43
|
+
"duree": "PT03H00MS",
|
|
44
|
+
"secteurClient": "Residentiel"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Tests for the HTTP client, against a local aiohttp test server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from aiohttp import web
|
|
10
|
+
|
|
11
|
+
from hydropeak_opendata import (
|
|
12
|
+
OpenDataClient,
|
|
13
|
+
OpenDataConnectionError,
|
|
14
|
+
OpenDataParseError,
|
|
15
|
+
OpenDataRateLimitError,
|
|
16
|
+
OpenDataResponseError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeOpenData:
|
|
21
|
+
"""Scriptable stand-in for the two Hydro-Québec endpoints."""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.feed_responses = [] # queue of callables returning web.Response
|
|
25
|
+
self.feed_requests = [] # recorded feed request headers
|
|
26
|
+
self.desc_pages = [] # payloads keyed by offset // 100
|
|
27
|
+
self.desc_status = 200
|
|
28
|
+
self.server = None
|
|
29
|
+
|
|
30
|
+
async def handle_feed(self, request):
|
|
31
|
+
self.feed_requests.append(dict(request.headers))
|
|
32
|
+
return self.feed_responses.pop(0)(request)
|
|
33
|
+
|
|
34
|
+
async def handle_descriptions(self, request):
|
|
35
|
+
if self.desc_status != 200:
|
|
36
|
+
return web.Response(status=self.desc_status)
|
|
37
|
+
offset = int(request.query.get("offset", 0))
|
|
38
|
+
return web.json_response(self.desc_pages[offset // 100])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_ok(payload, etag='"v1"'):
|
|
42
|
+
"""The endpoint mislabels its JSON as text/plain; the fake does too."""
|
|
43
|
+
return lambda request: web.Response(
|
|
44
|
+
text=json.dumps(payload), content_type="text/plain", headers={"ETag": etag}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def status(code):
|
|
49
|
+
return lambda request: web.Response(status=code)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def body(text):
|
|
53
|
+
return lambda request: web.Response(text=text, content_type="text/plain")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
async def fake(aiohttp_server):
|
|
58
|
+
fake = FakeOpenData()
|
|
59
|
+
app = web.Application()
|
|
60
|
+
app.router.add_get("/feed", fake.handle_feed)
|
|
61
|
+
app.router.add_get("/descriptions", fake.handle_descriptions)
|
|
62
|
+
fake.server = await aiohttp_server(app)
|
|
63
|
+
return fake
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
async def client(fake, aiohttp_client):
|
|
68
|
+
session = await aiohttp_client(fake.server)
|
|
69
|
+
return OpenDataClient(
|
|
70
|
+
session.session,
|
|
71
|
+
peak_events_url=str(fake.server.make_url("/feed")),
|
|
72
|
+
offer_descriptions_url=str(fake.server.make_url("/descriptions")),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_get_feed_parses_mislabeled_json(client, fake, winter_feed):
|
|
77
|
+
fake.feed_responses = [json_ok(winter_feed)]
|
|
78
|
+
feed = await client.get_feed()
|
|
79
|
+
|
|
80
|
+
assert feed.offers[0] == "Credit hivernal Residentiel (CPC-D)"
|
|
81
|
+
assert len(feed.events) == 4
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_get_feed_revalidates_with_etag(client, fake, winter_feed):
|
|
85
|
+
fake.feed_responses = [json_ok(winter_feed, etag='"v1"'), status(304)]
|
|
86
|
+
|
|
87
|
+
first = await client.get_feed()
|
|
88
|
+
second = await client.get_feed()
|
|
89
|
+
|
|
90
|
+
assert fake.feed_requests[0].get("If-None-Match") is None
|
|
91
|
+
assert fake.feed_requests[1].get("If-None-Match") == '"v1"'
|
|
92
|
+
assert second is first # cached object served on 304
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def test_get_feed_304_without_cache_is_an_error(client, fake):
|
|
96
|
+
fake.feed_responses = [status(304)]
|
|
97
|
+
with pytest.raises(OpenDataResponseError) as excinfo:
|
|
98
|
+
await client.get_feed()
|
|
99
|
+
assert excinfo.value.status == 304
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def test_get_feed_error_statuses(client, fake):
|
|
103
|
+
fake.feed_responses = [status(429)]
|
|
104
|
+
with pytest.raises(OpenDataRateLimitError):
|
|
105
|
+
await client.get_feed()
|
|
106
|
+
|
|
107
|
+
fake.feed_responses = [status(503)]
|
|
108
|
+
with pytest.raises(OpenDataResponseError) as excinfo:
|
|
109
|
+
await client.get_feed()
|
|
110
|
+
assert excinfo.value.status == 503
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_get_feed_invalid_json(client, fake):
|
|
114
|
+
fake.feed_responses = [body("<html>maintenance</html>")]
|
|
115
|
+
with pytest.raises(OpenDataParseError):
|
|
116
|
+
await client.get_feed()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_get_feed_network_error(fake, aiohttp_client):
|
|
120
|
+
session = await aiohttp_client(fake.server)
|
|
121
|
+
unreachable = OpenDataClient(
|
|
122
|
+
session.session, peak_events_url="http://127.0.0.1:1/feed", request_timeout=2
|
|
123
|
+
)
|
|
124
|
+
with pytest.raises(OpenDataConnectionError):
|
|
125
|
+
await unreachable.get_feed()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_failed_refresh_does_not_clobber_cache(client, fake, winter_feed):
|
|
129
|
+
fake.feed_responses = [json_ok(winter_feed), status(503), status(304)]
|
|
130
|
+
|
|
131
|
+
first = await client.get_feed()
|
|
132
|
+
with pytest.raises(OpenDataResponseError):
|
|
133
|
+
await client.get_feed()
|
|
134
|
+
third = await client.get_feed()
|
|
135
|
+
|
|
136
|
+
assert third is first
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def test_concurrent_callers_are_serialized(client, fake, winter_feed):
|
|
140
|
+
fake.feed_responses = [json_ok(winter_feed, etag='"v1"'), status(304)]
|
|
141
|
+
|
|
142
|
+
first, second = await asyncio.gather(client.get_feed(), client.get_feed())
|
|
143
|
+
|
|
144
|
+
assert first is second
|
|
145
|
+
assert fake.feed_requests[1].get("If-None-Match") == '"v1"'
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_get_events_and_offers(client, fake, winter_feed):
|
|
149
|
+
fake.feed_responses = [json_ok(winter_feed), status(304), status(304)]
|
|
150
|
+
|
|
151
|
+
offers = await client.get_available_offers()
|
|
152
|
+
all_events = await client.get_events()
|
|
153
|
+
cpc_events = await client.get_events("Credit hivernal Residentiel (CPC-D)")
|
|
154
|
+
|
|
155
|
+
assert "Flex Residentiel (TPC-DPC)" in offers
|
|
156
|
+
assert len(all_events) == 4
|
|
157
|
+
assert len(cpc_events) == 3
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_get_offer_descriptions_paginates(client, fake):
|
|
161
|
+
def make_record(index):
|
|
162
|
+
return {"offresdisponibles": f"OFFER_{index}", "description_fr": f"desc {index}"}
|
|
163
|
+
|
|
164
|
+
fake.desc_pages = [
|
|
165
|
+
{"total_count": 150, "results": [make_record(i) for i in range(100)]},
|
|
166
|
+
{"total_count": 150, "results": [make_record(i) for i in range(100, 150)]},
|
|
167
|
+
]
|
|
168
|
+
descriptions = await client.get_offer_descriptions()
|
|
169
|
+
|
|
170
|
+
assert len(descriptions) == 150
|
|
171
|
+
assert descriptions[0].offer == "OFFER_0"
|
|
172
|
+
assert descriptions[-1].offer == "OFFER_149"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_get_offer_descriptions_rate_limited(client, fake):
|
|
176
|
+
fake.desc_status = 429
|
|
177
|
+
with pytest.raises(OpenDataRateLimitError):
|
|
178
|
+
await client.get_offer_descriptions()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tests for feed parsing and models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from hydropeak_opendata import OpenDataParseError, parse_feed
|
|
10
|
+
from hydropeak_opendata.models import parse_offer_description
|
|
11
|
+
|
|
12
|
+
EST = timezone(timedelta(hours=-5))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_parse_summer_feed(summer_feed):
|
|
16
|
+
feed = parse_feed(summer_feed)
|
|
17
|
+
|
|
18
|
+
assert "Credit hivernal Residentiel (CPC-D)" in feed.offers
|
|
19
|
+
assert "OEA" in feed.offers
|
|
20
|
+
assert len(feed.offers) == 38
|
|
21
|
+
|
|
22
|
+
assert len(feed.events) == 7
|
|
23
|
+
# sorted by start
|
|
24
|
+
starts = [event.start for event in feed.events]
|
|
25
|
+
assert starts == sorted(starts)
|
|
26
|
+
# datetimes are timezone-aware
|
|
27
|
+
assert all(event.start.tzinfo is not None for event in feed.events)
|
|
28
|
+
|
|
29
|
+
assert feed.last_execution == datetime(2026, 8, 15, 19, 21, 22)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_offers_are_verbatim(winter_feed):
|
|
33
|
+
feed = parse_feed(winter_feed)
|
|
34
|
+
assert feed.offers[0] == "Credit hivernal Residentiel (CPC-D)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_events_for_offer_exact_match(winter_feed):
|
|
38
|
+
feed = parse_feed(winter_feed)
|
|
39
|
+
|
|
40
|
+
events = feed.events_for_offer("Credit hivernal Residentiel (CPC-D)")
|
|
41
|
+
assert len(events) == 3
|
|
42
|
+
assert all(event.offer == "Credit hivernal Residentiel (CPC-D)" for event in events)
|
|
43
|
+
|
|
44
|
+
assert feed.events_for_offer("Flex Residentiel (TPC-DPC)") != ()
|
|
45
|
+
assert feed.events_for_offer("nonexistent") == ()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_event_fields(winter_feed):
|
|
49
|
+
feed = parse_feed(winter_feed)
|
|
50
|
+
event = feed.events[0]
|
|
51
|
+
|
|
52
|
+
assert event.start == datetime(2026, 1, 9, 6, 0, tzinfo=EST)
|
|
53
|
+
assert event.end == datetime(2026, 1, 9, 9, 0, tzinfo=EST)
|
|
54
|
+
assert event.period == "AM"
|
|
55
|
+
assert event.sector == "Residentiel"
|
|
56
|
+
assert event.duration == timedelta(hours=3)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_event_is_active(winter_feed):
|
|
60
|
+
feed = parse_feed(winter_feed)
|
|
61
|
+
event = feed.events[0]
|
|
62
|
+
|
|
63
|
+
assert event.is_active(datetime(2026, 1, 9, 7, 0, tzinfo=EST))
|
|
64
|
+
assert not event.is_active(datetime(2026, 1, 9, 5, 59, tzinfo=EST))
|
|
65
|
+
assert not event.is_active(datetime(2026, 1, 9, 9, 1, tzinfo=EST))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_parse_feed_rejects_bad_payloads():
|
|
69
|
+
with pytest.raises(OpenDataParseError):
|
|
70
|
+
parse_feed([])
|
|
71
|
+
with pytest.raises(OpenDataParseError):
|
|
72
|
+
parse_feed({"evenements": []})
|
|
73
|
+
with pytest.raises(OpenDataParseError):
|
|
74
|
+
parse_feed({"offresDisponibles": [], "evenements": "nope"})
|
|
75
|
+
with pytest.raises(OpenDataParseError):
|
|
76
|
+
parse_feed({"offresDisponibles": [], "evenements": [{"offre": "X"}]})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_parse_feed_tolerates_missing_last_execution(winter_feed):
|
|
80
|
+
del winter_feed["derniereExecution"]
|
|
81
|
+
assert parse_feed(winter_feed).last_execution is None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_parse_offer_description():
|
|
85
|
+
record = {
|
|
86
|
+
"offresdisponibles": "CPC-D",
|
|
87
|
+
"type_clientele": "residentiel",
|
|
88
|
+
"description_fr": "Crédit hivernal",
|
|
89
|
+
"doc_fr": "https://example.test/fr",
|
|
90
|
+
"doc_eng": "https://example.test/en",
|
|
91
|
+
"debut": "2026-12-01",
|
|
92
|
+
"fin": "2027-03-31",
|
|
93
|
+
}
|
|
94
|
+
desc = parse_offer_description(record)
|
|
95
|
+
assert desc.offer == "CPC-D"
|
|
96
|
+
assert desc.client_type == "residentiel"
|
|
97
|
+
assert desc.start.isoformat() == "2026-12-01"
|
|
98
|
+
assert desc.end.isoformat() == "2027-03-31"
|
|
99
|
+
|
|
100
|
+
with pytest.raises(OpenDataParseError):
|
|
101
|
+
parse_offer_description({"description_fr": "no offer key"})
|