elec-tariffs 0.1.0__py3-none-any.whl

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,87 @@
1
+ """Cost French electricity consumption hour by hour under Base, HP/HC and Tempo."""
2
+
3
+ from ._version import __version__
4
+ from .engine import (
5
+ TEMPO_OFF_PEAK,
6
+ HourCost,
7
+ cost_hour,
8
+ fixed_cost_per_hour,
9
+ is_off_peak,
10
+ period_at,
11
+ price_at,
12
+ split_hour,
13
+ tempo_day,
14
+ )
15
+ from .errors import (
16
+ ElecTariffsError,
17
+ MissingColorError,
18
+ MissingPriceError,
19
+ TempoAuthError,
20
+ TempoError,
21
+ TempoFormatError,
22
+ TempoRateLimitError,
23
+ TempoServerError,
24
+ TrvError,
25
+ TrvFormatError,
26
+ TrvServerError,
27
+ )
28
+ from .models import (
29
+ TRANCHES,
30
+ ContractKind,
31
+ ContractRules,
32
+ DayCalendar,
33
+ PricePeriod,
34
+ TempoColor,
35
+ TimeRange,
36
+ Tranche,
37
+ )
38
+ from .sources import (
39
+ FallbackTempoSource,
40
+ RteOfficialTempoClient,
41
+ RtePublicTempoClient,
42
+ TempoCalendarSource,
43
+ TrvClient,
44
+ parse_trv_csv,
45
+ season_bounds,
46
+ season_of,
47
+ )
48
+
49
+ __all__ = [
50
+ "TEMPO_OFF_PEAK",
51
+ "TRANCHES",
52
+ "ContractKind",
53
+ "ContractRules",
54
+ "DayCalendar",
55
+ "ElecTariffsError",
56
+ "FallbackTempoSource",
57
+ "HourCost",
58
+ "MissingColorError",
59
+ "MissingPriceError",
60
+ "PricePeriod",
61
+ "RteOfficialTempoClient",
62
+ "RtePublicTempoClient",
63
+ "TempoAuthError",
64
+ "TempoCalendarSource",
65
+ "TempoColor",
66
+ "TempoError",
67
+ "TempoFormatError",
68
+ "TempoRateLimitError",
69
+ "TempoServerError",
70
+ "TimeRange",
71
+ "Tranche",
72
+ "TrvClient",
73
+ "TrvError",
74
+ "TrvFormatError",
75
+ "TrvServerError",
76
+ "__version__",
77
+ "cost_hour",
78
+ "fixed_cost_per_hour",
79
+ "is_off_peak",
80
+ "parse_trv_csv",
81
+ "period_at",
82
+ "price_at",
83
+ "season_bounds",
84
+ "season_of",
85
+ "split_hour",
86
+ "tempo_day",
87
+ ]
@@ -0,0 +1,3 @@
1
+ """Version of the package, read by the build backend."""
2
+
3
+ __version__ = "0.1.0"
elec_tariffs/engine.py ADDED
@@ -0,0 +1,140 @@
1
+ """Split an hour of consumption into tranches and cost it.
2
+
3
+ An hourly statistic row starts at the top of an hour. Every hour is costed as two
4
+ half hours with half the energy each: that is the finest boundary of an off-peak
5
+ range, and it assumes the consumption is even within the hour.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from datetime import date, datetime, time, timedelta
11
+ from typing import Final
12
+
13
+ from .errors import MissingColorError, MissingPriceError
14
+ from .models import (
15
+ ContractKind,
16
+ ContractRules,
17
+ DayCalendar,
18
+ PricePeriod,
19
+ TempoColor,
20
+ TimeRange,
21
+ Tranche,
22
+ )
23
+
24
+ TEMPO_DAY_START: Final = time(6)
25
+ TEMPO_OFF_PEAK: Final = (TimeRange(start=time(22), end=time(6)),)
26
+
27
+ _TEMPO_TRANCHES: Final = {
28
+ (TempoColor.BLUE, True): Tranche.BLUE_HC,
29
+ (TempoColor.BLUE, False): Tranche.BLUE_HP,
30
+ (TempoColor.WHITE, True): Tranche.WHITE_HC,
31
+ (TempoColor.WHITE, False): Tranche.WHITE_HP,
32
+ (TempoColor.RED, True): Tranche.RED_HC,
33
+ (TempoColor.RED, False): Tranche.RED_HP,
34
+ }
35
+ _SATURDAY: Final = 5
36
+
37
+
38
+ def tempo_day(local: datetime) -> date:
39
+ """Return the Tempo day that `local` belongs to: they run from 6:00 to 6:00."""
40
+ wall = local.replace(tzinfo=None) - timedelta(hours=TEMPO_DAY_START.hour)
41
+ return wall.date()
42
+
43
+
44
+ def is_off_peak(value: time, ranges: Sequence[TimeRange]) -> bool:
45
+ """Tell whether a time of day falls in one of the off-peak ranges."""
46
+ return any(time_range.contains(value) for time_range in ranges)
47
+
48
+
49
+ def _tranche(local: datetime, rules: ContractRules, calendar: DayCalendar) -> Tranche:
50
+ """Return the tranche of the half hour starting at `local`."""
51
+ if rules.kind is ContractKind.BASE:
52
+ return Tranche.BASE
53
+ if rules.kind is ContractKind.HPHC:
54
+ weekend = rules.weekend_off_peak and local.weekday() >= _SATURDAY
55
+ if weekend or is_off_peak(local.time(), rules.off_peak):
56
+ return Tranche.HC
57
+ return Tranche.HP
58
+ day = tempo_day(local)
59
+ color = calendar.get(day)
60
+ if color is None:
61
+ raise MissingColorError(day)
62
+ return _TEMPO_TRANCHES[color, is_off_peak(local.time(), TEMPO_OFF_PEAK)]
63
+
64
+
65
+ def split_hour(
66
+ start: datetime, kwh: float, rules: ContractRules, calendar: DayCalendar
67
+ ) -> dict[Tranche, float]:
68
+ """Split the energy of the hour starting at `start` (local, aware) into tranches.
69
+
70
+ Raise MissingColorError when a Tempo contract needs a color that is not known.
71
+ """
72
+ if start.minute or start.second or start.microsecond:
73
+ msg = f"{start.isoformat()} is not the start of an hour"
74
+ raise ValueError(msg)
75
+ split: dict[Tranche, float] = {}
76
+ for half in (start, start.replace(minute=30)):
77
+ tranche = _tranche(half, rules, calendar)
78
+ split[tranche] = split.get(tranche, 0.0) + kwh / 2
79
+ return split
80
+
81
+
82
+ def period_at(periods: Sequence[PricePeriod], day: date) -> PricePeriod:
83
+ """Return the price period in force on `day`, or raise MissingPriceError."""
84
+ for period in periods:
85
+ if period.covers(day):
86
+ return period
87
+ raise MissingPriceError(day)
88
+
89
+
90
+ def price_at(periods: Sequence[PricePeriod], day: date, tranche: Tranche) -> float:
91
+ """Return the price of a tranche on `day`, or raise MissingPriceError."""
92
+ period = period_at(periods, day)
93
+ if tranche not in period.prices:
94
+ raise MissingPriceError(day)
95
+ return period.prices[tranche]
96
+
97
+
98
+ def fixed_cost_per_hour(period: PricePeriod, start: datetime) -> float:
99
+ """Spread the yearly fixed part evenly over the hours of the year of `start`."""
100
+ year = start.year
101
+ days = (date(year + 1, 1, 1) - date(year, 1, 1)).days
102
+ return period.fixed_per_year / days / 24
103
+
104
+
105
+ @dataclass(frozen=True, kw_only=True)
106
+ class HourCost:
107
+ """Energy (kWh) and variable cost per tranche of one hour, and its fixed cost."""
108
+
109
+ energy: dict[Tranche, float]
110
+ variable: dict[Tranche, float]
111
+ fixed: float
112
+
113
+ @property
114
+ def total(self) -> float:
115
+ """Variable and fixed cost together."""
116
+ return sum(self.variable.values()) + self.fixed
117
+
118
+
119
+ def cost_hour(
120
+ start: datetime,
121
+ kwh: float,
122
+ rules: ContractRules,
123
+ periods: Sequence[PricePeriod],
124
+ calendar: DayCalendar,
125
+ ) -> HourCost:
126
+ """Cost the hour starting at `start` (local, aware).
127
+
128
+ Prices change at midnight local time, so the whole hour uses the prices of its
129
+ day. Raise MissingColorError or MissingPriceError when data is missing.
130
+ """
131
+ energy = split_hour(start, kwh, rules, calendar)
132
+ period = period_at(periods, start.date())
133
+ variable: dict[Tranche, float] = {}
134
+ for tranche, tranche_kwh in energy.items():
135
+ if tranche not in period.prices:
136
+ raise MissingPriceError(start.date())
137
+ variable[tranche] = tranche_kwh * period.prices[tranche]
138
+ return HourCost(
139
+ energy=energy, variable=variable, fixed=fixed_cost_per_hour(period, start)
140
+ )
elec_tariffs/errors.py ADDED
@@ -0,0 +1,57 @@
1
+ """Exceptions raised by elec-tariffs."""
2
+
3
+ from datetime import date
4
+
5
+
6
+ class ElecTariffsError(Exception):
7
+ """Base class of every error raised by this library."""
8
+
9
+
10
+ class MissingColorError(ElecTariffsError):
11
+ """The Tempo color of a day is not known yet."""
12
+
13
+ def __init__(self, day: date) -> None:
14
+ """Keep the day whose color is missing."""
15
+ super().__init__(f"No Tempo color for {day.isoformat()}")
16
+ self.day = day
17
+
18
+
19
+ class MissingPriceError(ElecTariffsError):
20
+ """No price period covers a day."""
21
+
22
+ def __init__(self, day: date) -> None:
23
+ """Keep the day that no price period covers."""
24
+ super().__init__(f"No price period covers {day.isoformat()}")
25
+ self.day = day
26
+
27
+
28
+ class TrvError(ElecTariffsError):
29
+ """The regulated prices could not be read."""
30
+
31
+
32
+ class TrvServerError(TrvError):
33
+ """data.gouv.fr could not be reached or answered with an error."""
34
+
35
+
36
+ class TrvFormatError(TrvError):
37
+ """A regulated price file does not have the expected columns or values."""
38
+
39
+
40
+ class TempoError(ElecTariffsError):
41
+ """The Tempo calendar could not be read."""
42
+
43
+
44
+ class TempoServerError(TempoError):
45
+ """The calendar source could not be reached or answered with a server error."""
46
+
47
+
48
+ class TempoRateLimitError(TempoError):
49
+ """The calendar source asks to slow down (HTTP 429)."""
50
+
51
+
52
+ class TempoAuthError(TempoError):
53
+ """The RTE API refused the client credentials."""
54
+
55
+
56
+ class TempoFormatError(TempoError):
57
+ """The calendar source answered with data in an unexpected format."""
elec_tariffs/models.py ADDED
@@ -0,0 +1,104 @@
1
+ """Contracts, tranches and prices."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from datetime import date, time
6
+ from enum import StrEnum
7
+
8
+
9
+ class ContractKind(StrEnum):
10
+ """How a contract splits consumption into tranches."""
11
+
12
+ BASE = "base"
13
+ HPHC = "hphc"
14
+ TEMPO = "tempo"
15
+
16
+
17
+ class TempoColor(StrEnum):
18
+ """Color of a Tempo day."""
19
+
20
+ BLUE = "blue"
21
+ WHITE = "white"
22
+ RED = "red"
23
+
24
+
25
+ class Tranche(StrEnum):
26
+ """A part of the consumption billed at one price."""
27
+
28
+ BASE = "base"
29
+ HC = "hc"
30
+ HP = "hp"
31
+ BLUE_HC = "blue_hc"
32
+ BLUE_HP = "blue_hp"
33
+ WHITE_HC = "white_hc"
34
+ WHITE_HP = "white_hp"
35
+ RED_HC = "red_hc"
36
+ RED_HP = "red_hp"
37
+
38
+
39
+ TRANCHES: Mapping[ContractKind, tuple[Tranche, ...]] = {
40
+ ContractKind.BASE: (Tranche.BASE,),
41
+ ContractKind.HPHC: (Tranche.HC, Tranche.HP),
42
+ ContractKind.TEMPO: (
43
+ Tranche.BLUE_HC,
44
+ Tranche.BLUE_HP,
45
+ Tranche.WHITE_HC,
46
+ Tranche.WHITE_HP,
47
+ Tranche.RED_HC,
48
+ Tranche.RED_HP,
49
+ ),
50
+ }
51
+
52
+ DayCalendar = Mapping[date, TempoColor]
53
+
54
+
55
+ @dataclass(frozen=True, kw_only=True)
56
+ class TimeRange:
57
+ """Off-peak hours from `start` to `end`, crossing midnight when end <= start."""
58
+
59
+ start: time
60
+ end: time
61
+
62
+ def __post_init__(self) -> None:
63
+ """Only accept whole and half hours, the finest split an hourly row allows."""
64
+ for value in (self.start, self.end):
65
+ if value.minute not in (0, 30) or value.second or value.microsecond:
66
+ msg = f"{value.isoformat()} is not on a whole or half hour"
67
+ raise ValueError(msg)
68
+
69
+ def contains(self, value: time) -> bool:
70
+ """Tell whether `value` falls in the range (start included, end excluded)."""
71
+ if self.start < self.end:
72
+ return self.start <= value < self.end
73
+ return value >= self.start or value < self.end
74
+
75
+
76
+ @dataclass(frozen=True, kw_only=True)
77
+ class ContractRules:
78
+ """What decides the tranche of each half hour."""
79
+
80
+ kind: ContractKind
81
+ off_peak: tuple[TimeRange, ...] = ()
82
+ weekend_off_peak: bool = False
83
+
84
+ def __post_init__(self) -> None:
85
+ """Refuse options that only make sense for an HP/HC contract."""
86
+ if self.kind is not ContractKind.HPHC and (
87
+ self.off_peak or self.weekend_off_peak
88
+ ):
89
+ msg = "Off-peak ranges only apply to HP/HC contracts"
90
+ raise ValueError(msg)
91
+
92
+
93
+ @dataclass(frozen=True, kw_only=True)
94
+ class PricePeriod:
95
+ """Prices in force from `start` to `end` (both included, `end` None if open)."""
96
+
97
+ start: date
98
+ end: date | None
99
+ fixed_per_year: float
100
+ prices: Mapping[Tranche, float] = field(default_factory=dict)
101
+
102
+ def covers(self, day: date) -> bool:
103
+ """Tell whether the period applies on `day`."""
104
+ return self.start <= day and (self.end is None or day <= self.end)
elec_tariffs/py.typed ADDED
File without changes
@@ -0,0 +1,22 @@
1
+ """Where prices and Tempo colors come from."""
2
+
3
+ from .tempo import (
4
+ FallbackTempoSource,
5
+ RteOfficialTempoClient,
6
+ RtePublicTempoClient,
7
+ TempoCalendarSource,
8
+ season_bounds,
9
+ season_of,
10
+ )
11
+ from .trv import TrvClient, parse_trv_csv
12
+
13
+ __all__ = [
14
+ "FallbackTempoSource",
15
+ "RteOfficialTempoClient",
16
+ "RtePublicTempoClient",
17
+ "TempoCalendarSource",
18
+ "TrvClient",
19
+ "parse_trv_csv",
20
+ "season_bounds",
21
+ "season_of",
22
+ ]
@@ -0,0 +1,394 @@
1
+ """Tempo calendar: the color of each day, from RTE.
2
+
3
+ Two sources give the same data:
4
+
5
+ - RTE's public Tempo page backend (`services-rte.com/cms/open_data`), without an
6
+ account. It is not documented and may change without notice, so its answers are
7
+ checked strictly.
8
+ - RTE's official "Tempo Like Supply Contract" API, which needs the client ID and
9
+ secret of an application created on data.rte-france.com.
10
+ """
11
+
12
+ import base64
13
+ import time as monotonic_time
14
+ from collections.abc import Callable, Sequence
15
+ from datetime import date, datetime, time, timedelta
16
+ from typing import Any, Final, Protocol
17
+ from zoneinfo import ZoneInfo
18
+
19
+ import aiohttp
20
+
21
+ from elec_tariffs._version import __version__
22
+ from elec_tariffs.errors import (
23
+ TempoAuthError,
24
+ TempoError,
25
+ TempoFormatError,
26
+ TempoRateLimitError,
27
+ TempoServerError,
28
+ )
29
+ from elec_tariffs.models import TempoColor
30
+
31
+ PUBLIC_BASE_URL: Final = "https://www.services-rte.com/cms/open_data/v1"
32
+ OFFICIAL_BASE_URL: Final = "https://digital.iservices.rte-france.com"
33
+ TOKEN_PATH: Final = "/token/oauth/" # noqa: S105 - a path, not a password
34
+ CALENDAR_PATH: Final = "/open_api/tempo_like_supply_contract/v1/tempo_like_calendars"
35
+
36
+ MAX_RED_DAYS: Final = 22
37
+ MAX_WHITE_DAYS: Final = 43
38
+ FIRST_SEASON: Final = "2014-2015"
39
+ OFFICIAL_MAX_DAYS: Final = 366
40
+
41
+ PARIS: Final = ZoneInfo("Europe/Paris")
42
+ USER_AGENT: Final = (
43
+ f"elec-tariffs/{__version__} (+https://github.com/damou8/elec-tariffs)"
44
+ )
45
+
46
+ _SEASON_START_MONTH: Final = 9
47
+ _TIMEOUT: Final = aiohttp.ClientTimeout(total=30)
48
+ _TOKEN_MARGIN: Final = 60
49
+ _DEFAULT_TOKEN_LIFETIME: Final = 3600
50
+ _COLORS: Final = {
51
+ "BLUE": TempoColor.BLUE,
52
+ "WHITE": TempoColor.WHITE,
53
+ "RED": TempoColor.RED,
54
+ }
55
+ _HTTP_OK: Final = 200
56
+ _HTTP_UNAUTHORIZED: Final = 401
57
+ _HTTP_FORBIDDEN: Final = 403
58
+ _HTTP_TOO_MANY_REQUESTS: Final = 429
59
+ _HTTP_SERVER_ERROR: Final = 500
60
+
61
+ type Calendar = dict[date, TempoColor]
62
+
63
+
64
+ def season_of(day: date) -> str:
65
+ """Return the Tempo season of a day, like "2025-2026" (September to August)."""
66
+ first = day.year if day.month >= _SEASON_START_MONTH else day.year - 1
67
+ return f"{first}-{first + 1}"
68
+
69
+
70
+ def season_bounds(season: str) -> tuple[date, date]:
71
+ """Return the first and last day of a season named like "2025-2026"."""
72
+ first = int(season.split("-", 1)[0])
73
+ return date(first, _SEASON_START_MONTH, 1), date(first + 1, 8, 31)
74
+
75
+
76
+ def _seasons(start: date, end: date) -> list[str]:
77
+ first = int(season_of(start).split("-", 1)[0])
78
+ last = int(season_of(end).split("-", 1)[0])
79
+ return [f"{year}-{year + 1}" for year in range(first, last + 1)]
80
+
81
+
82
+ def _color(value: Any) -> TempoColor:
83
+ color = _COLORS.get(value) if isinstance(value, str) else None
84
+ if color is None:
85
+ msg = f"Unknown Tempo color {value!r}"
86
+ raise TempoFormatError(msg)
87
+ return color
88
+
89
+
90
+ def parse_public_values(payload: Any) -> Calendar:
91
+ """Read a `{"values": {"YYYY-MM-DD": "BLUE", ...}}` answer of the public source."""
92
+ values = payload.get("values") if isinstance(payload, dict) else None
93
+ if not isinstance(values, dict):
94
+ msg = "The answer has no 'values' object"
95
+ raise TempoFormatError(msg)
96
+ calendar: Calendar = {}
97
+ for key, value in values.items():
98
+ if key.endswith("-fallback"):
99
+ continue
100
+ try:
101
+ day = date.fromisoformat(key)
102
+ except ValueError as err:
103
+ msg = f"Unexpected key {key!r}"
104
+ raise TempoFormatError(msg) from err
105
+ calendar[day] = _color(value)
106
+ return calendar
107
+
108
+
109
+ def check_season(calendar: Calendar, season: str) -> None:
110
+ """Refuse a season with days outside it or more red or white days than allowed."""
111
+ first, last = season_bounds(season)
112
+ if any(not first <= day <= last for day in calendar):
113
+ msg = f"Days outside the {season} season"
114
+ raise TempoFormatError(msg)
115
+ colors = list(calendar.values())
116
+ if (
117
+ colors.count(TempoColor.RED) > MAX_RED_DAYS
118
+ or colors.count(TempoColor.WHITE) > MAX_WHITE_DAYS
119
+ ):
120
+ msg = f"Too many red or white days in the {season} season"
121
+ raise TempoFormatError(msg)
122
+
123
+
124
+ def _raise_for_status(status: int) -> None:
125
+ if status == _HTTP_OK:
126
+ return
127
+ if status in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN):
128
+ msg = f"Access refused (HTTP {status})"
129
+ raise TempoAuthError(msg)
130
+ if status == _HTTP_TOO_MANY_REQUESTS:
131
+ msg = "Too many requests (HTTP 429)"
132
+ raise TempoRateLimitError(msg)
133
+ if status >= _HTTP_SERVER_ERROR:
134
+ msg = f"Server error (HTTP {status})"
135
+ raise TempoServerError(msg)
136
+ msg = f"Unexpected answer (HTTP {status})"
137
+ raise TempoFormatError(msg)
138
+
139
+
140
+ class TempoCalendarSource(Protocol):
141
+ """Something that gives the Tempo color of days."""
142
+
143
+ @property
144
+ def name(self) -> str:
145
+ """Short, stable name of the source, used in logs and diagnostics."""
146
+ ...
147
+
148
+ async def async_calendar(self, start: date, end: date) -> Calendar:
149
+ """Return the known colors from `start` to `end`, both included.
150
+
151
+ Days not published yet are missing from the result.
152
+ """
153
+ ...
154
+
155
+
156
+ class RtePublicTempoClient:
157
+ """RTE's public Tempo page backend, no account needed."""
158
+
159
+ def __init__(
160
+ self, session: aiohttp.ClientSession, *, base_url: str = PUBLIC_BASE_URL
161
+ ) -> None:
162
+ """Use the caller's HTTP session; `base_url` replaces RTE's address."""
163
+ self._session = session
164
+ self._base_url = base_url.rstrip("/")
165
+
166
+ @property
167
+ def name(self) -> str:
168
+ """Name of the source."""
169
+ return "rte_public"
170
+
171
+ async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
172
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
173
+ try:
174
+ async with self._session.get(
175
+ f"{self._base_url}/{path}",
176
+ params=params,
177
+ headers=headers,
178
+ timeout=_TIMEOUT,
179
+ ) as response:
180
+ _raise_for_status(response.status)
181
+ return await response.json(content_type=None)
182
+ except TempoAuthError as err:
183
+ msg = f"The public Tempo source refused the request: {err}"
184
+ raise TempoFormatError(msg) from err
185
+ except ValueError as err:
186
+ msg = "The public Tempo source did not answer with JSON"
187
+ raise TempoFormatError(msg) from err
188
+ except (aiohttp.ClientError, TimeoutError) as err:
189
+ msg = f"Could not reach the public Tempo source: {err}"
190
+ raise TempoServerError(msg) from err
191
+
192
+ async def async_season(self, season: str) -> Calendar:
193
+ """Return the colors of a season published so far."""
194
+ calendar = parse_public_values(await self._get("tempo", {"season": season}))
195
+ check_season(calendar, season)
196
+ return calendar
197
+
198
+ async def async_today_tomorrow(self) -> Calendar:
199
+ """Return today's color and tomorrow's once published."""
200
+ return parse_public_values(await self._get("tempoLight"))
201
+
202
+ async def async_calendar(self, start: date, end: date) -> Calendar:
203
+ """Return the known colors from `start` to `end`, both included."""
204
+ if (end - start).days <= 1:
205
+ light = await self.async_today_tomorrow()
206
+ if start in light and end in light:
207
+ return {
208
+ day: color for day, color in light.items() if start <= day <= end
209
+ }
210
+ calendar: Calendar = {}
211
+ for season in _seasons(start, end):
212
+ calendar.update(await self.async_season(season))
213
+ return {day: color for day, color in calendar.items() if start <= day <= end}
214
+
215
+
216
+ class RteOfficialTempoClient:
217
+ """RTE's official Tempo Like Supply Contract API (OAuth2 client credentials)."""
218
+
219
+ def __init__(
220
+ self,
221
+ session: aiohttp.ClientSession,
222
+ client_id: str,
223
+ client_secret: str,
224
+ *,
225
+ base_url: str = OFFICIAL_BASE_URL,
226
+ clock: Callable[[], float] = monotonic_time.monotonic,
227
+ ) -> None:
228
+ """Use the caller's HTTP session and the application's credentials.
229
+
230
+ `base_url` replaces RTE's address and `clock` the monotonic clock that
231
+ tells when the token expires.
232
+ """
233
+ self._session = session
234
+ self._base_url = base_url.rstrip("/")
235
+ credentials = f"{client_id}:{client_secret}".encode()
236
+ self._basic_auth = f"Basic {base64.b64encode(credentials).decode('ascii')}"
237
+ self._clock = clock
238
+ self._token: str | None = None
239
+ self._expires_at = 0.0
240
+
241
+ @property
242
+ def name(self) -> str:
243
+ """Name of the source."""
244
+ return "rte_official"
245
+
246
+ async def _async_token(self, *, refresh: bool = False) -> str:
247
+ if self._token and not refresh and self._clock() < self._expires_at:
248
+ return self._token
249
+ try:
250
+ async with self._session.post(
251
+ f"{self._base_url}{TOKEN_PATH}",
252
+ data={"grant_type": "client_credentials"},
253
+ headers={"Authorization": self._basic_auth, "User-Agent": USER_AGENT},
254
+ timeout=_TIMEOUT,
255
+ ) as response:
256
+ if response.status == 400: # noqa: PLR2004 - OAuth answers 400 for a bad client
257
+ msg = "RTE refused the client credentials (HTTP 400)"
258
+ raise TempoAuthError(msg)
259
+ _raise_for_status(response.status)
260
+ payload = await response.json(content_type=None)
261
+ except ValueError as err:
262
+ msg = "The RTE token endpoint did not answer with JSON"
263
+ raise TempoFormatError(msg) from err
264
+ except (aiohttp.ClientError, TimeoutError) as err:
265
+ msg = f"Could not reach RTE: {err}"
266
+ raise TempoServerError(msg) from err
267
+ token = payload.get("access_token") if isinstance(payload, dict) else None
268
+ if not isinstance(token, str) or not token:
269
+ msg = "The RTE token answer has no access_token"
270
+ raise TempoFormatError(msg)
271
+ lifetime = payload.get("expires_in", _DEFAULT_TOKEN_LIFETIME)
272
+ if not isinstance(lifetime, int):
273
+ lifetime = _DEFAULT_TOKEN_LIFETIME
274
+ self._token = token
275
+ self._expires_at = self._clock() + max(lifetime - _TOKEN_MARGIN, 0)
276
+ return token
277
+
278
+ async def async_check_credentials(self) -> None:
279
+ """Get a token, raising TempoAuthError if RTE refuses the credentials."""
280
+ await self._async_token(refresh=True)
281
+
282
+ async def _async_get_calendar(
283
+ self, params: dict[str, str], *, refresh: bool
284
+ ) -> tuple[int, Any]:
285
+ """Return the status and, when it is 200, the JSON answer."""
286
+ token = await self._async_token(refresh=refresh)
287
+ headers = {
288
+ "Authorization": f"Bearer {token}",
289
+ "Accept": "application/json",
290
+ "User-Agent": USER_AGENT,
291
+ }
292
+ try:
293
+ async with self._session.get(
294
+ f"{self._base_url}{CALENDAR_PATH}",
295
+ params=params,
296
+ headers=headers,
297
+ timeout=_TIMEOUT,
298
+ ) as response:
299
+ if response.status != _HTTP_OK:
300
+ return response.status, None
301
+ return response.status, await response.json(content_type=None)
302
+ except ValueError as err:
303
+ msg = "The RTE calendar did not answer with JSON"
304
+ raise TempoFormatError(msg) from err
305
+ except (aiohttp.ClientError, TimeoutError) as err:
306
+ msg = f"Could not reach RTE: {err}"
307
+ raise TempoServerError(msg) from err
308
+
309
+ async def _async_chunk(self, start: date, end: date) -> Calendar:
310
+ params = {
311
+ "start_date": datetime.combine(start, time(), PARIS).isoformat(),
312
+ "end_date": datetime.combine(
313
+ end + timedelta(days=1), time(), PARIS
314
+ ).isoformat(),
315
+ }
316
+ status, payload = await self._async_get_calendar(params, refresh=False)
317
+ if status == _HTTP_UNAUTHORIZED:
318
+ # The token may have been revoked before it expired: get a new one once.
319
+ status, payload = await self._async_get_calendar(params, refresh=True)
320
+ _raise_for_status(status)
321
+ return parse_official_values(payload)
322
+
323
+ async def async_calendar(self, start: date, end: date) -> Calendar:
324
+ """Return the known colors from `start` to `end`, both included."""
325
+ calendar: Calendar = {}
326
+ chunk_start = start
327
+ while chunk_start <= end:
328
+ chunk_end = min(end, chunk_start + timedelta(days=OFFICIAL_MAX_DAYS - 1))
329
+ calendar.update(await self._async_chunk(chunk_start, chunk_end))
330
+ chunk_start = chunk_end + timedelta(days=1)
331
+ return {day: color for day, color in calendar.items() if start <= day <= end}
332
+
333
+
334
+ def parse_official_values(payload: Any) -> Calendar:
335
+ """Read a `tempo_like_calendars.values[]` answer of the official API."""
336
+ container = (
337
+ payload.get("tempo_like_calendars") if isinstance(payload, dict) else None
338
+ )
339
+ values = container.get("values") if isinstance(container, dict) else None
340
+ if not isinstance(values, list):
341
+ msg = "The answer has no 'tempo_like_calendars.values' list"
342
+ raise TempoFormatError(msg)
343
+ calendar: Calendar = {}
344
+ for item in values:
345
+ start = item.get("start_date") if isinstance(item, dict) else None
346
+ if not isinstance(start, str):
347
+ msg = f"Unexpected calendar item {item!r}"
348
+ raise TempoFormatError(msg)
349
+ try:
350
+ day = datetime.fromisoformat(start).date()
351
+ except ValueError as err:
352
+ msg = f"Unexpected start_date {start!r}"
353
+ raise TempoFormatError(msg) from err
354
+ calendar[day] = _color(item.get("value"))
355
+ return calendar
356
+
357
+
358
+ class FallbackTempoSource:
359
+ """Ask each source in turn and return the first answer."""
360
+
361
+ def __init__(self, sources: Sequence[TempoCalendarSource]) -> None:
362
+ """Try `sources` in this order."""
363
+ if not sources:
364
+ msg = "At least one source is needed"
365
+ raise ValueError(msg)
366
+ self._sources = tuple(sources)
367
+ self.last_source: str | None = None
368
+ self.last_errors: dict[str, TempoError] = {}
369
+
370
+ @property
371
+ def name(self) -> str:
372
+ """Names of the sources, in order."""
373
+ return "+".join(source.name for source in self._sources)
374
+
375
+ async def async_calendar(self, start: date, end: date) -> Calendar:
376
+ """Return the first source's answer; raise the last error if all fail.
377
+
378
+ `last_source` names the source that answered, `last_errors` the errors of
379
+ the sources tried before it (or of all of them).
380
+ """
381
+ self.last_errors = {}
382
+ self.last_source = None
383
+ error: TempoError | None = None
384
+ for source in self._sources:
385
+ try:
386
+ calendar = await source.async_calendar(start, end)
387
+ except TempoError as err:
388
+ self.last_errors[source.name] = err
389
+ error = err
390
+ continue
391
+ self.last_source = source.name
392
+ return calendar
393
+ assert error is not None # noqa: S101 - at least one source was tried
394
+ raise error
@@ -0,0 +1,121 @@
1
+ """Regulated prices (Tarif Bleu) published on data.gouv.fr.
2
+
3
+ Each CSV has one row per price period and subscribed power: start and end dates
4
+ (DD/MM/YYYY, an empty end for the prices in force), the yearly fixed part and the
5
+ price of each tranche, with and without taxes. Rows of powers not offered at the
6
+ time are empty.
7
+ """
8
+
9
+ import csv
10
+ from collections.abc import Mapping
11
+ from datetime import date, datetime
12
+ from typing import Final
13
+
14
+ import aiohttp
15
+
16
+ from elec_tariffs.errors import TrvFormatError, TrvServerError
17
+ from elec_tariffs.models import ContractKind, PricePeriod, Tranche
18
+
19
+ TRV_URLS: Final[Mapping[ContractKind, str]] = {
20
+ ContractKind.BASE: "https://www.data.gouv.fr/fr/datasets/r/c13d05e5-9e55-4d03-bf7e-042a2ade7e49",
21
+ ContractKind.HPHC: "https://www.data.gouv.fr/fr/datasets/r/f7303b3a-93c7-4242-813d-84919034c416",
22
+ ContractKind.TEMPO: "https://www.data.gouv.fr/fr/datasets/r/0c3d1d36-c412-4620-8566-e5cbb4fa2b5a",
23
+ }
24
+
25
+ _PRICE_COLUMNS: Final[Mapping[ContractKind, Mapping[Tranche, str]]] = {
26
+ ContractKind.BASE: {Tranche.BASE: "PART_VARIABLE_TTC"},
27
+ ContractKind.HPHC: {
28
+ Tranche.HC: "PART_VARIABLE_HC_TTC",
29
+ Tranche.HP: "PART_VARIABLE_HP_TTC",
30
+ },
31
+ ContractKind.TEMPO: {
32
+ Tranche.BLUE_HC: "PART_VARIABLE_HCBleu_TTC",
33
+ Tranche.BLUE_HP: "PART_VARIABLE_HPBleu_TTC",
34
+ Tranche.WHITE_HC: "PART_VARIABLE_HCBlanc_TTC",
35
+ Tranche.WHITE_HP: "PART_VARIABLE_HPBlanc_TTC",
36
+ Tranche.RED_HC: "PART_VARIABLE_HCRouge_TTC",
37
+ Tranche.RED_HP: "PART_VARIABLE_HPRouge_TTC",
38
+ },
39
+ }
40
+ _START: Final = "DATE_DEBUT"
41
+ _END: Final = "DATE_FIN"
42
+ _POWER: Final = "P_SOUSCRITE"
43
+ _FIXED: Final = "PART_FIXE_TTC"
44
+ _TIMEOUT: Final = aiohttp.ClientTimeout(total=30)
45
+
46
+
47
+ def _date(value: str) -> date:
48
+ return datetime.strptime(value, "%d/%m/%Y").date() # noqa: DTZ007
49
+
50
+
51
+ def _number(value: str) -> float:
52
+ return float(value.replace(",", "."))
53
+
54
+
55
+ def parse_trv_csv(text: str, kind: ContractKind, kva: int) -> list[PricePeriod]:
56
+ """Return the price periods of one contract kind and power, oldest first.
57
+
58
+ Raise TrvFormatError when a column is missing or a value can't be read.
59
+ """
60
+ reader = csv.DictReader(text.lstrip("").splitlines(), delimiter=";")
61
+ price_columns = _PRICE_COLUMNS[kind]
62
+ required = {_START, _END, _POWER, _FIXED, *price_columns.values()}
63
+ missing = required - set(reader.fieldnames or ())
64
+ if missing:
65
+ msg = f"Missing columns in the {kind} prices: {', '.join(sorted(missing))}"
66
+ raise TrvFormatError(msg)
67
+
68
+ periods: list[PricePeriod] = []
69
+ for raw in reader:
70
+ # A short row leaves None in its last columns; extra fields have no name.
71
+ row = {name: (value or "").strip() for name, value in raw.items() if name}
72
+ if row[_POWER] != str(kva) or not row[_FIXED]:
73
+ continue
74
+ try:
75
+ periods.append(
76
+ PricePeriod(
77
+ start=_date(row[_START]),
78
+ end=_date(row[_END]) if row[_END] else None,
79
+ fixed_per_year=_number(row[_FIXED]),
80
+ prices={
81
+ tranche: _number(row[column])
82
+ for tranche, column in price_columns.items()
83
+ },
84
+ )
85
+ )
86
+ except ValueError as err:
87
+ msg = f"Unreadable {kind} price row starting {row[_START]}: {err}"
88
+ raise TrvFormatError(msg) from err
89
+ return sorted(periods, key=lambda period: period.start)
90
+
91
+
92
+ class TrvClient:
93
+ """Download the regulated prices from data.gouv.fr."""
94
+
95
+ def __init__(
96
+ self,
97
+ session: aiohttp.ClientSession,
98
+ *,
99
+ urls: Mapping[ContractKind, str] = TRV_URLS,
100
+ ) -> None:
101
+ """Use the caller's HTTP session; `urls` replaces the data.gouv.fr files."""
102
+ self._session = session
103
+ self._urls = urls
104
+
105
+ async def async_price_periods(
106
+ self, kind: ContractKind, kva: int
107
+ ) -> list[PricePeriod]:
108
+ """Return the price periods of one contract kind and power, oldest first."""
109
+ try:
110
+ async with self._session.get(
111
+ self._urls[kind], timeout=_TIMEOUT
112
+ ) as response:
113
+ response.raise_for_status()
114
+ text = await response.text(encoding="utf-8")
115
+ except UnicodeDecodeError as err:
116
+ msg = f"The {kind} prices are not UTF-8 text"
117
+ raise TrvFormatError(msg) from err
118
+ except (aiohttp.ClientError, TimeoutError) as err:
119
+ msg = f"Could not download the {kind} prices: {err}"
120
+ raise TrvServerError(msg) from err
121
+ return parse_trv_csv(text, kind, kva)
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: elec-tariffs
3
+ Version: 0.1.0
4
+ Summary: Cost French electricity consumption hour by hour under Base, HP/HC and Tempo contracts
5
+ Project-URL: Homepage, https://github.com/damou8/elec-tariffs
6
+ Project-URL: Issues, https://github.com/damou8/elec-tariffs/issues
7
+ Author: damou8
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: edf,electricity,home-assistant,linky,rte,tariff,tempo
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Natural Language :: English
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Home Automation
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.14
21
+ Requires-Dist: aiohttp>=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # elec-tariffs
25
+
26
+ Cost French electricity consumption hour by hour under Base, HP/HC and Tempo contracts.
27
+
28
+ `elec-tariffs` takes hourly consumption (as a Linky meter reports it) and works out what it cost under one or more contracts: which tranche each hour falls into, its price on that day, and its share of the yearly subscription. It also downloads the data that needs:
29
+
30
+ - the regulated prices (Tarif Bleu) of every period since 2012, from [data.gouv.fr](https://www.data.gouv.fr/fr/datasets/historique-des-tarifs-reglementes-de-vente-delectricite-pour-les-consommateurs-residentiels/);
31
+ - the color of each Tempo day, from RTE.
32
+
33
+ It is async (aiohttp), fully typed and has no dependency on Home Assistant. It was written for the [Electricity Contracts](https://github.com/damou8/electricity_contracts) Home Assistant integration.
34
+
35
+ It is not affiliated with EDF, RTE or Enedis.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install elec-tariffs
41
+ ```
42
+
43
+ Python 3.14 or later.
44
+
45
+ ## Use
46
+
47
+ ```python
48
+ import asyncio
49
+ from datetime import date, datetime
50
+ from zoneinfo import ZoneInfo
51
+
52
+ import aiohttp
53
+
54
+ from elec_tariffs import (
55
+ ContractKind,
56
+ ContractRules,
57
+ RtePublicTempoClient,
58
+ TrvClient,
59
+ cost_hour,
60
+ )
61
+
62
+ PARIS = ZoneInfo("Europe/Paris")
63
+
64
+
65
+ async def main() -> None:
66
+ async with aiohttp.ClientSession() as session:
67
+ prices = await TrvClient(session).async_price_periods(ContractKind.TEMPO, 6)
68
+ colors = await RtePublicTempoClient(session).async_calendar(
69
+ date(2026, 1, 4), date(2026, 1, 5)
70
+ )
71
+
72
+ # 1.8 kWh used from 19:00 to 20:00 on 5 January 2026.
73
+ cost = cost_hour(
74
+ datetime(2026, 1, 5, 19, tzinfo=PARIS),
75
+ 1.8,
76
+ ContractRules(kind=ContractKind.TEMPO),
77
+ prices,
78
+ colors,
79
+ )
80
+ print(cost.energy, cost.variable, cost.fixed, cost.total)
81
+
82
+
83
+ asyncio.run(main())
84
+ ```
85
+
86
+ An HP/HC contract takes its own off-peak ranges, to the half hour, and can make weekends off-peak:
87
+
88
+ ```python
89
+ from datetime import time
90
+
91
+ from elec_tariffs import ContractKind, ContractRules, TimeRange
92
+
93
+ rules = ContractRules(
94
+ kind=ContractKind.HPHC,
95
+ off_peak=(
96
+ TimeRange(start=time(12, 30), end=time(14)),
97
+ TimeRange(start=time(22), end=time(6, 30)),
98
+ ),
99
+ weekend_off_peak=True,
100
+ )
101
+ ```
102
+
103
+ Prices you set yourself are `PricePeriod` objects: a first and last day (`None` while in force), the yearly fixed part and a price per tranche, all with taxes.
104
+
105
+ ## How an hour is costed
106
+
107
+ - **Tranches.** Each hour is split into two half hours with half the energy each, so an off-peak range that starts or ends on a half hour is followed. This assumes the consumption is even within the hour.
108
+ - **Tempo.** A Tempo day runs from 6:00 to 6:00, so a night takes the color of the day that started the evening before. Off-peak hours are 22:00 to 6:00.
109
+ - **Prices** change at midnight on the first day of a price period (1 February and 1 August for the regulated prices). An hour uses the prices of its calendar day.
110
+ - **Subscription.** The yearly fixed part is spread evenly over the hours of the year (8,760, or 8,784 in a leap year).
111
+ - **Missing data.** An hour whose Tempo color or price isn't known raises `MissingColorError` or `MissingPriceError` instead of guessing.
112
+
113
+ ## Tempo calendar sources
114
+
115
+ | Source | Account | Notes |
116
+ | --- | --- | --- |
117
+ | `RtePublicTempoClient` | none | The backend of RTE's public [Tempo calendar page](https://www.services-rte.com/fr/visualisez-les-donnees-publiees-par-rte/calendrier-des-offres-de-fourniture-de-type-tempo.html). It isn't documented and RTE can change it without notice, so its answers are checked strictly: an unexpected format raises `TempoFormatError` instead of giving wrong colors. |
118
+ | `RteOfficialTempoClient` | free [data.rte-france.com](https://data.rte-france.com/catalog/-/api/consumption/Tempo-Like-Supply-Contract/v1.1) application | RTE's documented "Tempo Like Supply Contract" API, with OAuth2 client credentials. The token is kept until it expires and renewed once if refused. Long ranges are split into requests of 366 days at most. **Not yet tested against the live API**: its tests follow the format used by other clients. |
119
+
120
+ `FallbackTempoSource` asks each source in turn, for example the public one first and the official one when it fails. It records which source answered and the errors of the others.
121
+
122
+ Past colors never change, so callers should cache them and only ask for the current season and the next day.
123
+
124
+ To create the official API application: sign up on data.rte-france.com, open the "Tempo Like Supply Contract" API, choose "S'abonner à l'API" (this creates an application), then copy its client ID and secret.
125
+
126
+ ## Errors
127
+
128
+ Every error derives from `ElecTariffsError`:
129
+
130
+ - `MissingColorError`, `MissingPriceError` (with the `day` concerned);
131
+ - `TrvServerError`, `TrvFormatError` for data.gouv.fr;
132
+ - `TempoServerError` (network or 5xx), `TempoRateLimitError` (429), `TempoAuthError` (credentials refused) and `TempoFormatError` for the Tempo sources.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ python3.14 -m venv .venv
138
+ .venv/bin/pip install -r requirements-dev.txt -e .
139
+ .venv/bin/ruff check && .venv/bin/ruff format --check && .venv/bin/mypy
140
+ .venv/bin/pytest --cov
141
+ ```
142
+
143
+ The tests use recorded answers in `tests/fixtures/` and a local HTTP server; they make no network requests.
144
+
145
+ Releases are published to PyPI by GitHub Actions when a GitHub release is published; the tag (`vX.Y.Z`) must match `src/elec_tariffs/_version.py`.
146
+
147
+ ## License
148
+
149
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ elec_tariffs/__init__.py,sha256=B_6bxtr5iG9oGkjpPmaDK0x9JV20zFsncN1ryJ6wYtM,1680
2
+ elec_tariffs/_version.py,sha256=SMtRk4qxf0OYKoTBdCtPkBUDYVklfxDXkbrQZ69TsZo,80
3
+ elec_tariffs/engine.py,sha256=ie2dKaImyTNN0ATN_OSB_2tTDBwmGtsZFhm-OxW01pI,4903
4
+ elec_tariffs/errors.py,sha256=iEGZLg0sKiIq7mHTaPDOGhKiiOGaKhdUHmbM4eyXF8U,1540
5
+ elec_tariffs/models.py,sha256=eXa_oyegUc3DjoyxIiDS5Oyv_8E00k4dh5n1czuSMtg,2880
6
+ elec_tariffs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ elec_tariffs/sources/__init__.py,sha256=UwPJLU6cZ5mM9KML5DNRwU2JXAZs5Z-K38MNXrT8Woc,454
8
+ elec_tariffs/sources/tempo.py,sha256=KJgXILZUNKYNti9QPn2VHFm5-P402F5GgmzhxYXXxVY,15270
9
+ elec_tariffs/sources/trv.py,sha256=YKmyApGN64Wsn9gPflPVTMxa-vyCbyNBranv8PT3ehs,4651
10
+ elec_tariffs-0.1.0.dist-info/METADATA,sha256=sYDLgyzVKbw2lwhLnKgDt_L0SKoi1Scpr55A7xUs864,6329
11
+ elec_tariffs-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ elec_tariffs-0.1.0.dist-info/licenses/LICENSE,sha256=0ETqjySAVmmSopcwiOYZ2G05CrpF11Uotvki5FeXvyo,1063
13
+ elec_tariffs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 damou8
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.