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,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import ssl
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import NoReturn, cast
|
|
7
|
+
|
|
8
|
+
from kpubdata.config import KPubDataConfig
|
|
9
|
+
from kpubdata.core.models import DatasetRef, Query, RecordBatch, SchemaDescriptor
|
|
10
|
+
from kpubdata.exceptions import (
|
|
11
|
+
AuthError,
|
|
12
|
+
DatasetNotFoundError,
|
|
13
|
+
ParseError,
|
|
14
|
+
ProviderResponseError,
|
|
15
|
+
)
|
|
16
|
+
from kpubdata.providers._common import build_schema_from_metadata, coerce_int, load_catalogue
|
|
17
|
+
from kpubdata.transport.decode import decode_json
|
|
18
|
+
from kpubdata.transport.http import HttpTransport, TransportConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("kpubdata.provider.lofin")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LofinAdapter:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
config: KPubDataConfig | None = None,
|
|
28
|
+
transport: HttpTransport | None = None,
|
|
29
|
+
catalogue: Sequence[DatasetRef] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._config: KPubDataConfig = config or KPubDataConfig()
|
|
32
|
+
if transport is not None and not isinstance(transport, HttpTransport):
|
|
33
|
+
self._transport: HttpTransport = transport
|
|
34
|
+
else:
|
|
35
|
+
ssl_ctx = ssl.create_default_context()
|
|
36
|
+
ssl_ctx.check_hostname = False
|
|
37
|
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
38
|
+
ssl_ctx.set_ciphers("DEFAULT:@SECLEVEL=1")
|
|
39
|
+
transport_config = TransportConfig(
|
|
40
|
+
timeout=self._config.timeout,
|
|
41
|
+
max_retries=self._config.max_retries,
|
|
42
|
+
ssl_context=ssl_ctx,
|
|
43
|
+
)
|
|
44
|
+
self._transport = HttpTransport(transport_config)
|
|
45
|
+
|
|
46
|
+
datasets = tuple(catalogue) if catalogue is not None else self._load_default_catalogue()
|
|
47
|
+
self._datasets: tuple[DatasetRef, ...] = datasets
|
|
48
|
+
self._datasets_by_key: dict[str, DatasetRef] = {
|
|
49
|
+
dataset.dataset_key: dataset for dataset in self._datasets
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
return "lofin"
|
|
55
|
+
|
|
56
|
+
def list_datasets(self) -> list[DatasetRef]:
|
|
57
|
+
return list(self._datasets)
|
|
58
|
+
|
|
59
|
+
def search_datasets(self, text: str) -> list[DatasetRef]:
|
|
60
|
+
needle = text.casefold()
|
|
61
|
+
return [
|
|
62
|
+
dataset
|
|
63
|
+
for dataset in self._datasets
|
|
64
|
+
if needle in dataset.id.casefold() or needle in dataset.name.casefold()
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
def get_dataset(self, dataset_key: str) -> DatasetRef:
|
|
68
|
+
dataset = self._datasets_by_key.get(dataset_key)
|
|
69
|
+
if dataset is not None:
|
|
70
|
+
return dataset
|
|
71
|
+
|
|
72
|
+
raise DatasetNotFoundError(
|
|
73
|
+
f"Dataset not found: lofin.{dataset_key}",
|
|
74
|
+
provider="lofin",
|
|
75
|
+
dataset_id=f"lofin.{dataset_key}",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def query_records(self, dataset: DatasetRef, query: Query) -> RecordBatch:
|
|
79
|
+
page = query.page or 1
|
|
80
|
+
page_size = query.page_size or 10
|
|
81
|
+
|
|
82
|
+
all_items: list[dict[str, object]] = []
|
|
83
|
+
raw_pages: list[dict[str, object]] = []
|
|
84
|
+
total_count: int | None = None
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
url = self._build_request_url(
|
|
88
|
+
dataset, page=page, page_size=page_size, filters=query.filters
|
|
89
|
+
)
|
|
90
|
+
payload = self._request_and_decode(url, dataset.id)
|
|
91
|
+
raw_pages.append(payload)
|
|
92
|
+
|
|
93
|
+
body, items = self._validate_envelope(payload, dataset)
|
|
94
|
+
all_items.extend(items)
|
|
95
|
+
|
|
96
|
+
total_count = coerce_int(body.get("list_total_count"), 0)
|
|
97
|
+
if not items:
|
|
98
|
+
break
|
|
99
|
+
if len(items) < page_size:
|
|
100
|
+
break
|
|
101
|
+
if len(all_items) >= total_count:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
page += 1
|
|
105
|
+
|
|
106
|
+
return RecordBatch(
|
|
107
|
+
items=all_items,
|
|
108
|
+
dataset=dataset,
|
|
109
|
+
total_count=total_count,
|
|
110
|
+
next_page=None,
|
|
111
|
+
raw=raw_pages,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def get_record(self, _dataset: DatasetRef, _key: dict[str, object]) -> dict[str, object] | None:
|
|
115
|
+
raise NotImplementedError("TODO: implement lofin get_record")
|
|
116
|
+
|
|
117
|
+
def get_schema(self, dataset: DatasetRef) -> SchemaDescriptor | None:
|
|
118
|
+
return build_schema_from_metadata(dataset)
|
|
119
|
+
|
|
120
|
+
def call_raw(self, dataset: DatasetRef, operation: str, params: dict[str, object]) -> object:
|
|
121
|
+
_ = operation
|
|
122
|
+
page = self._int_param(params, "pIndex", self._int_param(params, "page", 1))
|
|
123
|
+
page_size = self._int_param(params, "pSize", self._int_param(params, "page_size", 10))
|
|
124
|
+
extra_keys = {"pIndex", "page", "pSize", "page_size"}
|
|
125
|
+
filters = {k: v for k, v in params.items() if k not in extra_keys}
|
|
126
|
+
|
|
127
|
+
url = self._build_request_url(dataset, page=page, page_size=page_size, filters=filters)
|
|
128
|
+
payload = self._request_and_decode(url, dataset.id)
|
|
129
|
+
_ = self._validate_envelope(payload, dataset)
|
|
130
|
+
return payload
|
|
131
|
+
|
|
132
|
+
def _require_api_key(self) -> str:
|
|
133
|
+
return self._config.require_provider_key("lofin")
|
|
134
|
+
|
|
135
|
+
def _build_request_url(
|
|
136
|
+
self,
|
|
137
|
+
dataset: DatasetRef,
|
|
138
|
+
*,
|
|
139
|
+
page: int,
|
|
140
|
+
page_size: int,
|
|
141
|
+
filters: dict[str, object] | None = None,
|
|
142
|
+
) -> str:
|
|
143
|
+
base_url_raw = dataset.raw_metadata.get("base_url")
|
|
144
|
+
if not isinstance(base_url_raw, str) or not base_url_raw:
|
|
145
|
+
raise ProviderResponseError(
|
|
146
|
+
"Dataset metadata missing base_url",
|
|
147
|
+
provider="lofin",
|
|
148
|
+
dataset_id=dataset.id,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
dataset_code = self._require_dataset_metadata(dataset, "api_code")
|
|
152
|
+
api_key = self._require_api_key()
|
|
153
|
+
safe_page = page if page > 0 else 1
|
|
154
|
+
safe_page_size = page_size if page_size > 0 else 10
|
|
155
|
+
url = (
|
|
156
|
+
f"{base_url_raw}/{dataset_code}"
|
|
157
|
+
f"?Key={api_key}&Type=json&pIndex={safe_page}&pSize={safe_page_size}"
|
|
158
|
+
)
|
|
159
|
+
if filters:
|
|
160
|
+
for key, value in filters.items():
|
|
161
|
+
url += f"&{key}={value}"
|
|
162
|
+
return url
|
|
163
|
+
|
|
164
|
+
def _request_and_decode(self, url: str, dataset_id: str = "") -> dict[str, object]:
|
|
165
|
+
response = self._transport.request("GET", url)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
decoded_obj: object = decode_json(response.content)
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
raise ParseError("Failed to parse LOFIN response", provider="lofin") from exc
|
|
171
|
+
|
|
172
|
+
if isinstance(decoded_obj, dict):
|
|
173
|
+
payload = cast(dict[str, object], decoded_obj)
|
|
174
|
+
self._raise_for_top_level_result(payload, dataset_id)
|
|
175
|
+
return payload
|
|
176
|
+
|
|
177
|
+
raise ParseError("Decoded payload is not an object", provider="lofin")
|
|
178
|
+
|
|
179
|
+
def _validate_envelope(
|
|
180
|
+
self, payload: dict[str, object], dataset: DatasetRef
|
|
181
|
+
) -> tuple[dict[str, object], list[dict[str, object]]]:
|
|
182
|
+
dataset_code = self._require_dataset_metadata(dataset, "api_code")
|
|
183
|
+
body_obj = payload.get(dataset_code)
|
|
184
|
+
if not isinstance(body_obj, list) or not body_obj:
|
|
185
|
+
raise ProviderResponseError(
|
|
186
|
+
f"Malformed response envelope: missing {dataset_code}",
|
|
187
|
+
provider="lofin",
|
|
188
|
+
dataset_id=dataset.id,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
body_list = cast(list[object], body_obj)
|
|
192
|
+
first_entry = body_list[0]
|
|
193
|
+
if not isinstance(first_entry, dict):
|
|
194
|
+
raise ProviderResponseError(
|
|
195
|
+
f"Malformed response envelope: invalid {dataset_code} body",
|
|
196
|
+
provider="lofin",
|
|
197
|
+
dataset_id=dataset.id,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
head_entry = cast(dict[str, object], first_entry)
|
|
201
|
+
head_obj = head_entry.get("head")
|
|
202
|
+
if not isinstance(head_obj, list) or not head_obj:
|
|
203
|
+
legacy_body = cast(dict[str, object], first_entry)
|
|
204
|
+
self._raise_for_result(legacy_body, dataset.id)
|
|
205
|
+
items = self._normalize_rows(legacy_body.get("row"))
|
|
206
|
+
return legacy_body, items
|
|
207
|
+
|
|
208
|
+
head_items = cast(list[object], head_obj)
|
|
209
|
+
metadata: dict[str, object] = {}
|
|
210
|
+
result_payload: Mapping[str, object] | None = None
|
|
211
|
+
for head_item in head_items:
|
|
212
|
+
if not isinstance(head_item, dict):
|
|
213
|
+
continue
|
|
214
|
+
head_dict = cast(dict[str, object], head_item)
|
|
215
|
+
if "list_total_count" in head_dict:
|
|
216
|
+
metadata["list_total_count"] = head_dict.get("list_total_count")
|
|
217
|
+
if "RESULT" in head_dict:
|
|
218
|
+
result_obj = head_dict.get("RESULT")
|
|
219
|
+
if isinstance(result_obj, Mapping):
|
|
220
|
+
result_payload = cast(Mapping[str, object], result_obj)
|
|
221
|
+
|
|
222
|
+
if result_payload is None:
|
|
223
|
+
raise ProviderResponseError(
|
|
224
|
+
f"Malformed response envelope: missing {dataset_code} RESULT",
|
|
225
|
+
provider="lofin",
|
|
226
|
+
dataset_id=dataset.id,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
self._raise_for_result({"RESULT": dict(result_payload)}, dataset.id)
|
|
230
|
+
|
|
231
|
+
rows_entry = body_list[1] if len(body_list) > 1 else None
|
|
232
|
+
rows_wrapper: object = None
|
|
233
|
+
if isinstance(rows_entry, dict):
|
|
234
|
+
rows_dict = cast(dict[str, object], rows_entry)
|
|
235
|
+
rows_wrapper = rows_dict.get("row")
|
|
236
|
+
|
|
237
|
+
items = self._normalize_rows(rows_wrapper)
|
|
238
|
+
return metadata, items
|
|
239
|
+
|
|
240
|
+
def _raise_for_top_level_result(self, payload: Mapping[str, object], dataset_id: str) -> None:
|
|
241
|
+
result_obj = payload.get("RESULT")
|
|
242
|
+
if not isinstance(result_obj, list):
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
result_list = cast(list[object], result_obj)
|
|
246
|
+
if not result_list:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
first_result = result_list[0]
|
|
250
|
+
if not isinstance(first_result, Mapping):
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
self._raise_for_result(
|
|
254
|
+
{"RESULT": dict(cast(Mapping[str, object], first_result))}, dataset_id
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _raise_for_result(self, payload: Mapping[str, object], dataset_id: str) -> None:
|
|
258
|
+
result_obj = payload.get("RESULT")
|
|
259
|
+
if not isinstance(result_obj, dict):
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
result_dict = cast(dict[str, object], result_obj)
|
|
263
|
+
code_raw = result_dict.get("CODE")
|
|
264
|
+
message_raw = result_dict.get("MESSAGE")
|
|
265
|
+
code = code_raw if isinstance(code_raw, str) else "ERROR-000"
|
|
266
|
+
message = message_raw if isinstance(message_raw, str) else "Provider returned error"
|
|
267
|
+
logger.debug(
|
|
268
|
+
"LOFIN result",
|
|
269
|
+
extra={"result_code": code, "result_msg": message, "dataset_id": dataset_id},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if code == "INFO-000":
|
|
273
|
+
return
|
|
274
|
+
if code == "INFO-200":
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
self._raise_for_result_code(code, message, dataset_id)
|
|
278
|
+
|
|
279
|
+
def _raise_for_result_code(self, code: str, msg: str, dataset_id: str) -> NoReturn:
|
|
280
|
+
if code in {"ERROR-290", "ERROR-300"}:
|
|
281
|
+
raise AuthError(
|
|
282
|
+
msg, provider="lofin", provider_code=code, dataset_id=dataset_id or None
|
|
283
|
+
)
|
|
284
|
+
if code.startswith("ERROR-"):
|
|
285
|
+
raise ProviderResponseError(
|
|
286
|
+
msg,
|
|
287
|
+
provider="lofin",
|
|
288
|
+
provider_code=code,
|
|
289
|
+
dataset_id=dataset_id or None,
|
|
290
|
+
)
|
|
291
|
+
raise ProviderResponseError(
|
|
292
|
+
msg,
|
|
293
|
+
provider="lofin",
|
|
294
|
+
provider_code=code,
|
|
295
|
+
dataset_id=dataset_id or None,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _require_dataset_metadata(self, dataset: DatasetRef, key: str) -> str:
|
|
299
|
+
value = dataset.raw_metadata.get(key)
|
|
300
|
+
if isinstance(value, str) and value:
|
|
301
|
+
return value
|
|
302
|
+
raise ProviderResponseError(
|
|
303
|
+
f"Dataset metadata missing {key}",
|
|
304
|
+
provider="lofin",
|
|
305
|
+
dataset_id=dataset.id,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _normalize_rows(self, rows_wrapper: object) -> list[dict[str, object]]:
|
|
309
|
+
if rows_wrapper is None:
|
|
310
|
+
return []
|
|
311
|
+
if isinstance(rows_wrapper, list):
|
|
312
|
+
rows = cast(list[object], rows_wrapper)
|
|
313
|
+
return [cast(dict[str, object], item) for item in rows if isinstance(item, dict)]
|
|
314
|
+
if isinstance(rows_wrapper, dict):
|
|
315
|
+
return [cast(dict[str, object], rows_wrapper)]
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def _int_param(cls, params: Mapping[str, object], key: str, default: int) -> int:
|
|
320
|
+
value = params.get(key)
|
|
321
|
+
coerced = coerce_int(value, default)
|
|
322
|
+
return coerced if coerced > 0 else default
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _load_default_catalogue() -> tuple[DatasetRef, ...]:
|
|
326
|
+
return load_catalogue("kpubdata.providers.lofin", "lofin")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
__all__ = ["LofinAdapter"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"dataset_key": "expenditure_budget",
|
|
4
|
+
"name": "지방재정 세출결산총괄 (LOFIN Expenditure Budget Summary)",
|
|
5
|
+
"api_code": "AJGCF",
|
|
6
|
+
"base_url": "https://www.lofin365.go.kr/lf/hub",
|
|
7
|
+
"representation": "api_json",
|
|
8
|
+
"default_operation": "list",
|
|
9
|
+
"operations": ["list", "raw"],
|
|
10
|
+
"query_support": {
|
|
11
|
+
"pagination": "offset",
|
|
12
|
+
"max_page_size": 1000
|
|
13
|
+
},
|
|
14
|
+
"fields": [
|
|
15
|
+
{"name": "fyr", "title": "회계연도", "type": "string"},
|
|
16
|
+
{"name": "wa_laf_cd", "title": "광역자치단체코드", "type": "string"},
|
|
17
|
+
{"name": "wa_laf_hg_nm", "title": "광역자치단체명", "type": "string"},
|
|
18
|
+
{"name": "laf_cd", "title": "자치단체코드", "type": "string"},
|
|
19
|
+
{"name": "laf_hg_nm", "title": "자치단체명", "type": "string"},
|
|
20
|
+
{"name": "tot_pfa_amt", "title": "세출결산총액", "type": "string"},
|
|
21
|
+
{"name": "pfa_amt1", "title": "세출결산금액1", "type": "string"},
|
|
22
|
+
{"name": "pfa_amt2", "title": "세출결산금액2", "type": "string"},
|
|
23
|
+
{"name": "pfa_amt3", "title": "세출결산금액3", "type": "string"},
|
|
24
|
+
{"name": "pfa_amt4", "title": "세출결산금액4", "type": "string"}
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"dataset_key": "expenditure_function",
|
|
29
|
+
"name": "지방재정 기능별세출 (LOFIN Expenditure by Function)",
|
|
30
|
+
"api_code": "GGNSE",
|
|
31
|
+
"base_url": "https://www.lofin365.go.kr/lf/hub",
|
|
32
|
+
"representation": "api_json",
|
|
33
|
+
"default_operation": "list",
|
|
34
|
+
"operations": ["list", "raw"],
|
|
35
|
+
"query_support": {
|
|
36
|
+
"pagination": "offset",
|
|
37
|
+
"max_page_size": 1000
|
|
38
|
+
},
|
|
39
|
+
"fields": [
|
|
40
|
+
{"name": "fyr", "title": "회계연도", "type": "string"},
|
|
41
|
+
{"name": "wa_laf_cd", "title": "광역자치단체코드", "type": "string"},
|
|
42
|
+
{"name": "wa_laf_hg_nm", "title": "광역자치단체명", "type": "string"},
|
|
43
|
+
{"name": "laf_cd", "title": "자치단체코드", "type": "string"},
|
|
44
|
+
{"name": "laf_hg_nm", "title": "자치단체명", "type": "string"},
|
|
45
|
+
{"name": "fld_cd", "title": "분야코드", "type": "string"},
|
|
46
|
+
{"name": "fld_nm", "title": "분야명", "type": "string"},
|
|
47
|
+
{"name": "pol_bizct", "title": "정책사업", "type": "string"},
|
|
48
|
+
{"name": "fin_acv_amt", "title": "최종실적액", "type": "string"},
|
|
49
|
+
{"name": "padm_oper_exps", "title": "행정운영경비", "type": "string"},
|
|
50
|
+
{"name": "rgstr_dt", "title": "등록일시", "type": "string"},
|
|
51
|
+
{"name": "rgstr_usr_id", "title": "등록사용자ID", "type": "string"}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"dataset_key": "revenue_budget",
|
|
56
|
+
"name": "지방재정 세입결산총괄 (LOFIN Revenue Budget Summary)",
|
|
57
|
+
"api_code": "IIBBH",
|
|
58
|
+
"base_url": "https://www.lofin365.go.kr/lf/hub",
|
|
59
|
+
"representation": "api_json",
|
|
60
|
+
"default_operation": "list",
|
|
61
|
+
"operations": ["list", "raw"],
|
|
62
|
+
"query_support": {
|
|
63
|
+
"pagination": "offset",
|
|
64
|
+
"max_page_size": 1000
|
|
65
|
+
},
|
|
66
|
+
"fields": [
|
|
67
|
+
{"name": "fyr", "title": "회계연도", "type": "string"},
|
|
68
|
+
{"name": "wa_laf_cd", "title": "광역자치단체코드", "type": "string"},
|
|
69
|
+
{"name": "wa_laf_hg_nm", "title": "광역자치단체명", "type": "string"},
|
|
70
|
+
{"name": "laf_cd", "title": "자치단체코드", "type": "string"},
|
|
71
|
+
{"name": "laf_hg_nm", "title": "자치단체명", "type": "string"},
|
|
72
|
+
{"name": "total", "title": "세입결산총액", "type": "string"},
|
|
73
|
+
{"name": "pfa_amt1", "title": "세입결산금액1", "type": "string"},
|
|
74
|
+
{"name": "pfa_amt2", "title": "세입결산금액2", "type": "string"},
|
|
75
|
+
{"name": "pfa_amt3", "title": "세입결산금액3", "type": "string"},
|
|
76
|
+
{"name": "pfa_amt4", "title": "세입결산금액4", "type": "string"}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"dataset_key": "debt_ratio",
|
|
81
|
+
"name": "지방재정 채무비율현황 (LOFIN Debt Ratio Status)",
|
|
82
|
+
"api_code": "HEDFC",
|
|
83
|
+
"base_url": "https://www.lofin365.go.kr/lf/hub",
|
|
84
|
+
"representation": "api_json",
|
|
85
|
+
"default_operation": "list",
|
|
86
|
+
"operations": ["list", "raw"],
|
|
87
|
+
"query_support": {
|
|
88
|
+
"pagination": "offset",
|
|
89
|
+
"max_page_size": 1000
|
|
90
|
+
},
|
|
91
|
+
"fields": [
|
|
92
|
+
{"name": "fyr", "title": "회계연도", "type": "string"},
|
|
93
|
+
{"name": "wa_laf_cd", "title": "광역자치단체코드", "type": "string"},
|
|
94
|
+
{"name": "wa_laf_hg_nm", "title": "광역자치단체명", "type": "string"},
|
|
95
|
+
{"name": "laf_cd", "title": "자치단체코드", "type": "string"},
|
|
96
|
+
{"name": "laf_hg_nm", "title": "자치단체명", "type": "string"},
|
|
97
|
+
{"name": "pfa_amt1", "title": "채무액", "type": "string"},
|
|
98
|
+
{"name": "pfa_amt2", "title": "예산규모", "type": "string"},
|
|
99
|
+
{"name": "rate", "title": "채무비율", "type": "string"}
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"dataset_key": "fiscal_independence",
|
|
104
|
+
"name": "지방재정 재정자립도현황 (LOFIN Fiscal Independence)",
|
|
105
|
+
"api_code": "JFIED",
|
|
106
|
+
"base_url": "https://www.lofin365.go.kr/lf/hub",
|
|
107
|
+
"representation": "api_json",
|
|
108
|
+
"default_operation": "list",
|
|
109
|
+
"operations": ["list", "raw"],
|
|
110
|
+
"query_support": {
|
|
111
|
+
"pagination": "offset",
|
|
112
|
+
"max_page_size": 1000
|
|
113
|
+
},
|
|
114
|
+
"fields": [
|
|
115
|
+
{"name": "fyr", "title": "회계연도", "type": "string"},
|
|
116
|
+
{"name": "wa_laf_cd", "title": "광역자치단체코드", "type": "string"},
|
|
117
|
+
{"name": "wa_laf_hg_nm", "title": "광역자치단체명", "type": "string"},
|
|
118
|
+
{"name": "laf_cd", "title": "자치단체코드", "type": "string"},
|
|
119
|
+
{"name": "laf_hg_nm", "title": "자치단체명", "type": "string"},
|
|
120
|
+
{"name": "pfa_amt1", "title": "자주재원", "type": "string"},
|
|
121
|
+
{"name": "pfa_amt2", "title": "일반회계규모", "type": "string"},
|
|
122
|
+
{"name": "rate1", "title": "재정자립도", "type": "string"},
|
|
123
|
+
{"name": "pfa_amt3", "title": "자체수입", "type": "string"},
|
|
124
|
+
{"name": "rate2", "title": "자체수입비율", "type": "string"}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
]
|
kpubdata/py.typed
ADDED
|
File without changes
|
kpubdata/registry.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Provider adapter registry with registration-time validation.
|
|
2
|
+
|
|
3
|
+
Validates that registered adapters conform to the ProviderAdapter protocol
|
|
4
|
+
at registration time, not at call time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from threading import RLock
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from kpubdata.exceptions import ProviderNotRegisteredError
|
|
14
|
+
|
|
15
|
+
_REQUIRED_METHODS = (
|
|
16
|
+
"list_datasets",
|
|
17
|
+
"search_datasets",
|
|
18
|
+
"get_dataset",
|
|
19
|
+
"query_records",
|
|
20
|
+
"get_record",
|
|
21
|
+
"get_schema",
|
|
22
|
+
"call_raw",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProviderRegistry:
|
|
27
|
+
"""Thread-safe registry for provider adapters."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
"""Initialize empty eager/lazy adapter registries."""
|
|
31
|
+
self._adapters: dict[str, Any] = {}
|
|
32
|
+
self._lazy: dict[str, Any] = {}
|
|
33
|
+
self._lock = RLock()
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
"""Return concise debug representation."""
|
|
37
|
+
with self._lock:
|
|
38
|
+
eager_names = sorted(self._adapters.keys())
|
|
39
|
+
lazy_names = sorted(self._lazy.keys())
|
|
40
|
+
return f"ProviderRegistry(eager={eager_names}, lazy={lazy_names})"
|
|
41
|
+
|
|
42
|
+
def register(self, adapter: Any) -> None:
|
|
43
|
+
"""Register an adapter instance. Validates protocol conformance."""
|
|
44
|
+
self._validate_adapter(adapter)
|
|
45
|
+
provider_name = str(adapter.name).strip().lower()
|
|
46
|
+
|
|
47
|
+
with self._lock:
|
|
48
|
+
if provider_name in self._adapters or provider_name in self._lazy:
|
|
49
|
+
raise ValueError(f"Provider '{provider_name}' is already registered")
|
|
50
|
+
self._adapters[provider_name] = adapter
|
|
51
|
+
|
|
52
|
+
def register_lazy(self, name: str, factory: Any, *, skip_if_exists: bool = False) -> None:
|
|
53
|
+
"""Register a lazy-loaded adapter via callable factory.
|
|
54
|
+
|
|
55
|
+
When ``skip_if_exists`` is True, silently skip registration if the
|
|
56
|
+
provider name is already registered (eager or lazy). This is used
|
|
57
|
+
by ``Client`` to register built-in providers without conflicting
|
|
58
|
+
with user-registered adapters.
|
|
59
|
+
"""
|
|
60
|
+
normalized_name = name.strip().lower()
|
|
61
|
+
if not normalized_name:
|
|
62
|
+
msg = "Provider name cannot be empty"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
if not callable(factory):
|
|
65
|
+
msg = "Lazy adapter factory must be callable"
|
|
66
|
+
raise TypeError(msg)
|
|
67
|
+
|
|
68
|
+
with self._lock:
|
|
69
|
+
if normalized_name in self._adapters or normalized_name in self._lazy:
|
|
70
|
+
if skip_if_exists:
|
|
71
|
+
return
|
|
72
|
+
raise ValueError(f"Provider '{normalized_name}' is already registered")
|
|
73
|
+
self._lazy[normalized_name] = factory
|
|
74
|
+
|
|
75
|
+
def get(self, name: str) -> Any:
|
|
76
|
+
"""Retrieve adapter by provider name."""
|
|
77
|
+
normalized_name = name.strip().lower()
|
|
78
|
+
with self._lock:
|
|
79
|
+
adapter = self._adapters.get(normalized_name)
|
|
80
|
+
if adapter is not None:
|
|
81
|
+
return adapter
|
|
82
|
+
|
|
83
|
+
factory = self._lazy.pop(normalized_name, None)
|
|
84
|
+
|
|
85
|
+
if factory is None:
|
|
86
|
+
raise ProviderNotRegisteredError(f"Provider '{name}' is not registered")
|
|
87
|
+
|
|
88
|
+
lazy_adapter = factory()
|
|
89
|
+
self._validate_adapter(lazy_adapter)
|
|
90
|
+
adapter_name = str(lazy_adapter.name).strip().lower()
|
|
91
|
+
if adapter_name != normalized_name:
|
|
92
|
+
raise TypeError(
|
|
93
|
+
f"Lazy adapter name mismatch: expected '{normalized_name}', got '{adapter_name}'"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._adapters[normalized_name] = lazy_adapter
|
|
98
|
+
return lazy_adapter
|
|
99
|
+
|
|
100
|
+
def __contains__(self, name: str) -> bool:
|
|
101
|
+
"""Return True if adapter is registered eagerly or lazily."""
|
|
102
|
+
normalized_name = name.strip().lower()
|
|
103
|
+
with self._lock:
|
|
104
|
+
return normalized_name in self._adapters or normalized_name in self._lazy
|
|
105
|
+
|
|
106
|
+
def __iter__(self) -> Iterator[str]:
|
|
107
|
+
"""Iterate provider names currently known to the registry."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
names = set(self._adapters.keys()) | set(self._lazy.keys())
|
|
110
|
+
return iter(sorted(names))
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _validate_adapter(adapter: Any) -> None:
|
|
114
|
+
"""Check that adapter has required protocol methods."""
|
|
115
|
+
name = getattr(adapter, "name", None)
|
|
116
|
+
if not isinstance(name, str) or not name.strip():
|
|
117
|
+
msg = "Adapter must define a non-empty string attribute 'name'"
|
|
118
|
+
raise TypeError(msg)
|
|
119
|
+
|
|
120
|
+
missing = [
|
|
121
|
+
method_name for method_name in _REQUIRED_METHODS if not hasattr(adapter, method_name)
|
|
122
|
+
]
|
|
123
|
+
if missing:
|
|
124
|
+
raise TypeError(f"Adapter '{name}' is missing required methods: {missing}")
|
|
125
|
+
|
|
126
|
+
non_callable = [
|
|
127
|
+
method_name
|
|
128
|
+
for method_name in _REQUIRED_METHODS
|
|
129
|
+
if not callable(getattr(adapter, method_name, None))
|
|
130
|
+
]
|
|
131
|
+
if non_callable:
|
|
132
|
+
raise TypeError(f"Adapter '{name}' has non-callable required methods: {non_callable}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = ["ProviderRegistry"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Transport utilities for HTTP, retries, and content decoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kpubdata.transport.decode import decode_json, decode_xml, detect_content_type
|
|
6
|
+
from kpubdata.transport.http import HttpTransport, TransportConfig
|
|
7
|
+
from kpubdata.transport.retry import with_retry
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"HttpTransport",
|
|
11
|
+
"TransportConfig",
|
|
12
|
+
"decode_json",
|
|
13
|
+
"decode_xml",
|
|
14
|
+
"detect_content_type",
|
|
15
|
+
"with_retry",
|
|
16
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Response decoding utilities — content-type detection, JSON/XML helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..exceptions import ParseError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def detect_content_type(response: httpx.Response) -> str:
|
|
14
|
+
"""Detect whether response is JSON, XML, or other based on content-type header."""
|
|
15
|
+
header_value_obj = response.headers.get("content-type", "")
|
|
16
|
+
content_type = str(header_value_obj).lower()
|
|
17
|
+
if "json" in content_type:
|
|
18
|
+
return "json"
|
|
19
|
+
if "xml" in content_type:
|
|
20
|
+
return "xml"
|
|
21
|
+
return "other"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode_json(data: str | bytes) -> object:
|
|
25
|
+
"""Decode JSON with clear error on failure."""
|
|
26
|
+
try:
|
|
27
|
+
payload = data.decode("utf-8") if isinstance(data, bytes) else data
|
|
28
|
+
except UnicodeDecodeError as exc:
|
|
29
|
+
msg = "Failed to decode JSON bytes as UTF-8"
|
|
30
|
+
raise ParseError(msg) from exc
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(payload)
|
|
34
|
+
except json.JSONDecodeError as exc:
|
|
35
|
+
msg = "Failed to decode JSON payload"
|
|
36
|
+
raise ParseError(msg) from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_xml(data: str | bytes) -> dict[str, object]:
|
|
40
|
+
"""Decode XML to dict.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ImportError: If ``xmltodict`` is not installed.
|
|
44
|
+
ParseError: If parsing fails.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
xmltodict = import_module("xmltodict")
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
msg = (
|
|
50
|
+
"decode_xml requires optional dependency 'xmltodict'. Install with: "
|
|
51
|
+
"pip install kpubdata[xml]"
|
|
52
|
+
)
|
|
53
|
+
raise ImportError(msg) from exc
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
payload = data.decode("utf-8") if isinstance(data, bytes) else data
|
|
57
|
+
except UnicodeDecodeError as exc:
|
|
58
|
+
msg = "Failed to decode XML bytes as UTF-8"
|
|
59
|
+
raise ParseError(msg) from exc
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
parsed: object = xmltodict.parse(payload)
|
|
63
|
+
except Exception as exc: # noqa: BLE001
|
|
64
|
+
msg = "Failed to decode XML payload"
|
|
65
|
+
raise ParseError(msg) from exc
|
|
66
|
+
|
|
67
|
+
if isinstance(parsed, dict):
|
|
68
|
+
return parsed
|
|
69
|
+
|
|
70
|
+
msg = "XML payload did not decode into a dictionary"
|
|
71
|
+
raise ParseError(msg)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["decode_json", "decode_xml", "detect_content_type"]
|