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.
@@ -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,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -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
+ )
@@ -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"})