wiktionary-api 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.
wikt/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Python client for the Wikimedia Wiktionary REST API."""
2
+
3
+ from .client import Wiktionary
4
+ from .util import pdf_from_bytes
5
+
6
+ __all__ = [
7
+ "Wiktionary",
8
+ "pdf_from_bytes",
9
+ ]
10
+
11
+ __version__ = "0.1.0"
wikt/client.py ADDED
@@ -0,0 +1,754 @@
1
+ """Python Wiktionary API.
2
+
3
+ Synchronous client for the English Wiktionary Wikimedia REST API
4
+ (``rest_v1``) plus Commons Core helpers for downloading media bytes.
5
+
6
+ OpenAPI spec: https://en.wiktionary.org/api/rest_v1/?spec
7
+ Interactive docs: https://en.wiktionary.org/api/rest_v1/#/
8
+ """
9
+
10
+ # pylint: disable=too-many-lines,too-many-public-methods,too-many-arguments,too-many-positional-arguments
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from pprint import pprint
15
+
16
+ import requests
17
+
18
+ from . import util
19
+
20
+ _DEFAULT_USER_AGENT = (
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
22
+ "(KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
23
+ )
24
+
25
+ _MATH_TYPES = frozenset({"tex", "inline-tex", "chem"})
26
+ _MATH_RENDER_FORMATS = frozenset({"svg", "mml", "png"})
27
+
28
+
29
+ def _pdf_url(base: str, title: str, pdf_format: str | None, pdf_type: str | None) -> str:
30
+ """Build ``/page/pdf/{title}/{format}/{type}`` with validated options."""
31
+ if pdf_format is None:
32
+ format_ = "a4"
33
+ elif pdf_format in ["a4", "letter", "legal"]:
34
+ format_ = pdf_format
35
+ else:
36
+ raise ValueError("Invalid format. Valid formats are 'a4', 'letter', 'legal'.")
37
+
38
+ if pdf_type is None:
39
+ type_ = "desktop"
40
+ elif pdf_type in ["desktop", "mobile"]:
41
+ type_ = pdf_type
42
+ else:
43
+ raise ValueError("Invalid type. Valid types are 'desktop', 'mobile'.")
44
+
45
+ return f"{base}/page/pdf/{title}/{format_}/{type_}"
46
+
47
+
48
+ def _parse_media_types_filter(media_types_filter: str | list[str] | None) -> list[str] | None:
49
+ """Normalize and validate a media-type filter (``image``/``audio``/``video``)."""
50
+ if not media_types_filter:
51
+ return None
52
+
53
+ if isinstance(media_types_filter, str):
54
+ valid_media_types = [media_types_filter.lower()]
55
+ else:
56
+ valid_media_types = [media_type.lower() for media_type in media_types_filter]
57
+
58
+ for media_type in valid_media_types:
59
+ if media_type not in ["image", "audio", "video"]:
60
+ raise ValueError(
61
+ f"Invalid media type '{media_type}'. Valid media types are "
62
+ "'image', 'audio', 'video'."
63
+ )
64
+
65
+ return valid_media_types
66
+
67
+
68
+ def _media_titles_to_fetch(media_file_list: list, valid_media_types: list[str] | None) -> list[str]:
69
+ """Return media titles from a media-list payload, optionally filtered by type."""
70
+ titles = []
71
+ for item in media_file_list:
72
+ if valid_media_types and str(item["type"]).lower() not in valid_media_types:
73
+ continue
74
+ titles.append(item["title"])
75
+ return titles
76
+
77
+
78
+ def _optional_params(**kwargs) -> dict:
79
+ """Drop ``None`` values so optional query params are omitted."""
80
+ return {key: value for key, value in kwargs.items() if value is not None}
81
+
82
+
83
+ def _page_resource_url(base: str, resource: str, title: str, revision: int | None = None) -> str:
84
+ """Build ``/page/{resource}/{title}[/revision]``."""
85
+ if revision is not None:
86
+ return f"{base}/page/{resource}/{title}/{revision}"
87
+ return f"{base}/page/{resource}/{title}"
88
+
89
+
90
+ def _transform_url(
91
+ base: str, source: str, target: str, title: str | None = None, revision: int | None = None
92
+ ) -> str:
93
+ """Build ``/transform/{source}/to/{target}[/title[/revision]]``."""
94
+ path = f"/transform/{source}/to/{target}"
95
+ if title is not None:
96
+ path += f"/{title}"
97
+ if revision is not None:
98
+ path += f"/{revision}"
99
+ return f"{base}{path}"
100
+
101
+
102
+ def _request_headers(
103
+ accept_language: str | None = None, if_match: str | None = None, output_mode: str | None = None
104
+ ) -> dict:
105
+ """Build optional request headers used by several REST routes."""
106
+ headers = {}
107
+ if accept_language is not None:
108
+ headers["Accept-Language"] = accept_language
109
+ if if_match is not None:
110
+ headers["If-Match"] = if_match
111
+ if output_mode is not None:
112
+ headers["output-mode"] = output_mode
113
+ return headers
114
+
115
+
116
+ def _transform_form(
117
+ wikitext: str | None = None,
118
+ html: str | None = None,
119
+ body_only: bool | None = None,
120
+ stash: bool | None = None,
121
+ ) -> dict:
122
+ """Build multipart/form fields for transform endpoints."""
123
+ data = {}
124
+ if wikitext is not None:
125
+ data["wikitext"] = wikitext
126
+ if html is not None:
127
+ data["html"] = html
128
+ if body_only is not None:
129
+ data["body_only"] = body_only
130
+ if stash is not None:
131
+ data["stash"] = stash
132
+ return data
133
+
134
+
135
+ class Wiktionary:
136
+ """
137
+ Synchronous Python wrapper for the Wiktionary REST API.
138
+
139
+ Covers every path in the live OpenAPI spec at
140
+ ``https://en.wiktionary.org/api/rest_v1/?spec``, plus PDF rendering
141
+ (available on the service but omitted from the published spec) and
142
+ Commons Core helpers for downloading media bytes.
143
+
144
+ Wikimedia requires a descriptive User-Agent with your application name
145
+ and a contact email or URL.
146
+ """
147
+
148
+ def __init__(self, app_name: str, contact_info: str, timeout: int = 30):
149
+ self.base = "https://en.wiktionary.org/api/rest_v1"
150
+ self.media_base = "https://api.wikimedia.org/core/v1/commons"
151
+ self.app_name = app_name
152
+ self.contact_info = contact_info
153
+ self.timeout = timeout
154
+ self.session = requests.Session()
155
+ self.session.headers.update({"User-Agent": _DEFAULT_USER_AGENT})
156
+ self.set_user_agent(self.app_name, self.contact_info)
157
+
158
+ def set_user_agent(self, app_name: str, contact_info: str) -> None:
159
+ """
160
+ Set the User-Agent header (required by Wikimedia API policy).
161
+
162
+ Parameters:
163
+ app_name: Application name.
164
+ contact_info: Email address or contact URL.
165
+ """
166
+ self.session.headers.update({"User-Agent": f"{app_name} ({contact_info})"})
167
+
168
+ # ------------------------------------------------------------------
169
+ # Page content
170
+ # ------------------------------------------------------------------
171
+
172
+ def page(self) -> list:
173
+ """
174
+ List page-related API entry points.
175
+
176
+ Endpoint: ``GET /page/``
177
+ Stability: Stable
178
+ """
179
+ response = self.session.get(f"{self.base}/page/", timeout=self.timeout)
180
+ return response.json()["items"]
181
+
182
+ def get_definition(self, term: str, redirect: bool | None = None) -> list:
183
+ """
184
+ Get term definitions extracted from Wiktionary content.
185
+
186
+ Parameters:
187
+ term: Term to define.
188
+ redirect: If ``False``, redirect pages return HTTP 200 instead of 302.
189
+
190
+ Endpoint: ``GET /page/definition/{term}``
191
+ Stability: Stable
192
+ """
193
+ url = f"{self.base}/page/definition/{term}"
194
+ response = self.session.get(
195
+ url,
196
+ params=_optional_params(redirect=redirect),
197
+ timeout=self.timeout,
198
+ )
199
+ return response.json()
200
+
201
+ def get_revision(self, title: str, revision: int | None = None) -> dict:
202
+ """
203
+ Fetch revision metadata for a title.
204
+
205
+ Parameters:
206
+ title: Page title (underscores; percent-encode if needed).
207
+ revision: Optional specific revision ID. Latest if omitted.
208
+
209
+ Endpoint: ``GET /page/title/{title}/{revision}``
210
+ Stability: Stable
211
+ """
212
+ if revision is not None:
213
+ url = f"{self.base}/page/title/{title}/{revision}"
214
+ else:
215
+ url = f"{self.base}/page/title/{title}"
216
+
217
+ items = self.session.get(url, timeout=self.timeout).json()["items"]
218
+ if items:
219
+ return items[0]
220
+ return {}
221
+
222
+ def get_html(
223
+ self,
224
+ title: str,
225
+ revision: int | None = None,
226
+ redirect: bool | None = None,
227
+ stash: bool | None = None,
228
+ accept_language: str | None = None,
229
+ ) -> str:
230
+ """
231
+ Get HTML for a title (optionally a specific revision).
232
+
233
+ Endpoint: ``GET /page/html/{title}/{revision}``
234
+ Stability: Stable
235
+ """
236
+ url = _page_resource_url(self.base, "html", title, revision)
237
+ headers = _request_headers(accept_language=accept_language)
238
+ response = self.session.get(
239
+ url,
240
+ params=_optional_params(redirect=redirect, stash=stash),
241
+ headers=headers or None,
242
+ timeout=self.timeout,
243
+ )
244
+ return response.text
245
+
246
+ def get_lint(self, title: str, revision: int | None = None) -> dict | list:
247
+ """
248
+ Get linter errors for a page.
249
+
250
+ Endpoint: ``GET /page/lint/{title}/{revision}``
251
+ Stability: Stable
252
+ """
253
+ url = _page_resource_url(self.base, "lint", title, revision)
254
+ return self.session.get(url, timeout=self.timeout).json()
255
+
256
+ def get_mobile_html(
257
+ self, title: str, revision: int | None = None, redirect: bool | None = None
258
+ ) -> str:
259
+ """
260
+ Get page HTML optimized for mobile consumption.
261
+
262
+ Endpoint: ``GET /page/mobile-html/{title}/{revision}``
263
+ Stability: Stable
264
+ """
265
+ url = _page_resource_url(self.base, "mobile-html", title, revision)
266
+ response = self.session.get(
267
+ url,
268
+ params=_optional_params(redirect=redirect),
269
+ timeout=self.timeout,
270
+ )
271
+ return response.text
272
+
273
+ def get_mobile_html_offline_resources(self, title: str, revision: int | None = None) -> dict:
274
+ """
275
+ Get styles and scripts for offline mobile-html pages.
276
+
277
+ Endpoint: ``GET /page/mobile-html-offline-resources/{title}/{revision}``
278
+ Stability: Stable
279
+ """
280
+ url = _page_resource_url(self.base, "mobile-html-offline-resources", title, revision)
281
+ return self.session.get(url, timeout=self.timeout).json()
282
+
283
+ def get_media_file_list(self, title: str, revision: int | None = None) -> list:
284
+ """
285
+ List media items used on a wiki page (images, audio, video).
286
+
287
+ Endpoint: ``GET /page/media-list/{title}/{revision}``
288
+ Stability: Unstable
289
+ """
290
+ url = _page_resource_url(self.base, "media-list", title, revision)
291
+ return self.session.get(url, timeout=self.timeout).json()["items"]
292
+
293
+ def get_pdf(
294
+ self, title: str, pdf_format: str | None = None, pdf_type: str | None = None
295
+ ) -> bytes:
296
+ """
297
+ Render a page as PDF bytes.
298
+
299
+ Parameters:
300
+ title: Page title.
301
+ pdf_format: ``a4`` (default), ``letter``, or ``legal``.
302
+ pdf_type: ``desktop`` (default) or ``mobile``.
303
+
304
+ Endpoint: ``GET /page/pdf/{title}/{format}/{type}``
305
+ Stability: Experimental (available on the service; not in OpenAPI ``?spec``)
306
+ """
307
+ url = _pdf_url(self.base, title, pdf_format, pdf_type)
308
+ return self.session.get(url, timeout=self.timeout).content
309
+
310
+ def download_pdf(
311
+ self,
312
+ title: str,
313
+ pdf_format: str | None = None,
314
+ pdf_type: str | None = None,
315
+ output_name: str | None = None,
316
+ ) -> bool:
317
+ """
318
+ Download a PDF for ``title`` and save it to disk.
319
+
320
+ Returns:
321
+ ``True`` on success, ``False`` if the download or save fails.
322
+ """
323
+ output_name_ = output_name if output_name else f"{title}.pdf"
324
+ try:
325
+ pdf_bytes = self.get_pdf(title, pdf_format=pdf_format, pdf_type=pdf_type)
326
+ util.pdf_from_bytes(pdf_bytes, True, output_name_)
327
+ return True
328
+ except Exception as exc: # pylint: disable=broad-except
329
+ print(exc)
330
+ return False
331
+
332
+ def get_media_files(
333
+ self,
334
+ title: str,
335
+ revision: int | None = None,
336
+ media_types_filter: str | list[str] | None = None,
337
+ ) -> dict[str, bytes]:
338
+ """
339
+ Download media file bytes used on a page via the Commons Core API.
340
+
341
+ Parameters:
342
+ title: Page title.
343
+ revision: Optional page revision.
344
+ media_types_filter: ``image``, ``audio``, ``video``, or a list of those.
345
+
346
+ Returns:
347
+ Mapping of media file title → file bytes.
348
+ """
349
+ media_file_list = self.get_media_file_list(title, revision)
350
+ valid_media_types = _parse_media_types_filter(media_types_filter)
351
+ titles = _media_titles_to_fetch(media_file_list, valid_media_types)
352
+
353
+ media_files: dict[str, bytes] = {}
354
+ for file_name in titles:
355
+ file_site = f"{self.media_base}/file/{file_name}"
356
+ file_url = self.session.get(file_site, timeout=self.timeout).json()["original"]["url"]
357
+ media_files[file_name] = self.session.get(file_url, timeout=self.timeout).content
358
+ return media_files
359
+
360
+ def download_media_files(
361
+ self,
362
+ title: str,
363
+ revision: int | None = None,
364
+ media_types_filter: str | list[str] | None = None,
365
+ download_path: str | Path | None = None,
366
+ ) -> bool:
367
+ """
368
+ Download media files for a page and write them to ``download_path``.
369
+
370
+ Returns:
371
+ ``True`` on success, ``False`` on filesystem errors.
372
+ """
373
+ media_files = self.get_media_files(title, revision, media_types_filter)
374
+ dest_dir = Path(download_path) if download_path is not None else Path()
375
+ try:
376
+ for file_name, file_content in media_files.items():
377
+ output_path = dest_dir / file_name.removeprefix("File:")
378
+ with open(output_path, "wb") as handle:
379
+ handle.write(file_content)
380
+ return True
381
+ except Exception as exc: # pylint: disable=broad-except
382
+ print(exc)
383
+ return False
384
+
385
+ # ------------------------------------------------------------------
386
+ # Data / citation / mobile assets / reading lists
387
+ # ------------------------------------------------------------------
388
+
389
+ def get_citation(self, format_: str, query: str, accept_language: str | None = None) -> dict:
390
+ """
391
+ Get citation data for an article identifier.
392
+
393
+ Endpoint: ``GET /data/citation/{format}/{query}``
394
+ Stability: Stable
395
+ """
396
+ url = f"{self.base}/data/citation/{format_}/{query}"
397
+ headers = _request_headers(accept_language=accept_language)
398
+ response = self.session.get(url, headers=headers or None, timeout=self.timeout)
399
+ return response.json()
400
+
401
+ def get_mobile_css(self, type_: str) -> str:
402
+ """
403
+ Get CSS for mobile apps.
404
+
405
+ Endpoint: ``GET /data/css/mobile/{type}``
406
+ """
407
+ response = self.session.get(f"{self.base}/data/css/mobile/{type_}", timeout=self.timeout)
408
+ return response.text
409
+
410
+ def get_mobile_javascript(self, type_: str) -> str:
411
+ """
412
+ Get JavaScript for mobile apps.
413
+
414
+ Endpoint: ``GET /data/javascript/mobile/{type}``
415
+ """
416
+ response = self.session.get(
417
+ f"{self.base}/data/javascript/mobile/{type_}", timeout=self.timeout
418
+ )
419
+ return response.text
420
+
421
+ def get_i18n(self, type_: str) -> dict:
422
+ """
423
+ Get internationalization info.
424
+
425
+ Endpoint: ``GET /data/i18n/{type}``
426
+ """
427
+ response = self.session.get(f"{self.base}/data/i18n/{type_}", timeout=self.timeout)
428
+ return response.json()
429
+
430
+ def get_lists(self, next_: str | None = None, sort: str | None = None) -> dict:
431
+ """
432
+ Get all reading lists for the authenticated user.
433
+
434
+ Endpoint: ``GET /data/lists/``
435
+ """
436
+ response = self.session.get(
437
+ f"{self.base}/data/lists/",
438
+ params=_optional_params(next=next_, sort=sort),
439
+ timeout=self.timeout,
440
+ )
441
+ return response.json()
442
+
443
+ def create_list(self, data: dict, csrf_token: str) -> dict:
444
+ """
445
+ Create a reading list.
446
+
447
+ Endpoint: ``POST /data/lists/``
448
+ """
449
+ response = self.session.post(
450
+ f"{self.base}/data/lists/",
451
+ params={"csrf_token": csrf_token},
452
+ json=data,
453
+ timeout=self.timeout,
454
+ )
455
+ return response.json()
456
+
457
+ def create_lists_batch(self, batch: list, csrf_token: str) -> dict:
458
+ """
459
+ Create multiple reading lists.
460
+
461
+ Endpoint: ``POST /data/lists/batch``
462
+ """
463
+ response = self.session.post(
464
+ f"{self.base}/data/lists/batch",
465
+ params={"csrf_token": csrf_token},
466
+ json={"batch": batch},
467
+ timeout=self.timeout,
468
+ )
469
+ return response.json()
470
+
471
+ def get_list_changes_since(self, date: str, next_: str | None = None) -> dict:
472
+ """
473
+ Get recent changes to reading lists since ``date``.
474
+
475
+ Endpoint: ``GET /data/lists/changes/since/{date}``
476
+ """
477
+ response = self.session.get(
478
+ f"{self.base}/data/lists/changes/since/{date}",
479
+ params=_optional_params(next=next_),
480
+ timeout=self.timeout,
481
+ )
482
+ return response.json()
483
+
484
+ def get_lists_for_page(self, project: str, title: str, next_: str | None = None) -> dict:
485
+ """
486
+ Get lists that contain a given page.
487
+
488
+ Endpoint: ``GET /data/lists/pages/{project}/{title}``
489
+ """
490
+ response = self.session.get(
491
+ f"{self.base}/data/lists/pages/{project}/{title}",
492
+ params=_optional_params(next=next_),
493
+ timeout=self.timeout,
494
+ )
495
+ return response.json()
496
+
497
+ def setup_lists(self, csrf_token: str) -> dict:
498
+ """
499
+ Opt in to reading lists.
500
+
501
+ Endpoint: ``POST /data/lists/setup``
502
+ """
503
+ response = self.session.post(
504
+ f"{self.base}/data/lists/setup",
505
+ params={"csrf_token": csrf_token},
506
+ timeout=self.timeout,
507
+ )
508
+ return response.json()
509
+
510
+ def teardown_lists(self, csrf_token: str) -> dict:
511
+ """
512
+ Opt out of reading lists.
513
+
514
+ Endpoint: ``POST /data/lists/teardown``
515
+ """
516
+ response = self.session.post(
517
+ f"{self.base}/data/lists/teardown",
518
+ params={"csrf_token": csrf_token},
519
+ timeout=self.timeout,
520
+ )
521
+ return response.json()
522
+
523
+ def update_list(self, list_id: int, data: dict, csrf_token: str) -> dict:
524
+ """
525
+ Update a reading list.
526
+
527
+ Endpoint: ``PUT /data/lists/{id}``
528
+ """
529
+ response = self.session.put(
530
+ f"{self.base}/data/lists/{list_id}",
531
+ params={"csrf_token": csrf_token},
532
+ json=data,
533
+ timeout=self.timeout,
534
+ )
535
+ return response.json()
536
+
537
+ def delete_list(self, list_id: int) -> dict:
538
+ """
539
+ Delete a reading list.
540
+
541
+ Endpoint: ``DELETE /data/lists/{id}``
542
+ """
543
+ response = self.session.delete(f"{self.base}/data/lists/{list_id}", timeout=self.timeout)
544
+ return response.json()
545
+
546
+ def get_list_entries(
547
+ self, list_id: int, next_: str | None = None, sort: str | None = None
548
+ ) -> dict:
549
+ """
550
+ Get all entries of a reading list.
551
+
552
+ Endpoint: ``GET /data/lists/{id}/entries/``
553
+ """
554
+ response = self.session.get(
555
+ f"{self.base}/data/lists/{list_id}/entries/",
556
+ params=_optional_params(next=next_, sort=sort),
557
+ timeout=self.timeout,
558
+ )
559
+ return response.json()
560
+
561
+ def create_list_entry(self, list_id: int, data: dict, csrf_token: str) -> dict:
562
+ """
563
+ Create a list entry.
564
+
565
+ Endpoint: ``POST /data/lists/{id}/entries/``
566
+ """
567
+ response = self.session.post(
568
+ f"{self.base}/data/lists/{list_id}/entries/",
569
+ params={"csrf_token": csrf_token},
570
+ json=data,
571
+ timeout=self.timeout,
572
+ )
573
+ return response.json()
574
+
575
+ def create_list_entries_batch(self, list_id: int, batch: list, csrf_token: str) -> dict:
576
+ """
577
+ Create multiple list entries.
578
+
579
+ Endpoint: ``POST /data/lists/{id}/entries/batch``
580
+ """
581
+ response = self.session.post(
582
+ f"{self.base}/data/lists/{list_id}/entries/batch",
583
+ params={"csrf_token": csrf_token},
584
+ json={"batch": batch},
585
+ timeout=self.timeout,
586
+ )
587
+ return response.json()
588
+
589
+ def delete_list_entry(self, list_id: int, entry_id: int) -> dict:
590
+ """
591
+ Delete a list entry.
592
+
593
+ Endpoint: ``DELETE /data/lists/{id}/entries/{entry_id}``
594
+ """
595
+ response = self.session.delete(
596
+ f"{self.base}/data/lists/{list_id}/entries/{entry_id}",
597
+ timeout=self.timeout,
598
+ )
599
+ return response.json()
600
+
601
+ # ------------------------------------------------------------------
602
+ # Transforms
603
+ # ------------------------------------------------------------------
604
+
605
+ def transform_wikitext_to_html(
606
+ self,
607
+ wikitext: str,
608
+ title: str | None = None,
609
+ revision: int | None = None,
610
+ body_only: bool | None = None,
611
+ stash: bool | None = None,
612
+ ) -> str:
613
+ """
614
+ Transform Wikitext to HTML.
615
+
616
+ Endpoint: ``POST /transform/wikitext/to/html[/{title}[/{revision}]]``
617
+ Stability: Stable
618
+ """
619
+ url = _transform_url(self.base, "wikitext", "html", title, revision)
620
+ response = self.session.post(
621
+ url,
622
+ data=_transform_form(wikitext=wikitext, body_only=body_only, stash=stash),
623
+ timeout=self.timeout,
624
+ )
625
+ return response.text
626
+
627
+ def transform_html_to_wikitext(
628
+ self,
629
+ html: str,
630
+ title: str | None = None,
631
+ revision: int | None = None,
632
+ if_match: str | None = None,
633
+ ) -> str:
634
+ """
635
+ Transform HTML to Wikitext.
636
+
637
+ Endpoint: ``POST /transform/html/to/wikitext[/{title}[/{revision}]]``
638
+ Stability: Stable
639
+ """
640
+ url = _transform_url(self.base, "html", "wikitext", title, revision)
641
+ headers = _request_headers(if_match=if_match)
642
+ response = self.session.post(
643
+ url,
644
+ data=_transform_form(html=html),
645
+ headers=headers or None,
646
+ timeout=self.timeout,
647
+ )
648
+ return response.text
649
+
650
+ def transform_wikitext_to_lint(
651
+ self, wikitext: str, title: str | None = None, revision: int | None = None
652
+ ) -> dict | list:
653
+ """
654
+ Check Wikitext for lint errors.
655
+
656
+ Endpoint: ``POST /transform/wikitext/to/lint[/{title}[/{revision}]]``
657
+ Stability: Stable
658
+ """
659
+ url = _transform_url(self.base, "wikitext", "lint", title, revision)
660
+ response = self.session.post(
661
+ url,
662
+ data=_transform_form(wikitext=wikitext),
663
+ timeout=self.timeout,
664
+ )
665
+ return response.json()
666
+
667
+ def transform_wikitext_to_mobile_html(
668
+ self,
669
+ wikitext: str,
670
+ title: str,
671
+ accept_language: str | None = None,
672
+ output_mode: str | None = None,
673
+ ) -> str:
674
+ """
675
+ Transform Wikitext to mobile HTML.
676
+
677
+ Endpoint: ``POST /transform/wikitext/to/mobile-html/{title}``
678
+ Stability: Stable
679
+ """
680
+ url = _transform_url(self.base, "wikitext", "mobile-html", title)
681
+ headers = _request_headers(accept_language=accept_language, output_mode=output_mode)
682
+ response = self.session.post(
683
+ url,
684
+ data=_transform_form(wikitext=wikitext),
685
+ headers=headers or None,
686
+ timeout=self.timeout,
687
+ )
688
+ return response.text
689
+
690
+ # ------------------------------------------------------------------
691
+ # Math
692
+ # ------------------------------------------------------------------
693
+
694
+ def check_math(self, type_: str, formula: str) -> dict:
695
+ """
696
+ Check and normalize a TeX (or chem) formula.
697
+
698
+ The returned dict includes ``resource_location`` from the
699
+ ``x-resource-location`` response header (hash for render/formula).
700
+
701
+ Endpoint: ``POST /media/math/check/{type}``
702
+ Stability: Deprecated on this host (prefer Math API on wikimedia.org)
703
+ """
704
+ if type_ not in _MATH_TYPES:
705
+ raise ValueError(f"Invalid math type '{type_}'. Valid types are {sorted(_MATH_TYPES)}.")
706
+
707
+ response = self.session.post(
708
+ f"{self.base}/media/math/check/{type_}",
709
+ data={"q": formula},
710
+ timeout=self.timeout,
711
+ )
712
+ result = response.json()
713
+ result["resource_location"] = response.headers.get("x-resource-location")
714
+ return result
715
+
716
+ def get_math_formula(self, hash_: str) -> dict:
717
+ """
718
+ Get a previously stored formula by hash.
719
+
720
+ Endpoint: ``GET /media/math/formula/{hash}``
721
+ Stability: Deprecated on this host
722
+ """
723
+ response = self.session.get(f"{self.base}/media/math/formula/{hash_}", timeout=self.timeout)
724
+ return response.json()
725
+
726
+ def render_math(self, format_: str, hash_: str) -> bytes:
727
+ """
728
+ Render a previously checked formula (``svg``, ``mml``, or ``png``).
729
+
730
+ Endpoint: ``GET /media/math/render/{format}/{hash}``
731
+ Stability: Deprecated on this host
732
+ """
733
+ if format_ not in _MATH_RENDER_FORMATS:
734
+ raise ValueError(
735
+ f"Invalid render format '{format_}'. Valid formats are "
736
+ f"{sorted(_MATH_RENDER_FORMATS)}."
737
+ )
738
+
739
+ response = self.session.get(
740
+ f"{self.base}/media/math/render/{format_}/{hash_}",
741
+ timeout=self.timeout,
742
+ )
743
+ return response.content
744
+
745
+
746
+ def main() -> None:
747
+ """Demo entry point."""
748
+ wiki = Wiktionary("WiktionaryPy", "example@example.com")
749
+ pprint(wiki.get_definition("hello"))
750
+ pprint(dict(wiki.session.headers))
751
+
752
+
753
+ if __name__ == "__main__":
754
+ main()
wikt/py.typed ADDED
File without changes
wikt/util.py ADDED
@@ -0,0 +1,52 @@
1
+ """Util functions for the Wiktionary API"""
2
+
3
+ from io import BytesIO
4
+
5
+ from PyPDF2 import PdfReader, PdfWriter
6
+
7
+
8
+ def pdf_from_bytes(pdf_bytes: bytes, save: bool = False, output_file_path: str | None = None):
9
+ """
10
+ Parse PDF bytes into a ``PdfWriter``, optionally saving to disk.
11
+
12
+ Args:
13
+ pdf_bytes: Raw PDF payload (e.g. from ``Wiktionary.get_pdf``).
14
+ save: When ``True``, write the PDF to ``output_file_path``.
15
+ output_file_path: Destination path ending in ``.pdf`` (required if ``save``).
16
+
17
+ Returns:
18
+ A ``PdfWriter`` with the copied pages, or ``None`` if parsing fails.
19
+
20
+ Raises:
21
+ ValueError: If ``save`` is set without a valid ``.pdf`` output path.
22
+ """
23
+
24
+ if save and not output_file_path:
25
+ raise ValueError("save is true but output_file_path is not provided/empty")
26
+
27
+ if save and not output_file_path.endswith(".pdf"):
28
+ raise ValueError("output_file does not end with '.pdf'")
29
+
30
+ try:
31
+ # Create an in-memory stream from the bytes
32
+ bytes_stream = BytesIO(pdf_bytes)
33
+
34
+ # Read the PDF from the stream
35
+ pdf_reader = PdfReader(bytes_stream)
36
+
37
+ # Create a new PDF writer
38
+ pdf_writer = PdfWriter()
39
+
40
+ # Add all pages from the reader to the writer
41
+ for page in pdf_reader.pages:
42
+ pdf_writer.add_page(page)
43
+
44
+ if save:
45
+ with open(output_file_path, "wb") as output_file:
46
+ pdf_writer.write(output_file)
47
+
48
+ return pdf_writer
49
+
50
+ except Exception as e: # pylint: disable=broad-except
51
+ print(f"Error creating PDF from bytes: {e}")
52
+ return None
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: wiktionary-api
3
+ Version: 0.1.0
4
+ Summary: Synchronous Python client for the Wikimedia Wiktionary REST API
5
+ Project-URL: Homepage, https://github.com/NebilI/WiktionaryPythonAPI
6
+ Project-URL: Repository, https://github.com/NebilI/WiktionaryPythonAPI
7
+ Project-URL: Issues, https://github.com/NebilI/WiktionaryPythonAPI/issues
8
+ Project-URL: Documentation, https://github.com/NebilI/WiktionaryPythonAPI#readme
9
+ Author: Nebil Ibrahim
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: dictionary,requests,rest-api,wikimedia,wiktionary
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Text Processing :: Linguistic
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: pypdf2<4,>=3.0.1
26
+ Requires-Dist: requests<3,>=2.32
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Requires-Dist: pytest>=8.3; extra == 'dev'
30
+ Requires-Dist: ruff>=0.9; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # WiktionaryPythonAPI
34
+
35
+ Synchronous Python client for the [Wiktionary REST API](https://en.wiktionary.org/api/rest_v1/#/) and related [Commons Core](https://api.wikimedia.org/wiki/Core_REST_API) media downloads.
36
+
37
+ **PyPI:** `wiktionary-api` · **Import:** `wikt`
38
+
39
+ Covers **all 41** path patterns in the live OpenAPI spec (`?spec`), plus PDF rendering and Commons helpers. See [docs/ENDPOINTS.md](docs/ENDPOINTS.md) for the full path → method map.
40
+
41
+ ## Install
42
+
43
+ Works with any PEP 517 installer:
44
+
45
+ ```bash
46
+ pip install wiktionary-api
47
+ ```
48
+
49
+ ```bash
50
+ uv add wiktionary-api
51
+ ```
52
+
53
+ ```bash
54
+ poetry add wiktionary-api
55
+ ```
56
+
57
+ Requires **Python 3.10+**.
58
+
59
+ ## Quick start
60
+
61
+ Wikimedia requires a descriptive [User-Agent](https://foundation.wikimedia.org/wiki/Policy:Wikimedia_Foundation_User-Agent_Policy) with your application name and a contact email or URL.
62
+
63
+ ```python
64
+ from wikt import Wiktionary
65
+
66
+ wiki = Wiktionary("MyApp", "you@example.com")
67
+
68
+ # Definitions (Wiktionary-specific)
69
+ print(wiki.get_definition("hello"))
70
+
71
+ # HTML / revision metadata
72
+ print(wiki.get_html("hello")[:200])
73
+ print(wiki.get_revision("hello"))
74
+
75
+ # Math (check → render)
76
+ checked = wiki.check_math("tex", "E=mc^2")
77
+ svg = wiki.render_math("svg", checked["resource_location"])
78
+ ```
79
+
80
+ ## API surface (summary)
81
+
82
+ | Area | Examples |
83
+ |------|----------|
84
+ | Page content | `page`, `get_definition`, `get_html`, `get_lint`, `get_mobile_html`, `get_revision`, `get_media_file_list`, `get_pdf` |
85
+ | Data / mobile | `get_citation`, `get_mobile_css`, `get_mobile_javascript`, `get_i18n` |
86
+ | Reading lists | `get_lists`, `create_list`, `update_list`, `delete_list`, entry CRUD, setup/teardown |
87
+ | Transforms | `transform_wikitext_to_html`, `transform_html_to_wikitext`, `transform_wikitext_to_lint`, `transform_wikitext_to_mobile_html` |
88
+ | Math | `check_math`, `get_math_formula`, `render_math` |
89
+ | Commons helpers | `get_media_files`, `download_media_files` |
90
+
91
+ Method docstrings document parameters, return types, and REST paths. Official sandbox: https://en.wiktionary.org/api/rest_v1/#/
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ pip install -e ".[dev]"
97
+ # or: uv sync --all-extras
98
+ ruff check src tests
99
+ ruff format src tests
100
+ pytest tests/ -v
101
+ ```
102
+
103
+ ### Publishing
104
+
105
+ Releases publish to PyPI on `v*` tags via trusted publishing. See [.github/PYPI_PUBLISHING.md](.github/PYPI_PUBLISHING.md).
106
+
107
+ ## License
108
+
109
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,8 @@
1
+ wikt/__init__.py,sha256=J-_pjAD4mCWIAeIYc0yCNwREUyyjvPGPViQG1wg-V2g,202
2
+ wikt/client.py,sha256=ItO1HA5GDzaKdHIorKeU-N5gHnExhmgUX_tPLRNbrsU,25509
3
+ wikt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ wikt/util.py,sha256=s9Id49WZb7-PhdjnkbIB6e-vguQAPVTn50opUJtbesc,1622
5
+ wiktionary_api-0.1.0.dist-info/METADATA,sha256=xxEad86OFwhTApEipjy0NP5OA10SuTIjqYjkaQWBb-Q,3722
6
+ wiktionary_api-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ wiktionary_api-0.1.0.dist-info/licenses/LICENSE,sha256=2ZUv95E4ju6IepiCtjKlOhODaXbdqgf-W30wkXh4FmE,1070
8
+ wiktionary_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nebil Ibrahim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.