Coverage for src/wiktextract/extractor/en/inflection.py: 87%

1549 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 09:23 +0000

1# Code for parsing inflection tables. 

2# 

3# Copyright (c) 2021-2022 Tatu Ylonen. See file LICENSE and https://ylonen.org. 

4 

5import collections 

6import copy 

7import functools 

8import html 

9import re 

10import unicodedata 

11from typing import TYPE_CHECKING, Generator, Literal, Optional, Union 

12 

13from mediawiki_langcodes import code_to_name, name_to_code 

14from wikitextprocessor import MAGIC_FIRST, HTMLNode, NodeKind, WikiNode 

15 

16from ...clean import clean_value 

17from ...datautils import data_append, freeze, split_at_comma_semi 

18from ...tags import valid_tags 

19from ...wxr_context import WiktextractContext 

20from .form_descriptions import ( 

21 classify_desc, 

22 decode_tags, 

23 distw, 

24 match_links_to_form, 

25 parse_head_final_tags, 

26) 

27from .inflection_kludges import ka_decl_noun_template_cell 

28from .inflectiondata import infl_map, infl_start_map, infl_start_re 

29from .lang_specific_configs import get_lang_conf, lang_specific_tags 

30from .table_headers_heuristics_data import LANGUAGES_WITH_CELLS_AS_HEADERS 

31from .type_utils import FormData, WordData 

32 

33# --debug-text-cell WORD 

34# Command-line parameter for debugging. When parsing inflection tables, 

35# print out debug messages when encountering this text. 

36debug_cell_text: Optional[str] = None 

37 

38 

39def set_debug_cell_text(text: str) -> None: 

40 global debug_cell_text 

41 debug_cell_text = text 

42 

43 

44TagSets = list[tuple[str, ...]] 

45 

46# Column texts that are interpreted as an empty column. 

47IGNORED_COLVALUES = { 

48 "-", 

49 "־", 

50 "᠆", 

51 "‐", 

52 "‑", 

53 "‒", 

54 "–", 

55 "—", 

56 "―", 

57 "−", 

58 "⸺", 

59 "⸻", 

60 "﹘", 

61 "﹣", 

62 "-", 

63 "/", 

64 "?", 

65 "not used", 

66 "not applicable", 

67} 

68 

69# These tags are never inherited from above 

70# XXX merge with lang_specific 

71noinherit_tags = { 

72 "infinitive-i", 

73 "infinitive-i-long", 

74 "infinitive-ii", 

75 "infinitive-iii", 

76 "infinitive-iv", 

77 "infinitive-v", 

78} 

79 

80# Subject->object transformation mapping, when using dummy-object-concord 

81# to replace subject concord tags with object concord tags 

82object_concord_replacements = { 

83 "first-person": "object-first-person", 

84 "second-person": "object-second-person", 

85 "third-person": "object-third-person", 

86 "singular": "object-singular", 

87 "plural": "object-plural", 

88 "definite": "object-definite", 

89 "indefinite": "object-indefinite", 

90 "class-1": "object-class-1", 

91 "class-2": "object-class-2", 

92 "class-3": "object-class-3", 

93 "class-4": "object-class-4", 

94 "class-5": "object-class-5", 

95 "class-6": "object-class-6", 

96 "class-7": "object-class-7", 

97 "class-8": "object-class-8", 

98 "class-9": "object-class-9", 

99 "class-10": "object-class-10", 

100 "class-11": "object-class-11", 

101 "class-12": "object-class-12", 

102 "class-13": "object-class-13", 

103 "class-14": "object-class-14", 

104 "class-15": "object-class-15", 

105 "class-16": "object-class-16", 

106 "class-17": "object-class-17", 

107 "class-18": "object-class-18", 

108 "masculine": "object-masculine", 

109 "feminine": "object-feminine", 

110} 

111 

112# Words in title that cause addition of tags in all entries 

113title_contains_global_map = { 

114 "possessive": "possessive", 

115 "possessed forms of": "possessive", 

116 "predicative forms of": "predicative", 

117 "negative": "negative", 

118 "positive definite forms": "positive definite", 

119 "positive indefinite forms": "positive indefinite", 

120 "comparative": "comparative", 

121 "superlative": "superlative", 

122 "combined forms": "combined-form", 

123 "mutation": "mutation", 

124 "definite article": "definite", 

125 "indefinite article": "indefinite", 

126 "indefinite declension": "indefinite", 

127 "bare forms": "indefinite", # e.g., cois/Irish 

128 "definite declension": "definite", 

129 "pre-reform": "dated", 

130 "personal pronouns": "personal pronoun", 

131 "composed forms of": "multiword-construction", 

132 "subordinate-clause forms of": "subordinate-clause", 

133 "participles of": "participle", 

134 "variation of": "dummy-skip-this", # a'/Scottish Gaelic 

135 "command form of": "imperative", # a راتلل/Pashto 

136 "historical inflection of": "dummy-skip-this", # kork/Norwegian Nynorsk 

137 "obsolete declension": "obsolete", # März/German 20241111 

138 # käyminen/Finnish 20260707 

139 "first-person singular possessor": "first-person singular singular-possessive", 

140 "second-person singular possessor": "second-person singular singular-possessive", 

141 "first-person plural possessor": "first-person plural plural-possessive", 

142 "second-person plural possessor": "second-person plural plural-possessive", 

143 "third-person possessor": "third-person possessive", 

144} 

145for k, v in title_contains_global_map.items(): 

146 if any(t not in valid_tags for t in v.split()): 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 print("TITLE_CONTAINS_GLOBAL_MAP UNRECOGNIZED TAG: {}: {}".format(k, v)) 

148table_hdr_ign_part = r"(Inflection|Conjugation|Declension|Mutation) of [^\s]" 

149 

150table_hdr_ign_part_re = re.compile(r"(?i)(" + table_hdr_ign_part + ")") 

151# (?i) python regex extension, ignore case 

152title_contains_global_re = re.compile( 

153 r"(?i)(^|\b)({}|{})($|\b)".format( 

154 table_hdr_ign_part, 

155 "|".join(re.escape(x) for x in title_contains_global_map.keys()), 

156 ) 

157) 

158 

159# Words in title that cause addition of tags to table-tags "form" 

160title_contains_wordtags_map = { 

161 "pf": "perfective", 

162 "impf": "imperfective", 

163 "strong": "strong", 

164 "weak": "weak", 

165 "countable": "countable", 

166 "uncountable": "uncountable", 

167 "inanimate": "inanimate", 

168 "animate": "animate", 

169 "transitive": "transitive", 

170 "intransitive": "intransitive", 

171 "ditransitive": "ditransitive", 

172 "ambitransitive": "ambitransitive", 

173 "archaic": "archaic", 

174 "dated": "dated", 

175 "affirmative": "affirmative", 

176 "negative": "negative", 

177 "subject pronouns": "subjective", 

178 "object pronouns": "objective", 

179 "emphatic": "emphatic", 

180 "proper noun": "proper-noun", 

181 "no plural": "no-plural", 

182 "imperfective": "imperfective", 

183 "perfective": "perfective", 

184 "no supine stem": "no-supine", 

185 "no perfect stem": "no-perfect", 

186 "deponent": "deponent", 

187 "irregular": "irregular", 

188 "no short forms": "no-short-form", 

189 "iō-variant": "iō-variant", 

190 "1st declension": "declension-1", 

191 "2nd declension": "declension-2", 

192 "3rd declension": "declension-3", 

193 "4th declension": "declension-4", 

194 "5th declension": "declension-5", 

195 "6th declension": "declension-6", 

196 "first declension": "declension-1", 

197 "second declension": "declension-2", 

198 "third declension": "declension-3", 

199 "fourth declension": "declension-4", 

200 "fifth declension": "declension-5", 

201 "sixth declension": "declension-6", 

202 "1st conjugation": "conjugation-1", 

203 "2nd conjugation": "conjugation-2", 

204 "3rd conjugation": "conjugation-3", 

205 "4th conjugation": "conjugation-4", 

206 "5th conjugation": "conjugation-5", 

207 "6th conjugation": "conjugation-6", 

208 "7th conjugation": "conjugation-7", 

209 "first conjugation": "conjugation-1", 

210 "second conjugation": "conjugation-2", 

211 "third conjugation": "conjugation-3", 

212 "fourth conjugation": "conjugation-4", 

213 "fifth conjugation": "conjugation-5", 

214 "sixth conjugation": "conjugation-6", 

215 "seventh conjugation": "conjugation-7", 

216 # Corsican regional tags in table header 

217 "cismontane": "Cismontane", 

218 "ultramontane": "Ultramontane", 

219 "western lombard": "Western-Lombard", 

220 "eastern lombard": "Eastern-Lombard", 

221 "contracted": "contracted", 

222 "present": "present", 

223 "perfect": "perfect", 

224 "imperfect": "imperfect", 

225 "pluperfect": "pluperfect", 

226 "future": "future", 

227 "aorist": "aorist", 

228 "eastern armenian": "Eastern-Armenian", 

229 "western armenian": "Western-Armenian", 

230 "-al conjugation": "-al-conjugation", 

231 "-al negative conjugation": "-al-conjugation", 

232 "-il conjugation": "-il-conjugation", 

233 "-il negative conjugation": "-il-conjugation", 

234 "-el conjugation": "-el-conjugation", 

235 "-el negative conjugation": "-el-conjugation", 

236 "-ul conjugation": "-ul-conjugation", 

237 "-ul negative conjugation": "-ul-conjugation", 

238 "u-type": "u-type", 

239 "nominalized infinitive": "noun infinitive", 

240} 

241for k, v in title_contains_wordtags_map.items(): 

242 if any(t not in valid_tags for t in v.split()): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

243 print( 

244 "TITLE_CONTAINS_WORDTAGS_MAP UNRECOGNIZED TAG: {}: {}".format(k, v) 

245 ) 

246title_contains_wordtags_re = re.compile( 

247 r"(?i)(^|\b)({}|{})($|\b)".format( 

248 table_hdr_ign_part, 

249 "|".join( 

250 re.escape(x) 

251 for x in reversed( 

252 sorted(title_contains_wordtags_map.keys(), key=len) 

253 ) 

254 ), 

255 ) 

256) 

257 

258# Parenthesized elements in title that are converted to tags in 

259# "table-tags" form 

260title_elements_map = { 

261 "weak": "weak", 

262 "strong": "strong", 

263 "separable": "separable", 

264 "masculine": "masculine", 

265 "feminine": "feminine", 

266 "neuter": "neuter", 

267 "singular": "singular", 

268 "plural": "plural", 

269 "archaic": "archaic", 

270 "dated": "dated", 

271 "iterative": "iterative", 

272 "poetic": "poetic", 

273 "Attic": "Attic", 

274 "Epic": "Epic", 

275 "Aeolic": "Aeolic", 

276 "Arcadocypriot": "Arcadocypriot", 

277 "Old Attic": "Old-Attic", 

278 "Boeotian": "Boeotian", 

279 "Byzantine": "Byzantine", 

280 "Choral Doric": "Choral-Doric", 

281 "Doric": "Doric", 

282 "Elean": "Elean", 

283 "Epirote": "Epirote", 

284 "Ionic": "Ionic", 

285 "Koine": "Koine", 

286 "Cretan": "Cretan", 

287 "Corinthian": "Corinthian", 

288 "Laconian": "Laconian", 

289 "Later poetic": "Later-poetic-Ancient-Greek", 

290 "Lesbian": "Lesbian", 

291 "Locrian": "Locrian", 

292 "Lyric": "Lyric-Ancient-Greek", 

293 "Thessalian": "Thessalian", 

294 "Tragic": "Tragic-Ancient-Greek", 

295} 

296for k, v in title_elements_map.items(): 

297 if any(t not in valid_tags for t in v.split()): 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 print("TITLE_ELEMENTS_MAP UNRECOGNIZED TAG: {}: {}".format(k, v)) 

299 

300# Parenthized element starts to map them to tags for form for the rest of 

301# the element 

302title_elemstart_map = { 

303 "auxiliary": "auxiliary", 

304 "Kotus type": "class", 

305 "ÕS type": "class", 

306 "class": "class", 

307 "short class": "class", 

308 "type": "class", 

309 "strong class": "class", 

310 "weak class": "class", 

311 "accent paradigm": "accent-paradigm", 

312 "stem in": "class", 

313} 

314for k, v in title_elemstart_map.items(): 

315 if any(t not in valid_tags for t in v.split()): 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 print("TITLE_ELEMSTART_MAP UNRECOGNIZED TAG: {}: {}".format(k, v)) 

317title_elemstart_re = re.compile( 

318 r"^({}) ".format("|".join(re.escape(x) for x in title_elemstart_map.keys())) 

319) 

320 

321 

322# Regexp for cell starts that are likely definitions of reference symbols. 

323# See also nondef_re. 

324def_re = re.compile( 

325 r"(\s*•?\s+)?" 

326 r"((\*+|[△†0123456789⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻]+)([⁾):]|\s|(?=[A-Z]))|" 

327 r"\^(\*+|[△†])|" 

328 r"([¹²³⁴⁵⁶⁷⁸⁹])|" 

329 r"([ᴬᴮᴰᴱᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾᴿᵀᵁⱽᵂᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻᵝᵞᵟᶿᶥᵠᵡ]))" 

330) 

331# ᴺᴸᴴ persan/Old Irish 

332 

333# Regexp for cell starts that are exceptions to def_re and do not actually 

334# start a definition. 

335nondef_re = re.compile( 

336 r"(^\s*(1|2|3)\s+(sg|pl)\s*$|" # 1s or 3p etc. 

337 r"\s*\d\d?\s*/\s*\d\d?\s*$)" 

338) # taka/Swahili "15 / 17" 

339 

340 

341class InflCell: 

342 """Cell in an inflection table.""" 

343 

344 __slots__ = ( 

345 "text", 

346 "is_title", 

347 "colspan", 

348 "rowspan", 

349 "target", 

350 "links", 

351 ) 

352 

353 def __init__( 

354 self, 

355 text: str, 

356 is_title: bool, 

357 colspan: int, 

358 rowspan: int, 

359 target: str | None, 

360 cell_links: list[tuple[str, str]] | None = None, 

361 ) -> None: 

362 assert isinstance(text, str) 

363 assert is_title in (True, False) 

364 assert isinstance(colspan, int) and colspan >= 1 

365 assert isinstance(rowspan, int) and rowspan >= 1 

366 assert target is None or isinstance(target, str) 

367 self.text = text.strip() 

368 self.is_title = text and is_title 

369 self.colspan = colspan 

370 self.rowspan = rowspan 

371 self.target = target 

372 self.links = cell_links 

373 

374 def __str__(self) -> str: 

375 v = "{}/{}/{}/{!r}".format( 

376 self.text, self.is_title, self.colspan, self.rowspan 

377 ) 

378 if self.target: 

379 v += ": {!r}".format(self.target) 

380 return v 

381 

382 def __repr__(self) -> str: 

383 return str(self) 

384 

385 

386class HdrSpan: 

387 """Saved information about a header cell/span during the parsing 

388 of a table.""" 

389 

390 __slots__ = ( 

391 "start", 

392 "colspan", 

393 "rowspan", 

394 "rownum", # Row number where this occurred 

395 "tagsets", # list of tuples 

396 "text", # For debugging 

397 "all_headers_row", 

398 "expanded", # The header has been expanded to cover whole row/part 

399 ) 

400 

401 def __init__( 

402 self, 

403 start: int, 

404 colspan: int, 

405 rowspan: int, 

406 rownum: int, 

407 tagsets: TagSets, 

408 text: str, 

409 all_headers_row: bool, 

410 ) -> None: 

411 assert isinstance(start, int) and start >= 0 

412 assert isinstance(colspan, int) and colspan >= 1 

413 assert isinstance(rownum, int) 

414 assert isinstance(tagsets, list) 

415 for x in tagsets: 

416 assert isinstance(x, tuple) 

417 assert all_headers_row in (True, False) 

418 self.start = start 

419 self.colspan = colspan 

420 self.rowspan = rowspan 

421 self.rownum = rownum 

422 self.tagsets = list(tuple(sorted(set(tags))) for tags in tagsets) 

423 self.text = text 

424 self.all_headers_row = all_headers_row 

425 self.expanded = False 

426 

427 

428def is_superscript(ch: str) -> bool: 

429 """Returns True if the argument is a superscript character.""" 

430 assert isinstance(ch, str) and len(ch) == 1 

431 try: 

432 name = unicodedata.name(ch) 

433 except ValueError: 

434 return False 

435 return ( 

436 re.match( 

437 r"SUPERSCRIPT |" 

438 r"MODIFIER LETTER SMALL |" 

439 r"MODIFIER LETTER CAPITAL ", 

440 name, 

441 ) 

442 is not None 

443 ) 

444 

445 

446def remove_useless_tags(lang: str, pos: str, tags: set[str]) -> None: 

447 """Remove certain tag combinations from ``tags`` when they serve no purpose 

448 together (cover all options).""" 

449 assert isinstance(lang, str) 

450 assert isinstance(pos, str) 

451 assert isinstance(tags, set) 

452 if ( 

453 "animate" in tags 

454 and "inanimate" in tags 

455 and get_lang_conf(lang, "animate_inanimate_remove") 

456 ): 

457 tags.remove("animate") 

458 tags.remove("inanimate") 

459 if ( 

460 "virile" in tags 

461 and "nonvirile" in tags 

462 and get_lang_conf(lang, "virile_nonvirile_remove") 

463 ): 

464 tags.remove("virile") 

465 tags.remove("nonvirile") 

466 # If all numbers in the language are listed, remove them all 

467 numbers = get_lang_conf(lang, "numbers") 

468 if numbers and all(x in tags for x in numbers): 

469 for x in numbers: 

470 tags.remove(x) 

471 # If all genders in the language are listed, remove them all 

472 genders = get_lang_conf(lang, "genders") 

473 if genders and all(x in tags for x in genders): 

474 for x in genders: 

475 tags.remove(x) 

476 # If all voices in the language are listed, remove them all 

477 voices = get_lang_conf(lang, "voices") 

478 if voices and all(x in tags for x in voices): 

479 for x in voices: 

480 tags.remove(x) 

481 # If all strengths of the language are listed, remove them all 

482 strengths = get_lang_conf(lang, "strengths") 

483 if strengths and all(x in tags for x in strengths): 

484 for x in strengths: 

485 tags.remove(x) 

486 # If all persons of the language are listed, remove them all 

487 persons = get_lang_conf(lang, "persons") 

488 if persons and all(x in tags for x in persons): 

489 for x in persons: 

490 tags.remove(x) 

491 # If all definitenesses of the language are listed, remove them all 

492 definitenesses = get_lang_conf(lang, "definitenesses") 

493 if definitenesses and all(x in tags for x in definitenesses): 

494 for x in definitenesses: 

495 tags.remove(x) 

496 

497 

498def tagset_cats(tagset: TagSets) -> set[str]: 

499 """Returns a set of tag categories for the tagset (merged from all 

500 alternatives).""" 

501 return set(valid_tags[t] for ts in tagset for t in ts) 

502 

503 

504def or_tagsets( 

505 lang: str, pos: str, tagsets1: TagSets, tagsets2: TagSets 

506) -> TagSets: 

507 """Merges two tagsets (the new tagset just merges the tags from both, in 

508 all combinations). If they contain simple alternatives (differ in 

509 only one category), they are simply merged; otherwise they are split to 

510 more alternatives. The tagsets are assumed be sets of sorted tuples.""" 

511 assert isinstance(tagsets1, list) 

512 assert all(isinstance(x, tuple) for x in tagsets1) 

513 assert isinstance(tagsets2, list) 

514 assert all(isinstance(x, tuple) for x in tagsets1) 

515 tagsets: TagSets = [] # This will be the result 

516 

517 def add_tags(tags1: tuple[str, ...]) -> None: 

518 # CONTINUE 

519 if not tags1: 

520 return # empty set would merge with anything, won't change result 

521 if not tagsets: 

522 tagsets.append(tags1) 

523 return 

524 for tags2 in tagsets: 

525 # Determine if tags1 can be merged with tags2 

526 num_differ = 0 

527 if tags1 and tags2: 527 ↛ 545line 527 didn't jump to line 545 because the condition on line 527 was always true

528 cats1 = set(valid_tags[t] for t in tags1) 

