kpubdata 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.
- kpubdata/__init__.py +52 -0
- kpubdata/catalog.py +101 -0
- kpubdata/client.py +179 -0
- kpubdata/config.py +119 -0
- kpubdata/core/__init__.py +25 -0
- kpubdata/core/capability.py +56 -0
- kpubdata/core/dataset.py +153 -0
- kpubdata/core/models.py +156 -0
- kpubdata/core/protocol.py +60 -0
- kpubdata/core/representation.py +19 -0
- kpubdata/exceptions.py +116 -0
- kpubdata/providers/__init__.py +5 -0
- kpubdata/providers/_common.py +204 -0
- kpubdata/providers/bok/__init__.py +7 -0
- kpubdata/providers/bok/adapter.py +333 -0
- kpubdata/providers/bok/catalogue.json +25 -0
- kpubdata/providers/datago/__init__.py +7 -0
- kpubdata/providers/datago/adapter.py +313 -0
- kpubdata/providers/datago/catalogue.json +92 -0
- kpubdata/providers/kosis/__init__.py +7 -0
- kpubdata/providers/kosis/adapter.py +254 -0
- kpubdata/providers/kosis/catalogue.json +26 -0
- kpubdata/providers/lofin/__init__.py +7 -0
- kpubdata/providers/lofin/adapter.py +329 -0
- kpubdata/providers/lofin/catalogue.json +127 -0
- kpubdata/py.typed +0 -0
- kpubdata/registry.py +135 -0
- kpubdata/transport/__init__.py +16 -0
- kpubdata/transport/decode.py +74 -0
- kpubdata/transport/http.py +380 -0
- kpubdata/transport/retry.py +68 -0
- kpubdata-0.1.0.dist-info/METADATA +380 -0
- kpubdata-0.1.0.dist-info/RECORD +35 -0
- kpubdata-0.1.0.dist-info/WHEEL +4 -0
- kpubdata-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from typing import NoReturn, cast
|
|
6
|
+
|
|
7
|
+
from kpubdata.config import KPubDataConfig
|
|
8
|
+
from kpubdata.core.models import (
|
|
9
|
+
DatasetRef,
|
|
10
|
+
Query,
|
|
11
|
+
RecordBatch,
|
|
12
|
+
SchemaDescriptor,
|
|
13
|
+
)
|
|
14
|
+
from kpubdata.exceptions import (
|
|
15
|
+
AuthError,
|
|
16
|
+
DatasetNotFoundError,
|
|
17
|
+
InvalidRequestError,
|
|
18
|
+
ParseError,
|
|
19
|
+
ProviderResponseError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ServiceUnavailableError,
|
|
22
|
+
)
|
|
23
|
+
from kpubdata.providers._common import build_schema_from_metadata, coerce_int, load_catalogue
|
|
24
|
+
from kpubdata.transport.decode import decode_json
|
|
25
|
+
from kpubdata.transport.http import HttpTransport, TransportConfig
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("kpubdata.provider.bok")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BokAdapter:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
config: KPubDataConfig | None = None,
|
|
35
|
+
transport: HttpTransport | None = None,
|
|
36
|
+
catalogue: Sequence[DatasetRef] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._config: KPubDataConfig = config or KPubDataConfig()
|
|
39
|
+
transport_config = TransportConfig(
|
|
40
|
+
timeout=self._config.timeout,
|
|
41
|
+
max_retries=self._config.max_retries,
|
|
42
|
+
)
|
|
43
|
+
self._transport: HttpTransport = transport or HttpTransport(transport_config)
|
|
44
|
+
|
|
45
|
+
datasets = tuple(catalogue) if catalogue is not None else self._load_default_catalogue()
|
|
46
|
+
self._datasets: tuple[DatasetRef, ...] = datasets
|
|
47
|
+
self._datasets_by_key: dict[str, DatasetRef] = {
|
|
48
|
+
dataset.dataset_key: dataset for dataset in self._datasets
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
return "bok"
|
|
54
|
+
|
|
55
|
+
def list_datasets(self) -> list[DatasetRef]:
|
|
56
|
+
return list(self._datasets)
|
|
57
|
+
|
|
58
|
+
def search_datasets(self, text: str) -> list[DatasetRef]:
|
|
59
|
+
needle = text.casefold()
|
|
60
|
+
return [
|
|
61
|
+
dataset
|
|
62
|
+
for dataset in self._datasets
|
|
63
|
+
if needle in dataset.id.casefold() or needle in dataset.name.casefold()
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def get_dataset(self, dataset_key: str) -> DatasetRef:
|
|
67
|
+
dataset = self._datasets_by_key.get(dataset_key)
|
|
68
|
+
if dataset is not None:
|
|
69
|
+
return dataset
|
|
70
|
+
|
|
71
|
+
raise DatasetNotFoundError(
|
|
72
|
+
f"Dataset not found: bok.{dataset_key}",
|
|
73
|
+
provider="bok",
|
|
74
|
+
dataset_id=f"bok.{dataset_key}",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def query_records(self, dataset: DatasetRef, query: Query) -> RecordBatch:
|
|
78
|
+
page = query.page or 1
|
|
79
|
+
page_size = query.page_size or 10
|
|
80
|
+
frequency = self._resolve_frequency(query)
|
|
81
|
+
start_date = query.start_date or self._resolve_string_param(query, "start_date")
|
|
82
|
+
end_date = query.end_date or self._resolve_string_param(query, "end_date")
|
|
83
|
+
|
|
84
|
+
if start_date is None or end_date is None:
|
|
85
|
+
raise InvalidRequestError(
|
|
86
|
+
"BOK ECOS queries require start_date and end_date",
|
|
87
|
+
provider="bok",
|
|
88
|
+
dataset_id=dataset.id,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
all_items: list[dict[str, object]] = []
|
|
92
|
+
raw_pages: list[dict[str, object]] = []
|
|
93
|
+
total_count: int | None = None
|
|
94
|
+
|
|
95
|
+
while True:
|
|
96
|
+
start_index = (page - 1) * page_size + 1
|
|
97
|
+
end_index = page * page_size
|
|
98
|
+
url = self._build_request_url(
|
|
99
|
+
dataset,
|
|
100
|
+
operation=None,
|
|
101
|
+
start_index=start_index,
|
|
102
|
+
end_index=end_index,
|
|
103
|
+
frequency=frequency,
|
|
104
|
+
start_date=start_date,
|
|
105
|
+
end_date=end_date,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
payload = self._request_and_decode(url)
|
|
109
|
+
raw_pages.append(payload)
|
|
110
|
+
|
|
111
|
+
body, items = self._validate_envelope(payload, dataset.id)
|
|
112
|
+
all_items.extend(items)
|
|
113
|
+
|
|
114
|
+
total_count = coerce_int(body.get("list_total_count"), 0)
|
|
115
|
+
if not items:
|
|
116
|
+
break
|
|
117
|
+
if len(items) < page_size:
|
|
118
|
+
break
|
|
119
|
+
if page * page_size >= total_count:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
page += 1
|
|
123
|
+
|
|
124
|
+
return RecordBatch(
|
|
125
|
+
items=all_items,
|
|
126
|
+
dataset=dataset,
|
|
127
|
+
total_count=total_count,
|
|
128
|
+
next_page=None,
|
|
129
|
+
raw=raw_pages,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def get_record(self, _dataset: DatasetRef, _key: dict[str, object]) -> dict[str, object] | None:
|
|
133
|
+
raise NotImplementedError("TODO: implement bok get_record")
|
|
134
|
+
|
|
135
|
+
def get_schema(self, dataset: DatasetRef) -> SchemaDescriptor | None:
|
|
136
|
+
return build_schema_from_metadata(dataset)
|
|
137
|
+
|
|
138
|
+
def call_raw(self, dataset: DatasetRef, operation: str, params: dict[str, object]) -> object:
|
|
139
|
+
frequency = self._string_param(params, "frequency") or "M"
|
|
140
|
+
start_date = self._require_param(params, "start_date")
|
|
141
|
+
end_date = self._require_param(params, "end_date")
|
|
142
|
+
start_index = self._int_param(params, "start_index", 1)
|
|
143
|
+
end_index = self._int_param(params, "end_index", 10)
|
|
144
|
+
|
|
145
|
+
url = self._build_request_url(
|
|
146
|
+
dataset,
|
|
147
|
+
operation=operation,
|
|
148
|
+
start_index=start_index,
|
|
149
|
+
end_index=end_index,
|
|
150
|
+
frequency=frequency,
|
|
151
|
+
start_date=start_date,
|
|
152
|
+
end_date=end_date,
|
|
153
|
+
)
|
|
154
|
+
payload = self._request_and_decode(url)
|
|
155
|
+
_ = self._validate_envelope(payload, dataset.id)
|
|
156
|
+
return payload
|
|
157
|
+
|
|
158
|
+
def _require_api_key(self) -> str:
|
|
159
|
+
return self._config.require_provider_key("bok")
|
|
160
|
+
|
|
161
|
+
def _build_request_url(
|
|
162
|
+
self,
|
|
163
|
+
dataset: DatasetRef,
|
|
164
|
+
*,
|
|
165
|
+
operation: str | None,
|
|
166
|
+
start_index: int,
|
|
167
|
+
end_index: int,
|
|
168
|
+
frequency: str,
|
|
169
|
+
start_date: str,
|
|
170
|
+
end_date: str,
|
|
171
|
+
) -> str:
|
|
172
|
+
base_url_raw = dataset.raw_metadata.get("base_url")
|
|
173
|
+
if not isinstance(base_url_raw, str) or not base_url_raw:
|
|
174
|
+
raise ProviderResponseError(
|
|
175
|
+
"Dataset metadata missing base_url",
|
|
176
|
+
provider="bok",
|
|
177
|
+
dataset_id=dataset.id,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
selected_operation = operation or dataset.raw_metadata.get("default_operation")
|
|
181
|
+
if not isinstance(selected_operation, str) or not selected_operation:
|
|
182
|
+
raise ProviderResponseError(
|
|
183
|
+
"Dataset metadata missing default_operation",
|
|
184
|
+
provider="bok",
|
|
185
|
+
dataset_id=dataset.id,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
stat_code = self._require_dataset_metadata(dataset, "stat_code")
|
|
189
|
+
item_code1 = self._require_dataset_metadata(dataset, "item_code1")
|
|
190
|
+
api_key = self._require_api_key()
|
|
191
|
+
return (
|
|
192
|
+
f"{base_url_raw}/{api_key}/json/{selected_operation}/"
|
|
193
|
+
f"{start_index}/{end_index}/{stat_code}/{frequency}/{start_date}/{end_date}/{item_code1}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _request_and_decode(self, url: str) -> dict[str, object]:
|
|
197
|
+
response = self._transport.request("GET", url)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
decoded_obj: object = decode_json(response.content)
|
|
201
|
+
except ParseError as exc:
|
|
202
|
+
exc.provider = "bok"
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
if isinstance(decoded_obj, dict):
|
|
206
|
+
return cast(dict[str, object], decoded_obj)
|
|
207
|
+
|
|
208
|
+
raise ParseError("Decoded payload is not an object", provider="bok")
|
|
209
|
+
|
|
210
|
+
def _validate_envelope(
|
|
211
|
+
self, payload: dict[str, object], dataset_id: str = ""
|
|
212
|
+
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
|
213
|
+
self._raise_for_result(payload, dataset_id)
|
|
214
|
+
|
|
215
|
+
body_obj = payload.get("StatisticSearch")
|
|
216
|
+
if not isinstance(body_obj, dict):
|
|
217
|
+
raise ProviderResponseError(
|
|
218
|
+
"Malformed response envelope: missing StatisticSearch",
|
|
219
|
+
provider="bok",
|
|
220
|
+
dataset_id=dataset_id or None,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
body_dict = cast(dict[str, object], body_obj)
|
|
224
|
+
items = self._normalize_rows(body_dict.get("row"))
|
|
225
|
+
return body_dict, items
|
|
226
|
+
|
|
227
|
+
def _raise_for_result(self, payload: Mapping[str, object], dataset_id: str) -> None:
|
|
228
|
+
result_obj = payload.get("RESULT")
|
|
229
|
+
if not isinstance(result_obj, dict):
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
result_dict = cast(dict[str, object], result_obj)
|
|
233
|
+
code_raw = result_dict.get("CODE")
|
|
234
|
+
message_raw = result_dict.get("MESSAGE")
|
|
235
|
+
code = code_raw if isinstance(code_raw, str) else "ERROR"
|
|
236
|
+
message = message_raw if isinstance(message_raw, str) else "Provider returned error"
|
|
237
|
+
logger.debug(
|
|
238
|
+
"BOK ECOS result",
|
|
239
|
+
extra={"result_code": code, "result_msg": message, "dataset_id": dataset_id},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if code != "ERROR":
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
self._raise_for_result_code(code, message, dataset_id)
|
|
246
|
+
|
|
247
|
+
def _raise_for_result_code(self, code: str, msg: str, dataset_id: str) -> NoReturn:
|
|
248
|
+
normalized_msg = msg.casefold()
|
|
249
|
+
if "인증키" in msg or "api key" in normalized_msg or "auth" in normalized_msg:
|
|
250
|
+
raise AuthError(msg, provider="bok", provider_code=code, dataset_id=dataset_id or None)
|
|
251
|
+
if "호출한도" in msg or "too many" in normalized_msg or "rate limit" in normalized_msg:
|
|
252
|
+
raise RateLimitError(
|
|
253
|
+
msg, provider="bok", provider_code=code, dataset_id=dataset_id or None
|
|
254
|
+
)
|
|
255
|
+
if (
|
|
256
|
+
"점검" in msg
|
|
257
|
+
or "service unavailable" in normalized_msg
|
|
258
|
+
or "temporarily unavailable" in normalized_msg
|
|
259
|
+
):
|
|
260
|
+
raise ServiceUnavailableError(
|
|
261
|
+
msg,
|
|
262
|
+
provider="bok",
|
|
263
|
+
provider_code=code,
|
|
264
|
+
dataset_id=dataset_id or None,
|
|
265
|
+
)
|
|
266
|
+
if "필수" in msg or "invalid" in normalized_msg or "잘못" in msg:
|
|
267
|
+
raise InvalidRequestError(
|
|
268
|
+
msg, provider="bok", provider_code=code, dataset_id=dataset_id or None
|
|
269
|
+
)
|
|
270
|
+
raise ProviderResponseError(
|
|
271
|
+
msg, provider="bok", provider_code=code, dataset_id=dataset_id or None
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _resolve_frequency(self, query: Query) -> str:
|
|
275
|
+
return self._resolve_string_param(query, "frequency") or "M"
|
|
276
|
+
|
|
277
|
+
def _resolve_string_param(self, query: Query, key: str) -> str | None:
|
|
278
|
+
if key in query.extra:
|
|
279
|
+
extra_value = query.extra[key]
|
|
280
|
+
if isinstance(extra_value, str) and extra_value:
|
|
281
|
+
return extra_value
|
|
282
|
+
if key in query.filters:
|
|
283
|
+
filter_value = query.filters[key]
|
|
284
|
+
if isinstance(filter_value, str) and filter_value:
|
|
285
|
+
return filter_value
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def _require_dataset_metadata(self, dataset: DatasetRef, key: str) -> str:
|
|
289
|
+
value = dataset.raw_metadata.get(key)
|
|
290
|
+
if isinstance(value, str) and value:
|
|
291
|
+
return value
|
|
292
|
+
raise ProviderResponseError(
|
|
293
|
+
f"Dataset metadata missing {key}",
|
|
294
|
+
provider="bok",
|
|
295
|
+
dataset_id=dataset.id,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _normalize_rows(self, rows_wrapper: object) -> list[dict[str, object]]:
|
|
299
|
+
if rows_wrapper is None:
|
|
300
|
+
return []
|
|
301
|
+
if isinstance(rows_wrapper, list):
|
|
302
|
+
rows = cast(list[object], rows_wrapper)
|
|
303
|
+
return [cast(dict[str, object], item) for item in rows if isinstance(item, dict)]
|
|
304
|
+
if isinstance(rows_wrapper, dict):
|
|
305
|
+
return [cast(dict[str, object], rows_wrapper)]
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _string_param(params: Mapping[str, object], key: str) -> str | None:
|
|
310
|
+
value = params.get(key)
|
|
311
|
+
if isinstance(value, str) and value:
|
|
312
|
+
return value
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def _require_param(cls, params: Mapping[str, object], key: str) -> str:
|
|
317
|
+
value = cls._string_param(params, key)
|
|
318
|
+
if value is not None:
|
|
319
|
+
return value
|
|
320
|
+
raise InvalidRequestError(f"BOK ECOS raw calls require {key}", provider="bok")
|
|
321
|
+
|
|
322
|
+
@classmethod
|
|
323
|
+
def _int_param(cls, params: Mapping[str, object], key: str, default: int) -> int:
|
|
324
|
+
value = params.get(key)
|
|
325
|
+
coerced = coerce_int(value, default)
|
|
326
|
+
return coerced if coerced > 0 else default
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _load_default_catalogue() -> tuple[DatasetRef, ...]:
|
|
330
|
+
return load_catalogue("kpubdata.providers.bok", "bok")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
__all__ = ["BokAdapter"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"dataset_key": "base_rate",
|
|
4
|
+
"name": "한국은행 기준금리 (BOK Base Rate)",
|
|
5
|
+
"base_url": "https://ecos.bok.or.kr/api/StatisticSearch",
|
|
6
|
+
"default_operation": "StatisticSearch",
|
|
7
|
+
"representation": "api_json",
|
|
8
|
+
"stat_code": "722Y001",
|
|
9
|
+
"item_code1": "0101000",
|
|
10
|
+
"description": "Bank of Korea base interest rate historical data",
|
|
11
|
+
"operations": ["list", "raw"],
|
|
12
|
+
"query_support": {
|
|
13
|
+
"pagination": "offset",
|
|
14
|
+
"max_page_size": 1000
|
|
15
|
+
},
|
|
16
|
+
"fields": [
|
|
17
|
+
{"name": "TIME", "title": "기간", "type": "string"},
|
|
18
|
+
{"name": "DATA_VALUE", "title": "기준금리", "type": "string"},
|
|
19
|
+
{"name": "UNIT_NAME", "title": "단위", "type": "string"},
|
|
20
|
+
{"name": "STAT_CODE", "title": "통계표코드", "type": "string"},
|
|
21
|
+
{"name": "ITEM_CODE1", "title": "항목코드", "type": "string"},
|
|
22
|
+
{"name": "ITEM_NAME1", "title": "항목명", "type": "string"}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Data.go.kr adapter with curated dataset catalogue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import NoReturn, cast
|
|
8
|
+
|
|
9
|
+
from kpubdata.config import KPubDataConfig
|
|
10
|
+
from kpubdata.core.models import (
|
|
11
|
+
DatasetRef,
|
|
12
|
+
Query,
|
|
13
|
+
RecordBatch,
|
|
14
|
+
SchemaDescriptor,
|
|
15
|
+
)
|
|
16
|
+
from kpubdata.exceptions import (
|
|
17
|
+
AuthError,
|
|
18
|
+
DatasetNotFoundError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
ParseError,
|
|
21
|
+
ProviderResponseError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ServiceUnavailableError,
|
|
24
|
+
)
|
|
25
|
+
from kpubdata.providers._common import build_schema_from_metadata, coerce_int, load_catalogue
|
|
26
|
+
from kpubdata.transport.decode import decode_json, decode_xml, detect_content_type
|
|
27
|
+
from kpubdata.transport.http import HttpTransport, TransportConfig
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("kpubdata.provider.datago")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DataGoAdapter:
|
|
33
|
+
"""Adapter for data.go.kr (공공데이터포털).
|
|
34
|
+
|
|
35
|
+
Provides a curated catalogue of supported datasets from the
|
|
36
|
+
apis.data.go.kr endpoint family.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
config: KPubDataConfig | None = None,
|
|
43
|
+
transport: HttpTransport | None = None,
|
|
44
|
+
catalogue: Sequence[DatasetRef] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._config: KPubDataConfig = config or KPubDataConfig()
|
|
47
|
+
transport_config = TransportConfig(
|
|
48
|
+
timeout=self._config.timeout,
|
|
49
|
+
max_retries=self._config.max_retries,
|
|
50
|
+
)
|
|
51
|
+
self._transport: HttpTransport = transport or HttpTransport(transport_config)
|
|
52
|
+
|
|
53
|
+
datasets = tuple(catalogue) if catalogue is not None else self._load_default_catalogue()
|
|
54
|
+
self._datasets: tuple[DatasetRef, ...] = datasets
|
|
55
|
+
self._datasets_by_key: dict[str, DatasetRef] = {
|
|
56
|
+
dataset.dataset_key: dataset for dataset in self._datasets
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def name(self) -> str:
|
|
61
|
+
"""Return canonical provider key."""
|
|
62
|
+
|
|
63
|
+
return "datago"
|
|
64
|
+
|
|
65
|
+
def list_datasets(self) -> list[DatasetRef]:
|
|
66
|
+
"""List datasets available from data.go.kr."""
|
|
67
|
+
|
|
68
|
+
return list(self._datasets)
|
|
69
|
+
|
|
70
|
+
def search_datasets(self, text: str) -> list[DatasetRef]:
|
|
71
|
+
"""Search datasets available from data.go.kr."""
|
|
72
|
+
|
|
73
|
+
needle = text.casefold()
|
|
74
|
+
return [
|
|
75
|
+
dataset
|
|
76
|
+
for dataset in self._datasets
|
|
77
|
+
if needle in dataset.id.casefold() or needle in dataset.name.casefold()
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def get_dataset(self, dataset_key: str) -> DatasetRef:
|
|
81
|
+
"""Resolve provider-local dataset key for data.go.kr."""
|
|
82
|
+
|
|
83
|
+
dataset = self._datasets_by_key.get(dataset_key)
|
|
84
|
+
if dataset is not None:
|
|
85
|
+
return dataset
|
|
86
|
+
|
|
87
|
+
raise DatasetNotFoundError(
|
|
88
|
+
f"Dataset not found: datago.{dataset_key}",
|
|
89
|
+
provider="datago",
|
|
90
|
+
dataset_id=f"datago.{dataset_key}",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def query_records(self, dataset: DatasetRef, query: Query) -> RecordBatch:
|
|
94
|
+
"""Query records from a data.go.kr dataset."""
|
|
95
|
+
|
|
96
|
+
page = query.page or 1
|
|
97
|
+
page_size = query.page_size or 10
|
|
98
|
+
|
|
99
|
+
all_items: list[dict[str, object]] = []
|
|
100
|
+
raw_pages: list[dict[str, object]] = []
|
|
101
|
+
total_count: int | None = None
|
|
102
|
+
|
|
103
|
+
while True:
|
|
104
|
+
url = self._build_request_url(dataset)
|
|
105
|
+
params = self._build_base_params(dataset)
|
|
106
|
+
params["pageNo"] = str(page)
|
|
107
|
+
params["numOfRows"] = str(page_size)
|
|
108
|
+
|
|
109
|
+
reserved = {params_key.lower() for params_key in params}
|
|
110
|
+
reserved.update({"pageno", "numofrows"})
|
|
111
|
+
for key, raw_value in query.filters.items():
|
|
112
|
+
if key.lower() not in reserved:
|
|
113
|
+
value: object = raw_value
|
|
114
|
+
params[key] = str(value)
|
|
115
|
+
|
|
116
|
+
payload = self._request_and_decode(url, params)
|
|
117
|
+
raw_pages.append(payload)
|
|
118
|
+
|
|
119
|
+
body, items = self._validate_envelope(payload, dataset.id)
|
|
120
|
+
all_items.extend(items)
|
|
121
|
+
|
|
122
|
+
total_count = coerce_int(body.get("totalCount"), 0)
|
|
123
|
+
current_num_of_rows = coerce_int(body.get("numOfRows"), page_size)
|
|
124
|
+
|
|
125
|
+
if not items:
|
|
126
|
+
break
|
|
127
|
+
if len(items) < current_num_of_rows:
|
|
128
|
+
break
|
|
129
|
+
if page * page_size >= total_count:
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
page += 1
|
|
133
|
+
|
|
134
|
+
return RecordBatch(
|
|
135
|
+
items=all_items,
|
|
136
|
+
dataset=dataset,
|
|
137
|
+
total_count=total_count,
|
|
138
|
+
next_page=None,
|
|
139
|
+
raw=raw_pages,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def get_record(self, _dataset: DatasetRef, _key: dict[str, object]) -> dict[str, object] | None:
|
|
143
|
+
"""Get a single record from a data.go.kr dataset."""
|
|
144
|
+
|
|
145
|
+
raise NotImplementedError("TODO: implement datago get_record")
|
|
146
|
+
|
|
147
|
+
def get_schema(self, dataset: DatasetRef) -> SchemaDescriptor | None:
|
|
148
|
+
"""Get schema metadata for a data.go.kr dataset.
|
|
149
|
+
|
|
150
|
+
Returns schema from curated catalogue metadata when available.
|
|
151
|
+
data.go.kr has no live schema discovery endpoint, so this
|
|
152
|
+
returns ``None`` for datasets without explicitly curated field
|
|
153
|
+
definitions in the catalogue.
|
|
154
|
+
"""
|
|
155
|
+
return build_schema_from_metadata(dataset)
|
|
156
|
+
|
|
157
|
+
def call_raw(self, dataset: DatasetRef, operation: str, params: dict[str, object]) -> object:
|
|
158
|
+
"""Call provider-native data.go.kr API operation."""
|
|
159
|
+
|
|
160
|
+
url = self._build_request_url(dataset, operation)
|
|
161
|
+
request_params = self._build_base_params(dataset)
|
|
162
|
+
|
|
163
|
+
service_key_param = str(dataset.raw_metadata.get("service_key_param", "serviceKey"))
|
|
164
|
+
for key, value in params.items():
|
|
165
|
+
if key != service_key_param:
|
|
166
|
+
request_params[key] = str(value)
|
|
167
|
+
|
|
168
|
+
payload = self._request_and_decode(url, request_params)
|
|
169
|
+
_ = self._validate_envelope(payload, dataset.id)
|
|
170
|
+
return payload
|
|
171
|
+
|
|
172
|
+
def _require_api_key(self) -> str:
|
|
173
|
+
return self._config.require_provider_key("datago")
|
|
174
|
+
|
|
175
|
+
def _build_request_url(self, dataset: DatasetRef, operation: str | None = None) -> str:
|
|
176
|
+
base_url_raw = dataset.raw_metadata.get("base_url")
|
|
177
|
+
if not isinstance(base_url_raw, str) or not base_url_raw:
|
|
178
|
+
raise ProviderResponseError(
|
|
179
|
+
"Dataset metadata missing base_url",
|
|
180
|
+
provider="datago",
|
|
181
|
+
dataset_id=dataset.id,
|
|
182
|
+
)
|
|
183
|
+
selected_operation = operation or dataset.raw_metadata.get("default_operation")
|
|
184
|
+
if isinstance(selected_operation, str) and selected_operation:
|
|
185
|
+
return f"{base_url_raw}/{selected_operation}"
|
|
186
|
+
return base_url_raw
|
|
187
|
+
|
|
188
|
+
def _build_base_params(self, dataset: DatasetRef) -> dict[str, str]:
|
|
189
|
+
api_key = self._require_api_key()
|
|
190
|
+
service_key_param_raw = dataset.raw_metadata.get("service_key_param", "serviceKey")
|
|
191
|
+
format_param_raw = dataset.raw_metadata.get("format_param", "resultType")
|
|
192
|
+
service_key_param = (
|
|
193
|
+
service_key_param_raw
|
|
194
|
+
if isinstance(service_key_param_raw, str) and service_key_param_raw
|
|
195
|
+
else "serviceKey"
|
|
196
|
+
)
|
|
197
|
+
format_param = (
|
|
198
|
+
format_param_raw
|
|
199
|
+
if isinstance(format_param_raw, str) and format_param_raw
|
|
200
|
+
else "resultType"
|
|
201
|
+
)
|
|
202
|
+
return {service_key_param: api_key, format_param: "json"}
|
|
203
|
+
|
|
204
|
+
def _request_and_decode(self, url: str, params: Mapping[str, object]) -> dict[str, object]:
|
|
205
|
+
string_params = {key: str(value) for key, value in params.items()}
|
|
206
|
+
response = self._transport.request("GET", url, params=string_params)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
content_type = detect_content_type(response)
|
|
210
|
+
if content_type == "json":
|
|
211
|
+
decoded = decode_json(response.content)
|
|
212
|
+
elif content_type == "xml":
|
|
213
|
+
decoded = decode_xml(response.content)
|
|
214
|
+
else:
|
|
215
|
+
decoded = decode_json(response.content)
|
|
216
|
+
except ParseError as exc:
|
|
217
|
+
exc.provider = "datago"
|
|
218
|
+
raise
|
|
219
|
+
except ImportError as exc:
|
|
220
|
+
raise ParseError("Failed to parse data.go.kr response", provider="datago") from exc
|
|
221
|
+
|
|
222
|
+
if isinstance(decoded, dict):
|
|
223
|
+
return cast(dict[str, object], decoded)
|
|
224
|
+
|
|
225
|
+
raise ParseError("Decoded payload is not an object", provider="datago")
|
|
226
|
+
|
|
227
|
+
def _validate_envelope(
|
|
228
|
+
self, payload: dict[str, object], dataset_id: str = ""
|
|
229
|
+
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
|
230
|
+
response_obj = payload.get("response")
|
|
231
|
+
if not isinstance(response_obj, dict):
|
|
232
|
+
raise ProviderResponseError(
|
|
233
|
+
"Malformed response envelope: missing response",
|
|
234
|
+
provider="datago",
|
|
235
|
+
dataset_id=dataset_id or None,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
header_obj = response_obj.get("header")
|
|
239
|
+
if not isinstance(header_obj, dict):
|
|
240
|
+
raise ProviderResponseError(
|
|
241
|
+
"Malformed response envelope: missing header",
|
|
242
|
+
provider="datago",
|
|
243
|
+
dataset_id=dataset_id or None,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
header_dict = cast(dict[str, object], header_obj)
|
|
247
|
+
result_code = header_dict.get("resultCode")
|
|
248
|
+
if not isinstance(result_code, str):
|
|
249
|
+
raise ProviderResponseError(
|
|
250
|
+
"Malformed response envelope: missing resultCode",
|
|
251
|
+
provider="datago",
|
|
252
|
+
dataset_id=dataset_id or None,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
result_msg_raw = header_dict.get("resultMsg")
|
|
256
|
+
result_msg = (
|
|
257
|
+
result_msg_raw if isinstance(result_msg_raw, str) else "Provider returned error"
|
|
258
|
+
)
|
|
259
|
+
logger.debug(
|
|
260
|
+
"data.go.kr result",
|
|
261
|
+
extra={"result_code": result_code, "result_msg": result_msg, "dataset_id": dataset_id},
|
|
262
|
+
)
|
|
263
|
+
if result_code != "00":
|
|
264
|
+
self._raise_for_result_code(result_code, result_msg, dataset_id)
|
|
265
|
+
|
|
266
|
+
body_obj = response_obj.get("body")
|
|
267
|
+
body_dict: dict[str, object] = (
|
|
268
|
+
cast(dict[str, object], body_obj) if isinstance(body_obj, dict) else {}
|
|
269
|
+
)
|
|
270
|
+
items = self._normalize_items(body_dict.get("items"))
|
|
271
|
+
return body_dict, items
|
|
272
|
+
|
|
273
|
+
def _raise_for_result_code(self, code: str, msg: str, dataset_id: str) -> NoReturn:
|
|
274
|
+
if code in {"30", "31", "20", "32"}:
|
|
275
|
+
raise AuthError(msg, provider="datago", provider_code=code)
|
|
276
|
+
if code == "22":
|
|
277
|
+
raise RateLimitError(msg, provider="datago", provider_code=code, retryable=False)
|
|
278
|
+
if code == "10":
|
|
279
|
+
raise InvalidRequestError(msg, provider="datago", provider_code=code)
|
|
280
|
+
if code == "12":
|
|
281
|
+
raise DatasetNotFoundError(
|
|
282
|
+
msg,
|
|
283
|
+
provider="datago",
|
|
284
|
+
provider_code=code,
|
|
285
|
+
dataset_id=dataset_id,
|
|
286
|
+
)
|
|
287
|
+
if code in {"01", "02"}:
|
|
288
|
+
raise ServiceUnavailableError(msg, provider="datago", provider_code=code)
|
|
289
|
+
raise ProviderResponseError(msg, provider="datago", provider_code=code)
|
|
290
|
+
|
|
291
|
+
def _normalize_items(self, items_wrapper: object) -> list[dict[str, object]]:
|
|
292
|
+
if items_wrapper is None:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
if isinstance(items_wrapper, dict):
|
|
296
|
+
item_value = items_wrapper.get("item")
|
|
297
|
+
if isinstance(item_value, list):
|
|
298
|
+
return [item for item in item_value if isinstance(item, dict)]
|
|
299
|
+
if isinstance(item_value, dict):
|
|
300
|
+
return [item_value]
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
if isinstance(items_wrapper, list):
|
|
304
|
+
return [item for item in items_wrapper if isinstance(item, dict)]
|
|
305
|
+
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _load_default_catalogue() -> tuple[DatasetRef, ...]:
|
|
310
|
+
return load_catalogue("kpubdata.providers.datago", "datago")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = ["DataGoAdapter"]
|