mtgwiki 1.2.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.
- mtgwiki/__init__.py +22 -0
- mtgwiki/client.py +456 -0
- mtgwiki/io.py +38 -0
- mtgwiki/parser.py +434 -0
- mtgwiki/wiki.py +1091 -0
- mtgwiki-1.2.0.dist-info/METADATA +427 -0
- mtgwiki-1.2.0.dist-info/RECORD +10 -0
- mtgwiki-1.2.0.dist-info/WHEEL +5 -0
- mtgwiki-1.2.0.dist-info/licenses/LICENSE +21 -0
- mtgwiki-1.2.0.dist-info/top_level.txt +1 -0
mtgwiki/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""mtgwiki: small, generic, data-oriented MediaWiki client."""
|
|
2
|
+
|
|
3
|
+
from .client import APIError, Client, DEFAULT_API_URL, DEFAULT_USER_AGENT
|
|
4
|
+
from .io import read_jsonl, write_json, write_jsonl
|
|
5
|
+
from .parser import clean_wikitext, parse_wikitext
|
|
6
|
+
from .wiki import DEFAULT_EXTRACT_INCLUDE, Wiki
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"APIError",
|
|
10
|
+
"Client",
|
|
11
|
+
"DEFAULT_API_URL",
|
|
12
|
+
"DEFAULT_USER_AGENT",
|
|
13
|
+
"DEFAULT_EXTRACT_INCLUDE",
|
|
14
|
+
"Wiki",
|
|
15
|
+
"clean_wikitext",
|
|
16
|
+
"parse_wikitext",
|
|
17
|
+
"read_jsonl",
|
|
18
|
+
"write_json",
|
|
19
|
+
"write_jsonl",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "1.2.0"
|
mtgwiki/client.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Iterator
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_API_URL = "https://mtg.wiki/api.php"
|
|
17
|
+
DEFAULT_USER_AGENT = "mtgwiki/1.2.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(RuntimeError):
|
|
21
|
+
"""Raised when MediaWiki returns an API-level error."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: str, info: str | None = None):
|
|
24
|
+
self.code = code
|
|
25
|
+
self.info = info or "Unknown MediaWiki API error"
|
|
26
|
+
super().__init__(f"{code}: {self.info}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Client:
|
|
30
|
+
"""Polite, generic HTTP client for the MediaWiki Action API.
|
|
31
|
+
|
|
32
|
+
The client deliberately knows nothing about the meaning of wiki content.
|
|
33
|
+
It only handles transport concerns:
|
|
34
|
+
|
|
35
|
+
* JSON GET requests
|
|
36
|
+
* automatic MediaWiki continuation
|
|
37
|
+
* serialized requests
|
|
38
|
+
* minimum interval between real HTTP calls
|
|
39
|
+
* ``maxlag``
|
|
40
|
+
* ``Retry-After``
|
|
41
|
+
* exponential backoff
|
|
42
|
+
* bounded in-memory cache
|
|
43
|
+
* optional persistent SQLite cache
|
|
44
|
+
* request/cache statistics
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
api_url:
|
|
49
|
+
URL to a MediaWiki ``api.php`` endpoint.
|
|
50
|
+
user_agent:
|
|
51
|
+
User-Agent header. For sustained use, MediaWiki recommends adding
|
|
52
|
+
project/contact information.
|
|
53
|
+
min_interval:
|
|
54
|
+
Minimum number of seconds between *new* HTTP requests. The default
|
|
55
|
+
``0.5`` means at most about two requests per second when the server is
|
|
56
|
+
fast. Cache hits do not touch the network.
|
|
57
|
+
cache_ttl:
|
|
58
|
+
Cache lifetime in seconds. Set to ``0`` to disable caching.
|
|
59
|
+
cache_path:
|
|
60
|
+
Optional SQLite file for cache reuse across Python processes. If not
|
|
61
|
+
set, caching is memory-only.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
api_url: str = DEFAULT_API_URL,
|
|
67
|
+
*,
|
|
68
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
maxlag: int | None = 5,
|
|
71
|
+
retries: int = 4,
|
|
72
|
+
min_interval: float = 0.5,
|
|
73
|
+
cache_ttl: float = 300.0,
|
|
74
|
+
cache_max_entries: int = 512,
|
|
75
|
+
cache_path: str | Path | None = None,
|
|
76
|
+
session: requests.Session | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
if timeout <= 0:
|
|
79
|
+
raise ValueError("timeout must be > 0")
|
|
80
|
+
if retries < 0:
|
|
81
|
+
raise ValueError("retries must be >= 0")
|
|
82
|
+
if min_interval < 0:
|
|
83
|
+
raise ValueError("min_interval must be >= 0")
|
|
84
|
+
if cache_ttl < 0:
|
|
85
|
+
raise ValueError("cache_ttl must be >= 0")
|
|
86
|
+
if cache_max_entries < 0:
|
|
87
|
+
raise ValueError("cache_max_entries must be >= 0")
|
|
88
|
+
if not user_agent.strip():
|
|
89
|
+
raise ValueError("user_agent must not be empty")
|
|
90
|
+
|
|
91
|
+
self.api_url = api_url
|
|
92
|
+
self.timeout = float(timeout)
|
|
93
|
+
self.maxlag = maxlag
|
|
94
|
+
self.retries = int(retries)
|
|
95
|
+
self.min_interval = float(min_interval)
|
|
96
|
+
self.cache_ttl = float(cache_ttl)
|
|
97
|
+
self.cache_max_entries = int(cache_max_entries)
|
|
98
|
+
self.cache_path = Path(cache_path) if cache_path else None
|
|
99
|
+
|
|
100
|
+
self.session = session or requests.Session()
|
|
101
|
+
self.session.headers.update(
|
|
102
|
+
{
|
|
103
|
+
"User-Agent": user_agent,
|
|
104
|
+
"Accept": "application/json;q=0.9,*/*;q=0.8",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# One request/retry chain at a time. This keeps the client polite even
|
|
109
|
+
# if a caller later invokes it from several threads.
|
|
110
|
+
self._request_lock = threading.RLock()
|
|
111
|
+
self._last_http_request_started: float | None = None
|
|
112
|
+
|
|
113
|
+
# key -> (expires_at_monotonic, decoded_response)
|
|
114
|
+
self._cache: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
|
115
|
+
self._sqlite: sqlite3.Connection | None = None
|
|
116
|
+
if self.cache_path is not None and self.cache_ttl > 0:
|
|
117
|
+
self._open_sqlite_cache()
|
|
118
|
+
|
|
119
|
+
self._requests_total = 0
|
|
120
|
+
self._http_requests = 0
|
|
121
|
+
self._cache_hits = 0
|
|
122
|
+
self._memory_cache_hits = 0
|
|
123
|
+
self._disk_cache_hits = 0
|
|
124
|
+
self._retries_performed = 0
|
|
125
|
+
self._throttle_sleeps = 0
|
|
126
|
+
self._throttle_sleep_seconds = 0.0
|
|
127
|
+
self._retry_sleeps = 0
|
|
128
|
+
self._retry_sleep_seconds = 0.0
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Public API
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
def request(self, *, use_cache: bool = True, **params: Any) -> Any:
|
|
134
|
+
"""Perform one MediaWiki GET request and return decoded JSON.
|
|
135
|
+
|
|
136
|
+
``params`` are passed directly to MediaWiki. ``use_cache`` is local to
|
|
137
|
+
this client and is never sent to the server.
|
|
138
|
+
"""
|
|
139
|
+
request_params: dict[str, Any] = {
|
|
140
|
+
"format": "json",
|
|
141
|
+
"formatversion": 2,
|
|
142
|
+
**params,
|
|
143
|
+
}
|
|
144
|
+
if self.maxlag is not None and "maxlag" not in request_params:
|
|
145
|
+
request_params["maxlag"] = self.maxlag
|
|
146
|
+
|
|
147
|
+
action = str(request_params.get("action", "query"))
|
|
148
|
+
cacheable = use_cache and action in {"query", "parse", "paraminfo"}
|
|
149
|
+
|
|
150
|
+
with self._request_lock:
|
|
151
|
+
self._requests_total += 1
|
|
152
|
+
cache_key: str | None = None
|
|
153
|
+
|
|
154
|
+
if cacheable and self._cache_enabled:
|
|
155
|
+
cache_key = self._cache_key(request_params)
|
|
156
|
+
cached = self._cache_get(cache_key)
|
|
157
|
+
if cached is not None:
|
|
158
|
+
self._cache_hits += 1
|
|
159
|
+
return cached
|
|
160
|
+
|
|
161
|
+
data = self._request_uncached(request_params)
|
|
162
|
+
|
|
163
|
+
if cache_key is not None:
|
|
164
|
+
self._cache_put(cache_key, data)
|
|
165
|
+
|
|
166
|
+
return copy.deepcopy(data)
|
|
167
|
+
|
|
168
|
+
def iterate(self, **params: Any) -> Iterator[Any]:
|
|
169
|
+
"""Yield every batch of a continued MediaWiki request.
|
|
170
|
+
|
|
171
|
+
All continuation values returned by MediaWiki are fed back into the
|
|
172
|
+
next request automatically until no ``continue`` object remains.
|
|
173
|
+
"""
|
|
174
|
+
base = dict(params)
|
|
175
|
+
continuation: dict[str, Any] = {}
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
request_params = {**base, **continuation}
|
|
179
|
+
data = self.request(**request_params)
|
|
180
|
+
yield data
|
|
181
|
+
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
break
|
|
184
|
+
continuation = data.get("continue") or {}
|
|
185
|
+
if not continuation:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
def stats(self) -> dict[str, Any]:
|
|
189
|
+
"""Return request, cache and throttling statistics."""
|
|
190
|
+
hit_percent = (
|
|
191
|
+
self._cache_hits / self._requests_total * 100.0
|
|
192
|
+
if self._requests_total
|
|
193
|
+
else 0.0
|
|
194
|
+
)
|
|
195
|
+
return {
|
|
196
|
+
"logical_requests": self._requests_total,
|
|
197
|
+
"http_requests": self._http_requests,
|
|
198
|
+
"cache_hits": self._cache_hits,
|
|
199
|
+
"memory_cache_hits": self._memory_cache_hits,
|
|
200
|
+
"disk_cache_hits": self._disk_cache_hits,
|
|
201
|
+
"cache_hit_percent": round(hit_percent, 1),
|
|
202
|
+
"cache_entries": len(self._cache),
|
|
203
|
+
"persistent_cache": str(self.cache_path) if self.cache_path else None,
|
|
204
|
+
"retries": self._retries_performed,
|
|
205
|
+
"throttle_sleeps": self._throttle_sleeps,
|
|
206
|
+
"throttle_sleep_seconds": round(self._throttle_sleep_seconds, 3),
|
|
207
|
+
"retry_sleeps": self._retry_sleeps,
|
|
208
|
+
"retry_sleep_seconds": round(self._retry_sleep_seconds, 3),
|
|
209
|
+
"min_interval": self.min_interval,
|
|
210
|
+
"maxlag": self.maxlag,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def clear_cache(self) -> None:
|
|
214
|
+
"""Clear both memory and optional persistent caches."""
|
|
215
|
+
with self._request_lock:
|
|
216
|
+
self._cache.clear()
|
|
217
|
+
if self._sqlite is not None:
|
|
218
|
+
self._sqlite.execute("DELETE FROM cache")
|
|
219
|
+
self._sqlite.commit()
|
|
220
|
+
|
|
221
|
+
def reset_stats(self) -> None:
|
|
222
|
+
"""Reset statistics without clearing cached responses."""
|
|
223
|
+
with self._request_lock:
|
|
224
|
+
self._requests_total = 0
|
|
225
|
+
self._http_requests = 0
|
|
226
|
+
self._cache_hits = 0
|
|
227
|
+
self._memory_cache_hits = 0
|
|
228
|
+
self._disk_cache_hits = 0
|
|
229
|
+
self._retries_performed = 0
|
|
230
|
+
self._throttle_sleeps = 0
|
|
231
|
+
self._throttle_sleep_seconds = 0.0
|
|
232
|
+
self._retry_sleeps = 0
|
|
233
|
+
self._retry_sleep_seconds = 0.0
|
|
234
|
+
|
|
235
|
+
def close(self) -> None:
|
|
236
|
+
"""Close the HTTP session and optional persistent cache."""
|
|
237
|
+
with self._request_lock:
|
|
238
|
+
if self._sqlite is not None:
|
|
239
|
+
self._sqlite.close()
|
|
240
|
+
self._sqlite = None
|
|
241
|
+
close = getattr(self.session, "close", None)
|
|
242
|
+
if callable(close):
|
|
243
|
+
close()
|
|
244
|
+
|
|
245
|
+
def __enter__(self) -> "Client":
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
249
|
+
self.close()
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# HTTP / retries
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
def _request_uncached(self, request_params: dict[str, Any]) -> Any:
|
|
255
|
+
last_error: Exception | None = None
|
|
256
|
+
|
|
257
|
+
for attempt in range(self.retries + 1):
|
|
258
|
+
self._wait_for_http_slot()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
self._last_http_request_started = time.monotonic()
|
|
262
|
+
self._http_requests += 1
|
|
263
|
+
|
|
264
|
+
response = self.session.get(
|
|
265
|
+
self.api_url,
|
|
266
|
+
params=request_params,
|
|
267
|
+
timeout=self.timeout,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if response.status_code in {429, 502, 503, 504}:
|
|
271
|
+
if attempt >= self.retries:
|
|
272
|
+
response.raise_for_status()
|
|
273
|
+
|
|
274
|
+
self._retries_performed += 1
|
|
275
|
+
retry_after = response.headers.get("Retry-After")
|
|
276
|
+
minimum = 5.0 if response.status_code in {429, 503} else 1.0
|
|
277
|
+
self._retry_sleep(retry_after, attempt, minimum=minimum)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
response.raise_for_status()
|
|
281
|
+
data = response.json()
|
|
282
|
+
|
|
283
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
284
|
+
if error:
|
|
285
|
+
code = str(error.get("code", "apierror"))
|
|
286
|
+
info = str(error.get("info", "Unknown MediaWiki API error"))
|
|
287
|
+
|
|
288
|
+
if code in {"maxlag", "ratelimited"} and attempt < self.retries:
|
|
289
|
+
self._retries_performed += 1
|
|
290
|
+
self._retry_sleep(
|
|
291
|
+
response.headers.get("Retry-After"),
|
|
292
|
+
attempt,
|
|
293
|
+
minimum=5.0,
|
|
294
|
+
)
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
raise APIError(code, info)
|
|
298
|
+
|
|
299
|
+
return data
|
|
300
|
+
|
|
301
|
+
except (requests.RequestException, ValueError) as exc:
|
|
302
|
+
last_error = exc
|
|
303
|
+
if attempt >= self.retries:
|
|
304
|
+
raise
|
|
305
|
+
self._retries_performed += 1
|
|
306
|
+
self._retry_sleep(None, attempt, minimum=1.0)
|
|
307
|
+
|
|
308
|
+
if last_error:
|
|
309
|
+
raise last_error
|
|
310
|
+
raise RuntimeError("Request failed without an exception")
|
|
311
|
+
|
|
312
|
+
def _wait_for_http_slot(self) -> None:
|
|
313
|
+
if self.min_interval <= 0 or self._last_http_request_started is None:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
elapsed = time.monotonic() - self._last_http_request_started
|
|
317
|
+
delay = self.min_interval - elapsed
|
|
318
|
+
if delay <= 0:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
self._throttle_sleeps += 1
|
|
322
|
+
self._throttle_sleep_seconds += delay
|
|
323
|
+
time.sleep(delay)
|
|
324
|
+
|
|
325
|
+
def _retry_sleep(
|
|
326
|
+
self,
|
|
327
|
+
retry_after: str | None,
|
|
328
|
+
attempt: int,
|
|
329
|
+
*,
|
|
330
|
+
minimum: float,
|
|
331
|
+
) -> None:
|
|
332
|
+
seconds = self._retry_after_seconds(retry_after)
|
|
333
|
+
if seconds is None:
|
|
334
|
+
seconds = max(minimum, min(1.0 * (2**attempt), 60.0))
|
|
335
|
+
else:
|
|
336
|
+
seconds = max(minimum, seconds)
|
|
337
|
+
|
|
338
|
+
if seconds <= 0:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
self._retry_sleeps += 1
|
|
342
|
+
self._retry_sleep_seconds += seconds
|
|
343
|
+
time.sleep(seconds)
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _retry_after_seconds(value: str | None) -> float | None:
|
|
347
|
+
if not value:
|
|
348
|
+
return None
|
|
349
|
+
value = value.strip()
|
|
350
|
+
if not value:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
return max(0.0, float(value))
|
|
355
|
+
except ValueError:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
retry_at = parsedate_to_datetime(value)
|
|
360
|
+
if retry_at.tzinfo is None:
|
|
361
|
+
return None
|
|
362
|
+
return max(0.0, retry_at.timestamp() - time.time())
|
|
363
|
+
except (TypeError, ValueError, OverflowError):
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
# Cache
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
@property
|
|
370
|
+
def _cache_enabled(self) -> bool:
|
|
371
|
+
return self.cache_ttl > 0 and self.cache_max_entries > 0
|
|
372
|
+
|
|
373
|
+
def _open_sqlite_cache(self) -> None:
|
|
374
|
+
assert self.cache_path is not None
|
|
375
|
+
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
self._sqlite = sqlite3.connect(str(self.cache_path), check_same_thread=False)
|
|
377
|
+
self._sqlite.execute(
|
|
378
|
+
"""
|
|
379
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
380
|
+
key TEXT PRIMARY KEY,
|
|
381
|
+
expires REAL NOT NULL,
|
|
382
|
+
payload TEXT NOT NULL
|
|
383
|
+
)
|
|
384
|
+
"""
|
|
385
|
+
)
|
|
386
|
+
self._sqlite.execute("DELETE FROM cache WHERE expires <= ?", (time.time(),))
|
|
387
|
+
self._sqlite.commit()
|
|
388
|
+
|
|
389
|
+
def _cache_key(self, params: dict[str, Any]) -> str:
|
|
390
|
+
payload = {"api_url": self.api_url, "params": params}
|
|
391
|
+
return json.dumps(
|
|
392
|
+
payload,
|
|
393
|
+
sort_keys=True,
|
|
394
|
+
ensure_ascii=False,
|
|
395
|
+
separators=(",", ":"),
|
|
396
|
+
default=str,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _cache_get(self, key: str) -> Any | None:
|
|
400
|
+
item = self._cache.get(key)
|
|
401
|
+
if item is not None:
|
|
402
|
+
expires_at, data = item
|
|
403
|
+
if time.monotonic() < expires_at:
|
|
404
|
+
self._cache.move_to_end(key)
|
|
405
|
+
self._memory_cache_hits += 1
|
|
406
|
+
return copy.deepcopy(data)
|
|
407
|
+
del self._cache[key]
|
|
408
|
+
|
|
409
|
+
if self._sqlite is None:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
row = self._sqlite.execute(
|
|
413
|
+
"SELECT expires, payload FROM cache WHERE key = ?", (key,)
|
|
414
|
+
).fetchone()
|
|
415
|
+
if row is None:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
expires, payload = row
|
|
419
|
+
if float(expires) <= time.time():
|
|
420
|
+
self._sqlite.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
421
|
+
self._sqlite.commit()
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
data = json.loads(payload)
|
|
426
|
+
except (TypeError, json.JSONDecodeError):
|
|
427
|
+
self._sqlite.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
428
|
+
self._sqlite.commit()
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
self._disk_cache_hits += 1
|
|
432
|
+
self._memory_cache_put(key, data)
|
|
433
|
+
return copy.deepcopy(data)
|
|
434
|
+
|
|
435
|
+
def _cache_put(self, key: str, data: Any) -> None:
|
|
436
|
+
if not self._cache_enabled:
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
self._memory_cache_put(key, data)
|
|
440
|
+
|
|
441
|
+
if self._sqlite is not None:
|
|
442
|
+
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
443
|
+
expires = time.time() + self.cache_ttl
|
|
444
|
+
self._sqlite.execute(
|
|
445
|
+
"INSERT OR REPLACE INTO cache(key, expires, payload) VALUES (?, ?, ?)",
|
|
446
|
+
(key, expires, payload),
|
|
447
|
+
)
|
|
448
|
+
self._sqlite.commit()
|
|
449
|
+
|
|
450
|
+
def _memory_cache_put(self, key: str, data: Any) -> None:
|
|
451
|
+
expires_at = time.monotonic() + self.cache_ttl
|
|
452
|
+
self._cache[key] = (expires_at, copy.deepcopy(data))
|
|
453
|
+
self._cache.move_to_end(key)
|
|
454
|
+
|
|
455
|
+
while len(self._cache) > self.cache_max_entries:
|
|
456
|
+
self._cache.popitem(last=False)
|
mtgwiki/io.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_json(path: str | Path, data: Any, *, indent: int = 2) -> Path:
|
|
10
|
+
"""Write JSON with UTF-8 and return the resulting path."""
|
|
11
|
+
target = Path(path)
|
|
12
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
target.write_text(
|
|
14
|
+
json.dumps(data, ensure_ascii=False, indent=indent, default=str) + "\n",
|
|
15
|
+
encoding="utf-8",
|
|
16
|
+
)
|
|
17
|
+
return target
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_jsonl(path: str | Path, records: Iterable[Any]) -> Path:
|
|
21
|
+
"""Write one JSON object/value per line and return the resulting path."""
|
|
22
|
+
target = Path(path)
|
|
23
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
with target.open("w", encoding="utf-8", newline="\n") as handle:
|
|
25
|
+
for record in records:
|
|
26
|
+
handle.write(json.dumps(record, ensure_ascii=False, default=str))
|
|
27
|
+
handle.write("\n")
|
|
28
|
+
return target
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_jsonl(path: str | Path) -> list[Any]:
|
|
32
|
+
"""Read a JSON Lines file created by :func:`write_jsonl`."""
|
|
33
|
+
result: list[Any] = []
|
|
34
|
+
with Path(path).open("r", encoding="utf-8") as handle:
|
|
35
|
+
for line in handle:
|
|
36
|
+
if line.strip():
|
|
37
|
+
result.append(json.loads(line))
|
|
38
|
+
return result
|