529 cats2 = set(valid_tags[t] for t in tags2) 

530 cats = cats1 | cats2 

531 for cat in cats: 

532 tags1_in_cat = set(t for t in tags1 if valid_tags[t] == cat) 

533 tags2_in_cat = set(t for t in tags2 if valid_tags[t] == cat) 

534 if ( 

535 tags1_in_cat != tags2_in_cat 

536 or not tags1_in_cat 

537 or not tags2_in_cat 

538 ): 

539 num_differ += 1 

540 if not tags1_in_cat or not tags2_in_cat: 

541 # Prevent merging if one is empty 

542 num_differ += 1 

543 # print("tags1={} tags2={} num_differ={}" 

544 # .format(tags1, tags2, num_differ)) 

545 if num_differ <= 1: 

546 # Yes, they can be merged 

547 tagsets.remove(tags2) 

548 tags_s = set(tags1) | set(tags2) 

549 remove_useless_tags(lang, pos, tags_s) 

550 tags_t = tuple(sorted(tags_s)) 

551 add_tags(tags_t) # Could result in further merging 

552 return 

553 # If we could not merge, add to tagsets 

554 tagsets.append(tags1) 

555 

556 for tags in tagsets1: 

557 add_tags(tags) 

558 for tags in tagsets2: 

559 add_tags(tags) 

560 if not tagsets: 

561 tagsets.append(()) 

562 

563 # print("or_tagsets: {} + {} -> {}" 

564 # .format(tagsets1, tagsets2, tagsets)) 

565 return tagsets 

566 

567 

568def and_tagsets( 

569 lang: str, 

570 pos: str, 

571 tagsets1: list[tuple[str, ...]], 

572 tagsets2: list[tuple[str, ...]], 

573) -> list[tuple[str, ...]]: 

574 """Merges tagsets by taking union of all cobinations, without trying 

575 to determine whether they are compatible.""" 

576 assert isinstance(tagsets1, list) and len(tagsets1) >= 1 

577 assert all(isinstance(x, tuple) for x in tagsets1) 

578 assert isinstance(tagsets2, list) and len(tagsets2) >= 1 

579 assert all(isinstance(x, tuple) for x in tagsets1) 

580 new_tagsets = [] 

581 tags: Union[set[str], tuple[str, ...]] 

582 for tags1 in tagsets1: 

583 for tags2 in tagsets2: 

584 tags = set(tags1) | set(tags2) 

585 remove_useless_tags(lang, pos, tags) 

586 if "dummy-ignored-text-cell" in tags: 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true

587 tags.remove("dummy-ignored-text-cell") 

588 tags = tuple(sorted(tags)) 

589 if tags not in new_tagsets: 589 ↛ 583line 589 didn't jump to line 583 because the condition on line 589 was always true

590 new_tagsets.append(tags) 

591 # print("and_tagsets: {} + {} -> {}" 

592 # .format(tagsets1, tagsets2, new_tagsets)) 

593 return new_tagsets 

594 

595 

596@functools.lru_cache(65536) 

597def extract_cell_content( 

598 lang: str, word: str, col: str 

599) -> tuple[str, list[str], list[tuple[str, str]], list[str]]: 

600 """Cleans a row/column header for later processing. This returns 

601 (cleaned, refs, defs, tags).""" 

602 # print("EXTRACT_CELL_CONTENT {!r}".format(col)) 

603 hdr_tags = [] 

604 col = re.sub(r"(?s)\s*,\s*$", "", col) 

605 col = re.sub(r"(?s)\s*•\s*$", "", col) 

606 col = re.sub(r"\s+", " ", col) 

607 col = col.strip() 

608 if re.search( 

609 r"^\s*(There are |" 

610 r"\* |" 

611 r"see |" 

612 r"See |" 

613 r"Use |" 

614 r"use the |" 

615 r"Only used |" 

616 r"The forms in |" 

617 r"these are also written |" 

618 r"The genitive can be |" 

619 r"Genitive forms are rare or non-existant|" 

620 r"Accusative Note: |" 

621 r"Classifier Note: |" 

622 r"Noun: Assamese nouns are |" 

623 r"the active conjugation|" 

624 r"the instrumenal singular|" 

625 r"Note:|" 

626 r"\^* Note:|" 

627 r"possible mutated form |" 

628 r"The future tense: )", 

629 col, 

630 ): 

631 return "dummy-ignored-text-cell", [], [], [] 

632 

633 # Temporarily remove final parenthesized part (if separated by whitespace), 

634 # so that we can extract reference markers before it. 

635 final_paren = "" 

636 m = re.search(r"\s+\([^)]*\)$", col) 

637 if m is not None: 

638 final_paren = m.group(0) 

639 col = col[: m.start()] 

640 

641 # Extract references and tag markers 

642 refs = [] 

643 special_references = get_lang_conf(lang, "special_references") 

644 while True: 

645 m = re.search(r"\^(.|\([^)]*\))$", col) 

646 if not m: 

647 break 

648 r = m.group(1) 

649 if r.startswith("(") and r.endswith(")"): 

650 r = r[1:-1] 

651 for r1 in r.split(","): 

652 if r1 == "rare": 652 ↛ 653line 652 didn't jump to line 653 because the condition on line 652 was never true

653 hdr_tags.append("rare") 

654 elif special_references and r1 in special_references: 

655 hdr_tags.extend(special_references[r1].split()) 

656 else: 

657 # v = m.group(1) 

658 if r1.startswith("(") and r1.endswith(")"): 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 r1 = r1[1:-1] 

660 refs.append(unicodedata.normalize("NFKD", r1)) 

661 col = col[: m.start()] 

662 # See if it is a ref definition 

663 # print("BEFORE REF CHECK: {!r}".format(col)) 

664 m = def_re.match(col) 

665 # print(f"Before def_re: {refs=}") 

666 if m and not nondef_re.match(col): 

667 ofs = 0 

668 ref = None 

669 deflst = [] 

670 for m in re.finditer(def_re, col): 

671 if ref: 

672 deflst.append((ref, col[ofs : m.start()].strip())) 

673 ref = unicodedata.normalize( 

674 "NFKD", m.group(3) or m.group(5) or m.group(6) or "" 

675 ) 

676 ofs = m.end() 

677 if ref: 677 ↛ 680line 677 didn't jump to line 680 because the condition on line 677 was always true

678 deflst.append((ref, col[ofs:].strip())) 

679 # print("deflst:", deflst) 

680 return "", [], deflst, [] 

681 # See if it *looks* like a reference to a definition 

682 # print(f"After def_re: {refs=}") 

683 while col: 

684 if is_superscript(col[-1]) or col[-1] in ("†",): 

685 if col.endswith("ʳᵃʳᵉ"): 

686 hdr_tags.append("rare") 

687 col = col[:-4].strip() 

688 continue 

689 if special_references: 

690 stop_flag = False 

691 for r in special_references: 

692 if col.endswith(r): 

693 hdr_tags.extend(special_references[r].split()) 

694 col = col[: -len(r)].strip() 

695 stop_flag = True 

696 break # this for loop 

697 if stop_flag: 

698 continue # this while loop 

699 # Numbers and H/L/N are useful information 

700 refs.append(unicodedata.normalize("NFKD", col[-1])) 

701 col = col[:-1] 

702 else: 

703 break 

704 

705 # Check for another form of note definition 

706 if ( 706 ↛ 712line 706 didn't jump to line 712 because the condition on line 706 was never true

707 len(col) > 2 

708 and col[1] in (")", " ", ":") 

709 and col[0].isdigit() 

710 and not re.match(nondef_re, col) 

711 ): 

712 return "", [], [(col[0], col[2:].strip())], [] 

713 col = col.strip() 

714 

715 # Extract final "*" reference symbols. Sometimes there are multiple. 

716 m = re.search(r"\*+$", col) 

717 if m is not None: 

718 col = col[: m.start()] 

719 refs.append(unicodedata.normalize("NFKD", m.group(0))) 

720 if col.endswith("(*)"): 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true

721 col = col[:-3].strip() 

722 refs.append("*") 

723 

724 # Put back the final parenthesized part 

725 col = col.strip() + final_paren 

726 # print("EXTRACT_CELL_CONTENT: orig_col={!r} col={!r} refs={!r} hdr_tags={}" 

727 # .format(orig_col, col, refs, hdr_tags)) 

728 return col.strip(), refs, [], hdr_tags 

729 

730 

731@functools.lru_cache(10000) 

732def parse_title( 

733 title: str, source: str 

734) -> tuple[list[str], list[str], list[FormData]]: 

735 """Parses inflection table title. This returns (global_tags, table_tags, 

736 extra_forms), where ``global_tags`` is tags to be added to each inflection 

737 entry, ``table_tags`` are tags for the word but not to be added to every 

738 form, and ``extra_forms`` is dictionary describing additional forms to be 

739 included in the part-of-speech entry).""" 

740 assert isinstance(title, str) 

741 assert isinstance(source, str) 

742 title = html.unescape(title) 

743 title = re.sub(r"(?i)<[^>]*>", "", title).strip() 

744 title = re.sub(r"\s+", " ", title) 

745 # print("PARSE_TITLE:", title) 

746 global_tags: list[str] = [] 

747 table_tags: list[str] = [] 

748 extra_forms = [] 

749 # Add certain global tags based on contained words 

750 for m in re.finditer(title_contains_global_re, title): 

751 v = m.group(0).lower() 

752 if re.match(table_hdr_ign_part_re, v): 752 ↛ 753line 752 didn't jump to line 753 because the condition on line 752 was never true

753 continue 

754 global_tags.extend(title_contains_global_map[v].split()) 

755 # Add certain tags to table-tags "form" based on contained words 

756 for m in re.finditer(title_contains_wordtags_re, title): 

757 v = m.group(0).lower() 

758 if re.match(table_hdr_ign_part_re, v): 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true

759 continue 

760 table_tags.extend(title_contains_wordtags_map[v].split()) 

761 if re.search(r"Conjugation of (s’|se ).*French verbs", title): 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true

762 global_tags.append("reflexive") 

763 # Check for <x>-type at the beginning of title (e.g., Armenian) and various 

764 # other ways of specifying an inflection class. 

765 for m in re.finditer( 

766 r"\b(" 

767 r"[\w/]+-type|" 

768 r"accent-\w+|" 

769 r"[\w/]+-stem|" 

770 r"[^ ]+ gradation|" 

771 r"\b(stem in [\w/ ]+)|" 

772 r"[^ ]+ alternation|" 

773 r"(First|Second|Third|Fourth|Fifth|Sixth|Seventh) " 

774 r"(Conjugation|declension)|" 

775 r"First and second declension|" 

776 r"(1st|2nd|3rd|4th|5th|6th) declension|" 

777 r"\w[\w/ ]* harmony" 

778 r")\b", 

779 title, 

780 ): 

781 dt: FormData = {"form": m.group(1), "source": source, "tags": ["class"]} 

782 extra_forms.append(dt) 

783 # Parse parenthesized part from title 

784 for m in re.finditer(r"\(([^)]*)\)", title): 

785 for elem in m.group(1).split(","): 

786 # group(0) is the whole string, group(1) first parens 

787 elem = elem.strip() 

788 if elem in title_elements_map: 

789 table_tags.extend(title_elements_map[elem].split()) 

790 else: 

791 m1 = re.match(title_elemstart_re, elem) 

792 if m1: 

793 tags = title_elemstart_map[m1.group(1)].split() 

794 dt = { 

795 "form": elem[m1.end() :], 

796 "source": source, 

797 "tags": tags, 

798 } 

799 extra_forms.append(dt) 

800 # For titles that contains no parenthesized parts, do some special 

801 # handling to still interpret parts from them 

802 if "(" not in title: 

803 # No parenthesized parts 

804 m1 = re.search(r"\b(Portuguese) (-.* verb) ", title) 

805 if m1 is not None: 

806 dt = {"form": m1.group(2), "tags": ["class"], "source": source} 

807 extra_forms.append(dt) 

808 for elem in title.split(","): 

809 elem = elem.strip() 

810 if elem in title_elements_map: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true

811 table_tags.extend(title_elements_map[elem].split()) 

812 elif elem.endswith("-stem"): 812 ↛ 813line 812 didn't jump to line 813 because the condition on line 812 was never true

813 dt = {"form": elem, "tags": ["class"], "source": source} 

814 extra_forms.append(dt) 

815 return global_tags, table_tags, extra_forms 

816 

817 

818def expand_header( 

819 wxr: WiktextractContext, 

820 tablecontext: "TableContext", 

821 word: str, 

822 lang: str, 

823 pos: str, 

824 text: str, 

825 base_tags: Union[list[str], set[str], tuple[str, ...]], 

826 silent=False, 

827 ignore_tags=False, 

828 depth=0, 

829 column_number: int | None = None, 

830) -> list[tuple[str, ...]]: 

831 """Expands a cell header to tagset, handling conditional expressions 

832 in infl_map. This returns list of tuples of tags, each list element 

833 describing an alternative interpretation. ``base_tags`` is combined 

834 column and row tags for the cell in which the text is being interpreted 

835 (conditional expressions in inflection data may depend on it). 

836 If ``silent`` is True, then no warnings will be printed. If ``ignore_tags`` 

837 is True, then tags listed in "if" will be ignored in the test (this is 

838 used when trying to heuristically detect whether a non-<th> cell is anyway 

839 a header).""" 

840 assert isinstance(wxr, WiktextractContext) 

841 assert isinstance(word, str) 

842 assert isinstance(lang, str) 

843 assert isinstance(pos, str) 

844 assert isinstance(text, str) 

845 assert isinstance(base_tags, (list, tuple, set)) 

846 assert silent in (True, False) 

847 assert isinstance(depth, int) 

848 # print("EXPAND_HDR: text={!r} base_tags={!r}".format(text, base_tags)) 

849 # First map the text using the inflection map 

850 text = clean_value(wxr, text) 

851 combined_return: list[tuple[str, ...]] = [] 

852 parts = split_at_comma_semi(text, separators=[";"]) 

853 for text in parts: 

854 if not text: 854 ↛ 855line 854 didn't jump to line 855 because the condition on line 854 was never true

855 continue 

856 if text in infl_map: 

857 v = infl_map[text] # list or string 

858 else: 

859 m = re.match(infl_start_re, text) 

860 if m is not None: 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true

861 v = infl_start_map[m.group(1)] 

862 # print("INFL_START {} -> {}".format(text, v)) 

863 elif re.match(r"Notes", text): 

864 # Ignored header 

865 # print("IGNORING NOTES") 

866 combined_return = or_tagsets( 

867 lang, pos, combined_return, [("dummy-skip-this",)] 

868 ) 

869 # this just adds dummy-skip-this 

870 continue 

871 elif text in IGNORED_COLVALUES: 

872 combined_return = or_tagsets( 

873 lang, pos, combined_return, [("dummy-ignore-skipped",)] 

874 ) 

875 continue 

876 # Try without final parenthesized part 

877 text_without_parens = re.sub(r"[,/]?\s+\([^)]*\)\s*$", "", text) 

878 if text_without_parens in infl_map: 

879 v = infl_map[text_without_parens] 

880 elif m is None: 880 ↛ 896line 880 didn't jump to line 896 because the condition on line 880 was always true

881 if not silent: 

882 wxr.wtp.debug( 

883 "inflection table: unrecognized header: {}".format( 

884 repr(text) 

885 ), 

886 sortid="inflection/735", 

887 ) 

888 # Unrecognized header 

889 combined_return = or_tagsets( 

890 lang, pos, combined_return, [("error-unrecognized-form",)] 

891 ) 

892 continue 

893 

894 # Then loop interpreting the value, until the value is a simple string. 

895 # This may evaluate nested conditional expressions. 

896 default_else = None 

897 while True: 

898 # If it is a string, we are done. 

899 if isinstance(v, str): 

900 tags = set(v.split()) 

901 remove_useless_tags(lang, pos, tags) 

902 tagset = [tuple(sorted(tags))] 

903 break 

904 # For a list, just interpret it as alternatives. (Currently the 

905 # alternatives must directly be strings.) 

906 if isinstance(v, (list, tuple)): 

907 tagset = [] 

908 for x in v: 

909 tags = set(x.split()) 

910 remove_useless_tags(lang, pos, tags) 

911 tags_t = tuple(sorted(tags)) 

912 if tags_t not in tagset: 912 ↛ 908line 912 didn't jump to line 908 because the condition on line 912 was always true

913 tagset.append(tags_t) 

914 break 

915 # Otherwise the value should be a dictionary describing a 

916 # conditional expression. 

917 if not isinstance(v, dict): 917 ↛ 918line 917 didn't jump to line 918 because the condition on line 917 was never true

918 wxr.wtp.debug( 

919 "inflection table: internal: " 

920 "UNIMPLEMENTED INFL_MAP VALUE: {}".format(infl_map[text]), 

921 sortid="inflection/767", 

922 ) 

923 tagset = [()] 

924 break 

925 # Evaluate the conditional expression. 

926 assert isinstance(v, dict) 

927 cond: Union[bool, str] = "default-true" 

928 c: Union[str, list[str], set[str]] = "" 

929 # Handle "lang" condition. The value must be either a 

930 # single language or a list of languages, and the 

931 # condition evaluates to True if the table is one of 

932 # those languages. 

933 if "lang" in v: 

934 c = v["lang"] 

935 # check if it's a code and transform if necessary 

936 if isinstance(c, str): 

937 if c != lang: 

938 cond = lang == code_to_name(c, "en") 

939 else: 

940 cond = True 

941 else: 

942 assert isinstance(c, (list, tuple, set)) 

943 if lang not in c: 

944 cond = name_to_code(lang, "en") in c 

945 else: 

946 cond = True 

947 # Handle "nested-table-depth" condition. The value must 

948 # be an int or list of ints, and the condition evaluates 

949 # True if the depth is one of those values. 

950 # "depth" is how deep into a nested table tree the current 

951 # table lies. It is first started in handle_wikitext_table, 

952 # so only applies to tables-within-tables, not other 

953 # WikiNode content. `depth` is currently only passed as a 

954 # parameter down the table parsing stack, and not stored. 

955 if cond and "nested-table-depth" in v: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true

956 d = v["nested-table-depth"] 

957 if isinstance(d, int): 

958 cond = d == depth 

959 else: 

960 assert isinstance(d, (list, tuple, set)) 

961 cond = depth in d 

962 # Column index: check if we're in position X of the row 

963 if cond and "column-index" in v: 

964 index = v["column-index"] 

965 if isinstance(index, int): 965 ↛ 968line 965 didn't jump to line 968 because the condition on line 965 was always true

966 cond = index == column_number 

967 else: 

968 assert isinstance(index, (list, tuple, set)) 

969 cond = column_number in index 

970 # Handle inflection-template condition. Must be a string 

971 # or list of strings, and if tablecontext.template_name is in 

972 # those, accept the condition. 

973 # TableContext.template_name is passed down from page/ 

974 # parse_inflection, before parsing and expanding itself 

975 # has begun. 

976 if cond and tablecontext and "inflection-template" in v: 

977 d1 = v["inflection-template"] 

978 if isinstance(d1, str): 978 ↛ 981line 978 didn't jump to line 981 because the condition on line 978 was always true

979 cond = d1 == tablecontext.template_name 

980 else: 

981 assert isinstance(d1, (list, tuple, set)) 

982 cond = tablecontext.template_name in d1 

983 # Handle "pos" condition. The value must be either a single 

984 # part-of-speech or a list of them, and the condition evaluates to 

985 # True if the part-of-speech is any of those listed. 

986 if cond and "pos" in v: 

987 c = v["pos"] 

988 if isinstance(c, str): 

989 cond = c == pos 

990 else: 

991 assert isinstance(c, (list, tuple, set)) 

992 cond = pos in c 

993 # Handle "if" condition. The value must be a string containing a 

994 # space-separated list of tags. The condition evaluates to True if 

995 # ``base_tags`` contains all of the listed tags. If the condition 

996 # is of the form "any: ...tags...", then any of the tags will be 

997 # enough. 

998 if cond and "if" in v and not ignore_tags: 

999 c = v["if"] 

1000 assert isinstance(c, str) 

1001 # "if" condition is true if any of the listed tags is present if 

1002 # it starts with "any:", otherwise all must be present 

1003 if c.startswith("any: "): 

1004 cond = any(t in base_tags for t in c[5:].split()) 

