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/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
+ #----------------------------------------------------------------------------------------------------------------------------