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