1005 else: 

1006 cond = all(t in base_tags for t in c.split()) 

1007 

1008 # Handle "default" assignment. Store the value to be used 

1009 # as a default later. 

1010 if "default" in v: 

1011 assert isinstance(v["default"], str) 

1012 default_else = v["default"] 

1013 

1014 # Warning message about missing conditions for debugging. 

1015 

1016 if cond == "default-true" and not default_else and not silent: 

1017 wxr.wtp.debug( 

1018 "inflection table: IF MISSING COND: word={} " 

1019 "lang={} text={} base_tags={} c={} cond={}".format( 

1020 word, lang, text, base_tags, c, cond 

1021 ), 

1022 sortid="inflection/851", 

1023 ) 

1024 # Based on the result of evaluating the condition, select either 

1025 # "then" part or "else" part. 

1026 if cond: 

1027 v = v.get("then", "") 

1028 else: 

1029 v1 = v.get("else") 

1030 if v1 is None: 

1031 if default_else is not None: 

1032 v = default_else 

1033 else: 

1034 if not silent: 

1035 wxr.wtp.debug( 

1036 "inflection table: IF WITHOUT ELSE EVALS " 

1037 "False: " 

1038 "{}/{} {!r} base_tags={}".format( 

1039 word, lang, text, base_tags 

1040 ), 

1041 sortid="inflection/865", 

1042 ) 

1043 v = "error-unrecognized-form" 

1044 else: 

1045 v = v1 

1046 

1047 # Merge the resulting tagset from this header part with the other 

1048 # tagsets from the whole header 

1049 combined_return = or_tagsets(lang, pos, combined_return, tagset) 

1050 

1051 # Return the combined tagsets, or empty tagset if we got no tagsets 

1052 if not combined_return: 

1053 combined_return = [()] 

1054 return combined_return 

1055 

1056 

1057def compute_coltags( 

1058 lang: str, 

1059 pos: str, 

1060 hdrspans: list[HdrSpan], 

1061 start: int, 

1062 colspan: int, 

1063 celltext: str, 

1064) -> list[tuple[str, ...]]: 

1065 """Computes column tags for a column of the given width based on the 

1066 current header spans.""" 

1067 assert isinstance(lang, str) 

1068 assert isinstance(pos, str) 

1069 assert isinstance(hdrspans, list) 

1070 assert isinstance(start, int) and start >= 0 

1071 assert isinstance(colspan, int) and colspan >= 1 

1072 assert isinstance(celltext, str) # For debugging only 

1073 # print("COMPUTE_COLTAGS CALLED start={} colspan={} celltext={!r}" 

1074 # .format(start, colspan, celltext)) 

1075 # For debugging, set this to the form for whose cell you want debug prints 

1076 if celltext == debug_cell_text: 1076 ↛ 1077line 1076 didn't jump to line 1077 because the condition on line 1076 was never true

1077 print( 

1078 "COMPUTE_COLTAGS CALLED start={} colspan={} celltext={!r}".format( 

1079 start, colspan, celltext 

1080 ) 

1081 ) 

1082 for hdrspan in hdrspans: 

1083 print( 

1084 " row={} start={} colspans={} tagsets={}".format( 

1085 hdrspan.rownum, 

1086 hdrspan.start, 

1087 hdrspan.colspan, 

1088 hdrspan.tagsets, 

1089 ) 

1090 ) 

1091 used = set() 

1092 coltags: list[tuple[str, ...]] = [()] 

1093 last_header_row = 1000000 

1094 # Iterate through the headers in reverse order, i.e., headers lower in the 

1095 # table (closer to the cell) first. 

1096 row_tagsets: list[tuple[str, ...]] = [()] 

1097 row_tagsets_rownum = 1000000 

1098 used_hdrspans = set() 

1099 for hdrspan in reversed(hdrspans): 

1100 if ( 

1101 hdrspan.start + hdrspan.colspan <= start 

1102 or hdrspan.start >= start + colspan 

1103 ): 

1104 # Does not horizontally overlap current cell. Ignore this hdrspan. 

1105 if celltext == debug_cell_text: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true

1106 print( 

1107 "Ignoring row={} start={} colspan={} tagsets={}".format( 

1108 hdrspan.rownum, 

1109 hdrspan.start, 

1110 hdrspan.colspan, 

1111 hdrspan.tagsets, 

1112 ) 

1113 ) 

1114 continue 

1115 # If the cell partially overlaps the current cell, assume we have 

1116 # reached something unrelated and abort. 

1117 if ( 

1118 hdrspan.start < start 

1119 and hdrspan.start + hdrspan.colspan > start 

1120 and hdrspan.start + hdrspan.colspan < start + colspan 

1121 ): 

1122 if celltext == debug_cell_text: 1122 ↛ 1123line 1122 didn't jump to line 1123 because the condition on line 1122 was never true

1123 print( 

1124 "break on partial overlap at start {} {} {}".format( 

1125 hdrspan.start, hdrspan.colspan, hdrspan.tagsets 

1126 ) 

1127 ) 

1128 break 

1129 if ( 

1130 hdrspan.start < start + colspan 

1131 and hdrspan.start > start 

1132 and hdrspan.start + hdrspan.colspan > start + colspan 

1133 and not hdrspan.expanded 

1134 ): 

1135 if celltext == debug_cell_text: 1135 ↛ 1136line 1135 didn't jump to line 1136 because the condition on line 1135 was never true

1136 print( 

1137 "break on partial overlap at end {} {} {}".format( 

1138 hdrspan.start, hdrspan.colspan, hdrspan.tagsets 

1139 ) 

1140 ) 

1141 break 

1142 # Check if we have already used this cell. 

1143 if id(hdrspan) in used_hdrspans: 

1144 continue 

1145 # We are going to use this cell. 

1146 used_hdrspans.add(id(hdrspan)) 

1147 tagsets = hdrspan.tagsets 

1148 # If the hdrspan is fully inside the current cell and does not cover 

1149 # it fully, check if we should merge information from multiple cells. 

1150 if not hdrspan.expanded and ( 

1151 hdrspan.start > start 

1152 or hdrspan.start + hdrspan.colspan < start + colspan 

1153 ): 

1154 # Multiple columns apply to the current cell, only 

1155 # gender/number/case tags present 

1156 # If there are no tags outside the range in any of the 

1157 # categories included in these cells, don't add anything 

1158 # (assume all choices valid in the language are possible). 

1159 in_cats = set( 

1160 valid_tags[t] 

1161 for x in hdrspans 

1162 if x.rownum == hdrspan.rownum 

1163 and x.start >= start 

1164 and x.start + x.colspan <= start + colspan 

1165 for tt in x.tagsets 

1166 for t in tt 

1167 ) 

1168 if celltext == debug_cell_text: 1168 ↛ 1169line 1168 didn't jump to line 1169 because the condition on line 1168 was never true

1169 print("in_cats={} tagsets={}".format(in_cats, tagsets)) 

1170 # Merge the tagsets into existing tagsets. This merges 

1171 # alternatives into the same tagset if there is only one 

1172 # category different; otherwise this splits the tagset into 

1173 # more alternatives. 

1174 includes_all_on_row = True 

1175 for x in hdrspans: 

1176 # print("X: x.rownum={} x.start={}".format(x.rownum, x.start)) 

1177 if x.rownum != hdrspan.rownum: 

1178 continue 

1179 if x.start < start or x.start + x.colspan > start + colspan: 

1180 if celltext == debug_cell_text: 1180 ↛ 1181line 1180 didn't jump to line 1181 because the condition on line 1180 was never true

1181 print( 

1182 "NOT IN RANGE: {} {} {}".format( 

1183 x.start, x.colspan, x.tagsets 

1184 ) 

1185 ) 

1186 includes_all_on_row = False 

1187 continue 

1188 if id(x) in used_hdrspans: 

1189 if celltext == debug_cell_text: 1189 ↛ 1190line 1189 didn't jump to line 1190 because the condition on line 1189 was never true

1190 print( 

1191 "ALREADY USED: {} {} {}".format( 

1192 x.start, x.colspan, x.tagsets 

1193 ) 

1194 ) 

1195 continue 

1196 used_hdrspans.add(id(x)) 

1197 if celltext == debug_cell_text: 1197 ↛ 1198line 1197 didn't jump to line 1198 because the condition on line 1197 was never true

1198 print( 

1199 "Merging into wide col: x.rownum={} " 

1200 "x.start={} x.colspan={} " 

1201 "start={} colspan={} tagsets={} x.tagsets={}".format( 

1202 x.rownum, 

1203 x.start, 

1204 x.colspan, 

1205 start, 

1206 colspan, 

1207 tagsets, 

1208 x.tagsets, 

1209 ) 

1210 ) 

1211 tagsets = or_tagsets(lang, pos, tagsets, x.tagsets) 

1212 # If all headers on the row were included, ignore them. 

1213 # See e.g. kunna/Swedish/Verb. 

1214 ts_cats = tagset_cats(tagsets) 

1215 if ( 

1216 includes_all_on_row 

1217 or 

1218 # Kludge, see fut/Hungarian/Verb 

1219 ("tense" in ts_cats and "object" in ts_cats) 

1220 ): 

1221 tagsets = [()] 

1222 # For limited categories, if the category doesn't appear 

1223 # outside, we won't include the category 

1224 if not in_cats - set( 

1225 ("gender", "number", "person", "case", "category", "voice") 

1226 ): 

1227 # Sometimes we have masc, fem, neut and plural, so treat 

1228 # number and gender as the same here (if one given, look for 

1229 # the other too) 

1230 if "number" in in_cats or "gender" in in_cats: 

1231 in_cats.update(("number", "gender")) 

1232 # Determine which categories occur outside on 

1233 # the same row. Ignore headers that have been expanded 

1234 # to cover the whole row/part of it. 

1235 out_cats = set( 

1236 valid_tags[t] 

1237 for x in hdrspans 

1238 if x.rownum == hdrspan.rownum 

1239 and not x.expanded 

1240 and ( 

1241 x.start < start or x.start + x.colspan > start + colspan 

1242 ) 

1243 for tt in x.tagsets 

1244 for t in tt 

1245 ) 

1246 if celltext == debug_cell_text: 1246 ↛ 1247line 1246 didn't jump to line 1247 because the condition on line 1246 was never true

1247 print("in_cats={} out_cats={}".format(in_cats, out_cats)) 

1248 # Remove all inside categories that do not appear outside 

1249 

1250 new_tagsets = [] 

1251 for ts in tagsets: 

1252 tags = tuple( 

1253 sorted(t for t in ts if valid_tags[t] in out_cats) 

1254 ) 

1255 if tags not in new_tagsets: 1255 ↛ 1251line 1255 didn't jump to line 1251 because the condition on line 1255 was always true

1256 new_tagsets.append(tags) 

1257 if celltext == debug_cell_text and new_tagsets != tagsets: 1257 ↛ 1258line 1257 didn't jump to line 1258 because the condition on line 1257 was never true

1258 print( 

1259 "Removed tags that do not " 

1260 "appear outside {} -> {}".format( 

1261 # have_hdr never used? 

1262 tagsets, 

1263 new_tagsets, 

1264 ) 

1265 ) 

1266 tagsets = new_tagsets 

1267 key = (hdrspan.start, hdrspan.colspan) 

1268 if key in used: 

1269 if celltext == debug_cell_text: 1269 ↛ 1270line 1269 didn't jump to line 1270 because the condition on line 1269 was never true

1270 print( 

1271 "Cellspan already used: start={} " 

1272 "colspan={} rownum={} {}".format( 

1273 hdrspan.start, 

1274 hdrspan.colspan, 

1275 hdrspan.rownum, 

1276 hdrspan.tagsets, 

1277 ) 

1278 ) 

1279 action = get_lang_conf(lang, "reuse_cellspan") 

1280 # can be "stop", "skip" or "reuse" 

1281 if action == "stop": 

1282 break 

1283 if action == "skip": 

1284 continue 

1285 assert action == "reuse" 

1286 tcats = tagset_cats(tagsets) 

1287 # Most headers block using the same column position above. However, 

1288 # "register" tags don't do this (cf. essere/Italian/verb: "formal") 

1289 if len(tcats) != 1 or "register" not in tcats: 

1290 used.add(key) 

1291 # If we have moved to a different row, merge into column tagsets 

1292 # (we use different and_tagsets within the row) 

1293 if row_tagsets_rownum != hdrspan.rownum: 

1294 # row_tagsets_rownum was initialized as 10000000 

1295 ret = and_tagsets(lang, pos, coltags, row_tagsets) 

1296 if celltext == debug_cell_text: 1296 ↛ 1297line 1296 didn't jump to line 1297 because the condition on line 1296 was never true

1297 print( 

1298 "merging rows: {} {} -> {}".format( 

1299 coltags, row_tagsets, ret 

1300 ) 

1301 ) 

1302 coltags = ret 

1303 row_tagsets = [()] 

1304 row_tagsets_rownum = hdrspan.rownum 

1305 # Merge into coltags 

1306 if hdrspan.all_headers_row and hdrspan.rownum + 1 == last_header_row: 

1307 # If this row is all headers and immediately preceeds the last 

1308 # header we accepted, take any header from there. 

1309 row_tagsets = and_tagsets(lang, pos, row_tagsets, tagsets) 

1310 if celltext == debug_cell_text: 1310 ↛ 1311line 1310 didn't jump to line 1311 because the condition on line 1310 was never true

1311 print("merged (next header row): {}".format(row_tagsets)) 

1312 else: 

1313 # new_cats is for the new tags (higher up in the table) 

1314 new_cats = tagset_cats(tagsets) 

1315 # cur_cats is for the tags already collected (lower in the table) 

1316 cur_cats = tagset_cats(coltags) 

1317 if celltext == debug_cell_text: 1317 ↛ 1318line 1317 didn't jump to line 1318 because the condition on line 1317 was never true

1318 print( 

1319 "row={} start={} colspan={} tagsets={} coltags={} " 

1320 "new_cats={} cur_cats={}".format( 

1321 hdrspan.rownum, 

1322 hdrspan.start, 

1323 hdrspan.colspan, 

1324 tagsets, 

1325 coltags, 

1326 new_cats, 

1327 cur_cats, 

1328 ) 

1329 ) 

1330 if "detail" in new_cats: 

1331 if not any(coltags): # Only if no tags so far 

1332 coltags = or_tagsets(lang, pos, coltags, tagsets) 

1333 if celltext == debug_cell_text: 1333 ↛ 1334line 1333 didn't jump to line 1334 because the condition on line 1333 was never true

1334 print("stopping on detail after merge") 

1335 break 

1336 # Here, we block bleeding of categories from above 

1337 elif "non-finite" in cur_cats and "non-finite" in new_cats: 

1338 stop = get_lang_conf(lang, "stop_non_finite_non_finite") 

1339 if stop: 1339 ↛ 1365line 1339 didn't jump to line 1365 because the condition on line 1339 was always true

1340 if celltext == debug_cell_text: 1340 ↛ 1341line 1340 didn't jump to line 1341 because the condition on line 1340 was never true

1341 print("stopping on non-finite-non-finite") 

1342 break 

1343 elif "non-finite" in cur_cats and "voice" in new_cats: 

1344 stop = get_lang_conf(lang, "stop_non_finite_voice") 

1345 if stop: 1345 ↛ 1365line 1345 didn't jump to line 1365 because the condition on line 1345 was always true

1346 if celltext == debug_cell_text: 1346 ↛ 1347line 1346 didn't jump to line 1347 because the condition on line 1346 was never true

1347 print("stopping on non-finite-voice") 

1348 break 

1349 elif "non-finite" in new_cats and cur_cats & set( 

1350 ("person", "number") 

1351 ): 

1352 if celltext == debug_cell_text: 1352 ↛ 1353line 1352 didn't jump to line 1353 because the condition on line 1352 was never true

1353 print("stopping on non-finite new") 

1354 break 

1355 elif "non-finite" in new_cats and "tense" in new_cats: 

1356 stop = get_lang_conf(lang, "stop_non_finite_tense") 

1357 if stop: 

1358 if celltext == debug_cell_text: 1358 ↛ 1359line 1358 didn't jump to line 1359 because the condition on line 1358 was never true

1359 print("stopping on non-finite new") 

1360 break 

1361 elif "non-finite" in cur_cats and new_cats & set(("mood",)): 1361 ↛ 1362line 1361 didn't jump to line 1362 because the condition on line 1361 was never true

1362 if celltext == debug_cell_text: 

1363 print("stopping on non-finite cur") 

1364 break 

1365 if ( 

1366 "tense" in new_cats 

1367 and any("imperative" in x for x in coltags) 

1368 and get_lang_conf(lang, "imperative_no_tense") 

1369 ): 

1370 if celltext == debug_cell_text: 1370 ↛ 1371line 1370 didn't jump to line 1371 because the condition on line 1370 was never true

1371 print("skipping tense in imperative") 

1372 continue 

1373 elif ( 

1374 "mood" in new_cats 

1375 and "mood" in cur_cats 

1376 and 

1377 # Allow if all new tags are already in current set 

1378 any( 

1379 t not in ts1 

1380 for ts1 in coltags # current 

1381 for ts2 in tagsets # new (from above) 

1382 for t in ts2 

1383 ) 

1384 ): 

1385 skip = get_lang_conf(lang, "skip_mood_mood") 

1386 if skip: 

1387 if celltext == debug_cell_text: 1387 ↛ 1388line 1387 didn't jump to line 1388 because the condition on line 1387 was never true

1388 print("skipping on mood-mood") 

1389 # we continue to next header 

1390 else: 

1391 if celltext == debug_cell_text: 1391 ↛ 1392line 1391 didn't jump to line 1392 because the condition on line 1391 was never true

1392 print("stopping on mood-mood") 

1393 break 

1394 elif "tense" in new_cats and "tense" in cur_cats: 

1395 skip = get_lang_conf(lang, "skip_tense_tense") 

1396 if skip: 

1397 if celltext == debug_cell_text: 1397 ↛ 1398line 1397 didn't jump to line 1398 because the condition on line 1397 was never true

1398 print("skipping on tense-tense") 

1399 # we continue to next header 

1400 else: 

1401 if celltext == debug_cell_text: 1401 ↛ 1402line 1401 didn't jump to line 1402 because the condition on line 1401 was never true

1402 print("stopping on tense-tense") 

1403 break 

1404 elif "aspect" in new_cats and "aspect" in cur_cats: 

1405 if celltext == debug_cell_text: 1405 ↛ 1406line 1405 didn't jump to line 1406 because the condition on line 1405 was never true

1406 print("skipping on aspect-aspect") 

1407 continue 

1408 elif "number" in cur_cats and "number" in new_cats: 

1409 if celltext == debug_cell_text: 1409 ↛ 1410line 1409 didn't jump to line 1410 because the condition on line 1409 was never true

1410 print("stopping on number-number") 

1411 break 

1412 elif "number" in cur_cats and "gender" in new_cats: 

1413 if celltext == debug_cell_text: 1413 ↛ 1414line 1413 didn't jump to line 1414 because the condition on line 1413 was never true

1414 print("stopping on number-gender") 

1415 break 

1416 elif "person" in cur_cats and "person" in new_cats: 

1417 if celltext == debug_cell_text: 1417 ↛ 1418line 1417 didn't jump to line 1418 because the condition on line 1417 was never true

1418 print("stopping on person-person") 

1419 break 

1420 else: 

1421 # Merge tags and continue to next header up/left in the table. 

1422 row_tagsets = and_tagsets(lang, pos, row_tagsets, tagsets) 

1423 if celltext == debug_cell_text: 1423 ↛ 1424line 1423 didn't jump to line 1424 because the condition on line 1423 was never true

1424 print("merged: {}".format(coltags)) 

1425 # Update the row number from which we have last taken headers 

1426 last_header_row = hdrspan.rownum 

1427 # Merge the final row tagset into coltags 

1428 coltags = and_tagsets(lang, pos, coltags, row_tagsets) 

1429 # print( 

1430 # "HDRSPANS:", list((x.start, x.colspan, x.tagsets) for x in hdrspans) 

1431 # ) 

1432 if celltext == debug_cell_text: 1432 ↛ 1433line 1432 didn't jump to line 1433 because the condition on line 1432 was never true

1433 print("COMPUTE_COLTAGS {} {}: {}".format(start, colspan, coltags)) 

