Coverage for src/wiktextract/wiktionary.py: 75%
305 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-26 05:53 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-26 05:53 +0000
1# Wiktionary parser for extracting a lexicon and various other information
2# from wiktionary. This file contains code to uncompress the Wiktionary
3# dump file and to separate it into individual pages.
4#
5# Copyright (c) 2018-2022, 2024 Tatu Ylonen. See file LICENSE and https://ylonen.org
7import atexit
8import io
9import json
10import os
11import re
12import tarfile
13import tempfile
14import time
15from concurrent.futures import ProcessPoolExecutor
16from copy import deepcopy
17from multiprocessing import current_process, get_all_start_methods, get_context
18from pathlib import Path
19from traceback import format_exc
20from typing import TextIO
22from wikitextprocessor import Page
23from wikitextprocessor.core import CollatedErrorReturnData, ErrorMessageData
24from wikitextprocessor.dumpparser import process_dump
26from .import_utils import import_extractor_module
27from .page import parse_page
28from .thesaurus import (
29 emit_words_in_thesaurus,
30 extract_thesaurus_data,
31 thesaurus_linkage_number,
32)
33from .wxr_context import WiktextractContext
34from .wxr_logging import logger
37def page_handler(
38 page: Page,
39) -> tuple[list[dict[str, str]], CollatedErrorReturnData]:
40 # Make sure there are no newlines or other strange characters in the
41 # title. They could cause security problems at several post-processing
42 # steps.
43 # We've given the page_handler function an extra wxr attribute previously.
44 # This should never cause an exception, and if it does, we want it to.
46 # Helps debug extraction hangs. This writes the path of each file being
47 # processed into /tmp/wiktextract*/wiktextract-*. Once a hang
48 # has been observed, these files contain page(s) that hang. They should
49 # be checked before aborting the process, as an interrupt might delete them.
50 with tempfile.TemporaryDirectory(prefix="wiktextract") as tmpdirname:
51 debug_path = "{}/wiktextract-{}".format(tmpdirname, os.getpid())
52 with open(debug_path, "w", encoding="utf-8") as f:
53 f.write(page.title + "\n")
55 worker_wxr.wtp.start_page(page.title)
56 try:
57 title = re.sub(r"[\s\000-\037]+", " ", page.title)
58 title = title.strip()
59 if page.redirect_to is not None:
60 page_data = [
61 {
62 "title": title,
63 "redirect": page.redirect_to,
64 "pos": "hard-redirect",
65 }
66 ]
67 else:
68 # XXX Sign gloss pages?
69 start_t = time.time()
70 page_data = parse_page(worker_wxr, title, page.body) # type: ignore[arg-type]
71 dur = time.time() - start_t
72 if dur > 100: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 logger.warning(
74 "====== WARNING: PARSING PAGE TOOK {:.1f}s: {}".format(
75 dur, title
76 )
77 )
79 return page_data, worker_wxr.wtp.to_return()
80 except Exception:
81 worker_wxr.wtp.error(
82 f'=== EXCEPTION while parsing page "{page.title}" '
83 f"in process {current_process().name}",
84 format_exc(),
85 "page_handler_exception",
86 )
87 return [], worker_wxr.wtp.to_return()
90def parse_wiktionary(
91 wxr: WiktextractContext,
92 dump_path: str,
93 num_processes: int | None,
94 phase1_only: bool,
95 namespace_ids: set[int],
96 out_f: TextIO,
97 human_readable: bool = False,
98 override_folders: list[str] | list[Path] | None = None,
99 skip_extract_dump: bool = False,
100 save_pages_path: str | Path | None = None,
101) -> None:
102 """Parses Wiktionary from the dump file ``path`` (which should point
103 to a "enwiktionary-<date>-pages-articles.xml.bz2" file. This
104 calls `word_cb(data)` for all words defined for languages in `languages`."""
105 capture_language_codes = wxr.config.capture_language_codes
106 if capture_language_codes is not None: 106 ↛ 111line 106 didn't jump to line 111 because the condition on line 106 was always true
107 assert isinstance(capture_language_codes, (list, tuple, set))
108 for x in capture_language_codes:
109 assert isinstance(x, str)
111 logger.info("First phase - extracting templates, macros, and pages")
112 if override_folders is not None: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 override_folders = [Path(folder) for folder in override_folders]
114 if save_pages_path is not None: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 save_pages_path = Path(save_pages_path)
117 analyze_template_mod = import_extractor_module(
118 wxr.wtp.lang_code, "analyze_template"
119 )
120 process_dump(
121 wxr.wtp,
122 dump_path,
123 namespace_ids,
124 override_folders,
125 skip_extract_dump,
126 save_pages_path,
127 analyze_template_mod.analyze_template
128 if analyze_template_mod is not None
129 else None,
130 )
132 if not phase1_only: 132 ↛ exitline 132 didn't return from function 'parse_wiktionary' because the condition on line 132 was always true
133 reprocess_wiktionary(wxr, num_processes, out_f, human_readable)
136def sanitize_and_write_json_data(
137 data: dict, out_f: TextIO, human_readable: bool
138) -> None:
139 if out_f is not None: 139 ↛ exitline 139 didn't return from function 'sanitize_and_write_json_data' because the condition on line 139 was always true
140 if human_readable: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 out_json = json.dumps(
142 data, indent=2, sort_keys=True, ensure_ascii=False
143 )
144 else:
145 out_json = json.dumps(data, ensure_ascii=False)
146 # Sanitize output for weird Unicode punctuation that breaks json
147 # on other systems and applications.
148 out_json = out_json.replace("\u2028", "")
149 out_f.write(out_json)
150 out_f.write("\n")
153def estimate_progress(
154 processed_pages: int, all_pages: int, start_time: float, last_time: float
155) -> float:
156 current_time = time.time()
157 processed_pages += 1
158 if current_time - last_time > 1:
159 remaining_pages = all_pages - processed_pages
160 estimate_seconds = (
161 (current_time - start_time) / processed_pages * remaining_pages
162 )
163 logger.info(
164 " ... {}/{} pages ({:.1%}) processed, "
165 "{:02d}:{:02d}:{:02d} remaining".format(
166 processed_pages,
167 all_pages,
168 processed_pages / all_pages,
169 int(estimate_seconds / 3600),
170 int(estimate_seconds / 60 % 60),
171 int(estimate_seconds % 60),
172 )
173 )
174 last_time = current_time
175 return last_time
178def check_error(
179 wxr: WiktextractContext,
180 dt: dict,
181 word: str | None,
182 lang: str | None,
183 pos: str | None,
184 msg: str,
185 called_from: str | None = None,
186) -> None:
187 """Formats and outputs an error message about data format checks."""
188 if called_from is None: 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true
189 called_from = "wiktionary/179/20240425"
190 else:
191 called_from = "wiktionary/179/20240425" + called_from
192 # msg += ": " + json.dumps(dt, sort_keys=True, ensure_ascii=False)
193 prefix = word or ""
194 if lang: 194 ↛ 196line 194 didn't jump to line 196 because the condition on line 194 was always true
195 prefix += "/" + lang
196 if pos: 196 ↛ 198line 196 didn't jump to line 198 because the condition on line 196 was always true
197 prefix += "/" + pos
198 if prefix: 198 ↛ 200line 198 didn't jump to line 200 because the condition on line 198 was always true
199 msg = prefix + ": " + msg
200 print(msg)
201 config = wxr.config
202 if len(config.debugs) > 100000: # Avoid excessive size 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 return
204 error_data: ErrorMessageData = {
205 "msg": msg,
206 "trace": "",
207 "title": word,
208 "section": lang,
209 "subsection": pos,
210 "called_from": called_from,
211 "path": tuple(),
212 }
213 config.debugs.append(error_data)
216def check_tags(
217 wxr: WiktextractContext,
218 dt: dict,
219 word: str,
220 lang: str,
221 pos: str,
222 item: dict,
223) -> None:
224 assert isinstance(item, dict)
225 tags = item.get("tags")
226 if tags is None:
227 return
228 if not isinstance(tags, (list, tuple)): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 check_error(
230 wxr,
231 dt,
232 word,
233 lang,
234 pos,
235 '"tags" field value must be a list of strings: {}'.format(
236 repr(tags)
237 ),
238 )
239 return
240 for tag in tags:
241 if not isinstance(tag, str): 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true
242 check_error(
243 wxr,
244 dt,
245 word,
246 lang,
247 pos,
248 '"tags" field should only contain strings: {}'.format(
249 repr(tag)
250 ),
251 )
252 continue
253 # XXX enable the following later (currently too many bogus tags in
254 # non-English editions). Tag values should be standardized across
255 # editions, except for uppercase tags (e.g., regional variants).
256 if wxr.wtp.lang_code in ("en",): # Check edition 256 ↛ 240line 256 didn't jump to line 240 because the condition on line 256 was always true
257 from .tags import uppercase_tags, valid_tags
259 if tag not in valid_tags and tag not in uppercase_tags: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 if len(tag) > 0 and tag[0].isupper():
261 check_error(
262 wxr,
263 dt,
264 word,
265 lang,
266 pos,
267 f"invalid uppercase tag {tag} not in or uppercase_tags",
268 called_from="uppercase_tags",
269 )
270 else:
271 check_error(
272 wxr,
273 dt,
274 word,
275 lang,
276 pos,
277 f"invalid tag {tag} not in valid_tags "
278 "or uppercase_tags",
279 )
282def check_str_fields(
283 wxr: WiktextractContext,
284 dt: dict,
285 word: str,
286 lang: str,
287 pos: str,
288 item: dict,
289 fields: list[str],
290 mandatory: bool = False,
291 empty_ok: bool = False,
292) -> None:
293 """Checks that each of the listed fields contains a non-empty string.
294 Non-existent fields are ok unless ``mandatory`` is True."""
295 assert isinstance(item, dict)
296 for field in fields:
297 v = item.get(field)
298 if field not in item:
299 if mandatory:
300 check_error(
301 wxr,
302 dt,
303 word,
304 lang,
305 pos,
306 "{!r} is missing and should be a{} string: {}".format(
307 field,
308 "" if empty_ok else " non-empty",
309 json.dumps(item, sort_keys=True, ensure_ascii=False),
310 ),
311 )
312 continue
313 if not isinstance(v, str): 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true
314 check_error(
315 wxr,
316 dt,
317 word,
318 lang,
319 pos,
320 "{!r} should be a{} string: {}".format(
321 field,
322 "" if empty_ok else " non-empty",
323 json.dumps(item, sort_keys=True, ensure_ascii=False),
324 ),
325 )
326 if not v and not empty_ok:
327 check_error(
328 wxr,
329 dt,
330 word,
331 lang,
332 pos,
333 "{!r} should contain a non-empty string: {}".format(
334 field, json.dumps(item, sort_keys=True, ensure_ascii=False)
335 ),
336 )
339def check_dict_list_fields(
340 wxr: WiktextractContext,
341 dt: dict,
342 word: str,
343 lang: str,
344 pos: str,
345 item: dict,
346 fields: list[str],
347) -> bool:
348 """Checks that each listed field, if present, is a list of dicts."""
349 assert isinstance(item, dict)
350 for field in fields:
351 lst = item.get(field)
352 if lst is None:
353 continue
354 if not isinstance(lst, (list, tuple)): 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 check_error(
356 wxr,
357 dt,
358 word,
359 lang,
360 pos,
361 "{!r} should be a list of dicts: {}".format(
362 field, json.dumps(lst, sort_keys=True, ensure_ascii=False)
363 ),
364 )
365 return False
366 for x in lst:
367 if not isinstance(x, dict): 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 check_error(
369 wxr,
370 dt,
371 word,
372 lang,
373 pos,
374 "{!r} should be a list of dicts: {}".format(
375 field, json.dumps(x, sort_keys=True, ensure_ascii=False)
376 ),
377 )
378 return False
379 return True
382def check_str_list_fields(
383 wxr: WiktextractContext,
384 dt: dict,
385 word: str,
386 lang: str,
387 pos: str,
388 item: dict,
389 fields: list[str],
390) -> None:
391 """Checks that each of the listed fields contains a list of non-empty
392 strings or is not present."""
393 assert isinstance(item, dict)
394 for field in fields:
395 lst = item.get(field)
396 if lst is None:
397 continue
398 if not isinstance(lst, (list, tuple)): 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 check_error(
400 wxr,
401 dt,
402 word,
403 lang,
404 pos,
405 "{!r} should be a list of dicts: {}".format(
406 field, json.dumps(item, sort_keys=True)
407 ),
408 )
409 continue
410 for x in lst:
411 if not isinstance(x, str) or not x: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 check_error(
413 wxr,
414 dt,
415 word,
416 lang,
417 pos,
418 "{!r} should be a list of non-empty strings: {}".format(
419 field, json.dumps(item, sort_keys=True)
420 ),
421 )
422 break
425def check_json_data(wxr: WiktextractContext, dt: dict) -> None:
426 """Performs some basic checks on the generated data."""
427 word = dt.get("word", dt.get("title"))
428 if word is None: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 check_error(
430 wxr,
431 dt,
432 None,
433 None,
434 None,
435 'missing "word" or "title" field in data',
436 )
437 return
438 if "title" in dt:
439 return # redirect pages don't have following fields
440 lang = dt.get("lang")
441 if not lang: 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true
442 check_error(wxr, dt, word, None, None, 'missing "lang" field in data')
443 return
444 pos = dt.get("pos")
445 if not pos: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
446 check_error(wxr, dt, word, lang, pos, 'missing "pos" field in data')
447 return
448 if not dt.get("lang_code"): 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 check_error(
450 wxr, dt, word, lang, pos, 'missing "lang_code" field in data'
451 )
452 check_tags(wxr, dt, word, lang, pos, dt)
453 check_str_fields(wxr, dt, word, lang, pos, dt, ["etymology_text"])
454 num = dt.get("etymology_number")
455 if num is not None and not isinstance(num, str): 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 check_error(
457 wxr,
458 dt,
459 word,
460 lang,
461 pos,
462 '"etymology_number" has been changed to str',
463 )
464 # Check that certain fields, if present, contain lists of dicts
465 if not check_dict_list_fields( 465 ↛ 491line 465 didn't jump to line 491 because the condition on line 465 was never true
466 wxr,
467 dt,
468 word,
469 lang,
470 pos,
471 dt,
472 [
473 "forms",
474 "senses",
475 "synonyms",
476 "antonyms",
477 "hypernyms",
478 "holonyms",
479 "meronyms",
480 "coordinate_terms",
481 "derived",
482 "related",
483 "sounds",
484 "translations",
485 "descendants",
486 "etymology_templates",
487 "head_templates",
488 "inflection_templates",
489 ],
490 ):
491 return # Avoid further processing because it would cause type errors
492 # Check the "forms" field
493 forms = dt.get("forms") or []
494 for form in forms:
495 check_tags(wxr, dt, word, lang, pos, form)
496 tags = dt.get("tags")
497 if not isinstance(tags, (list, tuple)) or "table-tags" not in tags: 497 ↛ 494line 497 didn't jump to line 494 because the condition on line 497 was always true
498 check_str_fields(
499 wxr, dt, word, lang, pos, form, ["form"], mandatory=True
500 )
501 check_str_list_fields(
502 wxr,
503 dt,
504 word,
505 lang,
506 pos,
507 dt,
508 ["categories", "topics", "wikidata", "wikipedia"],
509 )
510 # Check the "senses" field
511 senses = dt.get("senses") or []
512 if not senses: 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true
513 check_error(
514 wxr,
515 dt,
516 word,
517 lang,
518 pos,
519 'missing "senses" in data (must have at least one '
520 'sense, add empty sense with "no-gloss" tag if none '
521 "otherwise available)",
522 )
523 return
524 for sense in senses:
525 check_str_list_fields(
526 wxr, dt, word, lang, pos, sense, ["glosses", "raw_glosses"]
527 )
528 # Extra check: should have no-gloss tag if no glosses
529 for field in ("glosses", "raw_glosses"):
530 glosses = sense.get(field) or []
531 if ( 531 ↛ 536line 531 didn't jump to line 536 because the condition on line 531 was never true
532 not glosses
533 and isinstance(sense.get("tags"), str)
534 and "no-gloss" not in sense.get("tags", "").split()
535 ):
536 check_error(
537 wxr,
538 dt,
539 word,
540 lang,
541 pos,
542 "{!r} should have at least one gloss or "
543 '"no-gloss" in "tags"'.format(field),
544 )
545 continue
546 check_tags(wxr, dt, word, lang, pos, sense)
547 check_str_list_fields(
548 wxr,
549 dt,
550 word,
551 lang,
552 pos,
553 sense,
554 ["categories", "topics", "wikidata", "wikipedia"],
555 )
556 check_str_fields(
557 wxr, dt, word, lang, pos, sense, ["english"]
558 ) # DEPRECATED in favor of "translation"
559 check_str_fields(wxr, dt, word, lang, pos, sense, ["translation"])
560 if not check_dict_list_fields( 560 ↛ 580line 560 didn't jump to line 580 because the condition on line 560 was never true
561 wxr,
562 dt,
563 word,
564 lang,
565 pos,
566 sense,
567 [
568 "alt_of",
569 "form_of",
570 "synonyms",
571 "antonyms",
572 "hypernyms",
573 "holonyms",
574 "meronyms",
575 "coordinate_terms",
576 "derived",
577 "related",
578 ],
579 ):
580 continue
581 for field in ("alt_of", "form_of"):
582 lst = sense.get(field)
583 if lst is None:
584 continue
585 for item in lst:
586 check_str_fields(
587 wxr, dt, word, lang, pos, item, ["word"], mandatory=True
588 )
589 check_str_fields(
590 wxr, dt, word, lang, pos, item, ["extra"], mandatory=False
591 )
593 for field in (
594 "synonyms",
595 "antonyms",
596 "hypernyms",
597 "holonyms",
598 "meronyms",
599 "coordinate_terms",
600 "derived",
601 "related",
602 ):
603 lst = sense.get(field)
604 if lst is None:
605 continue
606 for item in lst:
607 check_str_fields(
608 wxr, dt, word, lang, pos, item, ["word"], mandatory=True
609 )
610 check_tags(wxr, dt, word, lang, pos, item)
611 check_str_fields(
612 wxr,
613 dt,
614 word,
615 lang,
616 pos,
617 item,
618 [
619 "english", # DEPRECATED in favor of "translation"
620 "translation",
621 "roman",
622 "sense",
623 "taxonomic",
624 ],
625 mandatory=False,
626 empty_ok=True,
627 )
628 check_str_list_fields(
629 wxr, dt, word, lang, pos, item, ["topics"]
630 )
631 # Check the "sounds" field
632 # We will permit having any number of different types (ipa, enpr, etc)
633 # in the same sound entry or in different sound entries.
634 sounds = dt.get("sounds", [])
635 for item in sounds:
636 check_str_fields(
637 wxr,
638 dt,
639 word,
640 lang,
641 pos,
642 item,
643 ["ipa", "enpr", "audio", "ogg_url", "mp3_url", "audio-ipa", "text"],
644 )
645 check_tags(wxr, dt, word, lang, pos, item)
646 check_str_list_fields(
647 wxr, dt, word, lang, pos, item, ["homophones", "hyphenation"]
648 )
649 # Check the "translations" field
650 translations = dt.get("translations") or []
651 for item in translations:
652 check_str_fields(
653 wxr, dt, word, lang, pos, item, ["word"], mandatory=True
654 )
655 check_tags(wxr, dt, word, lang, pos, item)
656 check_str_fields(
657 wxr,
658 dt,
659 word,
660 lang,
661 pos,
662 item,
663 [
664 "alt",
665 "code", # DEPRECATED for "lang_code"
666 "lang_code",
667 "english", # DEPRECATED in favor of "translation"
668 "translation",
669 "lang",
670 "note",
671 "roman",
672 "sense",
673 "taxonomic",
674 ],
675 )
676 if not item.get("lang_code") and not item.get("lang"): 676 ↛ 677line 676 didn't jump to line 677 because the condition on line 676 was never true
677 check_error(
678 wxr,
679 dt,
680 word,
681 lang,
682 pos,
683 '"translations" items must contain at least one '
684 'of "lang_code" and "lang" (normally both): {}'.format(
685 json.dumps(item, sort_keys=True, ensure_ascii=False)
686 ),
687 )
688 # Check the "etymology_templates", "head_templates", and
689 # "inflection_templates" fields
690 for field in [
691 "etymology_templates",
692 "head_templates",
693 "inflection_templates",
694 ]:
695 lst = dt.get(field)
696 if lst is None:
697 continue
698 for item in lst:
699 check_str_fields(
700 wxr, dt, word, lang, pos, item, ["name"], mandatory=True
701 )
702 check_str_fields(
703 wxr,
704 dt,
705 word,
706 lang,
707 pos,
708 item,
709 ["expansion"],
710 # empty_ok=True because there are some templates
711 # that generate empty expansions.
712 mandatory=False,
713 empty_ok=True,
714 )
715 args = item.get("args")
716 if args is None: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true
717 continue
718 if not isinstance(args, dict): 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true
719 check_error(
720 wxr,
721 dt,
722 word,
723 lang,
724 pos,
725 '{!r} item "args" value must be a dict: {}'.format(
726 field, json.dumps(args, sort_keys=True)
727 ),
728 )
729 continue
730 for k, v in args.items():
731 if not isinstance(k, str) or not isinstance(v, str): 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true
732 check_error(
733 wxr,
734 dt,
735 word,
736 lang,
737 pos,
738 '{!r} item "args" must be a dict with '
739 "string keys and values: {}".format(
740 field, json.dumps(args, sort_keys=True)
741 ),
742 )
743 continue
744 # Check the "descendants" field
745 descendants = dt.get("descendants") or []
746 for item in descendants:
747 check_str_fields(wxr, dt, word, lang, pos, item, ["text"])
748 depth = item.get("depth")
749 if depth is not None and not isinstance(depth, int): 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true
750 check_error(
751 wxr,
752 dt,
753 word,
754 lang,
755 pos,
756 '"descentants" field "depth" must be an int',
757 )
758 check_dict_list_fields(wxr, dt, word, lang, pos, item, ["templates"])
759 # XXX should check that they are valid templates, perhaps turn
760 # template checking code above into a function
763def init_worker(wxr: WiktextractContext) -> None:
764 global worker_wxr
765 worker_wxr = wxr
766 worker_wxr.reconnect_databases()
767 atexit.register(worker_wxr.remove_unpicklable_objects)
770def reprocess_wiktionary(
771 wxr: WiktextractContext,
772 num_processes: int | None,
773 out_f: TextIO,
774 human_readable: bool = False,
775 search_pattern: str | None = None,
776) -> None:
777 """Reprocesses the Wiktionary from the sqlite db."""
778 logger.info("Second phase - processing pages")
780 # Extract thesaurus data. This iterates over thesaurus pages,
781 # but is very fast.
782 if ( 782 ↛ 788line 782 didn't jump to line 788 because the condition on line 782 was always true
783 wxr.config.extract_thesaurus_pages
784 and thesaurus_linkage_number(wxr.thesaurus_db_conn) == 0 # type: ignore[arg-type]
785 ):
786 extract_thesaurus_data(wxr, num_processes)
788 emitted = set()
789 process_ns_ids: list[int] = list(
790 {
791 wxr.wtp.NAMESPACE_DATA.get(ns, {}).get("id", 0) # type: ignore[call-overload]
792 for ns in wxr.config.extract_ns_names
793 }
794 )
795 start_time = time.time()
796 last_time = start_time
797 all_page_nums = wxr.wtp.saved_page_nums(
798 process_ns_ids, True, "wikitext", search_pattern
799 )
800 wxr.remove_unpicklable_objects()
801 with ProcessPoolExecutor(
802 max_workers=num_processes,
803 mp_context=get_context(
804 "forkserver" if "forkserver" in get_all_start_methods() else "spawn"
805 ),
806 initializer=init_worker,
807 initargs=(deepcopy(wxr),),
808 ) as executor:
809 wxr.reconnect_databases()
810 for processed_pages, (page_data, wtp_stats) in enumerate(
811 executor.map(
812 page_handler,
813 wxr.wtp.get_all_pages(
814 process_ns_ids, True, "wikitext", search_pattern
815 ),
816 chunksize=100, # default is 1 too slow
817 )
818 ):
819 wxr.config.merge_return(wtp_stats)
820 for dt in page_data:
821 check_json_data(wxr, dt)
822 sanitize_and_write_json_data(dt, out_f, human_readable)
823 word = dt.get("word")
824 lang_code = dt.get("lang_code")
825 pos = dt.get("pos")
826 if word and lang_code and pos:
827 emitted.add((word, lang_code, pos))
828 last_time = estimate_progress(
829 processed_pages, all_page_nums, start_time, last_time
830 )
832 if wxr.config.dump_file_lang_code == "en": 832 ↛ 834line 832 didn't jump to line 834 because the condition on line 832 was always true
833 emit_words_in_thesaurus(wxr, emitted, out_f, human_readable)
834 logger.info("Reprocessing wiktionary complete")
837def process_ns_page_title(page: Page, ns_name: str) -> tuple[str, str]:
838 text: str = page.body if page.body is not None else page.redirect_to # type: ignore[assignment]
839 title = page.title[page.title.find(":") + 1 :]
840 title = re.sub(r"(^|/)\.($|/)", r"\1__dotdot__\2", title)
841 title = re.sub(r"(^|/)\.\.($|/)", r"\1__dotdot__\2", title)
842 title = title.replace("//", "__slashslash__")
843 title = re.sub(r"^/", r"__slash__", title)
844 title = re.sub(r"/$", r"__slash__", title)
845 title = ns_name + "/" + title
846 return title, text
849def extract_namespace(
850 wxr: WiktextractContext, namespace: str, path: str
851) -> None:
852 """Extracts all pages in the given namespace and writes them to a .tar
853 file with the given path."""
854 logger.info(
855 f"Extracting pages from namespace {namespace} to tar file {path}"
856 )
857 ns_id: int = wxr.wtp.NAMESPACE_DATA.get(namespace, {}).get("id") # type: ignore[assignment, call-overload]
858 t = time.time()
859 with tarfile.open(path, "w") as tarf:
860 for page in wxr.wtp.get_all_pages([ns_id]):
861 title, text = process_ns_page_title(page, namespace)
862 text = text.encode("utf-8")
863 f = io.BytesIO(text)
864 title += ".txt"
865 ti = tarfile.TarInfo(name=title)
866 ti.size = len(text)
867 # According to documentation, TarInfo.mtime can be int, float,
868 # or even None in newer versions, but mypy can't tell because
869 # it's not annotated and assumes it can only be int
870 ti.mtime = t # type: ignore[assignment]
871 ti.uid = 0
872 ti.gid = 0
873 ti.type = tarfile.REGTYPE
874 tarf.addfile(ti, f)