statwrapper 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.
- statwrapper/__init__.py +31 -0
- statwrapper/api_clients/__init__.py +11 -0
- statwrapper/api_clients/dst_client.py +249 -0
- statwrapper/api_clients/eurostat_client.py +359 -0
- statwrapper/api_clients/pxweb2_client.py +170 -0
- statwrapper/api_clients/pxweb_client.py +244 -0
- statwrapper/base_api_client.py +79 -0
- statwrapper/exceptions.py +22 -0
- statwrapper/http.py +126 -0
- statwrapper/models.py +103 -0
- statwrapper/parsers.py +260 -0
- statwrapper/provider_registry.py +74 -0
- statwrapper/providers.json +662 -0
- statwrapper/statwrapper.py +103 -0
- statwrapper/utils.py +134 -0
- statwrapper-0.1.0.dist-info/METADATA +123 -0
- statwrapper-0.1.0.dist-info/RECORD +19 -0
- statwrapper-0.1.0.dist-info/WHEEL +5 -0
- statwrapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..base_api_client import APIWrapper
|
|
7
|
+
from ..models import DiscoveredDataset, Provider, ResolvedDatasetMetadata
|
|
8
|
+
from ..parsers import parse_pxweb2_discovery_table, parse_pxweb2_metadata_payload
|
|
9
|
+
from ..utils import parse_dt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PxWeb2Client(APIWrapper):
|
|
13
|
+
def __init__(self, provider: Provider, **kwargs: Any) -> None:
|
|
14
|
+
super().__init__(
|
|
15
|
+
provider_code=provider.provider_code,
|
|
16
|
+
label=provider.label,
|
|
17
|
+
language=kwargs.pop("language"),
|
|
18
|
+
json_request_handler=kwargs.pop("json_request_handler"),
|
|
19
|
+
text_request_handler=kwargs.pop("text_request_handler", None),
|
|
20
|
+
bytes_request_handler=kwargs.pop("bytes_request_handler", None),
|
|
21
|
+
logger=kwargs.pop("logger", None),
|
|
22
|
+
)
|
|
23
|
+
self.provider = provider
|
|
24
|
+
self.api_type = provider.api_type
|
|
25
|
+
self.base_api_url = provider.base_api_url or ""
|
|
26
|
+
self.base_web_url = provider.base_web_url
|
|
27
|
+
|
|
28
|
+
def _get_api_url(self, endpoint: str, dataset_code: str | None = None) -> str:
|
|
29
|
+
clean_api = self.base_api_url.rstrip("/")
|
|
30
|
+
if endpoint == "config":
|
|
31
|
+
return f"{clean_api}/config"
|
|
32
|
+
if endpoint == "tables":
|
|
33
|
+
return f"{clean_api}/tables"
|
|
34
|
+
if endpoint == "metadata" and dataset_code:
|
|
35
|
+
return f"{clean_api}/tables/{dataset_code}/metadata"
|
|
36
|
+
if endpoint == "data" and dataset_code:
|
|
37
|
+
return f"{clean_api}/tables/{dataset_code}/data"
|
|
38
|
+
raise ValueError(f"Unsupported endpoint: {endpoint}")
|
|
39
|
+
|
|
40
|
+
async def health_check(self, dataset_code: str | None = None) -> bool:
|
|
41
|
+
url = self._get_api_url("metadata" if dataset_code else "config", dataset_code)
|
|
42
|
+
payload = await self._get_json(url, params={"lang": self.language})
|
|
43
|
+
return payload is not None
|
|
44
|
+
|
|
45
|
+
async def discover_datasets(
|
|
46
|
+
self,
|
|
47
|
+
task_id: uuid.UUID,
|
|
48
|
+
**_: Any,
|
|
49
|
+
) -> list[DiscoveredDataset]:
|
|
50
|
+
discovered: list[DiscoveredDataset] = []
|
|
51
|
+
page_number = 1
|
|
52
|
+
total_pages = 1
|
|
53
|
+
while page_number <= total_pages:
|
|
54
|
+
payload = await self._get_json(
|
|
55
|
+
self._get_api_url("tables"),
|
|
56
|
+
params={
|
|
57
|
+
"lang": self.language,
|
|
58
|
+
"pageSize": 1000,
|
|
59
|
+
"pageNumber": page_number,
|
|
60
|
+
"includeDiscontinued": "true",
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
if not isinstance(payload, dict):
|
|
64
|
+
break
|
|
65
|
+
tables = payload.get("tables")
|
|
66
|
+
page_info = payload.get("page", {})
|
|
67
|
+
if not isinstance(tables, list):
|
|
68
|
+
break
|
|
69
|
+
total_pages = int(page_info.get("totalPages", page_number))
|
|
70
|
+
for table in tables:
|
|
71
|
+
if not isinstance(table, dict):
|
|
72
|
+
continue
|
|
73
|
+
parsed = parse_pxweb2_discovery_table(
|
|
74
|
+
table,
|
|
75
|
+
base_api_url=self.base_api_url,
|
|
76
|
+
base_web_url=self.base_web_url,
|
|
77
|
+
language=self.language,
|
|
78
|
+
)
|
|
79
|
+
if not parsed:
|
|
80
|
+
continue
|
|
81
|
+
updated = parsed["updated"] or parse_dt("1970-01-01T00:00:00+00:00")
|
|
82
|
+
if updated is None:
|
|
83
|
+
continue
|
|
84
|
+
discovered.append(
|
|
85
|
+
DiscoveredDataset(
|
|
86
|
+
task_id=task_id,
|
|
87
|
+
provider_code=self.provider_code,
|
|
88
|
+
dataset_code=parsed["dataset_code"],
|
|
89
|
+
language=self.language,
|
|
90
|
+
updated=updated,
|
|
91
|
+
label=parsed["label"],
|
|
92
|
+
source=parsed["source"],
|
|
93
|
+
note=parsed["note"],
|
|
94
|
+
description=parsed["description"],
|
|
95
|
+
time_unit=parsed["time_unit"],
|
|
96
|
+
first_period=parsed["first_period"],
|
|
97
|
+
last_period=parsed["last_period"],
|
|
98
|
+
discontinued=parsed["discontinued"],
|
|
99
|
+
paths=parsed["paths"],
|
|
100
|
+
subject_code=parsed["subject_code"],
|
|
101
|
+
metadata_url=parsed["metadata_url"],
|
|
102
|
+
data_url=parsed["data_url"],
|
|
103
|
+
web_url=parsed["web_url"],
|
|
104
|
+
extension=parsed["extension"],
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
page_number += 1
|
|
108
|
+
return discovered
|
|
109
|
+
|
|
110
|
+
async def resolve_dataset_metadata(
|
|
111
|
+
self,
|
|
112
|
+
discovered: DiscoveredDataset,
|
|
113
|
+
task_id: uuid.UUID | None = None,
|
|
114
|
+
**_: Any,
|
|
115
|
+
) -> ResolvedDatasetMetadata:
|
|
116
|
+
payload = await self._get_json(
|
|
117
|
+
self._get_api_url("metadata", discovered.dataset_code),
|
|
118
|
+
params={"lang": self.language, "outputFormat": "json-stat2"},
|
|
119
|
+
)
|
|
120
|
+
parsed = (
|
|
121
|
+
parse_pxweb2_metadata_payload(
|
|
122
|
+
payload,
|
|
123
|
+
default_note=discovered.note,
|
|
124
|
+
default_subject_label=discovered.subject_label,
|
|
125
|
+
default_official_statistics=discovered.official_statistics,
|
|
126
|
+
default_contact=discovered.contact,
|
|
127
|
+
default_extension=discovered.extension,
|
|
128
|
+
)
|
|
129
|
+
if isinstance(payload, dict)
|
|
130
|
+
else {
|
|
131
|
+
"dimensions": None,
|
|
132
|
+
"dimension_ids": None,
|
|
133
|
+
"required_dimensions": None,
|
|
134
|
+
"role": discovered.role,
|
|
135
|
+
"note": discovered.note,
|
|
136
|
+
"subject_label": discovered.subject_label,
|
|
137
|
+
"official_statistics": discovered.official_statistics,
|
|
138
|
+
"contact": discovered.contact,
|
|
139
|
+
"extension": dict(discovered.extension),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
return ResolvedDatasetMetadata(
|
|
143
|
+
task_id=task_id or discovered.task_id,
|
|
144
|
+
provider_code=discovered.provider_code,
|
|
145
|
+
dataset_code=discovered.dataset_code,
|
|
146
|
+
language=discovered.language,
|
|
147
|
+
updated=discovered.updated,
|
|
148
|
+
label=discovered.label or discovered.dataset_code,
|
|
149
|
+
time_unit=discovered.time_unit or "Other",
|
|
150
|
+
first_period=discovered.first_period or "",
|
|
151
|
+
last_period=discovered.last_period or discovered.first_period or "",
|
|
152
|
+
paths=discovered.paths or [],
|
|
153
|
+
role=parsed["role"] or {},
|
|
154
|
+
metadata_url=discovered.metadata_url
|
|
155
|
+
or self._get_api_url("metadata", discovered.dataset_code),
|
|
156
|
+
data_url=discovered.data_url or self._get_api_url("data", discovered.dataset_code),
|
|
157
|
+
dimension=parsed["dimensions"] or {},
|
|
158
|
+
required_dimensions=parsed["required_dimensions"] or {},
|
|
159
|
+
note=parsed["note"],
|
|
160
|
+
source=discovered.source,
|
|
161
|
+
description=discovered.description,
|
|
162
|
+
discontinued=discovered.discontinued,
|
|
163
|
+
subject_code=discovered.subject_code,
|
|
164
|
+
subject_label=parsed["subject_label"],
|
|
165
|
+
web_url=discovered.web_url,
|
|
166
|
+
official_statistics=parsed["official_statistics"],
|
|
167
|
+
contact=parsed["contact"],
|
|
168
|
+
dimension_ids=parsed["dimension_ids"],
|
|
169
|
+
extension=parsed["extension"],
|
|
170
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ..base_api_client import APIWrapper
|
|
9
|
+
from ..models import DiscoveredDataset, Provider, ResolvedDatasetMetadata
|
|
10
|
+
from ..utils import detect_role, determine_time_unit, parse_dt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _encode_segment(value: str) -> str:
|
|
14
|
+
return quote(value, safe="")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_path(path: str) -> str:
|
|
18
|
+
ids = [segment.strip() for segment in path.split("/") if segment.strip()]
|
|
19
|
+
return "/".join(ids)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class _PxWebTableEntry:
|
|
24
|
+
id: str
|
|
25
|
+
title: str
|
|
26
|
+
path: str
|
|
27
|
+
db_id: str
|
|
28
|
+
language: str
|
|
29
|
+
provider_code: str
|
|
30
|
+
base_api_url: str
|
|
31
|
+
base_web_url: str | None
|
|
32
|
+
published: str | None = None
|
|
33
|
+
updated: str | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def api_url(self) -> str:
|
|
37
|
+
return (
|
|
38
|
+
f"{self.base_api_url.rstrip('/')}/{self.language}/"
|
|
39
|
+
f"{_encode_segment(self.db_id)}/{_normalize_path(self.path)}/{_encode_segment(self.id)}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def web_url(self) -> str | None:
|
|
44
|
+
if not self.base_web_url:
|
|
45
|
+
return None
|
|
46
|
+
path_segment = _normalize_path(self.path).replace("/", "__")
|
|
47
|
+
return (
|
|
48
|
+
f"{self.base_web_url.rstrip('/')}/{self.language}/"
|
|
49
|
+
f"{_encode_segment(self.db_id)}/{_encode_segment(self.db_id)}__{path_segment}/{_encode_segment(self.id)}/"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PxWebClient(APIWrapper):
|
|
54
|
+
def __init__(self, provider: Provider, **kwargs: Any) -> None:
|
|
55
|
+
super().__init__(
|
|
56
|
+
provider_code=provider.provider_code,
|
|
57
|
+
label=provider.label,
|
|
58
|
+
language=kwargs.pop("language"),
|
|
59
|
+
json_request_handler=kwargs.pop("json_request_handler"),
|
|
60
|
+
text_request_handler=kwargs.pop("text_request_handler", None),
|
|
61
|
+
bytes_request_handler=kwargs.pop("bytes_request_handler", None),
|
|
62
|
+
logger=kwargs.pop("logger", None),
|
|
63
|
+
)
|
|
64
|
+
self.provider = provider
|
|
65
|
+
self.api_type = provider.api_type
|
|
66
|
+
self.base_api_url = provider.base_api_url
|
|
67
|
+
self.base_web_url = provider.base_web_url
|
|
68
|
+
extension = provider.extension if isinstance(provider.extension, dict) else {}
|
|
69
|
+
dbid = extension.get("dbid", {})
|
|
70
|
+
self.db_ids = set(dbid.keys()) if isinstance(dbid, dict) else set()
|
|
71
|
+
if not self.base_api_url or not self.db_ids:
|
|
72
|
+
raise ValueError("PxWeb provider requires base_api_url and extension.dbid")
|
|
73
|
+
|
|
74
|
+
async def health_check(self, dataset_code: str | None = None) -> bool:
|
|
75
|
+
if dataset_code is None:
|
|
76
|
+
db_id = next(iter(self.db_ids))
|
|
77
|
+
url = f"{self.base_api_url.rstrip('/')}/{self.language}/{_encode_segment(db_id)}"
|
|
78
|
+
else:
|
|
79
|
+
url = dataset_code
|
|
80
|
+
payload = await self._get_json(url, params={"filter": "*", "query": "*"})
|
|
81
|
+
return payload is not None
|
|
82
|
+
|
|
83
|
+
async def discover_datasets(
|
|
84
|
+
self,
|
|
85
|
+
task_id: uuid.UUID,
|
|
86
|
+
**_: Any,
|
|
87
|
+
) -> list[DiscoveredDataset]:
|
|
88
|
+
discovered: list[DiscoveredDataset] = []
|
|
89
|
+
for db_id in sorted(self.db_ids):
|
|
90
|
+
url = f"{self.base_api_url.rstrip('/')}/{self.language}/{_encode_segment(db_id)}"
|
|
91
|
+
payload = await self._get_json(url, params={"filter": "*", "query": "*"})
|
|
92
|
+
if not isinstance(payload, list):
|
|
93
|
+
continue
|
|
94
|
+
for entry_data in payload:
|
|
95
|
+
if not isinstance(entry_data, dict):
|
|
96
|
+
continue
|
|
97
|
+
entry_id = str(entry_data.get("id") or "").strip()
|
|
98
|
+
path = str(entry_data.get("path") or "").strip()
|
|
99
|
+
if not entry_id or not path:
|
|
100
|
+
continue
|
|
101
|
+
entry = _PxWebTableEntry(
|
|
102
|
+
id=entry_id,
|
|
103
|
+
title=str(entry_data.get("title") or entry_id).strip(),
|
|
104
|
+
path=path,
|
|
105
|
+
db_id=db_id,
|
|
106
|
+
language=self.language,
|
|
107
|
+
provider_code=self.provider_code,
|
|
108
|
+
base_api_url=self.base_api_url,
|
|
109
|
+
base_web_url=self.base_web_url,
|
|
110
|
+
published=str(entry_data.get("published") or "").strip() or None,
|
|
111
|
+
updated=str(entry_data.get("updated") or "").strip() or None,
|
|
112
|
+
)
|
|
113
|
+
updated = parse_dt(entry.updated) or parse_dt(entry.published)
|
|
114
|
+
if updated is None:
|
|
115
|
+
continue
|
|
116
|
+
path_items = [
|
|
117
|
+
{"id": segment, "label": segment}
|
|
118
|
+
for segment in entry.path.split("/")
|
|
119
|
+
if segment.strip()
|
|
120
|
+
]
|
|
121
|
+
discovered.append(
|
|
122
|
+
DiscoveredDataset(
|
|
123
|
+
task_id=task_id,
|
|
124
|
+
provider_code=self.provider_code,
|
|
125
|
+
dataset_code=entry.id,
|
|
126
|
+
language=self.language,
|
|
127
|
+
updated=updated,
|
|
128
|
+
label=entry.title,
|
|
129
|
+
metadata_url=entry.api_url,
|
|
130
|
+
data_url=entry.api_url,
|
|
131
|
+
web_url=entry.web_url,
|
|
132
|
+
paths=[path_items] if path_items else None,
|
|
133
|
+
subject_code=path_items[-1]["id"] if path_items else None,
|
|
134
|
+
subject_label=path_items[-1]["label"] if path_items else None,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return discovered
|
|
138
|
+
|
|
139
|
+
async def resolve_dataset_metadata(
|
|
140
|
+
self,
|
|
141
|
+
discovered: DiscoveredDataset,
|
|
142
|
+
task_id: uuid.UUID | None = None,
|
|
143
|
+
**_: Any,
|
|
144
|
+
) -> ResolvedDatasetMetadata:
|
|
145
|
+
metadata_url = discovered.metadata_url
|
|
146
|
+
if metadata_url is None:
|
|
147
|
+
raise ValueError("Discovered dataset is missing metadata_url")
|
|
148
|
+
payload = await self._get_json(metadata_url)
|
|
149
|
+
dimensions: dict[str, dict[str, Any]] = {}
|
|
150
|
+
dimension_ids: list[str] = []
|
|
151
|
+
required_dimensions: dict[str, bool | None] = {}
|
|
152
|
+
role: dict[str, list[str]] = {}
|
|
153
|
+
dimension_extensions: dict[str, dict[str, Any]] = {}
|
|
154
|
+
time_labels: list[str] = []
|
|
155
|
+
title = discovered.label or discovered.dataset_code
|
|
156
|
+
if isinstance(payload, dict):
|
|
157
|
+
payload_title = payload.get("title")
|
|
158
|
+
if isinstance(payload_title, str) and payload_title.strip():
|
|
159
|
+
title = payload_title.strip()
|
|
160
|
+
variables = payload.get("variables")
|
|
161
|
+
if isinstance(variables, list):
|
|
162
|
+
for position, var in enumerate(variables):
|
|
163
|
+
if not isinstance(var, dict):
|
|
164
|
+
continue
|
|
165
|
+
code = str(var.get("code") or "").strip()
|
|
166
|
+
if not code:
|
|
167
|
+
continue
|
|
168
|
+
label = str(var.get("text") or code).strip()
|
|
169
|
+
values = var.get("values") if isinstance(var.get("values"), list) else []
|
|
170
|
+
value_texts = (
|
|
171
|
+
var.get("valueTexts")
|
|
172
|
+
if isinstance(var.get("valueTexts"), list)
|
|
173
|
+
else []
|
|
174
|
+
)
|
|
175
|
+
index = [str(value) for value in values]
|
|
176
|
+
labels = {
|
|
177
|
+
str(value): str(value_texts[idx] if idx < len(value_texts) else value)
|
|
178
|
+
for idx, value in enumerate(values)
|
|
179
|
+
}
|
|
180
|
+
dimensions[code] = {
|
|
181
|
+
"label": label,
|
|
182
|
+
"category": {
|
|
183
|
+
"index": {
|
|
184
|
+
category_code: ordinal
|
|
185
|
+
for ordinal, category_code in enumerate(index)
|
|
186
|
+
},
|
|
187
|
+
"label": labels,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
dimension_ids.append(code)
|
|
191
|
+
elimination = var.get("elimination")
|
|
192
|
+
required_dimensions[code] = (
|
|
193
|
+
None if elimination is None else not bool(elimination)
|
|
194
|
+
)
|
|
195
|
+
inferred_role = detect_role(code, label, bool(var.get("time")))
|
|
196
|
+
if inferred_role is not None:
|
|
197
|
+
role.setdefault(inferred_role, []).append(code)
|
|
198
|
+
if inferred_role == "time":
|
|
199
|
+
time_labels = [label for label in labels.values() if label.strip()]
|
|
200
|
+
extras = {
|
|
201
|
+
key: value
|
|
202
|
+
for key, value in var.items()
|
|
203
|
+
if key
|
|
204
|
+
not in {"code", "text", "values", "valueTexts", "time", "elimination"}
|
|
205
|
+
}
|
|
206
|
+
extras["position"] = position
|
|
207
|
+
if extras:
|
|
208
|
+
dimension_extensions[code] = {"extension": extras}
|
|
209
|
+
sorted_time_labels = sorted(time_labels)
|
|
210
|
+
first_period = discovered.first_period or (sorted_time_labels[0] if sorted_time_labels else "")
|
|
211
|
+
last_period = discovered.last_period or (sorted_time_labels[-1] if sorted_time_labels else first_period)
|
|
212
|
+
time_unit = discovered.time_unit or determine_time_unit(first_period, last_period)
|
|
213
|
+
extension = dict(discovered.extension)
|
|
214
|
+
if dimension_extensions:
|
|
215
|
+
extension["dimension_extensions"] = dimension_extensions
|
|
216
|
+
return ResolvedDatasetMetadata(
|
|
217
|
+
task_id=task_id or discovered.task_id,
|
|
218
|
+
provider_code=discovered.provider_code,
|
|
219
|
+
dataset_code=discovered.dataset_code,
|
|
220
|
+
language=discovered.language,
|
|
221
|
+
updated=discovered.updated,
|
|
222
|
+
label=title,
|
|
223
|
+
time_unit=time_unit,
|
|
224
|
+
first_period=first_period,
|
|
225
|
+
last_period=last_period,
|
|
226
|
+
paths=discovered.paths or [],
|
|
227
|
+
role=role,
|
|
228
|
+
metadata_url=metadata_url,
|
|
229
|
+
data_url=discovered.data_url or metadata_url,
|
|
230
|
+
dimension=dimensions,
|
|
231
|
+
required_dimensions=required_dimensions,
|
|
232
|
+
note=discovered.note,
|
|
233
|
+
source=discovered.source,
|
|
234
|
+
description=discovered.description,
|
|
235
|
+
discontinued=discovered.discontinued,
|
|
236
|
+
subject_code=discovered.subject_code,
|
|
237
|
+
subject_label=discovered.subject_label,
|
|
238
|
+
web_url=discovered.web_url,
|
|
239
|
+
doc_url=discovered.doc_url,
|
|
240
|
+
official_statistics=discovered.official_statistics,
|
|
241
|
+
contact=discovered.contact,
|
|
242
|
+
dimension_ids=dimension_ids or None,
|
|
243
|
+
extension=extension,
|
|
244
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import DiscoveredDataset, ResolvedDatasetMetadata
|
|
9
|
+
|
|
10
|
+
JsonResponse = dict[str, Any] | list[Any] | None
|
|
11
|
+
JsonRequestHandler = Callable[..., Awaitable[JsonResponse]]
|
|
12
|
+
TextRequestHandler = Callable[..., Awaitable[str | None]]
|
|
13
|
+
BytesRequestHandler = Callable[..., Awaitable[bytes | None]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIWrapper(ABC):
|
|
17
|
+
"""Base class for dependency-free API wrappers."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
provider_code: str,
|
|
22
|
+
label: str,
|
|
23
|
+
language: str,
|
|
24
|
+
json_request_handler: JsonRequestHandler,
|
|
25
|
+
logger: logging.Logger | None = None,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.provider_code = provider_code
|
|
29
|
+
self.label = label
|
|
30
|
+
self.language = language
|
|
31
|
+
self.api_type = "generic"
|
|
32
|
+
self.json_request_handler = json_request_handler
|
|
33
|
+
self.text_request_handler: TextRequestHandler | None = kwargs.pop(
|
|
34
|
+
"text_request_handler",
|
|
35
|
+
None,
|
|
36
|
+
)
|
|
37
|
+
self.bytes_request_handler: BytesRequestHandler | None = kwargs.pop(
|
|
38
|
+
"bytes_request_handler",
|
|
39
|
+
None,
|
|
40
|
+
)
|
|
41
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
42
|
+
self._logger_prefix = f"[{self.provider_code}:{self.language}]"
|
|
43
|
+
|
|
44
|
+
def _log_prefix(self, message: str) -> str:
|
|
45
|
+
return f"{self._logger_prefix} {message}"
|
|
46
|
+
|
|
47
|
+
async def _get_json(self, url: str, **kwargs: Any) -> JsonResponse:
|
|
48
|
+
return await self.json_request_handler(url, **kwargs)
|
|
49
|
+
|
|
50
|
+
async def _get_text(self, url: str, **kwargs: Any) -> str | None:
|
|
51
|
+
if self.text_request_handler is None:
|
|
52
|
+
raise RuntimeError("text_request_handler is not configured")
|
|
53
|
+
return await self.text_request_handler(url, **kwargs)
|
|
54
|
+
|
|
55
|
+
async def _get_bytes(self, url: str, **kwargs: Any) -> bytes | None:
|
|
56
|
+
if self.bytes_request_handler is None:
|
|
57
|
+
raise RuntimeError("bytes_request_handler is not configured")
|
|
58
|
+
return await self.bytes_request_handler(url, **kwargs)
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def health_check(self, dataset_code: str | None = None) -> bool:
|
|
62
|
+
"""Return whether the provider is reachable."""
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def discover_datasets(
|
|
66
|
+
self,
|
|
67
|
+
task_id: Any,
|
|
68
|
+
**kwargs: Any,
|
|
69
|
+
) -> list[DiscoveredDataset]:
|
|
70
|
+
"""Discover datasets from the provider."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def resolve_dataset_metadata(
|
|
74
|
+
self,
|
|
75
|
+
discovered: DiscoveredDataset,
|
|
76
|
+
task_id: Any | None = None,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> ResolvedDatasetMetadata:
|
|
79
|
+
"""Resolve a discovered dataset into persistence-ready metadata."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StatwrapperError(Exception):
|
|
5
|
+
"""Base exception for statwrapper."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderNotFoundError(StatwrapperError):
|
|
9
|
+
def __init__(self, provider_code: str) -> None:
|
|
10
|
+
super().__init__(f"Provider not found: {provider_code!r}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnsupportedAPITypeError(StatwrapperError):
|
|
14
|
+
def __init__(self, api_type: str) -> None:
|
|
15
|
+
super().__init__(f"No wrapper registered for api_type={api_type!r}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsupportedLanguageError(StatwrapperError):
|
|
19
|
+
def __init__(self, provider_code: str, language: str) -> None:
|
|
20
|
+
super().__init__(
|
|
21
|
+
f"Language {language!r} is not supported by provider {provider_code!r}"
|
|
22
|
+
)
|
statwrapper/http.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlencode, urlparse
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class _HostLimiter:
|
|
14
|
+
interval: float
|
|
15
|
+
lock: asyncio.Lock
|
|
16
|
+
last_request_time: float = 0.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RateLimitedSession:
|
|
20
|
+
"""Minimal stdlib-backed async HTTP client with per-host spacing."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
default_rate: float = 1.0,
|
|
26
|
+
host_rates: dict[str, float] | None = None,
|
|
27
|
+
timeout: float = 60.0,
|
|
28
|
+
headers: dict[str, str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._default_interval = 1.0 / default_rate if default_rate > 0 else 0.0
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
self._headers = headers or {"User-Agent": "statwrapper/0.1.0"}
|
|
33
|
+
self._limiters: dict[str, _HostLimiter] = {}
|
|
34
|
+
for host, rate in (host_rates or {}).items():
|
|
35
|
+
hostname = urlparse(host).hostname or host
|
|
36
|
+
interval = 1.0 / rate if rate > 0 else 0.0
|
|
37
|
+
self._limiters[hostname] = _HostLimiter(interval=interval, lock=asyncio.Lock())
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> RateLimitedSession:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def _get_limiter(self, url: str) -> _HostLimiter:
|
|
46
|
+
hostname = urlparse(url).hostname or ""
|
|
47
|
+
limiter = self._limiters.get(hostname)
|
|
48
|
+
if limiter is None:
|
|
49
|
+
limiter = _HostLimiter(
|
|
50
|
+
interval=self._default_interval,
|
|
51
|
+
lock=asyncio.Lock(),
|
|
52
|
+
)
|
|
53
|
+
self._limiters[hostname] = limiter
|
|
54
|
+
return limiter
|
|
55
|
+
|
|
56
|
+
async def _wait_for_slot(self, url: str) -> None:
|
|
57
|
+
limiter = self._get_limiter(url)
|
|
58
|
+
async with limiter.lock:
|
|
59
|
+
if limiter.interval > 0:
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
elapsed = now - limiter.last_request_time
|
|
62
|
+
if elapsed < limiter.interval:
|
|
63
|
+
await asyncio.sleep(limiter.interval - elapsed)
|
|
64
|
+
limiter.last_request_time = time.monotonic()
|
|
65
|
+
|
|
66
|
+
def _build_url(self, url: str, params: dict[str, Any] | None) -> str:
|
|
67
|
+
if not params:
|
|
68
|
+
return url
|
|
69
|
+
encoded = urlencode(
|
|
70
|
+
{
|
|
71
|
+
key: value
|
|
72
|
+
for key, value in params.items()
|
|
73
|
+
if value is not None
|
|
74
|
+
},
|
|
75
|
+
doseq=True,
|
|
76
|
+
)
|
|
77
|
+
separator = "&" if "?" in url else "?"
|
|
78
|
+
return f"{url}{separator}{encoded}"
|
|
79
|
+
|
|
80
|
+
async def _read(self, url: str, params: dict[str, Any] | None = None) -> bytes | None:
|
|
81
|
+
await self._wait_for_slot(url)
|
|
82
|
+
request = Request(self._build_url(url, params), headers=self._headers)
|
|
83
|
+
try:
|
|
84
|
+
return await asyncio.to_thread(
|
|
85
|
+
lambda: urlopen(request, timeout=self._timeout).read()
|
|
86
|
+
)
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
async def get_bytes(
|
|
91
|
+
self,
|
|
92
|
+
url: str,
|
|
93
|
+
*,
|
|
94
|
+
params: dict[str, Any] | None = None,
|
|
95
|
+
**_: Any,
|
|
96
|
+
) -> bytes | None:
|
|
97
|
+
return await self._read(url, params=params)
|
|
98
|
+
|
|
99
|
+
async def get_text(
|
|
100
|
+
self,
|
|
101
|
+
url: str,
|
|
102
|
+
*,
|
|
103
|
+
params: dict[str, Any] | None = None,
|
|
104
|
+
encoding: str = "utf-8",
|
|
105
|
+
**_: Any,
|
|
106
|
+
) -> str | None:
|
|
107
|
+
payload = await self._read(url, params=params)
|
|
108
|
+
if payload is None:
|
|
109
|
+
return None
|
|
110
|
+
return payload.decode(encoding, errors="replace")
|
|
111
|
+
|
|
112
|
+
async def get_json(
|
|
113
|
+
self,
|
|
114
|
+
url: str,
|
|
115
|
+
*,
|
|
116
|
+
params: dict[str, Any] | None = None,
|
|
117
|
+
**_: Any,
|
|
118
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
119
|
+
payload = await self.get_text(url, params=params)
|
|
120
|
+
if payload is None:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
decoded = json.loads(payload)
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
return None
|
|
126
|
+
return decoded if isinstance(decoded, (dict, list)) else None
|