1434 assert isinstance(coltags, list) 

1435 assert all(isinstance(x, tuple) for x in coltags) 

1436 return coltags 

1437 

1438 

1439def parse_simple_table( 

1440 wxr: WiktextractContext, 

1441 tablecontext: "TableContext", 

1442 word: str, 

1443 lang: str, 

1444 pos: str, 

1445 rows: list[list[InflCell]], 

1446 titles: list[str], 

1447 source: str, 

1448 after: str, 

1449 depth: int, 

1450) -> list[FormData]: 

1451 """This is the default table parser. Despite its name, it can parse 

1452 complex tables. This returns a list of forms to be added to the 

1453 part-of-speech, or None if the table could not be parsed.""" 

1454 assert isinstance(wxr, WiktextractContext) 

1455 assert isinstance(tablecontext, TableContext) 

1456 assert isinstance(word, str) 

1457 assert isinstance(lang, str) 

1458 assert isinstance(pos, str) 

1459 assert isinstance(rows, list) 

1460 assert isinstance(source, str) 

1461 assert isinstance(after, str) 

1462 assert isinstance(depth, int) 

1463 for row in rows: 

1464 for cell in row: 

1465 assert isinstance(cell, InflCell) 

1466 assert isinstance(titles, list) 

1467 for x in titles: 

1468 assert isinstance(x, str) 

1469 

1470 # print("PARSE_SIMPLE_TABLE: TITLES:", titles) 

1471 if debug_cell_text: 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true

1472 print("ROWS:") 

1473 for row in rows: 

1474 print(" ", row) 

1475 

1476 # Check for forced rowspan kludge. See e.g. 

1477 # maorski/Serbo-Croatian. These are essentially multi-row 

1478 # cells implemented using <br> rather than separate cell. We fix this 

1479 # by identifying rows where this happens, and splitting the current row 

1480 # to multiple rows by synthesizing additional cells. 

1481 new_rows = [] 

1482 for row in rows: 

1483 split_row = ( 

1484 any(x.is_title and x.text in ("inanimate\nanimate",) for x in row) 

1485 and 

1486 # x is an InflCell 

1487 all(x.rowspan == 1 for x in row) 

1488 ) 

1489 if not split_row: 

1490 new_rows.append(row) 

1491 continue 

1492 row1 = [] 

1493 row2 = [] 

1494 for cell in row: 

1495 cell1 = copy.deepcopy(cell) 

1496 if "\n" in cell.text: 

1497 # Has more than one line - split this cell 

1498 parts = cell.text.strip().splitlines() 

1499 if len(parts) != 2: 1499 ↛ 1500line 1499 didn't jump to line 1500 because the condition on line 1499 was never true

1500 wxr.wtp.debug( 

1501 "forced rowspan kludge got {} parts: {!r}".format( 

1502 len(parts), cell.text 

1503 ), 

1504 sortid="inflection/1234", 

1505 ) 

1506 cell2 = copy.deepcopy(cell) 

1507 cell1.text = parts[0] 

1508 cell2.text = parts[1] 

1509 else: 

1510 cell1.rowspan = 2 

1511 cell2 = cell1 # ref, not a copy 

1512 row1.append(cell1) 

1513 row2.append(cell2) 

1514 new_rows.append(row1) 

1515 new_rows.append(row2) 

1516 rows = new_rows 

1517 # print("ROWS AFTER FORCED ROWSPAN KLUDGE:") 

1518 # for row in rows: 

1519 # print(" ", row) 

1520 

1521 # Parse definitions for references (from table itself and from text 

1522 # after it) 

1523 def_ht = {} 

1524 

1525 def add_defs(defs: list[tuple[str, str]]) -> None: 

1526 for ref, d in defs: 

1527 # print("DEF: ref={} d={}".format(ref, d)) 

1528 d = d.strip() 

1529 d = d.split(". ")[0].strip() # text before ". " 

1530 if not d: 1530 ↛ 1531line 1530 didn't jump to line 1531 because the condition on line 1530 was never true

1531 continue 

1532 if d.endswith("."): # catc ".."?? 

1533 d = d[:-1] 

1534 tags, topics = decode_tags(d, no_unknown_starts=True) 

1535 # print(f"{ref=}, {transformed=}, {tags=}") 

1536 if topics or any("error-unknown-tag" in ts for ts in tags): 

1537 d = d[0].lower() + d[1:] 

1538 tags, topics = decode_tags(d, no_unknown_starts=True) 

1539 if topics or any("error-unknown-tag" in ts for ts in tags): 

1540 # Failed to parse as tags 

1541 # print("Failed: topics={} tags={}" 

1542 # .format(topics, tags)) 

1543 continue 

1544 tags1_s: set[str] = set() 

1545 for ts in tags: 

1546 # Set.update is a union operation: definition tags are flat 

1547 tags1_s.update(ts) 

1548 tags1 = tuple(sorted(tags1_s)) 

1549 # print("DEFINED: {} -> {}".format(ref, tags1)) 

1550 def_ht[ref] = tags1 

1551 

1552 def generate_tags( 

1553 rowtags: list[tuple[str, ...]], table_tags: list[str] 

1554 ) -> tuple[ 

1555 list[tuple[str, ...]], list[tuple[str, ...]], list[tuple[str, ...]] 

1556 ]: 

1557 new_coltags: list[tuple[str, ...]] = [] 

1558 all_hdr_tags: list[tuple[str, ...]] = [] # list of tuples 

1559 new_rowtags: list[tuple[str, ...]] = [] 

1560 for rt0 in rowtags: 

1561 for ct0 in compute_coltags( 

1562 lang, 

1563 pos, 

1564 hdrspans, 

1565 col_idx, # col_idx=>start 

1566 colspan, 

1567 col, # cell_text 

1568 ): 

1569 base_tags: set[str] = ( 

1570 set(rt0) | set(ct0) | set(global_tags) | set(table_tags) 

1571 ) # Union. 

1572 # print(f"{rt0=}, {ct0=}, {global_tags=}," 

1573 # f" {table_tags=}, {base_tags=}") 

1574 alt_tags = expand_header( 

1575 wxr, 

1576 tablecontext, 

1577 word, 

1578 lang, 

1579 pos, 

1580 text, 

1581 base_tags, 

1582 depth=depth, 

1583 column_number=col_idx, 

1584 ) 

1585 # base_tags are used in infl_map "if"-conds. 

1586 for tt in alt_tags: 

1587 if tt not in all_hdr_tags: 

1588 all_hdr_tags.append(tt) 

1589 tt_s = set(tt) 

1590 # Add tags from referenced footnotes 

1591 tt_s.update(refs_tags) 

1592 # Sort, convert to tuple, and add to set of 

1593 # alternatives. 

1594 tt = tuple(sorted(tt_s)) 

1595 if tt not in new_coltags: 

1596 new_coltags.append(tt) 

1597 # Kludge (saprast/Latvian/Verb): ignore row tags 

1598 # if trying to add a non-finite after mood. 

1599 if any(valid_tags[t] == "mood" for t in rt0) and any( 

1600 valid_tags[t] == "non-finite" for t in tt 

1601 ): 

1602 tags = tuple(sorted(set(tt) | set(hdr_tags))) 

1603 else: 

1604 tags = tuple(sorted(set(tt) | set(rt0) | set(hdr_tags))) 

1605 if tags not in new_rowtags: 

1606 new_rowtags.append(tags) 

1607 return new_rowtags, new_coltags, all_hdr_tags 

1608 

1609 def add_new_hdrspan( 

1610 col: str, 

1611 hdrspans: list[HdrSpan], 

1612 store_new_hdrspan: bool, 

1613 col0_followed_by_nonempty: bool, 

1614 col0_hdrspan: Optional[HdrSpan], 

1615 ) -> tuple[str, bool, Optional[HdrSpan]]: 

1616 hdrspan = HdrSpan( 

1617 col_idx, colspan, rowspan, rownum, new_coltags, col, all_headers 

1618 ) 

1619 hdrspans.append(hdrspan) 

1620 

1621 # infl-map tag "dummy-store-hdrspan" causes this new hdrspan 

1622 # to be added to a register of stored hdrspans to be used 

1623 # later with "dummy-load-stored-hdrspans". 

1624 if store_new_hdrspan: 1624 ↛ 1625line 1624 didn't jump to line 1625 because the condition on line 1624 was never true

1625 tablecontext.stored_hdrspans.append(hdrspan) 

1626 

1627 # Handle headers that are above left-side header 

1628 # columns and are followed by personal pronouns in 

1629 # remaining columns (basically headers that 

1630 # evaluate to no tags). In such cases widen the 

1631 # left-side header to the full row. 

1632 if previously_seen: # id(cell) in seen_cells previously 

1633 col0_followed_by_nonempty = True 

1634 return col, col0_followed_by_nonempty, col0_hdrspan 

1635 elif col0_hdrspan is None: 

1636 col0_hdrspan = hdrspan 

1637 elif any(all_hdr_tags): 1637 ↛ 1705line 1637 didn't jump to line 1705 because the condition on line 1637 was always true

1638 col0_cats = tagset_cats(col0_hdrspan.tagsets) 

1639 later_cats = tagset_cats(all_hdr_tags) 

1640 col0_allowed = get_lang_conf(lang, "hdr_expand_first") 

1641 later_allowed = get_lang_conf(lang, "hdr_expand_cont") 

1642 later_allowed = later_allowed | set(["dummy"]) 

1643 # dummy2 has different behavior than plain dummy 

1644 # and does not belong here. 

1645 

1646 # print("col0_cats={} later_cats={} " 

1647 # "fol_by_nonempty={} col_idx={} end={} " 

1648 # "tagsets={}" 

1649 # .format(col0_cats, later_cats, 

1650 # col0_followed_by_nonempty, col_idx, 

1651 # col0_hdrspan.start + 

1652 # col0_hdrspan.colspan, 

1653 # col0_hdrspan.tagsets)) 

1654 # print("col0.rowspan={} rowspan={}" 

1655 # .format(col0_hdrspan.rowspan, rowspan)) 

1656 # Only expand if [col0_cats and later_cats are allowed 

1657 # and don't overlap] and [col0 has tags], and there have 

1658 # been [no disallowed cells in between]. 

1659 # 

1660 # There are three cases here: 

1661 # - col0_hdrspan set, continue with allowed current 

1662 # - col0_hdrspan set, expand, start new 

1663 # - col0_hdrspan set, no expand, start new 

1664 if ( 

1665 not col0_followed_by_nonempty 

1666 and 

1667 # XXX Only one cat of tags: kunna/Swedish 

1668 # XXX len(col0_cats) == 1 and 

1669 col0_hdrspan.rowspan >= rowspan 

1670 and 

1671 # from hdrspan 

1672 not (later_cats - later_allowed) 

1673 and not (col0_cats & later_cats) 

1674 ): 

1675 # First case: col0 set, continue 

1676 return col, col0_followed_by_nonempty, col0_hdrspan 

1677 # We are going to start new col0_hdrspan. Check if 

1678 # we should expand. 

1679 if ( 

1680 not col0_followed_by_nonempty 

1681 and not (col0_cats - col0_allowed) 

1682 and 

1683 # Only "allowed" allowed 

1684 # XXX len(col0_cats) == 1 and 

1685 col_idx > col0_hdrspan.start + col0_hdrspan.colspan 

1686 ): 

1687 # col_idx is beyond current colspan 

1688 # *Expand* current col0_hdrspan 

1689 # print("EXPANDING COL0 MID: {} from {} to {} " 

1690 # "cols {}" 

1691 # .format(col0_hdrspan.text, 

1692 # col0_hdrspan.colspan, 

1693 # col_idx - col0_hdrspan.start, 

1694 # col0_hdrspan.tagsets)) 

1695 col0_hdrspan.colspan = col_idx - col0_hdrspan.start 

1696 col0_hdrspan.expanded = True 

1697 # Clear old col0_hdrspan 

1698 if col == debug_cell_text: 1698 ↛ 1699line 1698 didn't jump to line 1699 because the condition on line 1698 was never true

1699 print("START NEW {}".format(hdrspan.tagsets)) 

1700 col0_hdrspan = None 

1701 # Now start new, unless it comes from previous row 

1702 if not previously_seen: 1702 ↛ 1705line 1702 didn't jump to line 1705 because the condition on line 1702 was always true

1703 col0_hdrspan = hdrspan 

1704 col0_followed_by_nonempty = False 

1705 return col, col0_followed_by_nonempty, col0_hdrspan 

1706 

1707 def split_text_into_alts(col: str) -> tuple[str, list[str], list[str]]: 

1708 # Split the cell text into alternatives 

1709 split_extra_tags = [] 

1710 if col and is_superscript(col[0]): 1710 ↛ 1711line 1710 didn't jump to line 1711 because the condition on line 1710 was never true

1711 alts = [col] 

1712 else: 

1713 separators = [";", "•", r"\n", " or "] 

1714 if " + " not in col: 

1715 separators.append(",") 

1716 if not col.endswith("/"): 

1717 separators.append("/") 

1718 if col in special_phrase_splits: 

1719 # Use language-specific special splits. 

1720 # These are phrases and constructions that have 

1721 # unique ways of splitting, not specific characters 

1722 # to split on like with the default splitting. 

1723 alts, tags = special_phrase_splits[col] 

1724 split_extra_tags = tags.split() 

1725 for x in split_extra_tags: 

1726 assert x in valid_tags 

1727 assert isinstance(alts, (list, tuple)) 

1728 assert isinstance(tags, str) 

1729 elif ( 1729 ↛ 1749line 1729 didn't jump to line 1749 because the condition on line 1729 was never true

1730 ( 

1731 m := re.match( 

1732 # word1, word2 (romanization1, romanization2) 

1733 r"\s*([^(),]+),([^(),]+)\(([^(),]+),([^(),]+)\)", 

1734 col, 

1735 ) 

1736 ) 

1737 # NOT `word, (tag, tag)` with an empty m.group(2)... 

1738 # There is a test that fails because of this. It's an 

1739 # outdated table, but still, ...Italian_verb1 

1740 and all(s.strip() for s in m.groups()) 

1741 and any( 

1742 ( 

1743 # except for entries like word1, word2 (tag2, tag2)... 

1744 classify_desc(s) in ("english", "romanization") 

1745 for s in (m.group(3), m.group(4)) 

1746 ) 

1747 ) 

1748 ): 

1749 alts = [m.group(1), m.group(2), m.group(3), m.group(4)] 

1750 else: 

1751 # Use default splitting. However, recognize 

1752 # language-specific replacements and change them to magic 

1753 # characters before splitting. This way we won't split 

1754 # them. This is important for, e.g., recognizing 

1755 # alternative pronouns. 

1756 # The magic characters are characters out of Unicode scope 

1757 # that are given a simple incremental value, int > unicode. 

1758 repls = {} 

1759 magic_ch = MAGIC_FIRST 

1760 trs = get_lang_conf(lang, "form_transformations") 

1761 # trs is a list of lists of strings 

1762 for _, v, _, _ in trs: 

1763 # v is a pattern string, like "^ich" 

1764 # form_transformations data is doing double-duty here, 

1765 # because the pattern strings are already known to us and 

1766 # not meant to be split. 

1767 m = re.search(v, col) 

1768 if m is not None: 

1769 # if pattern found in text 

1770 magic = chr(magic_ch) 

1771 magic_ch += 1 # next magic character value 

1772 col = re.sub(v, magic, col) # replace with magic ch 

1773 repls[magic] = m.group(0) 

1774 # remember what regex match string each magic char 

1775 # replaces. .group(0) is the whole match. 

1776 alts0 = split_at_comma_semi(col, separators=separators) 

1777 # with magic characters in place, split the text so that 

1778 # pre-transformation text is out of the way. 

1779 alts = [] 

1780 for alt in alts0: 

1781 # create a new list with the separated items and 

1782 # the magic characters replaced with the original texts. 

1783 for k, v in repls.items(): 

1784 alt = re.sub(k, v, alt) 

1785 alts.append(alt) 

1786 

1787 # Remove "*" from beginning of forms, as in non-attested 

1788 # or reconstructed forms. Otherwise it might confuse romanization 

1789 # detection. 

1790 alts = list(re.sub(r"^\*\*?([^ ])", r"\1", x) for x in alts) 

1791 alts = list( 

1792 x for x in alts if not re.match(r"pronounced with |\(with ", x) 

1793 ) 

1794 alts = list( 

1795 re.sub(r"^\((in the sense [^)]*)\)\s+", "", x) for x in alts 

1796 ) 

1797 return col, alts, split_extra_tags 

1798 

1799 def handle_parens( 

1800 form: str, roman: str, clitic: str | None, extra_tags: list[str] 

1801 ) -> tuple[str, str, str | None]: 

1802 if TYPE_CHECKING: 

1803 assert isinstance(paren, str) 

1804 assert isinstance(m, re.Match) 

1805 if re.match(r"[’'][a-z]([a-z][a-z]?)?$", paren): 

1806 # is there a clitic starting with apostrophe? 

1807 clitic = paren 

1808 # assume the whole paren is a clitic 

1809 # then remove paren from form 

1810 form = (form[: m.start()] + subst + form[m.end() :]).strip() 

1811 elif classify_desc(paren) == "tags": 

1812 tagsets1, topics1 = decode_tags(paren) 

1813 if not topics1: 1813 ↛ 1834line 1813 didn't jump to line 1834 because the condition on line 1813 was always true

1814 for ts in tagsets1: 

1815 ts = tuple(x for x in ts if " " not in x) 

1816 # There are some generated tags containing 

1817 # spaces; do not let them through here. 

1818 extra_tags.extend(ts) 

1819 form = (form[: m.start()] + subst + form[m.end() :]).strip() 

1820 # brackets contain romanization 

1821 elif ( 

1822 m.start() > 0 

1823 and not roman 

1824 and classify_desc(form[: m.start()]) == "other" 

1825 and 

1826 # "other" ~ text 

1827 classify_desc(paren) in ("romanization", "english") 

1828 and not re.search(r"^with |-form$", paren) 

1829 ): 

1830 roman = paren 

1831 form = (form[: m.start()] + subst + form[m.end() :]).strip() 

1832 elif re.search(r"^with |-form", paren): 1832 ↛ 1833line 1832 didn't jump to line 1833 because the condition on line 1832 was never true

1833 form = (form[: m.start()] + subst + form[m.end() :]).strip() 

1834 return form, roman, clitic 

1835 

1836 def merge_row_and_column_tags( 

1837 form: str, 

1838 some_has_covered_text: bool, 

1839 links: list[tuple[str, str]] | None = None, 

1840 ) -> tuple[list[FormData], str, bool]: 

1841 # Merge column tags and row tags. We give preference 

1842 # to moods etc coming from rowtags (cf. austteigen/German/Verb 

1843 # imperative forms). 

1844 

1845 # In certain cases, what a tag means depends on whether 

1846 # it is a row or column header. Depending on the language, 

1847 # we replace certain tags with others if they're in 

1848 # a column or row 

1849 

1850 ret: list[FormData] = [] 

1851 # rtagreplacs = get_lang_conf(lang, "rowtag_replacements") 

1852 # ctagreplacs = get_lang_conf(lang, "coltag_replacements") 

1853 for rt in sorted(rowtags): 

1854 if "dummy-use-as-coltags" in rt: 1854 ↛ 1855line 1854 didn't jump to line 1855 because the condition on line 1854 was never true

1855 continue 

1856 # if lang was in rowtag_replacements) 

1857 # if not rtagreplacs == None: 

1858 # rt = replace_directional_tags(rt, rtagreplacs) 

1859 for ct in sorted(coltags): 

1860 if "dummy-use-as-rowtags" in ct: 1860 ↛ 1861line 1860 didn't jump to line 1861 because the condition on line 1860 was never true

1861 continue 

1862 # if lang was in coltag_replacements 

1863 # if not ctagreplacs == None: 

1864 # ct = replace_directional_tags(ct, 

1865 # ctagreplacs) 

1866 tags = set(global_tags) 

1867 tags.update(extra_tags) 

1868 tags.update(rt) 

1869 tags.update(refs_tags) 

1870 tags.update(tablecontext.section_header) 

1871 # Merge tags from column. For certain kinds of tags, 

1872 # those coming from row take precedence. 

1873 old_tags = set(tags) 

1874 for t in ct: 

