ceps-api 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.
- ceps_api/__init__.py +29 -0
- ceps_api/catalog.py +231 -0
- ceps_api/client.py +346 -0
- ceps_api/convert.py +88 -0
- ceps_api/models.py +63 -0
- ceps_api/parsing.py +251 -0
- ceps_api/py.typed +0 -0
- ceps_api/soap.py +99 -0
- ceps_api/validation.py +77 -0
- ceps_api-0.1.0.dist-info/METADATA +346 -0
- ceps_api-0.1.0.dist-info/RECORD +13 -0
- ceps_api-0.1.0.dist-info/WHEEL +4 -0
- ceps_api-0.1.0.dist-info/licenses/LICENSE +21 -0
ceps_api/convert.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Functions for converting between the wrapper interface and the service interface, and for turning service responses into DataFrames."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .models import CepsResponseType
|
|
11
|
+
from .parsing import PRAGUE, Fields, Parsed, Rows, TimeSeries
|
|
12
|
+
|
|
13
|
+
# What the wrappers take as start and end.
|
|
14
|
+
DateLike = date | datetime | str
|
|
15
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def to_ceps_time(x: DateLike) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Accepts a date, datetime or ISO string ('YYYY-MM-DD', with a time or
|
|
20
|
+
without), gives the timestamp ČEPS wants, which is Czech local time
|
|
21
|
+
with no zone on it. One with a timezone is converted to Czech time
|
|
22
|
+
first; a naive one is taken as Czech time already.
|
|
23
|
+
|
|
24
|
+
The zone has to come off because the service does not read it: it
|
|
25
|
+
answers '...T10:00:00Z' with the data of 10:00 Czech time.
|
|
26
|
+
|
|
27
|
+
Local time cannot tell apart the two runs of the hour repeated when the
|
|
28
|
+
clocks go back. The service takes such a time as the second run; a
|
|
29
|
+
period that starts before 02:00 covers both. A time in the hour skipped
|
|
30
|
+
in spring is moved an hour on.
|
|
31
|
+
|
|
32
|
+
Raises ValueError for a string that is not a date or time.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(x, str):
|
|
35
|
+
try:
|
|
36
|
+
# fromisoformat does not know the Z of UTC before Python 3.11.
|
|
37
|
+
x = datetime.fromisoformat(x[:-1] + "+00:00" if x[-1:] in ("Z", "z") else x)
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise ValueError(f"not a date or time: {x!r}, expected e.g. "
|
|
40
|
+
"'2026-09-14' or '2026-09-14T10:00:00'") from None
|
|
41
|
+
if isinstance(x, datetime):
|
|
42
|
+
if x.tzinfo is not None:
|
|
43
|
+
x = x.astimezone(PRAGUE)
|
|
44
|
+
return x.strftime("%Y-%m-%dT%H:%M:%S")
|
|
45
|
+
return x.strftime("%Y-%m-%dT00:00:00")
|
|
46
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _prague(values: Iterable[datetime]) -> pd.DatetimeIndex:
|
|
49
|
+
"""
|
|
50
|
+
Datetimes with mixed +01:00/+02:00 offsets -> one Europe/Prague index.
|
|
51
|
+
Through UTC, so the repeated hour of the DST change stays two hours.
|
|
52
|
+
"""
|
|
53
|
+
return pd.DatetimeIndex(pd.to_datetime(list(values), utc=True)).tz_convert(PRAGUE)
|
|
54
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def to_dataframe(response_type: CepsResponseType, data: Parsed) -> pd.DataFrame:
|
|
57
|
+
"""
|
|
58
|
+
What the parser returns -> a DataFrame:
|
|
59
|
+
|
|
60
|
+
SERIES, ESTIMATED_PRICE index "timestamp" (Europe/Prague), a column per series
|
|
61
|
+
TABLE a row per row, date columns in Europe/Prague
|
|
62
|
+
CODES index "code", column "description"
|
|
63
|
+
MESSAGE one row, a column per field
|
|
64
|
+
"""
|
|
65
|
+
# The response type decides the shape of data, which the casts tell the type checker.
|
|
66
|
+
if response_type in (CepsResponseType.SERIES, CepsResponseType.ESTIMATED_PRICE):
|
|
67
|
+
series = cast(TimeSeries, data)
|
|
68
|
+
if not series:
|
|
69
|
+
return pd.DataFrame(index=pd.DatetimeIndex([], tz=PRAGUE, name="timestamp"))
|
|
70
|
+
df = pd.DataFrame(list(series.values()), index=_prague(series))
|
|
71
|
+
df.index.name = "timestamp"
|
|
72
|
+
return df.sort_index()
|
|
73
|
+
if response_type is CepsResponseType.TABLE:
|
|
74
|
+
df = pd.DataFrame(cast(Rows, data))
|
|
75
|
+
for col in df.columns:
|
|
76
|
+
# A column of dates only; one the parser left as text stays text.
|
|
77
|
+
values = df[col].dropna()
|
|
78
|
+
if len(values) and values.map(lambda v: isinstance(v, datetime)).all():
|
|
79
|
+
df[col] = pd.Series(_prague(df[col]), index=df.index)
|
|
80
|
+
if "date" in df.columns:
|
|
81
|
+
df = df.sort_values("date", kind="stable", ignore_index=True)
|
|
82
|
+
return df
|
|
83
|
+
fields = cast(Fields, data)
|
|
84
|
+
if response_type is CepsResponseType.CODES:
|
|
85
|
+
return pd.DataFrame({"description": pd.Series(fields, dtype=object)}).rename_axis("code")
|
|
86
|
+
# MESSAGE
|
|
87
|
+
return pd.DataFrame([fields]) if fields else pd.DataFrame()
|
|
88
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
ceps_api/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Types describing the methods of the ČEPS service used in catalog.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
12
|
+
class CepsResponseType(Enum):
|
|
13
|
+
"""The shape of a method's response, which decides the parser the wrappers use."""
|
|
14
|
+
|
|
15
|
+
# A plain time series: rows with a date and one value per series.
|
|
16
|
+
SERIES = "series"
|
|
17
|
+
# A time series whose rows have no date attribute (OdhadovanaCenaOdchylky).
|
|
18
|
+
ESTIMATED_PRICE = "estimated_price"
|
|
19
|
+
# Rows that are not one per timestamp: several share a date or they have
|
|
20
|
+
# none at all (EmergencyExchange, OfferPrices).
|
|
21
|
+
TABLE = "table"
|
|
22
|
+
# A list of codes with their descriptions (DataVersion, DataAgregation).
|
|
23
|
+
CODES = "codes"
|
|
24
|
+
# A single message of text fields (EmergencyExchangeACER).
|
|
25
|
+
MESSAGE = "message"
|
|
26
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CepsMethod:
|
|
29
|
+
"""What is known about one method of the ČEPS service."""
|
|
30
|
+
|
|
31
|
+
# The method description is by personal understanding, check the ČEPS documentation
|
|
32
|
+
# to see full description (it is in Czech and too long to put here).
|
|
33
|
+
description: str
|
|
34
|
+
|
|
35
|
+
# Parameters the WSDL marks as compulsory.
|
|
36
|
+
# Most methods need the dates; a few require nothing.
|
|
37
|
+
required_params: tuple[str, ...] = ("dateFrom", "dateTo")
|
|
38
|
+
|
|
39
|
+
# Parameters the WSDL marks as optional.
|
|
40
|
+
optional_params: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
# Needed parameters the service does not work without: it answers
|
|
43
|
+
# with a server error instead of data.
|
|
44
|
+
needed_params: tuple[str, ...] = ()
|
|
45
|
+
|
|
46
|
+
# The aggregations the method returns data for, from the ČEPS documentation and
|
|
47
|
+
# checked by trying. None means not known, so any aggregation is let through.
|
|
48
|
+
aggregations: tuple[str, ...] | None = None
|
|
49
|
+
|
|
50
|
+
# The data versions the method has, from the ČEPS documentation and
|
|
51
|
+
# checked by trying; any other comes back empty. None means not known,
|
|
52
|
+
# so any version is let through.
|
|
53
|
+
versions: tuple[str, ...] | None = None
|
|
54
|
+
|
|
55
|
+
# The values a filter parameter (param1, para1, param2) takes, from the
|
|
56
|
+
# ČEPS documentation and checked by trying. What they stand for is
|
|
57
|
+
# listed above CEPS_METHODS.
|
|
58
|
+
param_values: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
# The shape of the response, which decides the parser the wrappers use.
|
|
61
|
+
# Found out by experiment (the service does not say).
|
|
62
|
+
response_type: CepsResponseType = CepsResponseType.SERIES
|
|
63
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
ceps_api/parsing.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Turns a ČEPS response document into plain Python."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
from .models import CepsResponseType
|
|
10
|
+
from .soap import CepsError
|
|
11
|
+
|
|
12
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
13
|
+
PRAGUE = ZoneInfo("Europe/Prague")
|
|
14
|
+
|
|
15
|
+
# The attributes of a row that hold a date. The ČEPS schema
|
|
16
|
+
# (StructuredData.xsd) types the columns by their id: value13, value14 and
|
|
17
|
+
# value21 are dates, like the date of the row itself.
|
|
18
|
+
_DATE_IDS = ("date", "value13", "value14", "value21")
|
|
19
|
+
|
|
20
|
+
# One value of a row, as _value reads it.
|
|
21
|
+
Value = datetime | float | str
|
|
22
|
+
# What the parsers return: {timestamp: {series name: value}}, one dict per
|
|
23
|
+
# row, and {name: text}.
|
|
24
|
+
TimeSeries = dict[datetime, dict[str, Value]]
|
|
25
|
+
Rows = list[dict[str, Value]]
|
|
26
|
+
Fields = dict[str, str]
|
|
27
|
+
Parsed = TimeSeries | Rows | Fields
|
|
28
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _local(tag: str) -> str:
|
|
31
|
+
"""The tag without the namespace prefix ElementTree puts on it."""
|
|
32
|
+
return tag.rsplit("}", 1)[-1]
|
|
33
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _value(key: str, text: str) -> Value:
|
|
36
|
+
"""
|
|
37
|
+
One attribute of a row: a datetime for a date column, else a float if
|
|
38
|
+
the text reads as a number, else the text itself.
|
|
39
|
+
"""
|
|
40
|
+
if key in _DATE_IDS:
|
|
41
|
+
try:
|
|
42
|
+
return datetime.fromisoformat(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return text
|
|
45
|
+
try:
|
|
46
|
+
return float(text)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
# The schema warns that numbers in the text columns have a decimal comma.
|
|
50
|
+
try:
|
|
51
|
+
return float(text.replace(",", "."))
|
|
52
|
+
except ValueError:
|
|
53
|
+
return text
|
|
54
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def parse_rows(raw: bytes) -> Rows:
|
|
57
|
+
"""
|
|
58
|
+
Parses a raw ČEPS SOAP response into one dict per row.
|
|
59
|
+
|
|
60
|
+
Every report other than the two code lists and the ACER message is the
|
|
61
|
+
same document. It describes its columns once and then lists the rows:
|
|
62
|
+
|
|
63
|
+
<serie id="value3" name="mFRR5 [MW]" />
|
|
64
|
+
<serie id="value15" name="Unit" />
|
|
65
|
+
<item date="2024-06-01T00:00:00+02:00" value3="2579" value15="LIMIT_PRICE_CZK" />
|
|
66
|
+
|
|
67
|
+
which becomes
|
|
68
|
+
|
|
69
|
+
[{"date": datetime(2024, 6, 1, 0, 0, tzinfo=+02:00),
|
|
70
|
+
"mFRR5 [MW]": 2579.0,
|
|
71
|
+
"Unit": "LIMIT_PRICE_CZK"}]
|
|
72
|
+
|
|
73
|
+
A row has "date" only if the report gives it one, and several rows may
|
|
74
|
+
share it, which is why this is a list. A report with one row per
|
|
75
|
+
timestamp is easier to read with parse_series.
|
|
76
|
+
|
|
77
|
+
Dates come back with the Czech UTC offset attached, so they are
|
|
78
|
+
timezone-aware and safe to compare across the DST change. The schema
|
|
79
|
+
lists the columns value1 to value21; the service goes beyond that, so
|
|
80
|
+
the ids are taken as they come.
|
|
81
|
+
|
|
82
|
+
Raises CepsError when two columns share a name, which would lose values.
|
|
83
|
+
"""
|
|
84
|
+
root = ET.fromstring(raw)
|
|
85
|
+
# Column lookup: attribute name used in the rows -> readable series name.
|
|
86
|
+
names = {s.attrib["id"]: s.attrib["name"]
|
|
87
|
+
for s in root.iter() if _local(s.tag) == "serie" and s.get("id") and s.get("name")}
|
|
88
|
+
# date keeps its own name, so a series may not take it either.
|
|
89
|
+
taken = {"date"}
|
|
90
|
+
for name in names.values():
|
|
91
|
+
if name in taken:
|
|
92
|
+
raise CepsError(f"two columns are named {name!r}")
|
|
93
|
+
taken.add(name)
|
|
94
|
+
# Every attribute is one value; use the readable name, falling back to
|
|
95
|
+
# the raw attribute name (date has no series).
|
|
96
|
+
return [{names.get(key, key): _value(key, text) for key, text in item.attrib.items()}
|
|
97
|
+
for item in root.iter() if _local(item.tag) == "item"]
|
|
98
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def parse_series(raw: bytes) -> TimeSeries:
|
|
101
|
+
"""
|
|
102
|
+
Parses a raw ČEPS SOAP response into {timestamp: {series name: value}}.
|
|
103
|
+
|
|
104
|
+
The response describes its columns once and then lists the rows:
|
|
105
|
+
|
|
106
|
+
<serie id="value1" name="aFRR [EUR/MWh]" />
|
|
107
|
+
<item date="2026-09-14T00:00:00+02:00" value1="196.1663" />
|
|
108
|
+
|
|
109
|
+
which becomes
|
|
110
|
+
|
|
111
|
+
{datetime(2026, 9, 14, 0, 0, tzinfo=+02:00): {"aFRR [EUR/MWh]": 196.1663}}
|
|
112
|
+
|
|
113
|
+
Values are floats; one that is not a number is kept as the original
|
|
114
|
+
string. A response with no rows gives an empty dict.
|
|
115
|
+
|
|
116
|
+
Timestamps come back with the Czech UTC offset attached, so they
|
|
117
|
+
are timezone-aware and safe to compare across the DST change.
|
|
118
|
+
|
|
119
|
+
Raises CepsError for a report that is not one row per timestamp, where
|
|
120
|
+
this shape would lose rows: a row without a date or two rows with the
|
|
121
|
+
same one. parse_rows reads those.
|
|
122
|
+
"""
|
|
123
|
+
out: TimeSeries = {}
|
|
124
|
+
for row in parse_rows(raw):
|
|
125
|
+
when = row.pop("date", None)
|
|
126
|
+
if not isinstance(when, datetime):
|
|
127
|
+
raise CepsError("a row has no date, so this is not a time series")
|
|
128
|
+
if when in out:
|
|
129
|
+
raise CepsError(f"more than one row for {when.isoformat()}")
|
|
130
|
+
out[when] = row
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
134
|
+
def parse_estimated_price(raw: bytes) -> TimeSeries:
|
|
135
|
+
"""
|
|
136
|
+
Parses an OdhadovanaCenaOdchylky response into
|
|
137
|
+
{timestamp: {"Estimated price [Kč/MWh]": price}}, the shape of
|
|
138
|
+
parse_series.
|
|
139
|
+
|
|
140
|
+
Its rows have no date attribute, which is why parse_series does not
|
|
141
|
+
read it. The time is split over two columns instead:
|
|
142
|
+
|
|
143
|
+
<serie id="value2" name="Estimated price [Kč/MWh]" />
|
|
144
|
+
<serie id="value13" name="Date" />
|
|
145
|
+
<serie id="value15" name="Interval" />
|
|
146
|
+
<item value2="3934.46" value13="2026-10-05T02:00:00+02:00"
|
|
147
|
+
value15="00:00-00:15" />
|
|
148
|
+
|
|
149
|
+
Only the day of Date is meaningful (it is UTC midnight shown in Czech
|
|
150
|
+
time) and Interval is Czech wall-clock time, so the timestamp is that
|
|
151
|
+
day plus the start of the interval, with the Czech UTC offset attached
|
|
152
|
+
like the timestamps of parse_series.
|
|
153
|
+
|
|
154
|
+
On the day the clocks go back the 02:00-03:00 intervals are listed
|
|
155
|
+
twice; the second run gets the winter offset.
|
|
156
|
+
|
|
157
|
+
Raises CepsError, like parse_series, where this shape would lose rows:
|
|
158
|
+
a row without its day, interval or price, or two rows for the same
|
|
159
|
+
time.
|
|
160
|
+
"""
|
|
161
|
+
out: TimeSeries = {}
|
|
162
|
+
seen: set[datetime] = set()
|
|
163
|
+
for row in parse_rows(raw):
|
|
164
|
+
day, interval = row.pop("Date", None), row.pop("Interval", None)
|
|
165
|
+
# The price is the one remaining column.
|
|
166
|
+
if not isinstance(day, datetime) or not isinstance(interval, str) or not interval or not row:
|
|
167
|
+
raise CepsError("a row has no Date, Interval or price")
|
|
168
|
+
hour, minute = interval.split("-")[0].split(":")
|
|
169
|
+
local = datetime(day.year, day.month, day.day, int(hour), int(minute))
|
|
170
|
+
# A wall-clock time seen before is the repeated hour: fold=1 picks
|
|
171
|
+
# its second occurrence.
|
|
172
|
+
aware = local.replace(tzinfo=PRAGUE, fold=1 if local in seen else 0)
|
|
173
|
+
seen.add(local)
|
|
174
|
+
# Fixed offset rather than the zone itself, because two datetimes
|
|
175
|
+
# in the same zone compare equal regardless of fold and the
|
|
176
|
+
# repeated hour would overwrite itself as a dict key.
|
|
177
|
+
offset = aware.utcoffset()
|
|
178
|
+
assert offset is not None # aware has a zone
|
|
179
|
+
when = local.replace(tzinfo=timezone(offset))
|
|
180
|
+
if when in out:
|
|
181
|
+
raise CepsError(f"more than one row for {when.isoformat()}")
|
|
182
|
+
out[when] = row
|
|
183
|
+
return out
|
|
184
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def parse_codes(raw: bytes) -> Fields:
|
|
187
|
+
"""
|
|
188
|
+
Parses a DataVersion or DataAgregation response into {code: description}.
|
|
189
|
+
|
|
190
|
+
The response is a plain list:
|
|
191
|
+
|
|
192
|
+
<version name="RT " description="Reálná data" />
|
|
193
|
+
|
|
194
|
+
which becomes {"RT": "Reálná data"}. The service pads the codes with
|
|
195
|
+
spaces to three characters, so they are stripped.
|
|
196
|
+
"""
|
|
197
|
+
root = ET.fromstring(raw)
|
|
198
|
+
return {e.attrib["name"].strip(): e.attrib["description"]
|
|
199
|
+
for e in root.iter() if e.get("name") and e.get("description")}
|
|
200
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def parse_message(raw: bytes) -> Fields:
|
|
203
|
+
"""
|
|
204
|
+
Parses an EmergencyExchangeACER response into {field: text}.
|
|
205
|
+
|
|
206
|
+
The response is one message, however long the period asked for, with
|
|
207
|
+
one element per field:
|
|
208
|
+
|
|
209
|
+
<eventStatus>Active</eventStatus>
|
|
210
|
+
<eventStart>2025-02-09T00:00:00</eventStart>
|
|
211
|
+
|
|
212
|
+
which becomes {"eventStatus": "Active", "eventStart": "2025-02-09T00:00:00"}.
|
|
213
|
+
Everything stays text, as ČEPS sent it. The remarks field holds the
|
|
214
|
+
exchanges themselves, one "Direction-Type-MWh|" line each.
|
|
215
|
+
"""
|
|
216
|
+
root = ET.fromstring(raw)
|
|
217
|
+
# The fields are the elements without children that carry text; the
|
|
218
|
+
# wrappers around them have children.
|
|
219
|
+
return {_local(e.tag): e.text.strip()
|
|
220
|
+
for e in root.iter() if len(e) == 0 and e.text and e.text.strip()}
|
|
221
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
# The parser for each CepsMethod.response_type.
|
|
224
|
+
_PARSERS: dict[CepsResponseType, Callable[[bytes], Parsed]] = {
|
|
225
|
+
CepsResponseType.SERIES: parse_series,
|
|
226
|
+
CepsResponseType.ESTIMATED_PRICE: parse_estimated_price,
|
|
227
|
+
CepsResponseType.TABLE: parse_rows,
|
|
228
|
+
CepsResponseType.CODES: parse_codes,
|
|
229
|
+
CepsResponseType.MESSAGE: parse_message,
|
|
230
|
+
}
|
|
231
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def parse(response_type: CepsResponseType, raw: bytes) -> Parsed:
|
|
234
|
+
"""
|
|
235
|
+
Parses a raw ČEPS SOAP response with the parser of its response type:
|
|
236
|
+
|
|
237
|
+
SERIES parse_series
|
|
238
|
+
ESTIMATED_PRICE parse_estimated_price
|
|
239
|
+
TABLE parse_rows
|
|
240
|
+
CODES parse_codes
|
|
241
|
+
MESSAGE parse_message
|
|
242
|
+
|
|
243
|
+
Raises CepsError as the parser does, and for a response it cannot read:
|
|
244
|
+
one that is not XML (cut off, say) or has a value in a form it does not
|
|
245
|
+
know (an Interval that is not "HH:MM-HH:MM").
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
return _PARSERS[response_type](raw)
|
|
249
|
+
except (ET.ParseError, ValueError) as e:
|
|
250
|
+
raise CepsError(f"unreadable response ({e})") from None
|
|
251
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
ceps_api/py.typed
ADDED
|
File without changes
|
ceps_api/soap.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SOAP transport for the ČEPS service: envelope out, raw XML back."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import http.client
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from xml.sax.saxutils import escape
|
|
9
|
+
|
|
10
|
+
# SOAP endpoint (ASMX web service) that every request is POSTed to
|
|
11
|
+
CEPS_URL = "https://www.ceps.cz/_layouts/CepsData.asmx"
|
|
12
|
+
# XML namespace of the service; identifies the method elements in the envelope and the SOAPAction prefix
|
|
13
|
+
CEPS_NS = "https://www.ceps.cz/CepsData/"
|
|
14
|
+
|
|
15
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
class CepsError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def call(method: str, params: dict[str, object], timeout: int = 30) -> bytes:
|
|
21
|
+
"""
|
|
22
|
+
Posts one SOAP call and returns the response body for method in CEPS_METHODS.
|
|
23
|
+
Parameters that are None or empty are left out of the envelope.
|
|
24
|
+
timeout is in seconds.
|
|
25
|
+
|
|
26
|
+
Raises CepsError if the service answers with an error or with something
|
|
27
|
+
that is not XML (e.g. an HTML page during an outage), or cannot be
|
|
28
|
+
reached in time.
|
|
29
|
+
"""
|
|
30
|
+
# Build the request; passing data makes urllib send it as a POST.
|
|
31
|
+
request = urllib.request.Request(
|
|
32
|
+
CEPS_URL,
|
|
33
|
+
# The XML envelope, converted from str to bytes.
|
|
34
|
+
data=_envelope(method, params).encode("utf-8"),
|
|
35
|
+
# SOAPAction tells the server which method is being called.
|
|
36
|
+
headers={"Content-Type": "text/xml; charset=utf-8",
|
|
37
|
+
"SOAPAction" : f'"{CEPS_NS}{method}"'})
|
|
38
|
+
try:
|
|
39
|
+
# Send it and return the raw XML response.
|
|
40
|
+
with urllib.request.urlopen(request, timeout=timeout) as r:
|
|
41
|
+
# The service answers in text/xml; anything else is not its answer,
|
|
42
|
+
# e.g. a maintenance page, which may even parse as XML.
|
|
43
|
+
content_type = r.headers.get("Content-Type", "")
|
|
44
|
+
if "xml" not in content_type:
|
|
45
|
+
raise CepsError(f"{method}: unexpected answer from ČEPS ({content_type or 'no Content-Type'})")
|
|
46
|
+
body: bytes = r.read()
|
|
47
|
+
return body
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
# The server answered with an error status: raise its SOAP fault
|
|
50
|
+
# message if it sent one, otherwise just the HTTP code.
|
|
51
|
+
# "from None" hides the original urllib traceback.
|
|
52
|
+
message = _fault(e.read()) or f"HTTP {e.code}"
|
|
53
|
+
raise CepsError(f"{method}: {message}") from None
|
|
54
|
+
except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) as e:
|
|
55
|
+
# No answer at all: DNS, connection refused, timeout, or an answer cut
|
|
56
|
+
# off (HTTPException). URLError wraps the real cause in .reason.
|
|
57
|
+
reason = getattr(e, "reason", None) or e
|
|
58
|
+
raise CepsError(f"{method}: could not reach ČEPS ({reason})") from None
|
|
59
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
60
|
+
def _envelope(method: str, params: dict[str, object]) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Builds the SOAP request XML for one method call.
|
|
63
|
+
Each parameter becomes a <name>value</name> element inside the
|
|
64
|
+
method element; parameters that are None or empty are skipped.
|
|
65
|
+
Values are XML-escaped.
|
|
66
|
+
"""
|
|
67
|
+
# One <name>value</name> line per parameter; None/empty ones are dropped
|
|
68
|
+
# and values are escaped so characters like & or < cannot break the XML.
|
|
69
|
+
inner = "".join(f" <{k}>{escape(str(v))}</{k}>\n"
|
|
70
|
+
for k, v in params.items() if v not in (None, ""))
|
|
71
|
+
return (
|
|
72
|
+
# XML declaration.
|
|
73
|
+
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
74
|
+
# SOAP 1.1 envelope; the soap prefix is bound to the standard namespace.
|
|
75
|
+
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
|
|
76
|
+
'<soap:Body>'
|
|
77
|
+
# Method element in the ČEPS namespace, wrapping the parameters.
|
|
78
|
+
f'<{method} xmlns="{CEPS_NS}">\n{inner}'
|
|
79
|
+
f'</{method}>'
|
|
80
|
+
'</soap:Body>'
|
|
81
|
+
'</soap:Envelope>'
|
|
82
|
+
)
|
|
83
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
84
|
+
def _fault(body: bytes) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Pulls the error message out of a SOAP fault response.
|
|
87
|
+
Returns "" if the body is not XML or holds no message.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
root = ET.fromstring(body)
|
|
91
|
+
except ET.ParseError:
|
|
92
|
+
# Not XML at all, e.g. an HTML error page.
|
|
93
|
+
return ""
|
|
94
|
+
# Walk every element; endswith ignores the namespace prefix on the tag.
|
|
95
|
+
for e in root.iter():
|
|
96
|
+
if e.tag.endswith("faultstring") and e.text:
|
|
97
|
+
return e.text
|
|
98
|
+
return ""
|
|
99
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
ceps_api/validation.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The parameters of a call checked against the catalog, before a request goes out."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from .catalog import CEPS_AGGREGATIONS, CEPS_DATA_VERSIONS, CEPS_FUNCTIONS, CEPS_METHODS
|
|
7
|
+
|
|
8
|
+
# The argument names of the wrappers that the service spells differently.
|
|
9
|
+
# param1 is para1 for the methods whose catalog entry says so.
|
|
10
|
+
_SERVICE_NAMES = {"start": "dateFrom", "end": "dateTo", "aggregation": "agregation"}
|
|
11
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def service_name(method: str, name: str) -> str:
|
|
14
|
+
"""The service's name for an argument of a wrapper (param1 -> para1 where the catalog says so)."""
|
|
15
|
+
if name == "param1" and "para1" in CEPS_METHODS[method].optional_params:
|
|
16
|
+
return "para1"
|
|
17
|
+
return _SERVICE_NAMES.get(name, name)
|
|
18
|
+
#----------------------------------------------------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def validate(method: str, params: dict[str, object], shown: dict[str, str] | None = None) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Raises ValueError for a method, parameter, aggregation, function,
|
|
23
|
+
version or filter value the service does not have and for a missing
|
|
24
|
+
parameter it needs, instead of letting a request go out that would
|
|
25
|
+
fail or come back empty.
|
|
26
|
+
|
|
27
|
+
shown maps the service's parameter names to the ones the messages
|
|
28
|
+
use instead; the wrappers pass their argument names, so an error
|
|
29
|
+
says param1 where the service calls it para1.
|
|
30
|
+
"""
|
|
31
|
+
def names(params: Iterable[str]) -> str:
|
|
32
|
+
return ", ".join((shown or {}).get(p, p) for p in params)
|
|
33
|
+
|
|
34
|
+
if method not in CEPS_METHODS:
|
|
35
|
+
raise ValueError(f"Unknown ČEPS method {method!r}")
|
|
36
|
+
info = CEPS_METHODS[method]
|
|
37
|
+
# A parameter that is None or empty is left out of the request, so
|
|
38
|
+
# it is missing too.
|
|
39
|
+
missing = [p for p in info.required_params + info.needed_params
|
|
40
|
+
if params.get(p) in (None, "")]
|
|
41
|
+
if missing:
|
|
42
|
+
raise ValueError(f"{method} needs {names(missing)}")
|
|
43
|
+
allowed = info.required_params + info.optional_params
|
|
44
|
+
unknown = [p for p in params if p not in allowed]
|
|
45
|
+
if unknown:
|
|
46
|
+
raise ValueError(f"{method} does not take {names(unknown)}, "
|
|
47
|
+
f"expected {names(allowed) or 'no parameters'}")
|
|
48
|
+
agregation = params.get("agregation")
|
|
49
|
+
if agregation is not None and agregation not in CEPS_AGGREGATIONS:
|
|
50
|
+
raise ValueError(f"unknown aggregation {agregation!r}, "
|
|
51
|
+
f"expected one of {', '.join(CEPS_AGGREGATIONS)}")
|
|
52
|
+
supported = info.aggregations
|
|
53
|
+
if agregation is not None and supported is not None and agregation not in supported:
|
|
54
|
+
raise ValueError(f"{method} has no data for aggregation {agregation!r}, "
|
|
55
|
+
f"expected one of {', '.join(supported)}")
|
|
56
|
+
function = params.get("function")
|
|
57
|
+
if function is not None and function not in CEPS_FUNCTIONS:
|
|
58
|
+
raise ValueError(f"unknown function {function!r}, "
|
|
59
|
+
f"expected one of {', '.join(CEPS_FUNCTIONS)}")
|
|
60
|
+
# Without a function the service fails or returns rows with no values.
|
|
61
|
+
if agregation is not None and function is None:
|
|
62
|
+
raise ValueError(f"{method}: an aggregation needs a function, "
|
|
63
|
+
f"one of {', '.join(CEPS_FUNCTIONS)}")
|
|
64
|
+
version = params.get("version")
|
|
65
|
+
if version is not None and version not in CEPS_DATA_VERSIONS:
|
|
66
|
+
raise ValueError(f"unknown version {version!r}, "
|
|
67
|
+
f"expected one of {', '.join(CEPS_DATA_VERSIONS)}")
|
|
68
|
+
if version is not None and info.versions is not None and version not in info.versions:
|
|
69
|
+
raise ValueError(f"{method} has no data version {version!r}, "
|
|
70
|
+
f"expected one of {', '.join(info.versions)}")
|
|
71
|
+
# The service does not report a filter value it does not know.
|
|
72
|
+
for name, values in info.param_values.items():
|
|
73
|
+
value = params.get(name)
|
|
74
|
+
if value is not None and value not in values:
|
|
75
|
+
raise ValueError(f"{method} has no {names([name])} {value!r}, "
|
|
76
|
+
f"expected one of {', '.join(values)}")
|
|
77
|
+
#----------------------------------------------------------------------------------------------------------------------------
|