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.
@@ -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"]