1875 c = valid_tags[t] 

1876 if c in ("mood", "case", "number") and any( 

1877 valid_tags[tt] == c for tt in old_tags 

1878 ): 

1879 continue 

1880 tags.add(t) 

1881 

1882 # Extract language-specific tags from the 

1883 # form. This may also adjust the form. 

1884 form, lang_tags = lang_specific_tags(lang, pos, form) 

1885 tags.update(lang_tags) 

1886 

1887 # For non-finite verb forms, see if they have 

1888 # a gender/class suffix 

1889 if pos == "verb" and any( 

1890 valid_tags[t] == "non-finite" for t in tags 

1891 ): 

1892 form, tt = parse_head_final_tags(wxr, lang, form) 

1893 tags.update(tt) 

1894 

1895 # Remove "personal" tag if have nth person; these 

1896 # come up with e.g. reconhecer/Portuguese/Verb. But 

1897 # not if we also have "pronoun" 

1898 if ( 

1899 "personal" in tags 

1900 and "pronoun" not in tags 

1901 and any( 

1902 x in tags 

1903 for x in [ 

1904 "first-person", 

1905 "second-person", 

1906 "third-person", 

1907 ] 

1908 ) 

1909 ): 

1910 tags.remove("personal") 

1911 

1912 # If we have impersonal, remove person and number. 

1913 # This happens with e.g. viajar/Portuguese/Verb 

1914 if "impersonal" in tags: 

1915 tags = tags - set( 

1916 [ 

1917 "first-person", 

1918 "second-person", 

1919 "third-person", 

1920 "singular", 

1921 "plural", 

1922 ] 

1923 ) 

1924 

1925 # Remove unnecessary "positive" tag from verb forms 

1926 if pos == "verb" and "positive" in tags: 

1927 if "negative" in tags: 1927 ↛ 1928line 1927 didn't jump to line 1928 because the condition on line 1927 was never true

1928 tags.remove("negative") 

1929 tags.remove("positive") 

1930 

1931 # Many Russian (and other Slavic) inflection tables 

1932 # have animate/inanimate distinction that generates 

1933 # separate entries for neuter/feminine, but the 

1934 # distinction only applies to masculine. Remove them 

1935 # form neuter/feminine and eliminate duplicates. 

1936 if get_lang_conf(lang, "masc_only_animate"): 

1937 for t1 in ("animate", "inanimate"): 

1938 for t2 in ("neuter", "feminine"): 

1939 if ( 

1940 t1 in tags 

1941 and t2 in tags 

1942 and "masculine" not in tags 

1943 and "plural" not in tags 

1944 ): 

1945 tags.remove(t1) 

1946 

1947 # German adjective tables contain "(keiner)" etc 

1948 # for mixed declension plural. When the adjective 

1949 # disappears and it becomes just one word, remove 

1950 # the "includes-article" tag. e.g. eiskalt/German 

1951 if "includes-article" in tags and " " not in form: 

1952 tags.remove("includes-article") 

1953 

1954 # Handle ignored forms. We mark that the form was 

1955 # provided. This is important information; some words 

1956 # just do not have a certain form. However, there also 

1957 # many cases where no word in a language has a 

1958 # particular form. Post-processing could detect and 

1959 # remove such cases. 

1960 if form in IGNORED_COLVALUES: 

1961 # if cell text seems to be ignorable 

1962 if "dummy-ignore-skipped" in tags: 

1963 continue 

1964 if ( 

1965 col_idx not in has_covering_hdr 

1966 and some_has_covered_text 

1967 ): 

1968 continue 

1969 # don't ignore this cell if there's been a header 

1970 # above it 

1971 form = "-" 

1972 elif col_idx in has_covering_hdr: 

1973 some_has_covered_text = True 

1974 

1975 # Handle ambiguous object concord. If a header 

1976 # gives the "dummy-object-concord"-tag to a word, 

1977 # replace person, number and gender tags with 

1978 # their "object-" counterparts so that the verb 

1979 # agrees with the object instead. 

1980 # Use only when the verb has ONLY object agreement! 

1981 # a پخول/Pashto 

1982 if "dummy-object-concord" in tags: 1982 ↛ 1983line 1982 didn't jump to line 1983 because the condition on line 1982 was never true

1983 for subtag, objtag in object_concord_replacements.items(): 

1984 if subtag in tags: 

1985 tags.remove(subtag) 

1986 tags.add(objtag) 

1987 

1988 # Remove the dummy mood tag that we sometimes 

1989 # use to block adding other mood and related 

1990 # tags 

1991 tags = tags - set( 

1992 [ 

1993 "dummy-mood", 

1994 "dummy-tense", 

1995 "dummy-ignore-skipped", 

1996 "dummy-object-concord", 

1997 "dummy-reset-headers", 

1998 "dummy-use-as-coltags", 

1999 "dummy-use-as-rowtags", 

2000 "dummy-store-hdrspan", 

2001 "dummy-load-stored-hdrspans", 

2002 "dummy-reset-stored-hdrspans", 

2003 "dummy-section-header", 

2004 ] 

2005 ) 

2006 

2007 # Perform language-specific tag replacements according 

2008 # to rules in a table. 

2009 lang_tag_mappings = get_lang_conf(lang, "lang_tag_mappings") 

2010 if lang_tag_mappings is not None: 2010 ↛ 2011line 2010 didn't jump to line 2011 because the condition on line 2010 was never true

2011 for pre, post in lang_tag_mappings.items(): 

2012 if all(t in tags for t in pre): 

2013 tags = (tags - set(pre)) | set(post) 

2014 

2015 # Warn if there are entries with empty tags 

2016 if not tags: 

2017 wxr.wtp.debug( 

2018 "inflection table: empty tags for {}".format(form), 

2019 sortid="inflection/1826", 

2020 ) 

2021 

2022 # Warn if form looks like IPA 

2023 ########## XXX ######## 

2024 # Because IPA is its own unicode block, we could also 

2025 # technically do a Unicode name check to see if a string 

2026 # contains IPA. Not all valid IPA characters are in the 

2027 # IPA extension block, so you can technically have false 

2028 # negatives if it's something like /toki/, but it 

2029 # shouldn't give false positives. 

2030 # Alternatively, you could make a list of IPA-admissible 

2031 # characters and reject non-IPA stuff with that. 

2032 if re.match(r"\s*/.*/\s*$", form): 2032 ↛ 2033line 2032 didn't jump to line 2033 because the condition on line 2032 was never true

2033 wxr.wtp.debug( 

2034 "inflection table form looks like IPA: " 

2035 "form={} tags={}".format(form, tags), 

2036 sortid="inflection/1840", 

2037 ) 

2038 

2039 # Note that this checks `form`, not `in tags` 

2040 if form == "dummy-ignored-text-cell": 2040 ↛ 2041line 2040 didn't jump to line 2041 because the condition on line 2040 was never true

2041 continue 

2042 

2043 if "dummy-remove-this-cell" in tags: 2043 ↛ 2044line 2043 didn't jump to line 2044 because the condition on line 2043 was never true

2044 continue 

2045 

2046 # Add the form 

2047 tags_list = list(sorted(tags)) 

2048 dt: FormData = { 

2049 "form": form, 

2050 "tags": tags_list, 

2051 "source": source, 

2052 } 

2053 if roman: 

2054 dt["roman"] = roman 

2055 if ipa: 

2056 dt["ipa"] = ipa 

2057 if cell_links is not None and ( 

2058 matched_links := match_links_to_form( 

2059 wxr, form, cell_links, None 

2060 ) 

2061 ): 

2062 dt["links"] = matched_links 

2063 ret.append(dt) 

2064 # If we got separate clitic form, add it 

2065 if clitic: 

2066 dt = { 

2067 "form": clitic, 

2068 "tags": tags_list + ["clitic"], 

2069 "source": source, 

2070 } 

2071 ret.append(dt) 

2072 return ret, form, some_has_covered_text 

2073 

2074 # First extract definitions from cells 

2075 # See defs_ht for footnote defs stuff 

2076 for row in rows: 

2077 for cell in row: 

2078 text, refs, defs, hdr_tags = extract_cell_content( 

2079 lang, word, cell.text 

2080 ) 

2081 # refs, defs = footnote stuff, defs -> (ref, def) 

2082 add_defs(defs) 

2083 # Extract definitions from text after table 

2084 text, refs, defs, hdr_tags = extract_cell_content(lang, word, after) 

2085 add_defs(defs) 

2086 

2087 # Then extract the actual forms 

2088 ret = [] 

2089 hdrspans: list[HdrSpan] = [] 

2090 first_col_has_text = False 

2091 rownum = 0 

2092 title = None 

2093 global_tags = [] 

2094 table_tags = [] 

2095 special_phrase_splits = get_lang_conf(lang, "special_phrase_splits") 

2096 form_replacements = get_lang_conf(lang, "form_replacements") 

2097 form_transformations = get_lang_conf(lang, "form_transformations") 

2098 possibly_ignored_forms = get_lang_conf(lang, "conditionally_ignored_cells") 

2099 cleanup_rules = get_lang_conf(lang, "minor_text_cleanups") 

2100 

2101 for title in titles: 

2102 more_global_tags, more_table_tags, extra_forms = parse_title( 

2103 title, source 

2104 ) 

2105 global_tags.extend(more_global_tags) 

2106 table_tags.extend(more_table_tags) 

2107 ret.extend(extra_forms) 

2108 cell_rowcnt: collections.defaultdict[int, int] = collections.defaultdict( 

2109 int 

2110 ) 

2111 seen_cells = set() 

2112 has_covering_hdr = set() 

2113 some_has_covered_text = False 

2114 for row in rows: 

2115 # print("ROW:", row) 

2116 # print("====") 

2117 # print(f"Start of PREVIOUS row hdrspans:" 

2118 # f"{tuple(sp.tagsets for sp in hdrspans)}") 

2119 # print(f"Start of row txt: {tuple(t.text for t in row)}") 

2120 if not row: 2120 ↛ 2121line 2120 didn't jump to line 2121 because the condition on line 2120 was never true

2121 continue # Skip empty rows 

2122 all_headers = all(x.is_title or not x.text.strip() for x in row) 

2123 text = row[0].text 

2124 if ( 

2125 row[0].is_title 

2126 and text 

2127 and not is_superscript(text[0]) 

2128 and text not in infl_map # zealous inflation map? 

2129 and ( 

2130 re.match(r"Inflection ", text) 

2131 or re.sub( 

2132 r"\s+", 

2133 " ", # flatten whitespace 

2134 re.sub( 

2135 r"\s*\([^)]*\)", 

2136 "", 

2137 # Remove whitespace+parens 

2138 text, 

2139 ), 

2140 ).strip() 

2141 not in infl_map 

2142 ) 

2143 and not re.match(infl_start_re, text) 

2144 and all( 

2145 x.is_title == row[0].is_title and x.text == text 

2146 # all InflCells in `row` have the same is_title and text 

2147 for x in row 

2148 ) 

2149 ): 

2150 if text and title is None: 

2151 # Only if there were no titles previously make the first 

2152 # text that is found the title 

2153 title = text 

2154 if re.match(r"(Note:|Notes:)", title): 2154 ↛ 2155line 2154 didn't jump to line 2155 because the condition on line 2154 was never true

2155 continue # not a title 

2156 more_global_tags, more_table_tags, extra_forms = parse_title( 

2157 title, source 

2158 ) 

2159 global_tags.extend(more_global_tags) 

2160 table_tags.extend(more_table_tags) 

2161 ret.extend(extra_forms) 

2162 continue # Skip title rows without incrementing i 

2163 if "dummy-skip-this" in global_tags: 2163 ↛ 2164line 2163 didn't jump to line 2164 because the condition on line 2163 was never true

2164 return [] 

2165 rowtags: list[tuple[str, ...]] = [()] 

2166 # have_hdr = False 

2167 # have_hdr never used? 

2168 have_text = False 

2169 samecell_cnt = 0 

2170 col0_hdrspan = None # col0 or later header (despite its name) 

2171 col0_followed_by_nonempty = False 

2172 row_empty = True 

2173 for col_idx, cell in enumerate(row): 

2174 colspan = cell.colspan # >= 1 

2175 rowspan = cell.rowspan # >= 1 

2176 cell_links = cell.links # for weird links 

2177 previously_seen = id(cell) in seen_cells 

2178 # checks to see if this cell was in the previous ROW 

2179 seen_cells.add(id(cell)) 

2180 if samecell_cnt == 0: 

2181 # First column of a (possible multi-column) cell 

2182 samecell_cnt = colspan - 1 

2183 else: 

2184 assert samecell_cnt > 0 

2185 samecell_cnt -= 1 

2186 continue 

2187 

2188 # is_first_row_of_cell = cell_rowcnt[id(cell)] == 0 

2189 # never used? 

2190 

2191 # defaultdict(int) around line 1900 

2192 cell_rowcnt[id(cell)] += 1 

2193 # => how many cols this spans 

2194 col: str = cell.text 

2195 if not col: 

2196 continue 

2197 row_empty = False 

2198 is_title = cell.is_title 

2199 

2200 # If the cell has a target, i.e., text after colon, interpret 

2201 # it as simply specifying a value for that value and ignore 

2202 # it otherwise. 

2203 if cell.target: 

2204 text, refs, defs, hdr_tags = extract_cell_content( 

2205 lang, word, col 

2206 ) 

2207 if not text: 2207 ↛ 2208line 2207 didn't jump to line 2208 because the condition on line 2207 was never true

2208 continue 

2209 refs_tags: set[str] = set() 

2210 for ref in refs: # gets tags from footnotes 2210 ↛ 2211line 2210 didn't jump to line 2211 because the loop on line 2210 never started

2211 if ref in def_ht: 

2212 refs_tags.update(def_ht[ref]) 

2213 rowtags = expand_header( 

2214 wxr, 

2215 tablecontext, 

2216 word, 

2217 lang, 

2218 pos, 

2219 text, 

2220 [], 

2221 silent=True, 

2222 depth=depth, 

2223 column_number=col_idx, 

2224 ) 

2225 rowtags = list( 

2226 set(tuple(sorted(set(x) | refs_tags)) for x in rowtags) 

2227 ) 

2228 is_title = False 

2229 col = cell.target 

2230 

2231 # print(rownum, col_idx, col) 

2232 # print(f"is_title: {is_title}") 

2233 if is_title: 

2234 # It is a header cell 

2235 text, refs, defs, hdr_tags = extract_cell_content( 

2236 lang, word, col 

2237 ) 

2238 if not text: 

2239 continue 

2240 # Extract tags from referenced footnotes 

2241 refs_tags = set() 

2242 for ref in refs: 

2243 if ref in def_ht: 

2244 refs_tags.update(def_ht[ref]) 

2245 

2246 # Expand header to tags 

2247 v = expand_header( 

2248 wxr, 

2249 tablecontext, 

2250 word, 

2251 lang, 

2252 pos, 

2253 text, 

2254 [], 

2255 silent=True, 

2256 depth=depth, 

2257 column_number=col_idx, 

2258 ) 

2259 # print("EXPANDED {!r} to {}".format(text, v)) 

2260 

2261 if col_idx == 0: 

2262 # first_col_has_text is used for a test to ignore 

2263 # upper-left cells that are just text without 

2264 # header info 

2265 first_col_has_text = True 

2266 # Check if the header expands to reset hdrspans 

2267 if any("dummy-reset-headers" in tt for tt in v): 

2268 new_hdrspans = [] 

2269 for hdrspan in hdrspans: 

2270 # if there are HdrSpan objects (abstract headers with 

2271 # row- and column-spans) that are to the left or at the 

2272 # same row or below, KEEP those; things above and to 

2273 # the right of the hdrspan with dummy-reset-headers 

2274 # are discarded. Tags from the header together with 

2275 # dummy-reset-headers are kept as normal. 

2276 if ( 

2277 hdrspan.start + hdrspan.colspan < col_idx 

2278 or hdrspan.rownum > rownum - cell.rowspan 

2279 ): 

2280 new_hdrspans.append(hdrspan) 

2281 hdrspans = new_hdrspans 

2282 

2283 for tt in v: 

2284 if "dummy-section-header" in tt: 2284 ↛ 2285line 2284 didn't jump to line 2285 because the condition on line 2284 was never true

2285 tablecontext.section_header = tt 

2286 break 

2287 if "dummy-reset-section-header" in tt: 2287 ↛ 2288line 2287 didn't jump to line 2288 because the condition on line 2287 was never true

2288 tablecontext.section_header = tuple() 

2289 # Text between headers on a row causes earlier headers to 

2290 # be reset 

2291 if have_text: 

2292 # print(" HAVE_TEXT BEFORE HDR:", col) 

2293 # Reset rowtags if new title column after previous 

2294 # text cells 

2295 # +-----+-----+-----+-----+ 

2296 # |hdr-a|txt-a|hdr-B|txt-B| 

2297 # +-----+-----+-----+-----+ 

2298 # ^reset rowtags=> 

2299 # XXX beware of header "—": "" - must not clear on that if 

2300 # it expands to no tags 

2301 rowtags = [()] 

2302 # have_hdr = True 

2303 # have_hdr never used? 

2304 # print("HAVE_HDR: {} rowtags={}".format(col, rowtags)) 

2305 # Update rowtags and coltags 

2306 has_covering_hdr.add(col_idx) # col_idx == current column 

2307 # has_covering_hdr is a set that has the col_idx-ids of columns 

2308 # that have previously had some kind of header. It is never 

2309 # resetted inside the col_idx-loops OR the bigger rows-loop, so 

2310 # applies to the whole table. 

2311 

2312 new_coltags: list[tuple[str, ...]] 

2313 all_hdr_tags: list[tuple[str, ...]] 

2314 rowtags, new_coltags, all_hdr_tags = generate_tags( 

2315 rowtags, table_tags 

2316 ) 

2317 

2318 if any("dummy-skip-this" in ts for ts in rowtags): 

2319 continue # Skip this cell 

2320 

2321 if any("dummy-load-stored-hdrspans" in ts for ts in v): 2321 ↛ 2322line 2321 didn't jump to line 2322 because the condition on line 2321 was never true

2322 hdrspans.extend(tablecontext.stored_hdrspans) 

2323 

2324 if any("dummy-reset-stored-hdrspans" in ts for ts in v): 2324 ↛ 2325line 2324 didn't jump to line 2325 because the condition on line 2324 was never true

2325 tablecontext.stored_hdrspans = [] 

2326 

2327 if any("dummy-store-hdrspan" in ts for ts in v): 2327 ↛ 2329line 2327 didn't jump to line 2329 because the condition on line 2327 was never true

2328 # print(f"STORED: {col}") 

2329 store_new_hdrspan = True 

2330 else: 

2331 store_new_hdrspan = False 

2332 

2333 new_coltags = list( 

2334 x 

2335 for x in new_coltags 

2336 if not any(t in noinherit_tags for t in x) 

2337 ) 

2338 # print("new_coltags={} previously_seen={} all_hdr_tags={}" 

2339 # .format(new_coltags, previously_seen, all_hdr_tags)) 

2340 if any(new_coltags): 

2341 ( 

2342 col, 

2343 col0_followed_by_nonempty, 

2344 col0_hdrspan, 

2345 ) = add_new_hdrspan( 

2346 col, 

2347 hdrspans, 

2348 store_new_hdrspan, 

2349 col0_followed_by_nonempty, 

2350 col0_hdrspan, 

2351 ) 

2352 

2353 continue 

2354 

2355 # These values are ignored, at least for now 

2356 if re.match(r"^(# |\(see )", col): 2356 ↛ 2357line 2356 didn't jump to line 2357 because the condition on line 2356 was never true

2357 continue 

2358 

2359 if any("dummy-skip-this" in ts for ts in rowtags): 

2360 continue # Skip this cell 

2361 

2362 # If the word has no rowtags and is a multi-row cell, then 

2363 # ignore this. This happens with empty separator rows 

2364 # within a rowspan>1 cell. cf. wander/English/Conjugation. 

2365 if rowtags == [()] and rowspan > 1: 

2366 continue 

2367 

2368 # Minor cleanup. See e.g. είμαι/Greek/Verb present participle. 

2369 if cleanup_rules: 

2370 for regx, substitution in cleanup_rules.items(): 

2371 col = re.sub(regx, substitution, col) 

2372 

