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