2373 if ( 2373 ↛ 2378line 2373 didn't jump to line 2378 because the condition on line 2373 was never true

2374 col_idx == 0 

2375 and not first_col_has_text 

2376 and get_lang_conf(lang, "ignore_top_left_text_cell") is True 

2377 ): 

2378 continue # Skip text at top left, as in Icelandic, Faroese 

2379 

2380 # if col0_hdrspan is not None: 

2381 # print("COL0 FOLLOWED NONHDR: {!r} by {!r}" 

2382 # .format(col0_hdrspan.text, col)) 

2383 col0_followed_by_nonempty = True 

2384 have_text = True 

2385 

2386 # Determine column tags for the multi-column cell 

2387 combined_coltags = compute_coltags( 

2388 lang, pos, hdrspans, col_idx, colspan, col 

2389 ) 

2390 if any("dummy-ignored-text-cell" in ts for ts in combined_coltags): 2390 ↛ 2391line 2390 didn't jump to line 2391 because the condition on line 2390 was never true

2391 continue 

2392 

2393 # Split the text into separate forms. First simplify spaces except 

2394 # newline. 

2395 col = re.sub(r"[ \t\r]+", " ", col) 

2396 # Split the cell text into alternatives 

2397 

2398 col, alts, split_extra_tags = split_text_into_alts(col) 

2399 

2400 # Some cells have mixed form content, like text and romanization, 

2401 # or text and IPA. Handle these. 

2402 altss = handle_mixed_lines(alts, tablecontext) 

2403 

2404 altsss = list((x, combined_coltags, cell_links) for x in altss) 

2405 

2406 # Generate forms from the alternatives 

2407 # alts is a list of (tuple of forms, tuple of tags) 

2408 coltags: list[tuple[str, ...]] 

2409 base_roman: str 

2410 ipa: str 

2411 for (form, base_roman, ipa), coltags, cell_links in altsss: 

2412 form = form.strip() 

2413 extra_tags: list[str] = [] 

2414 extra_tags.extend(split_extra_tags) 

2415 # Handle special splits again here, so that we can have custom 

2416 # mappings from form to form and tags. 

2417 if form in form_replacements: 

2418 replacement, tags = form_replacements[form] 

2419 for x in tags.split(): 

2420 assert x in valid_tags 

2421 assert isinstance(replacement, str) 

2422 assert isinstance(tags, str) 

2423 form = replacement 

2424 extra_tags.extend(tags.split()) 

2425 

2426 check_romanization_form_transformation = False 

2427 # loop over regexes in form_transformation and replace text 

2428 # in form using regex patterns 

2429 # this does a bit of the same stuff the above does, 

2430 # but with regexes and re.sub() instead 

2431 subst: str 

2432 for ( 

2433 form_transformations_pos, 

2434 vv, 

2435 subst, 

2436 tags, 

2437 ) in form_transformations: 

2438 # v is a pattern string, like "^ich" 

2439 if ( 

2440 isinstance(form_transformations_pos, str) 

2441 and pos != form_transformations_pos 

2442 ) or ( 

2443 (not isinstance(form_transformations_pos, str)) 

2444 and pos not in form_transformations_pos 

2445 ): 

2446 continue 

2447 m: re.Match | None = re.search(vv, form) 

2448 if m is not None: 

2449 if base_roman: 2449 ↛ 2450line 2449 didn't jump to line 2450 because the condition on line 2449 was never true

2450 for _, rom_v, rom_sub, _ in form_transformations: 

2451 rom_m = re.search(rom_v, base_roman) 

2452 if rom_m is not None: 

2453 base_roman = re.sub( 

2454 rom_v, rom_sub, base_roman 

2455 ) 

2456 break 

2457 form = re.sub(vv, subst, form) 

2458 for x in tags.split(): 

2459 assert x in valid_tags 

2460 extra_tags.extend(tags.split()) 

2461 check_romanization_form_transformation = True 

2462 break 

2463 

2464 # Clean the value, extracting reference symbols 

2465 form, refs, defs, hdr_tags = extract_cell_content( 

2466 lang, word, form 

2467 ) 

2468 # if refs: 

2469 # print("REFS:", refs) 

2470 extra_tags.extend(hdr_tags) 

2471 # Extract tags from referenced footnotes 

2472 refs_tags = set() 

2473 for ref in refs: 

2474 if ref in def_ht: 

2475 refs_tags.update(def_ht[ref]) 

2476 

2477 if base_roman: 

2478 if check_romanization_form_transformation: 2478 ↛ 2482line 2478 didn't jump to line 2482 because the condition on line 2478 was never true

2479 # because form_transformations are used to handle things 

2480 # where the romanization has the "same" structure, we 

2481 # need to handle that here too.... 

2482 for ( 

2483 _, 

2484 vv, 

2485 subst, 

2486 _, 

2487 ) in form_transformations: 

2488 # v is a pattern string, like "^ich" 

2489 m = re.search(vv, base_roman) 

2490 if m is not None: 

2491 base_roman = re.sub(vv, subst, base_roman) 

2492 # XXX add tag stuff here if needed 

2493 break 

2494 

2495 base_roman, _, _, hdr_tags = extract_cell_content( 

2496 lang, word, base_roman 

2497 ) 

2498 extra_tags.extend(hdr_tags) 

2499 

2500 # Do some additional cleanup on the cell. 

2501 form = re.sub(r"^\s*,\s*", "", form) 

2502 form = re.sub(r"\s*,\s*$", "", form) 

2503 form = re.sub(r"\s*(,\s*)+", ", ", form) 

2504 form = re.sub(r"(?i)^Main:", "", form) 

2505 form = re.sub(r"\s+", " ", form) 

2506 form = form.strip() 

2507 

2508 # Look for parentheses that have semantic meaning 

2509 form, et = find_semantic_parens(form, lang) 

2510 extra_tags.extend(et) 

2511 

2512 # Handle parentheses in the table element. We parse 

2513 # tags anywhere and romanizations anywhere but beginning. 

2514 roman: str = base_roman 

2515 paren: str | None = None 

2516 clitic: str | None = None 

2517 m = re.search(r"(\s+|^)\(([^)]*)\)", form) 

2518 # start|spaces + (anything) 

2519 if m is not None: 

2520 subst = m.group(1) 

2521 paren = m.group(2) 

2522 else: 

2523 m = re.search(r"\(([^)]*)\)(\s+|$)", form) 

2524 # (anything) + spaces|end 

2525 if m is not None: 2525 ↛ 2526line 2525 didn't jump to line 2526 because the condition on line 2525 was never true

2526 paren = m.group(1) 

2527 subst = m.group(2) 

2528 if paren is not None: 

2529 form, roman, clitic = handle_parens( 

2530 form, roman, clitic, extra_tags 

2531 ) 

2532 

2533 # Ignore certain forms that are not really forms, 

2534 # unless they're really, really close to the article title 

2535 if form in ( 2535 ↛ 2540line 2535 didn't jump to line 2540 because the condition on line 2535 was never true

2536 "", 

2537 "unchanged", 

2538 "after an", # in sona/Irish/Adj/Mutation 

2539 ): 

2540 Lev = distw([form], word) 

2541 if form and Lev < 0.1: 

2542 wxr.wtp.debug( 

2543 "accepted possible false positive '{}' with" 

2544 "> 0.1 Levenshtein distance in {}/{}".format( 

2545 form, word, lang 

2546 ), 

2547 sortid="inflection/2213", 

2548 ) 

2549 elif form and Lev < 0.3: 

2550 wxr.wtp.debug( 

2551 "skipped possible match '{}' with > 0.3" 

2552 "Levenshtein distance in {}/{}".format( 

2553 form, word, lang 

2554 ), 

2555 sortid="inflection/2218", 

2556 ) 

2557 continue 

2558 else: 

2559 continue 

2560 # print("ROWTAGS={} COLTAGS={} REFS_TAGS={} " 

2561 # "FORM={!r} ROMAN={!r}" 

2562 # .format(rowtags, coltags, refs_tags, 

2563 # form, roman)) 

2564 

2565 # Merge tags from row and column and do miscellaneous 

2566 # tag-related handling. 

2567 ( 

2568 merge_ret, 

2569 form, 

2570 some_has_covered_text, 

2571 ) = merge_row_and_column_tags( 

2572 form, some_has_covered_text, cell_links 

2573 ) 

2574 ret.extend(merge_ret) 

2575 

2576 # End of row. 

2577 rownum += 1 

2578 # For certain languages, if the row was empty, reset 

2579 # hdrspans (saprast/Latvian/Verb, but not aussteigen/German/Verb). 

2580 if row_empty and get_lang_conf(lang, "empty_row_resets"): 

2581 hdrspans = [] 

2582 # Check if we should expand col0_hdrspan. 

2583 if col0_hdrspan is not None: 

2584 col0_allowed = get_lang_conf(lang, "hdr_expand_first") 

2585 col0_cats = tagset_cats(col0_hdrspan.tagsets) 

2586 # Only expand if col0_cats and later_cats are allowed 

2587 # and don't overlap and col0 has tags, and there have 

2588 # been no disallowed cells in between. 

2589 if ( 

2590 not col0_followed_by_nonempty 

2591 and not (col0_cats - col0_allowed) 

2592 and 

2593 # len(col0_cats) == 1 and 

2594 col_idx > col0_hdrspan.start + col0_hdrspan.colspan 

2595 ): 

2596 # If an earlier header is only followed by headers that yield 

2597 # no tags, expand it to entire row 

2598 # print("EXPANDING COL0: {} from {} to {} cols {}" 

2599 # .format(col0_hdrspan.text, col0_hdrspan.colspan, 

2600 # len(row) - col0_hdrspan.start, 

2601 # col0_hdrspan.tagsets)) 

2602 col0_hdrspan.colspan = len(row) - col0_hdrspan.start 

2603 col0_hdrspan.expanded = True 

2604 # XXX handle refs and defs 

2605 # for x in hdrspans: 

2606 # print(" HDRSPAN {} {} {} {!r}" 

2607 # .format(x.start, x.colspan, x.tagsets, x.text)) 

2608 

2609 # Post-process German nouns with articles in separate columns. We move the 

2610 # definite/indefinite/usually-without-article markers into the noun and 

2611 # remove the article entries. 

2612 if get_lang_conf(lang, "articles_in_separate_columns") and any( 

2613 "noun" in x["tags"] for x in ret 

2614 ): 

2615 new_ret = [] 

2616 saved_tags: set[str] = set() 

2617 had_noun = False 

2618 for dt in ret: 

2619 tags = dt["tags"] 

2620 # print(tags) 

2621 if "noun" in tags: 

2622 tags = list( 

2623 sorted(set(t for t in tags if t != "noun") | saved_tags) 

2624 ) 

2625 had_noun = True 

2626 elif ( 2626 ↛ 2653line 2626 didn't jump to line 2653 because the condition on line 2626 was always true

2627 "indefinite" in tags 

2628 or "definite" in tags 

2629 or "usually-without-article" in tags 

2630 or "without-article" in tags 

2631 ): 

2632 if had_noun: 

2633 saved_tags = set(tags) 

2634 else: 

2635 saved_tags = saved_tags | set(tags) # E.g. Haus/German 

2636 remove_useless_tags(lang, pos, saved_tags) 

2637 saved_tags = saved_tags & set( 

2638 [ 

2639 "masculine", 

2640 "feminine", 

2641 "neuter", 

2642 "singular", 

2643 "plural", 

2644 "indefinite", 

2645 "definite", 

2646 "usually-without-article", 

2647 "without-article", 

2648 ] 

2649 ) 

2650 had_noun = False 

2651 continue # Skip the articles 

2652 

2653 dt = dt.copy() 

2654 dt["tags"] = tags 

2655 new_ret.append(dt) 

2656 ret = new_ret 

2657 

2658 elif possibly_ignored_forms: 

2659 # Some languages have tables with cells that are kind of separated 

2660 # and difficult to handle, like eulersche Formel/German where 

2661 # the definite and indefinite articles are just floating. 

2662 # If a language has a dict of conditionally_ignored_cells, 

2663 # and if the contents of a cell is found in one of the rules 

2664 # there, ignore that cell if it 

2665 # 1. Does not have the appropriate tag (like "definite" for "die") 

2666 # and 

2667 # 2. The title of the article is not one of the other co-words 

2668 # (ie. it's an article for the definite articles in german etc.) 

2669 # pass 

2670 new_ret = [] 

2671 for cell_data in ret: 

2672 tags = cell_data["tags"] 

2673 text = cell_data["form"] 

2674 skip_this = False 

2675 for key_tag, ignored_forms in possibly_ignored_forms.items(): 

2676 if text not in ignored_forms: 2676 ↛ 2678line 2676 didn't jump to line 2678 because the condition on line 2676 was always true

2677 continue 

2678 if word in ignored_forms: 

2679 continue 

2680 if key_tag not in tags: 

2681 skip_this = True 

2682 

2683 if skip_this: 2683 ↛ 2684line 2683 didn't jump to line 2684 because the condition on line 2683 was never true

2684 continue 

2685 new_ret.append(cell_data) 

2686 

2687 ret = new_ret 

2688 

2689 # Post-process English inflection tables, addding "multiword-construction" 

2690 # when the number of words has increased. 

2691 if lang == "English" and pos == "verb": 

2692 word_words = len(word.split()) 

2693 new_ret = [] 

2694 for dt in ret: 

2695 form = dt.get("form", "") 

2696 if len(form.split()) > word_words: 

2697 dt = dt.copy() 

2698 dt["tags"] = list(dt.get("tags", [])) 

2699 # This strange copy-assigning shuffle is preventative black 

2700 # magic; do not touch lest you invoke deep bugs. 

2701 data_append(dt, "tags", "multiword-construction") 

2702 new_ret.append(dt) 

2703 ret = new_ret 

2704 

2705 # Always insert "table-tags" detail as the first entry in any inflection 

2706 # table. This way we can reliably detect where a new table starts. 

2707 # Table-tags applies until the next table-tags entry. 

2708 if ret or table_tags: 

2709 table_tags = sorted(set(table_tags)) 

2710 dt = { 

2711 "form": " ".join(table_tags), 

2712 "source": source, 

2713 "tags": ["table-tags"], 

2714 } 

2715 if dt["form"] == "": 

2716 dt["form"] = "no-table-tags" 

2717 if tablecontext.template_name: 

2718 tn: FormData = { 

2719 "form": tablecontext.template_name, 

2720 "source": source, 

2721 "tags": ["inflection-template"], 

2722 } 

2723 ret = [dt] + [tn] + ret 

2724 else: 

2725 ret = [dt] + ret 

2726 

2727 return ret 

2728 

2729 

2730def find_semantic_parens(form: str, lang: str) -> tuple[str, list[str]]: 

2731 # "Some languages" (=Greek) use brackets to mark things that 

2732 # require tags, like (informality), [rarity] and {archaicity}. 

2733 extra_tags = [] 

2734 if re.match(r"\([^][(){}]*\)$", form): 

2735 if get_lang_conf(lang, "parentheses_for_informal"): 

2736 form = form[1:-1] 

2737 extra_tags.append("informal") 

2738 else: 

2739 form = form[1:-1] 

2740 elif re.match(r"\{\[[^][(){}]*\]\}$", form): 

2741 if get_lang_conf(lang, "square_brackets_for_rare") and get_lang_conf( 2741 ↛ 2748line 2741 didn't jump to line 2748 because the condition on line 2741 was always true

2742 lang, "curly_brackets_for_archaic" 

2743 ): 

2744 # είμαι/Greek/Verb 

2745 form = form[2:-2] 

2746 extra_tags.extend(["rare", "archaic"]) 

2747 else: 

2748 form = form[2:-2] 

2749 elif re.match(r"\{[^][(){}]*\}$", form): 

2750 if get_lang_conf(lang, "curly_brackets_for_archaic"): 2750 ↛ 2755line 2750 didn't jump to line 2755 because the condition on line 2750 was always true

2751 # είμαι/Greek/Verb 

2752 form = form[1:-1] 

2753 extra_tags.extend(["archaic"]) 

2754 else: 

2755 form = form[1:-1] 

2756 elif re.match(r"\[[^][(){}]*\]$", form): 

2757 if get_lang_conf(lang, "square_brackets_for_rare"): 2757 ↛ 2762line 2757 didn't jump to line 2762 because the condition on line 2757 was always true

2758 # είμαι/Greek/Verb 

2759 form = form[1:-1] 

2760 extra_tags.append("rare") 

2761 else: 

2762 form = form[1:-1] 

2763 return form, extra_tags 

2764 

2765 

2766def handle_mixed_lines( 

2767 alts: list[str], tablecontext: "TableContext" 

2768) -> list[tuple[str, str, str]]: 

2769 # Handle the special case where romanization is given under 

2770 # normal form, e.g. in Russian. There can be multiple 

2771 # comma-separated forms in each case. We also handle the case 

2772 # where instead of romanization we have IPA pronunciation 

2773 # (e.g., avoir/French/verb). 

2774 len2 = len(alts) // 2 

2775 

2776 if len(alts) == 1 and "(" not in alts[0]: 

2777 return [(alts[0], "", "")] 

2778 

2779 # Check for IPAs (forms first, IPAs under) 

2780 # base, base, IPA, IPA 

2781 if ( 

2782 len(alts) % 2 == 0 # Divisibly by two 

2783 and all( 

2784 re.match(r"^\s*/.*/\s*$", x) # Inside slashes = IPA 

2785 for x in alts[len2:] 

2786 ) 

2787 and not any( 

2788 re.match(r"^\s*/.*/\s*$", x) # first half without slashes 

2789 for x in alts[:len2] 

2790 ) 

2791 ): # In the second half of alts 

2792 return list( 

2793 (alts[i], "", alts[i + len2]) 

2794 # List of tuples: (base, "", ipa) 

2795 for i in range(len2) 

2796 ) 

2797 # base, base, base, IPA 

2798 elif ( 

2799 len(alts) > 2 

2800 and re.match(r"^\s*/.*/\s*$", alts[-1]) 

2801 and all(not x.startswith("/") for x in alts[:-1]) 

2802 ): 

2803 # Only if the last alt is IPA 

2804 return list((alts[i], "", alts[-1]) for i in range(len(alts) - 1)) 

2805 

2806 # base, IPA, IPA, IPA 

2807 elif ( 

2808 len(alts) > 2 

2809 and not alts[0].startswith("/") 

2810 and all(re.match(r"^\s*/.*/\s*$", x) for x in alts[1:]) 

2811 ): 

2812 # First is base and the rest is IPA alternatives 

2813 return list((alts[0], "", x) for x in alts[1:]) 

2814 

2815 alt_classifications = list( 

2816 classify_desc( 

2817 re.sub( 

2818 r"\^.*$", 

2819 "", 

2820 # Remove ends of strings starting from ^. 

2821 # Supescripts have been already removed 

2822 # from the string, while ^xyz needs to be 

2823 # removed separately, though it's usually 

2824 # something with a single letter? 

2825 "".join(xx for xx in x if not is_superscript(xx)) 

2826 # Remove trailing footnote asterisks that mess with 

2827 # classification 

2828 .strip("* "), 

2829 ) 

2830 ) 

2831 for x in alts 

2832 ) 

2833 

2834 # Check for romanizations, forms first, romanizations under 

2835 if ( 

2836 len(alts) % 2 == 0 

2837 and not any("(" in x for x in alts) 

2838 and all(x == "other" for x in alt_classifications[:len2]) 

2839 and all( 

2840 x in ("romanization", "english") for x in alt_classifications[len2:] 

2841 ) 

2842 ): 

2843 return list((alts[i], alts[i + len2], "") for i in range(len2)) 

2844 # Check for romanizations, forms and romanizations alternating 

2845 elif ( 

2846 len(alts) % 2 == 0 

2847 and not any("(" in x for x in alts) 

2848 and all( 

2849 alt_classifications[i] == "other" for i in range(0, len(alts), 2) 

2850 ) 

2851 and all( 

2852 alt_classifications[i] in ("romanization", "english") 

2853 for i in range(1, len(alts), 2) 

2854 ) 

2855 ): 

2856 # odds 

2857 return list((alts[i], alts[i + 1], "") for i in range(0, len(alts), 2)) 

2858 # evens 

2859 # Handle complex Georgian entries with alternative forms and* 

2860 # *romanizations. It's a bit of a mess. Remove this kludge if not 

2861 # needed anymore. NOTE THAT THE PARENTHESES ON THE WEBSITE ARE NOT 

2862 # DISPLAYED. They are put inside their own span elements that are 

2863 # then hidden with some CSS. 

2864 # https://en.wiktionary.org/wiki/%E1%83%90%E1%83%9B%E1%83%94%E1%83%A0%E1%83%98%E1%83%99%E1%83%98%E1%83%A1_%E1%83%A8%E1%83%94%E1%83%94%E1%83%A0%E1%83%97%E1%83%94%E1%83%91%E1%83%A3%E1%83%9A%E1%83%98_%E1%83%A8%E1%83%A2%E1%83%90%E1%83%A2%E1%83%94%E1%83%91%E1%83%98 

2865 # ამერიკის შეერთებულ შტატებს(ა) (ameriḳis šeertebul šṭaṭebs(a)) 

2866 # The above should generate two alts entries, with two different 

2867 # parallel versions, one without (a) and with (a) at the end, 

2868 # for both the Georgian original and the romanization. 

2869 elif ( 

2870 tablecontext.template_name == "ka-decl-noun" 

2871 and len(alts) >= 1 

2872 and any(" (" in alt_ for alt_ in alts) 

2873 ): 

2874 return ka_decl_noun_template_cell(alts) 

2875 elif ( 

2876 len(alts) > 2 

2877 and alt_classifications[0] == "other" 

2878 and all( 

2879 x in ("romanization", "english") for x in alt_classifications[1:] 

2880 ) 

2881 ): 

2882 return list((alts[0], x, "") for x in alts[1:]) 

2883 else: 

2884 new_alts = [] 

2885 for alt in alts: 

2886 lst = [""] 

2887 idx = 0 

2888 for m in re.finditer( 

2889 r"(^|\w|\*)\((\w+(/\w+)*)\)", 

2890 # start OR letter OR asterisk (word/word*) 

2891 # \\___________group 1_______/ \ \_g3_/// 

2892 # \ \__gr. 2_// 

2893 # \_____________group 0________________/ 

2894 alt, 

2895 ): 

2896 v = m.group(2) # (word/word/word...) 

2897 if ( 

2898 classify_desc(v) == "tags" # Tags inside parens 

2899 or m.group(0) == alt 

2900 ): # All in parens 

2901 continue 

2902 new_lst = [] 

2903 for x in lst: 

2904 x += alt[idx : m.start()] + m.group(1) 

2905 # alt until letter or asterisk 

2906 idx = m.end() 

2907 vparts = v.split("/") 

2908 # group(2) = ["word", "wörd"...] 

2909 if len(vparts) == 1: 

2910 new_lst.append(x) 

2911 new_lst.append(x + v) 

2912 # "kind(er)" -> ["kind", "kinder"] 

2913 else: 

2914 for vv in vparts: 

2915 new_lst.append(x + vv) 

2916 # "lampai(tten/den)" -> 

2917 # ["lampaitten", "lampaiden"] 

2918 lst = new_lst 

2919 for x in lst: 

2920 new_alts.append(x + alt[idx:]) 

2921 # add the end of alt 

2922 return list((x, "", "") for x in new_alts) 

2923 # [form, no romz, no ipa] 

2924 return [] 

2925 

2926 

2927def handle_generic_table( 

2928 wxr: WiktextractContext, 

2929 tablecontext: "TableContext", 

2930 data: WordData, 

2931 word: str, 

2932 lang: str, 

2933 pos: str, 

2934 rows: list[list[InflCell]], 

2935 titles: list[str], 

2936 source: str, 

2937 after: str, 

2938 depth: int, 

2939) -> None: 

2940 assert isinstance(wxr, WiktextractContext) 

2941 assert isinstance(data, dict) 

2942 assert isinstance(word, str) 

2943 assert isinstance(lang, str) 

2944 assert isinstance(pos, str) 

2945 assert isinstance(rows, list) 

2946 assert isinstance(source, str) 

2947 assert isinstance(after, str) 

2948 assert isinstance(depth, int) 

2949 for row in rows: 

2950 assert isinstance(row, list) 

2951 for x in row: 

2952 assert isinstance(x, InflCell) 

2953 assert isinstance(titles, list) 

2954 for s in titles: 

2955 assert isinstance(s, str) 

2956 

2957 # Try to parse the table as a simple table 

2958 ret = parse_simple_table( 

2959 wxr, tablecontext, word, lang, pos, rows, titles, source, after, depth 

2960 ) 

2961 if ret is None: 2961 ↛ 2964line 2961 didn't jump to line 2964 because the condition on line 2961 was never true

2962 # XXX handle other table formats 

2963 # We were not able to handle the table 

2964 wxr.wtp.debug( 

2965 "unhandled inflection table format, {}/{}".format(word, lang), 

2966 sortid="inflection/2370", 

2967 ) 

2968 return 

2969 

2970 # Add the returned forms but eliminate duplicates. 

2971 have_forms = set() 

2972 for dt in ret: 

2973 fdt = freeze(dt) 

2974 if fdt in have_forms: 

2975 continue # Don't add duplicates 

2976 # Some Russian words have Declension and Pre-reform declension partially 

2977 # duplicating same data. Don't add "dated" tags variant if already have 

2978 # the same without "dated" from the modern declension table 

2979 

2980 tags = dt.get("tags", []) 

2981 for dated_tag in ("dated",): 

2982 if dated_tag in tags: 

2983 dt2 = dt.copy() 

2984 tags2 = list(x for x in tags if x != dated_tag) 

2985 dt2["tags"] = tags2 

2986 if tags2 and freeze(dt2) in have_forms: 2986 ↛ 2987line 2986 didn't jump to line 2987 because the condition on line 2986 was never true

2987 break # Already have without archaic 

2988 else: 

2989 if "table-tags" not in tags: 

2990 have_forms.add(fdt) 

2991 data_append(data, "forms", dt) 

2992 

2993 

2994def determine_header( 

2995 wxr: WiktextractContext, 

2996 tablecontext, 

2997 lang: str, 

2998 word: str, 

2999 pos: str, 

3000 table_kind: NodeKind, 

3001 kind: NodeKind | str, 

3002 style: str | None, 

3003 row: list[InflCell], 

3004 col: WikiNode, 

3005 celltext: str, 

3006 titletext: str, 

3007 cols_headered: list[bool], 

3008 target: str | None, 

3009 cellstyle: str, 

3010 # is_title, 

3011 # hdr_expansion, 

3012 # target, 

3013 # celltext, 

3014) -> tuple[bool, list[tuple[str, ...]], str | None, str]: 

3015 assert isinstance(table_kind, NodeKind) 

3016 assert isinstance(kind, (NodeKind, str)) 

3017 assert style is None or isinstance(style, str) 

3018 assert cellstyle is None or isinstance(cellstyle, str) 

3019 

3020 header_kind: NodeKind | str 

3021 if table_kind == NodeKind.TABLE: 

3022 header_kind = NodeKind.TABLE_HEADER_CELL 

3023 elif table_kind == NodeKind.HTML: 3023 ↛ 3025line 3023 didn't jump to line 3025 because the condition on line 3023 was always true

3024 header_kind = "th" 

3025 idx = celltext.find(": ") 

3026 is_title = False 

3027 # remove anything in parentheses, compress whitespace, .strip() 

3028 cleaned_titletext = re.sub( 

3029 r"\s+", " ", re.sub(r"\s*\([^)]*\)", "", titletext) 

3030 ).strip() 

3031 cleaned, _, _, _ = extract_cell_content(lang, word, celltext) 

3032 cleaned = re.sub(r"\s+", " ", cleaned) 

3033 hdr_expansion = expand_header( 

3034 wxr, 

3035 tablecontext, 

3036 word, 

3037 lang, 

3038 pos, 

3039 cleaned, 

3040 [], 

3041 silent=True, 

3042 ignore_tags=True, 

3043 ) 

3044 candidate_hdr = not any( 

3045 any(t.startswith("error-") for t in ts) for ts in hdr_expansion 

3046 ) 

3047 # KJ candidate_hdr says that a specific cell is a candidate 

3048 # for being a header because it passed through expand_header 

3049 # without getting any "error-" tags; that is, the contents 

3050 # is "valid" for being a header; these are the false positives 

3051 # we want to catch 

3052 ignored_cell = any( 

3053 any(t.startswith("dummy-") for t in ts) for ts in hdr_expansion 

3054 ) 

3055 # ignored_cell should NOT be used to filter for headers, like 

3056 # candidate_hdr is used, but only to filter for related *debug 

3057 # messages*: some dummy-tags are actually half-way to headers, 

3058 # like ones with "Notes", so they MUST be headers, but later 

3059 # on they're ignored *as* headers so they don't need to print 

3060 # out any cells-as-headers debug messages. 

3061 if ( 

3062 candidate_hdr 

3063 and kind != header_kind 

3064 and cleaned != "" 

3065 and cleaned != "dummy-ignored-text-cell" 

3066 and cleaned not in IGNORED_COLVALUES 

3067 ): 

3068 # print("col: {}".format(col)) 

3069 if not ignored_cell and lang not in LANGUAGES_WITH_CELLS_AS_HEADERS: 

3070 wxr.wtp.debug( 

3071 "rejected heuristic header: " 

3072 "table cell identified as header and given " 

3073 "candidate status, BUT {} is not in " 

3074 "LANGUAGES_WITH_CELLS_AS_HEADERS; " 

3075 "cleaned text: {}".format(lang, cleaned), 

3076 sortid="inflection/2447", 

3077 ) 

3078 candidate_hdr = False 

3079 elif cleaned not in LANGUAGES_WITH_CELLS_AS_HEADERS.get(lang, ""): 

3080 wxr.wtp.debug( 

3081 "rejected heuristic header: " 

3082 "table cell identified as header and given " 

3083 "candidate status, BUT the cleaned text is " 

3084 "not in LANGUAGES_WITH_CELLS_AS_HEADERS[{}]; " 

3085 "cleaned text: {}".format(lang, cleaned), 

3086 sortid="inflection/2457", 

3087 ) 

3088 candidate_hdr = False 

3089 else: 

3090 wxr.wtp.debug( 

3091 "accepted heuristic header: " 

3092 "table cell identified as header and given " 

3093 "candidate status, AND the cleaned text is " 

3094 "in LANGUAGES_WITH_CELLS_AS_HEADERS[{}]; " 

3095 "cleaned text: {}".format(lang, cleaned), 

3096 sortid="inflection/2466", 

3097 ) 

3098 

3099 # If the cell starts with something that could start a 

3100 # definition (typically a reference symbol), make it a candidate 

3101 # regardless of whether the language is listed. 

3102 if re.match(def_re, cleaned) and not re.match(nondef_re, cleaned): 3102 ↛ 3103line 3102 didn't jump to line 3103 because the condition on line 3102 was never true

3103 candidate_hdr = True 

3104 

3105 # print("titletext={!r} hdr_expansion={!r} candidate_hdr={!r} " 

3106 # "lang={} pos={}" 

3107 # .format(titletext, hdr_expansion, candidate_hdr, 

3108 # lang, pos)) 

3109 if idx >= 0 and titletext[:idx] in infl_map: 

3110 target = titletext[idx + 2 :].strip() 

3111 celltext = celltext[:idx] 

3112 is_title = True 

3113 elif ( 

3114 kind == header_kind 

3115 and " + " not in titletext # For "avoir + blah blah"? 

3116 and not any( 

3117 isinstance(x, WikiNode) 

3118 and x.kind == NodeKind.HTML 

3119 and x.sarg == "span" 

3120 and x.attrs.get("lang") in ("az",) 

3121 for x in col.children 

3122 ) 

3123 ): 

3124 is_title = True 

3125 elif ( 

3126 candidate_hdr 

3127 and cleaned_titletext not in IGNORED_COLVALUES 

3128 and distw([cleaned_titletext], word) > 0.3 

3129 and cleaned_titletext not in ("I", "es") 

3130 ): 

3131 is_title = True 

3132 # if first column or same style as first column 

3133 elif ( 

3134 style == cellstyle 

3135 and 

3136 # and title is not identical to word name 

3137 titletext != word 

3138 and cleaned not in IGNORED_COLVALUES 

3139 and cleaned != "dummy-ignored-text-cell" 

3140 and 

3141 # the style composite string is not broken 

3142 not style.startswith("////") 

3143 and " + " not in titletext 

3144 ): 

3145 if not ignored_cell and lang not in LANGUAGES_WITH_CELLS_AS_HEADERS: 3145 ↛ 3146line 3145 didn't jump to line 3146 because the condition on line 3145 was never true

3146 wxr.wtp.debug( 

3147 "rejected heuristic header: " 

3148 "table cell identified as header based " 

3149 "on style, BUT {} is not in " 

3150 "LANGUAGES_WITH_CELLS_AS_HEADERS; " 

3151 "cleaned text: {}, style: {}".format(lang, cleaned, style), 

3152 sortid="inflection/2512", 

3153 ) 

3154 elif ( 3154 ↛ 3158line 3154 didn't jump to line 3158 because the condition on line 3154 was never true

3155 not ignored_cell 

3156 and cleaned not in LANGUAGES_WITH_CELLS_AS_HEADERS.get(lang, "") 

3157 ): 

3158 wxr.wtp.debug( 

3159 "rejected heuristic header: " 

3160 "table cell identified as header based " 

3161 "on style, BUT the cleaned text is " 

3162 "not in LANGUAGES_WITH_CELLS_AS_HEADERS[{}]; " 

3163 "cleaned text: {}, style: {}".format(lang, cleaned, style), 

3164 sortid="inflection/2522", 

3165 ) 

3166 else: 

3167 wxr.wtp.debug( 

3168 "accepted heuristic header: " 

3169 "table cell identified as header based " 

3170 "on style, AND the cleaned text is " 

3171 "in LANGUAGES_WITH_CELLS_AS_HEADERS[{}]; " 

3172 "cleaned text: {}, style: {}".format(lang, cleaned, style), 

3173 sortid="inflection/2530", 

3174 ) 

3175 is_title = True 

3176 if ( 3176 ↛ 3183line 3176 didn't jump to line 3183 because the condition on line 3176 was never true

3177 not is_title 

3178 and len(row) < len(cols_headered) 

3179 and cols_headered[len(row)] 

3180 ): 

3181 # Whole column has title suggesting they are headers 

3182 # (e.g. "Case") 

3183 is_title = True 

3184 if re.match( 

3185 r"Conjugation of |Declension of |Inflection of |" 

3186 r"Mutation of |Notes\b", # \b is word-boundary 

3187 titletext, 

3188 ): 

3189 is_title = True 

3190 return is_title, hdr_expansion, target, celltext 

3191 

3192 

3193class TableContext: 

3194 """Saved context used when parsing a table and its subtables.""" 

3195 

3196 __slot__ = ( 

3197 "stored_hdrspans", 

3198 "section_header", 

3199 "template_name", 

3200 ) 

3201 

3202 def __init__(self, template_name: str | None = None) -> None: 

3203 self.stored_hdrspans: list[HdrSpan] = [] 

3204 self.section_header: tuple[str, ...] = tuple() 

3205 if template_name is None: 

3206 self.template_name = "" 

3207 else: 

3208 self.template_name = template_name 

3209 

3210 

3211def handle_wikitext_or_html_table( 

3212 wxr: WiktextractContext, 

3213 word: str, 

3214 lang: str, 

3215 pos: str, 

3216 data: WordData, 

3217 tree: WikiNode, 

3218 titles: list[str], 

3219 source: str, 

3220 after: str, 

3221 tablecontext: TableContext | None = None, 

3222): 

3223 """Parses a table from parsed Wikitext format into rows and columns of 

3224 InflCell objects and then calls handle_generic_table() to parse it into 

3225 forms. This adds the forms into ``data``.""" 

3226 assert isinstance(wxr, WiktextractContext) 

3227 assert isinstance(word, str) 

3228 assert isinstance(lang, str) 

3229 assert isinstance(pos, str) 

3230 assert isinstance(data, dict) 

3231 assert isinstance(tree, WikiNode) 

3232 assert tree.kind == NodeKind.TABLE or ( 

3233 tree.kind == NodeKind.HTML and tree.sarg == "table" 

3234 ) 

3235 assert isinstance(titles, list) 

3236 assert isinstance(source, str) 

3237 for x in titles: 

3238 assert isinstance(x, str) 

3239 assert isinstance(after, str) 

3240 assert tablecontext is None or isinstance(tablecontext, TableContext) 

3241 # Imported here to avoid a circular import 

3242 from wiktextract.page import clean_node, recursively_extract 

3243 

3244 # from wikitextprocessor.parser import print_tree 

3245 # print_tree(tree) 

3246 # print("-------==========-------") 

3247 

3248 if not tablecontext: 

3249 tablecontext = TableContext() 

3250 

3251 # Get language specific text removal patterns 

3252 remove_text_patterns: tuple[str | re.Pattern, ...] | None = None 

3253 if rem := get_lang_conf(lang, "remove_text_patterns"): 

3254 for poses in rem.keys(): 

3255 if pos in poses: 

3256 remove_text_patterns = rem[poses] 

3257 break 

3258 

3259 def handle_table1( 

3260 wxr: WiktextractContext, 

3261 tablecontext: TableContext, 

3262 word: str, 

3263 lang: str, 

3264 pos: str, 

3265 data: WordData, 

3266 tree: WikiNode, 

3267 titles: list[str], 

3268 source: str, 

3269 after: str, 

3270 depth: int, 

3271 ) -> list[tuple[list[list[InflCell]], list[str], str, int]]: 

3272 # rows, titles, after, depth 

3273 """Helper function allowing the 'flattening' out of the table 

3274 recursion: instead of handling the tables in the wrong order 

3275 (recursively), this function adds to new_row that is then 

3276 iterated through in the main function at the end, creating 

3277 a longer table (still in pieces) in the correct order.""" 

3278 

3279 assert isinstance(data, dict) 

3280 assert isinstance(titles, list) 

3281 assert isinstance(source, str) 

3282 for x in titles: 

3283 assert isinstance(x, str) 

3284 assert isinstance(after, str) 

3285 assert isinstance(depth, int) 

3286 # print("HANDLE_WIKITEXT_TABLE", titles) 

3287 # if len(titles) > 0: 

3288 # wxr.wtp.debug(f"HANDLE_WIKITEXT_TABLE {titles=}") 

3289 

3290 # Filling for columns with rowspan > 1 

3291 col_gap_data: list[InflCell | None] = [] 

3292 # Number of remaining rows for which to fill the column 

3293 vertical_still_left: list[int] = [] 

3294 cols_headered: list[bool] = [] # [F, T, F, F...] 

3295 # True when the whole column contains headers, even 

3296 # when the cell is not considered a header; triggered 

3297 # by the "*" inflmap meta-tag. 

3298 rows: list[list[InflCell]] = [] 

3299 

3300 sub_ret = [] 

3301 

3302 # from wikitextprocessor.parser import print_tree 

3303 # print_tree(tree) 

3304 for node in tree.children: 

3305 if not isinstance(node, WikiNode): 

3306 continue 

3307 kind: NodeKind | str 

3308 if node.kind == NodeKind.HTML: 

3309 kind = node.sarg 

3310 else: 

3311 kind = node.kind 

3312 

3313 # print(" {}".format(node)) 

3314 if kind in (NodeKind.TABLE_CAPTION, "caption"): 

3315 # print(" CAPTION:", node) 

3316 if "inflection-table-title" in node.attrs.get("class", ""): 3316 ↛ 3317line 3316 didn't jump to line 3317 because the condition on line 3316 was never true

3317 titles = [clean_node(wxr, None, node.children)] 

3318 elif kind in (NodeKind.TABLE_ROW, "tr"): 

3319 if "vsShow" in node.attrs.get("class", "").split(): 

3320 # vsShow rows are those that are intially shown in tables 

3321 # that have more data. The hidden data duplicates these 

3322 # rows, so we skip it and just process the hidden data. 

3323 continue 

3324 

3325 # if ( 

3326 # len(node.children) == 1 

3327 # and node.children[0].attrs.get("class") == "separator" 

3328 # ): 

3329 # print("------------------ skip separator") 

3330 # continue 

3331 

3332 # Parse a table row. 

3333 row: list[InflCell] = [] 

3334 style = None 

3335 row_has_nonempty_cells = False 

3336 # Have nonempty cell not from rowspan 

3337 for col in get_table_cells(node): 

3338 # loop through each cell in the ROW 

3339 

3340 # The below skip is not needed anymore, because we "skip" in 

3341 # get_table_cells, but left here as a comment 

3342 # if not isinstance(col, WikiNode): 

3343 # # This skip is not used for counting, 

3344 # # "None" is not used in 

3345 # # indexing or counting or looping. 

3346 # continue 

3347 if col.kind == NodeKind.HTML: 

3348 kind = col.sarg 

3349 else: 

3350 kind = col.kind 

3351 if kind not in ( 3351 ↛ 3357line 3351 didn't jump to line 3357 because the condition on line 3351 was never true

3352 NodeKind.TABLE_HEADER_CELL, 

3353 NodeKind.TABLE_CELL, 

3354 "th", 

3355 "td", 

3356 ): 

3357 print(" UNEXPECTED ROW CONTENT: {}".format(col)) 

3358 continue 

3359 

3360 while ( 

3361 len(row) < len(vertical_still_left) 

3362 and vertical_still_left[len(row)] > 0 

3363 ): 

3364 # vertical_still_left is [...0, 0, 2...] for each 

3365 # column. It is populated at the end of the loop, at the 

3366 # same time as col_gap_data. This needs to be looped and 

3367 # filled this way because each `for col`-looping jumps 

3368 # straight to the next meaningful cell; there is no 

3369 # "None" cells, only emptiness between, and rowspan and 

3370 # colspan are just to generate the "fill- 

3371 vertical_still_left[len(row)] -= 1 

3372 

3373 # KJ Apr 2026 

3374 # type checking is ignored; I am pretty sure that 

3375 # row will never contain None, even if col_gap_data 

3376 # is `InflCell | None`, but this code is such 

3377 # spaghetti that it's hard to figure out, except 

3378 # by the process of elimination: this has never 

3379 # caused trouble before, ergo, it works. 

3380 row.append(col_gap_data[len(row)]) # type: ignore 

3381 

3382 # appending row is how "indexing" is 

3383 # done here; something is appended, 

3384 # like a filler-cell here or a "start" 

3385 # cell at the end of the row-loop, 

3386 # which increased len(row) which is 

3387 # then used as the target-index to check 

3388 # for gaps. vertical_still_left is 

3389 # the countdown to when to stop 

3390 # filling in gaps, and goes down to 0, 

3391 # and col_gap_data is not touched 

3392 # except when a new rowspan is needed, 

3393 # at the same time that 

3394 # vertical_still_left gets reassigned. 

3395 

3396 try: 

3397 rowspan = int(col.attrs.get("rowspan", "1")) # 🡙 

3398 colspan = int(col.attrs.get("colspan", "1")) # 🡘 

3399 except ValueError: 

3400 rowspan = 1 

3401 colspan = 1 

3402 # print("COL:", col) 

3403 

3404 # Too many of these errors 

3405 if colspan > 100: 

3406 # wxr.wtp.error( 

3407 # f"Colspan {colspan} over 30, set to 1", 

3408 # sortid="inflection/20250113a", 

3409 # ) 

3410 colspan = 100 

3411 if rowspan > 100: 3411 ↛ 3416line 3411 didn't jump to line 3416 because the condition on line 3411 was never true

3412 # wxr.wtp.error( 

3413 # f"Rowspan {rowspan} over 30, set to 1", 

3414 # sortid="inflection/20250113b", 

3415 # ) 

3416 rowspan = 100 

3417 

3418 # Process any nested tables recursively. 

3419 tables, rest = recursively_extract( 

3420 col, 

3421 lambda x: ( 

3422 isinstance(x, WikiNode) 

3423 and (x.kind == NodeKind.TABLE or x.sarg == "table") 

3424 ), 

3425 ) 

3426 

3427 # Clean the rest of the cell. 

3428 link_capture_dict: dict = {} 

3429 celltext = clean_node( 

3430 wxr, link_capture_dict, rest, collect_links=True 

3431 ) 

3432 cell_links: list[tuple[str, str]] | None = ( 

3433 link_capture_dict.get("links", None) 

3434 ) 

3435 # print(f"CLEANED: {celltext=}") 

3436 # print(f"SUBTABLES: {tables}") 

3437 # print(f"{link_capture_dict=}") 

3438 

3439 # Remove regexed patterns from text 

3440 if remove_text_patterns is not None: 

3441 for pat in remove_text_patterns: 

3442 celltext = re.sub(pat, "", celltext) 

3443 # print(f"AFTER: {celltext=} <<") 

3444 

3445 # Handle nested tables. 

3446 for tbl in tables: 

3447 # Some nested tables (e.g., croí/Irish) have subtitles 

3448 # as normal paragraphs in the same cell under a descrip- 

3449 # tive text that should be treated as a title (e.g., 

3450 # "Forms with the definite article", with "definite" not 

3451 # mentioned elsewhere). 

3452 new_titles = list(titles) 

3453 if celltext: 

3454 new_titles.append(celltext) 

3455 subtbl = handle_table1( 

3456 wxr, 

3457 tablecontext, 

3458 word, 

3459 lang, 

3460 pos, 

3461 data, 

3462 tbl, # type: ignore 

3463 new_titles, 

3464 source, 

3465 "", 

3466 depth + 1, 

3467 ) 

3468 if subtbl: 3468 ↛ 3446line 3468 didn't jump to line 3446 because the condition on line 3468 was always true

3469 sub_ret.append((rows, titles, after, depth)) 

3470 rows = [] 

3471 titles = [] 

3472 after = "" 

3473 sub_ret.extend(subtbl) 

3474 

3475 # This magic value is used as part of header detection 

3476 cellstyle = ( 

3477 col.attrs.get("style", "") 

3478 + "//" 

3479 + col.attrs.get("class", "") 

3480 + "//" 

3481 + str(kind) 

3482 ) 

3483 

3484 if not row: # if first column in row 

3485 style = cellstyle 

3486 target = None 

3487 titletext = celltext.strip() 

3488 while titletext and is_superscript(titletext[-1]): 

3489 titletext = titletext[:-1] 

3490 

3491 ( 

3492 is_title, 

3493 hdr_expansion, 

3494 target, 

3495 celltext, 

3496 ) = determine_header( 

3497 wxr, 

3498 tablecontext, 

3499 lang, 

3500 word, 

3501 pos, 

3502 tree.kind, 

3503 kind, 

3504 style, 

3505 row, 

3506 col, 

3507 celltext, 

3508 titletext, 

3509 cols_headered, 

3510 None, 

3511 cellstyle, 

3512 ) 

3513 

3514 if is_title: 

3515 # If this cell gets a "*" tag, make the whole column 

3516 # below it (toggling it in cols_headered = [F, F, T...]) 

3517 # into headers. 

3518 while len(cols_headered) <= len(row): 

3519 cols_headered.append(False) 

3520 if any("*" in tt for tt in hdr_expansion): 

3521 cols_headered[len(row)] = True 

3522 celltext = "" 

3523 # if row_has_nonempty_cells has been True at some point, it 

3524 # keeps on being True. 

3525 # if row_has_nonempty_cells or is_title or celltext != "": 

3526 # row_has_nonempty_cells = True 

3527 # ⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓⇓ 

3528 row_has_nonempty_cells |= is_title or celltext != "" 

3529 cell = InflCell( 

3530 celltext, is_title, colspan, rowspan, target, cell_links 

3531 ) 

3532 for _ in range(0, colspan): 

3533 # colspan🡘 current loop (col) or 1 

3534 # All the data-filling for colspan 

3535 # is done simply in this loop, 

3536 # while rowspan needs to use 

3537 # vertical_still_left to count gaps 

3538 # and col_gap_data to fill in 

3539 # those gaps with InflCell data. 

3540 if rowspan > 1: # rowspan🡙 current loop (col) or 1 

3541 while len(col_gap_data) <= len(row): 

3542 # Initialize col_gap_data/ed if 

3543 # it is lacking slots 

3544 # for each column; col_gap_data and 

3545 # vertical_still_left are never 

3546 # reset to [], during 

3547 # the whole table function. 

3548 col_gap_data.append(None) 

3549 vertical_still_left.append(0) 

3550 # Below is where the "rectangle" block of rowspan 

3551 # and colspan is filled for the future. 

3552 col_gap_data[len(row)] = cell 

3553 # col_gap_data contains cells that 

3554 # will be used in the 

3555 # future, or None 

3556 vertical_still_left[len(row)] = rowspan - 1 

3557 # A counter for how many gaps🡙 are still left to be 

3558 # filled (row.append or 

3559 # row[col_gap_data[len(row)] => 

3560 # rows), it is not reset to [], but decremented to 0 

3561 # each time a row gets something from col_gap_data. 

3562 # Append this cell 1+ times for colspan🡘 

3563 row.append(cell) 

3564 if not row: 

3565 continue 

3566 # After looping the original row-nodes above, fill 

3567 # in the rest of the row if the final cell has colspan 

3568 # (inherited from above, so a cell with rowspan and colspan) 

3569 for i in range(len(row), len(vertical_still_left)): 

3570 if vertical_still_left[i] <= 0: 

3571 continue 

3572 vertical_still_left[i] -= 1 

3573 while len(row) < i: 

3574 row.append(InflCell("", False, 1, 1, None)) 

3575 row.append(col_gap_data[i]) # type: ignore 

3576 # print(" ROW {!r}".format(row)) 

3577 if row_has_nonempty_cells: 3577 ↛ 3304line 3577 didn't jump to line 3304 because the condition on line 3577 was always true

3578 rows.append(row) 

3579 elif kind in ( 3579 ↛ 3304line 3579 didn't jump to line 3304 because the condition on line 3579 was always true

3580 NodeKind.TABLE_HEADER_CELL, 

3581 NodeKind.TABLE_CELL, 

3582 "th", 

3583 "td", 

3584 "span", 

3585 ): 

3586 # print(" TOP-LEVEL CELL", node) 

3587 pass 

3588 

3589 if sub_ret: 

3590 main_ret = sub_ret 

3591 main_ret.append((rows, titles, after, depth)) 

3592 else: 

3593 main_ret = [(rows, titles, after, depth)] 

3594 return main_ret 

3595 

3596 new_rows = handle_table1( 

3597 wxr, tablecontext, word, lang, pos, data, tree, titles, source, after, 0 

3598 ) 

3599 

3600 # Now we have a table that has been parsed into rows and columns of 

3601 # InflCell objects. Parse the inflection table from that format. 

3602 if new_rows: 3602 ↛ exitline 3602 didn't return from function 'handle_wikitext_or_html_table' because the condition on line 3602 was always true

3603 for rows, titles, after, depth in new_rows: 

3604 handle_generic_table( 

3605 wxr, 

3606 tablecontext, 

3607 data, 

3608 word, 

3609 lang, 

3610 pos, 

3611 rows, 

3612 titles, 

3613 source, 

3614 after, 

3615 depth, 

3616 ) 

3617 

3618 

3619def get_table_cells(node: WikiNode) -> Generator[WikiNode, None, None]: 

3620 """If a wikitext table cell contains HTML cells `<td>`, as they sometimes 

3621 do because it is easier to write wikitext conditionals that way, 

3622 those td-elements are parsed as child elements of the Wikitext cell. 

3623 This generator will yield wikitext and HTML direct children of 

3624 `node` and if a Wikitext TABLE_CELL has direct td-element children, 

3625 those are also yielded.""" 

3626 for col in node.children: 

3627 if not isinstance(col, WikiNode): 

3628 continue 

3629 if any( 

3630 isinstance(c, HTMLNode) and c.sarg in ("th", "td") 

3631 for c in col.children 

3632 ): 

3633 html_cells = [] 

3634 content = [] 

3635 for c in col.children: 

3636 if isinstance(c, HTMLNode) and c.sarg in ("th", "td"): 

3637 html_cells.append(c) 

3638 else: 

3639 content.append(c) 

3640 # Remove td-elements from col so they are not returned twice 

3641 col.children = content 

3642 yield col 

3643 for c in html_cells: 

3644 yield c 

3645 else: 

3646 yield col 

3647 

3648 

3649def handle_html_table( 

3650 wxr: WiktextractContext, 

3651 word: str, 

3652 lang: str, 

3653 pos: str, 

3654 data: WordData, 

3655 tree: WikiNode, 

3656 titles: list[str], 

3657 source: str, 

3658 after: str, 

3659 tablecontext: TableContext | None = None, 

3660) -> None: 

3661 """A passer-on function for html-tables, XXX, remove these?""" 

3662 handle_wikitext_or_html_table( 

3663 wxr, word, lang, pos, data, tree, titles, source, after, tablecontext 

3664 ) 

3665 

3666 

3667def handle_wikitext_table( 

3668 wxr: WiktextractContext, 

3669 word: str, 

3670 lang: str, 

3671 pos: str, 

3672 data: WordData, 

3673 tree: WikiNode, 

3674 titles: list[str], 

3675 source: str, 

3676 after: str, 

3677 tablecontext: TableContext | None = None, 

3678) -> None: 

3679 """A passer-on function for html-tables, XXX, remove these?""" 

3680 handle_wikitext_or_html_table( 

3681 wxr, word, lang, pos, data, tree, titles, source, after, tablecontext 

3682 ) 

3683 

3684 

3685def parse_inflection_section( 

3686 wxr: WiktextractContext, 

3687 data: WordData, 

3688 word: str, 

3689 lang: str, 

3690 pos: str, 

3691 section: str, 

3692 tree: WikiNode, 

3693 tablecontext: TableContext | None = None, 

3694) -> None: 

3695 """Parses an inflection section on a page. ``data`` should be the 

3696 data for a part-of-speech, and inflections will be added to it.""" 

3697 

3698 # print("PARSE_INFLECTION_SECTION {}/{}/{}/{}" 

3699 # .format(word, lang, pos, section)) 

3700 assert isinstance(wxr, WiktextractContext) 

3701 assert isinstance(data, dict) 

3702 assert isinstance(word, str) 

3703 assert isinstance(lang, str) 

3704 assert isinstance(section, str) 

3705 assert isinstance(tree, WikiNode) 

3706 assert tablecontext is None or isinstance(tablecontext, TableContext) 

3707 source = section 

3708 tables: list[ 

3709 tuple[Literal["html", "wikitext"], WikiNode, list[str], list[str]] 

3710 ] = [] 

3711 titleparts: list[str] = [] 

3712 preceding_bolded_title = "" 

3713 

3714 # from wikitextprocessor.parser import print_tree 

3715 # print_tree(tree) 

3716 # print("--------------******************----------------") 

3717 

3718 def process_tables() -> None: 

3719 for kind, node, titles, after_l in tables: 

3720 after = "".join(after_l).strip() 

3721 after = clean_value(wxr, after) 

3722 if kind == "wikitext": 

3723 handle_wikitext_table( 

3724 wxr, 

3725 word, 

3726 lang, 

3727 pos, 

3728 data, 

3729 node, 

3730 titles, 

3731 source, 

3732 after, 

3733 tablecontext=tablecontext, 

3734 ) 

3735 elif kind == "html": 3735 ↛ 3749line 3735 didn't jump to line 3749 because the condition on line 3735 was always true

3736 handle_html_table( 

3737 wxr, 

3738 word, 

3739 lang, 

3740 pos, 

3741 data, 

3742 node, 

3743 titles, 

3744 source, 

3745 after, 

3746 tablecontext=tablecontext, 

3747 ) 

3748 else: 

3749 raise RuntimeError( 

3750 "{}: unimplemented table kind {}".format(word, kind) 

3751 ) 

3752 

3753 def recurse_navframe(node: WikiNode | str, titles: list[str]) -> None: 

3754 nonlocal tables 

3755 nonlocal titleparts 

3756 titleparts = [] 

3757 old_tables = tables 

3758 tables = [] 

3759 

3760 recurse(node, [], navframe=True) 

3761 

3762 process_tables() 

3763 tables = old_tables 

3764 

3765 def recurse( 

3766 node: WikiNode 

3767 | str 

3768 | list[WikiNode | str] 

3769 | list[list[WikiNode | str]], 

3770 titles: list[str], 

3771 navframe=False, 

3772 ) -> None: 

3773 nonlocal tables 

3774 if isinstance(node, (list, tuple)): 

3775 for x in node: 

3776 recurse(x, titles, navframe) 

3777 return 

3778 if isinstance(node, str): 

3779 if tables: 

3780 tables[-1][-1].append(node) 

3781 elif navframe: 

3782 titleparts.append(node) 

3783 return 

3784 if not isinstance(node, WikiNode): 3784 ↛ 3785line 3784 didn't jump to line 3785 because the condition on line 3784 was never true

3785 if navframe: 

3786 wxr.wtp.debug( 

3787 "inflection table: unhandled in NavFrame: {}".format(node), 

3788 sortid="inflection/2907", 

3789 ) 

3790 return 

3791 kind = node.kind 

3792 if navframe: 

3793 if kind == NodeKind.HTML: 

3794 classes = node.attrs.get("class", "").split() 

3795 if "NavToggle" in classes: 3795 ↛ 3796line 3795 didn't jump to line 3796 because the condition on line 3795 was never true

3796 return 

3797 if "NavHead" in classes: 

3798 # print("NAVHEAD:", node) 

3799 recurse(node.children, titles, navframe) 

3800 return 

3801 if "NavContent" in classes: 

3802 # print("NAVCONTENT:", node) 

3803 title = "".join(titleparts).strip() 

3804 title = html.unescape(title) 

3805 title = title.strip() 

3806 new_titles = list(titles) 

3807 if not re.match(r"(Note:|Notes:)", title): 3807 ↛ 3809line 3807 didn't jump to line 3809 because the condition on line 3807 was always true

3808 new_titles.append(title) 

3809 recurse(node, new_titles, navframe=False) 

3810 return 

3811 else: 

3812 if kind == NodeKind.TABLE: 

3813 tables.append(("wikitext", node, titles, [])) 

3814 return 

3815 elif kind == NodeKind.HTML and node.sarg == "table": 

3816 htmlclasses = node.attrs.get("class", ()) 

3817 if "audiotable" in htmlclasses: 

3818 return 

3819 tables.append(("html", node, titles, [])) 

3820 return 

3821 elif kind in ( 3821 ↛ 3828line 3821 didn't jump to line 3828 because the condition on line 3821 was never true

3822 NodeKind.LEVEL2, 

3823 NodeKind.LEVEL3, 

3824 NodeKind.LEVEL4, 

3825 NodeKind.LEVEL5, 

3826 NodeKind.LEVEL6, 

3827 ): 

3828 return # Skip subsections 

3829 if ( 

3830 kind == NodeKind.HTML 

3831 and node.sarg == "div" 

3832 and "NavFrame" in node.attrs.get("class", "").split() 

3833 ): 

3834 recurse_navframe(node, titles) 

3835 return 

3836 if kind == NodeKind.LINK: 

3837 if len(node.largs) > 1: 

3838 recurse(node.largs[1:], titles, navframe) 

3839 else: 

3840 recurse(node.largs[0], titles, navframe) 

3841 return 

3842 if kind == NodeKind.HTML and node.sarg == "ref": 

3843 return 

3844 if kind == NodeKind.LIST and node.sarg == ";": 

3845 nonlocal preceding_bolded_title 

3846 from wiktextract.page import clean_node 

3847 

3848 preceding_bolded_title = clean_node(wxr, None, node).strip("; ") 

3849 for x in node.children: 

3850 recurse(x, titles, navframe) 

3851 

3852 assert tree.kind == NodeKind.ROOT 

3853 for x in tree.children: 

3854 if preceding_bolded_title != "": 

3855 recurse(x, [preceding_bolded_title]) 

3856 else: 

3857 recurse(x, []) 

3858 

3859 # Process the tables we found 

3860 process_tables() 

3861 

3862 # XXX this code is used for extracting tables for inflection tests 

3863 if wxr.config.expand_tables: 3863 ↛ 3864line 3863 didn't jump to line 3864 because the condition on line 3863 was never true

3864 if section != "Mutation": 

3865 with open(wxr.config.expand_tables, "w") as f: 

3866 f.write(word + "\n") 

3867 f.write(lang + "\n") 

3868 f.write(pos + "\n") 

3869 f.write(section + "\n") 

3870 text = wxr.wtp.node_to_wikitext(tree) 

3871 f.write(text + "\n")