Coverage for src/wiktextract/extractor/nl/inflection.py: 86%

271 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-26 05:53 +0000

1import re 

2from dataclasses import dataclass 

3 

4from wikitextprocessor.parser import ( 

5 LEVEL_KIND_FLAGS, 

6 NodeKind, 

7 TemplateNode, 

8 WikiNode, 

9) 

10 

11from ...page import clean_node 

12from ...wxr_context import WiktextractContext 

13from .models import Form, WordEntry 

14from .tags import translate_raw_tags 

15 

16FORMS_TABLE_TEMPLATES = frozenset( 

17 [ 

18 "-nlnoun-", 

19 "adjcomp", 

20 "-nlname-", 

21 "-denoun-", 

22 "-denoun1-", 

23 "-nlstam-", 

24 "-csadjc-comp-", 

25 "-dumstam-", 

26 ] 

27) 

28 

29 

30def extract_inflection_template( 

31 wxr: WiktextractContext, word_entry: WordEntry, t_node: TemplateNode 

32) -> None: 

33 if t_node.template_name in [ 

34 "-nlnoun-", 

35 "adjcomp", 

36 "-nlname-", 

37 "-denoun-", 

38 "-denoun1-", 

39 ]: 

40 extract_noun_adj_table(wxr, word_entry, t_node) 

41 elif t_node.template_name == "-nlstam-": 

42 extract_nlstam_template(wxr, word_entry, t_node) 

43 elif t_node.template_name.startswith("-csadjc-comp-"): 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true

44 extract_csadjc_comp_template(wxr, word_entry, t_node) 

45 elif t_node.template_name == "-dumstam-": 45 ↛ exitline 45 didn't return from function 'extract_inflection_template' because the condition on line 45 was always true

46 extract_dumstam_template(wxr, word_entry, t_node) 

47 

48 

49def extract_noun_adj_table( 

50 wxr: WiktextractContext, word_entry: WordEntry, t_node: TemplateNode 

51) -> None: 

52 # https://nl.wiktionary.org/wiki/Sjabloon:-nlnoun- 

53 # https://nl.wiktionary.org/wiki/Sjabloon:adjcomp 

54 expanded_node = wxr.wtp.parse( 

55 wxr.wtp.node_to_wikitext(t_node), expand_all=True 

56 ) 

57 column_headers = [] 

58 for table_node in expanded_node.find_child(NodeKind.TABLE): 

59 for row_node in table_node.find_child(NodeKind.TABLE_ROW): 

60 for header_node in row_node.find_child(NodeKind.TABLE_HEADER_CELL): 

61 header_text = clean_node(wxr, None, header_node) 

62 if header_text != "": 

63 column_headers.append(header_text) 

64 row_header = "" 

65 for col_index, data_node in enumerate( 

66 row_node.find_child(NodeKind.TABLE_CELL) 

67 ): 

68 if col_index == 0: 

69 row_header = clean_node(wxr, None, data_node) 

70 else: 

71 for form_str in clean_node( 

72 wxr, None, data_node 

73 ).splitlines(): 

74 if form_str not in ["", "-", wxr.wtp.title]: 

75 form = Form(form=form_str) 

76 if row_header not in ["", "naamwoord", "demoniem"]: 

77 form.raw_tags.append(row_header) 

78 if col_index - 1 < len(column_headers): 78 ↛ 82line 78 didn't jump to line 82 because the condition on line 78 was always true

79 form.raw_tags.append( 

80 column_headers[col_index - 1] 

81 ) 

82 translate_raw_tags(form) 

83 word_entry.forms.append(form) 

84 

85 for link_node in expanded_node.find_child(NodeKind.LINK): 

86 clean_node(wxr, word_entry, link_node) 

87 

88 

89def extract_nlstam_template( 

90 wxr: WiktextractContext, word_entry: WordEntry, t_node: TemplateNode 

91) -> None: 

92 # verb table 

93 # https://nl.wiktionary.org/wiki/Sjabloon:-nlstam- 

94 for arg in [2, 3]: 

95 form_texts = clean_node( 

96 wxr, None, t_node.template_parameters.get(arg, "") 

97 ) 

98 ipa_texts = clean_node( 

99 wxr, None, t_node.template_parameters.get(arg + 3, "") 

100 ).splitlines() 

101 for index, form_str in enumerate(form_texts.splitlines()): 

102 if form_str != "": 102 ↛ 101line 102 didn't jump to line 101 because the condition on line 102 was always true

103 form = Form(form=form_str) 

104 if index < len(ipa_texts): 104 ↛ 106line 104 didn't jump to line 106 because the condition on line 104 was always true

105 form.ipa = ipa_texts[index] 

106 form.tags.extend( 

107 ["past"] if arg == 2 else ["past", "participle"] 

108 ) 

109 word_entry.forms.append(form) 

110 clean_node(wxr, word_entry, t_node) 

111 if not word_entry.extracted_vervoeging_page: 

112 extract_vervoeging_page(wxr, word_entry) 

113 word_entry.extracted_vervoeging_page = True 

114 

115 

116def extract_vervoeging_page( 

117 wxr: WiktextractContext, word_entry: WordEntry 

118) -> None: 

119 page = wxr.wtp.get_page(f"{wxr.wtp.title}/vervoeging", 0) 

120 if page is None: 

121 return 

122 root = wxr.wtp.parse(page.body) 

123 table_templates = [ 

124 "-nlverb-", 

125 "-nlverb-reflex-", 

126 "-nlverb-onp-", 

127 "-dumverb-", 

128 ] 

129 for t_node in root.find_child(NodeKind.TEMPLATE): 

130 if t_node.template_name in table_templates: 130 ↛ 129line 130 didn't jump to line 129 because the condition on line 130 was always true

131 extract_nlverb_template(wxr, word_entry, t_node, "") 

132 sense = "" 

133 for lang_level_node in root.find_child(NodeKind.LEVEL2): 

134 lang_name = clean_node(wxr, None, lang_level_node.largs) 

135 if lang_name != word_entry.lang: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 continue 

137 for sense_level_node in lang_level_node.find_child_recursively( 

138 LEVEL_KIND_FLAGS 

139 ): 

140 sense = clean_node(wxr, None, sense_level_node.largs) 

141 for t_node in sense_level_node.find_child(NodeKind.TEMPLATE): 

142 if t_node.template_name in table_templates: 142 ↛ 141line 142 didn't jump to line 141 because the condition on line 142 was always true

143 extract_nlverb_template(wxr, word_entry, t_node, sense) 

144 # only have language level node 

145 for t_node in lang_level_node.find_child(NodeKind.TEMPLATE): 

146 if t_node.template_name in table_templates: 146 ↛ 145line 146 didn't jump to line 145 because the condition on line 146 was always true

147 extract_nlverb_template(wxr, word_entry, t_node, sense) 

148 

149 

150@dataclass 

151class TableHeader: 

152 text: str 

153 col_index: int 

154 colspan: int 

155 row_index: int 

156 rowspan: int 

157 

158 

159NLVERB_HEADER_PREFIXES = { 

160 "vervoeging van de bedrijvende vorm van": ["active"], 

161 "onpersoonlijke lijdende vorm": ["impersonal", "passive"], 

162 "lijdende vorm": ["passive"], 

163} 

164 

165 

166def extract_nlverb_template( 

167 wxr: WiktextractContext, 

168 word_entry: WordEntry, 

169 t_node: TemplateNode, 

170 sense: str, 

171) -> None: 

172 # https://nl.wiktionary.org/wiki/Sjabloon:-nlverb- 

173 # Sjabloon:-nlverb-reflex- 

174 # Sjabloon:-dumverb- 

175 expanded_node = wxr.wtp.parse( 

176 wxr.wtp.node_to_wikitext(t_node), expand_all=True 

177 ) 

178 for link_node in expanded_node.find_child(NodeKind.LINK): 

179 clean_node(wxr, word_entry, link_node) 

180 if t_node.template_name == "-dumverb-": 

181 extract_dumverb_table(wxr, word_entry, expanded_node, sense) 

182 return 

183 

184 for table_node in expanded_node.find_child(NodeKind.TABLE): 

185 row_index = 0 

186 shared_tags = [] 

187 shared_raw_tags = [] 

188 last_row_all_header = False 

189 col_headers = [] 

190 row_headers = [] 

191 for row_node in table_node.find_child(NodeKind.TABLE_ROW): 

192 col_index = 0 

193 for row_header in row_headers: 

194 if ( 

195 row_index >= row_header.row_index 

196 and row_index < row_header.row_index + row_header.rowspan 

197 ): 

198 col_index += row_header.rowspan 

199 

200 current_row_all_header = all( 

201 nlverb_table_cell_is_header(n) 

202 for n in row_node.find_child( 

203 NodeKind.TABLE_HEADER_CELL | NodeKind.TABLE_CELL 

204 ) 

205 ) 

206 if current_row_all_header and not last_row_all_header: 

207 row_index = 0 

208 shared_tags.clear() 

209 shared_raw_tags.clear() 

210 col_headers.clear() 

211 row_headers.clear() 

212 

213 small_tag = "" 

214 is_row_first_node = True 

215 for cell_node in row_node.find_child( 

216 NodeKind.TABLE_HEADER_CELL | NodeKind.TABLE_CELL 

217 ): 

218 cell_colspan = 1 

219 cell_colspan_str = cell_node.attrs.get("colspan", "1") 

220 if re.fullmatch(r"\d+", cell_colspan_str): 220 ↛ 222line 220 didn't jump to line 222 because the condition on line 220 was always true

221 cell_colspan = int(cell_colspan_str) 

222 cell_rowspan = 1 

223 cell_rowspan_str = cell_node.attrs.get("rowspan", "1") 

224 if re.fullmatch(r"\d+", cell_rowspan_str): 224 ↛ 226line 224 didn't jump to line 226 because the condition on line 224 was always true

225 cell_rowspan = int(cell_rowspan_str) 

226 cell_str = clean_node(wxr, None, cell_node).strip("| ") 

227 if cell_str in ["", "—", wxr.wtp.title]: 

228 pass 

229 elif nlverb_table_cell_is_header(cell_node): 

230 for ( 

231 header_prefix, 

232 prefix_tags, 

233 ) in NLVERB_HEADER_PREFIXES.items(): 

234 if cell_str.startswith(header_prefix): 

235 shared_tags.extend(prefix_tags) 

236 break 

237 else: 

238 if cell_str.startswith("vervoeging van "): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 pass 

240 elif current_row_all_header: 

241 if ( 

242 is_row_first_node 

243 and t_node.template_name == "-nlverb-" 

244 ): 

245 shared_raw_tags.append(cell_str) 

246 else: 

247 col_headers.append( 

248 TableHeader( 

249 cell_str, 

250 col_index, 

251 cell_colspan, 

252 row_index, 

253 cell_rowspan, 

254 ) 

255 ) 

256 else: 

257 if "(" in cell_str: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 cell_str = cell_str[ 

259 : cell_str.index("(") 

260 ].strip() 

261 row_headers.append( 

262 TableHeader( 

263 cell_str, 

264 col_index, 

265 cell_colspan, 

266 row_index, 

267 cell_rowspan, 

268 ) 

269 ) 

270 else: # data cell 

271 has_small_tag = False 

272 for small_node in cell_node.find_html("small"): 272 ↛ 273line 272 didn't jump to line 273 because the loop on line 272 never started

273 has_small_tag = True 

274 if has_small_tag: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 small_tag = cell_str 

276 col_index += cell_colspan 

277 continue 

278 form_texts = [] 

279 for cell_text in nl_cell_variants(wxr, cell_node): 

280 form_texts.extend(nl_split_cell(cell_text)) 

281 for form_str in form_texts: 

282 form_str = form_str.strip() 

283 if len(form_str) == 0: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 continue 

285 form = Form( 

286 form=form_str, 

287 tags=shared_tags, 

288 raw_tags=shared_raw_tags, 

289 source=f"{wxr.wtp.title}/vervoeging", 

290 sense=sense, 

291 ) 

292 if small_tag != "": 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 form.raw_tags.append(small_tag) 

294 small_tag = "" 

295 for row_header in row_headers: 

296 if ( 

297 row_index >= row_header.row_index 

298 and row_index 

299 < row_header.row_index + row_header.rowspan 

300 ): 

301 form.raw_tags.append(row_header.text) 

302 for col_header in col_headers: 

303 if ( 

304 col_index >= col_header.col_index 

305 and col_index 

306 < col_header.col_index + col_header.colspan 

307 ): 

308 form.raw_tags.append(col_header.text) 

309 translate_raw_tags(form) 

310 word_entry.forms.append(form) 

311 

312 col_index += cell_colspan 

313 is_row_first_node = False 

314 

315 row_index += 1 

316 last_row_all_header = current_row_all_header 

317 

318 

319def nlverb_table_cell_is_header(node: WikiNode) -> bool: 

320 return ( 

321 node.kind == NodeKind.TABLE_HEADER_CELL 

322 or node.attrs.get("class", "") == "infoboxrijhoofding" 

323 ) 

324 

325 

326def extract_csadjc_comp_template( 

327 wxr: WiktextractContext, word_entry: WordEntry, t_node: TemplateNode 

328) -> None: 

329 # https://nl.wiktionary.org/wiki/Sjabloon:-csadjc-comp-ý3- 

330 expanded_node = wxr.wtp.parse( 

331 wxr.wtp.node_to_wikitext(t_node), expand_all=True 

332 ) 

333 for table in expanded_node.find_child(NodeKind.TABLE): 

334 for row in table.find_child(NodeKind.TABLE_ROW): 

335 row_header = "" 

336 for cell_node in row.find_child( 

337 NodeKind.TABLE_HEADER_CELL | NodeKind.TABLE_CELL 

338 ): 

339 if cell_node.kind == NodeKind.TABLE_HEADER_CELL: 

340 row_header = clean_node(wxr, None, cell_node) 

341 elif cell_node.kind == NodeKind.TABLE_CELL: 

342 form_text = clean_node(wxr, None, cell_node) 

343 if form_text not in ["", wxr.wtp.title]: 

344 form = Form(form=form_text) 

345 if row_header != "": 

346 form.raw_tags.append(row_header) 

347 translate_raw_tags(form) 

348 word_entry.forms.append(form) 

349 

350 

351def extract_dumstam_template( 

352 wxr: WiktextractContext, word_entry: WordEntry, t_node: TemplateNode 

353) -> None: 

354 # https://nl.wiktionary.org/wiki/Sjabloon:-dumstam- 

355 tags = [ 

356 ["infinitive"], 

357 ["past", "singular"], 

358 ["past", "plural"], 

359 ["past", "participle"], 

360 ] 

361 for arg_name in range(1, 5): 

362 word = clean_node( 

363 wxr, None, t_node.template_parameters.get(arg_name, "") 

364 ) 

365 if word not in ["", word_entry.word]: 

366 form = Form(form=word, tags=tags[arg_name - 1]) 

367 word_entry.forms.append(form) 

368 clean_node(wxr, word_entry, t_node) 

369 if not word_entry.extracted_vervoeging_page: 369 ↛ exitline 369 didn't return from function 'extract_dumstam_template' because the condition on line 369 was always true

370 extract_vervoeging_page(wxr, word_entry) 

371 word_entry.extracted_vervoeging_page = True 

372 

373 

374def extract_dumverb_table( 

375 wxr: WiktextractContext, 

376 word_entry: WordEntry, 

377 expanded_node: WikiNode, 

378 sense: str, 

379) -> None: 

380 table_node = expanded_node 

381 for t_node in expanded_node.find_child(NodeKind.TABLE): 381 ↛ 384line 381 didn't jump to line 384 because the loop on line 381 didn't complete

382 table_node = t_node 

383 break 

384 col_headers = [] 

385 last_row_all_header = False 

386 for row_node in table_node.find_child(NodeKind.TABLE_ROW): 

387 col_index = 0 

388 row_header = "" 

389 current_row_all_header = all( 

390 nlverb_table_cell_is_header(n) 

391 for n in row_node.find_child( 

392 NodeKind.TABLE_HEADER_CELL | NodeKind.TABLE_CELL 

393 ) 

394 ) 

395 if current_row_all_header and not last_row_all_header: 

396 col_headers.clear() 

397 for cell_node in row_node.find_child( 

398 NodeKind.TABLE_HEADER_CELL | NodeKind.TABLE_CELL 

399 ): 

400 cell_colspan = 1 

401 cell_colspan_str = cell_node.attrs.get("colspan", "1") 

402 if re.fullmatch(r"\d+", cell_colspan_str): 402 ↛ 404line 402 didn't jump to line 404 because the condition on line 402 was always true

403 cell_colspan = int(cell_colspan_str) 

404 cell_str = clean_node(wxr, None, cell_node).strip("!| \n") 

405 if cell_str in ["", "—", wxr.wtp.title]: 

406 continue 

407 is_header = nlverb_table_cell_is_header(cell_node) 

408 if is_header: 

409 if current_row_all_header: 

410 col_headers.append( 

411 TableHeader( 

412 cell_str, 

413 col_index, 

414 cell_colspan, 

415 0, 

416 0, 

417 ) 

418 ) 

419 col_index += cell_colspan 

420 else: 

421 row_header = cell_str 

422 else: 

423 for cell_line in cell_str.splitlines(): 

424 cell_line = cell_line.strip() 

425 if cell_line == "": 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true

426 continue 

427 form = Form( 

428 form=cell_line, 

429 source=f"{wxr.wtp.title}/vervoeging", 

430 sense=sense, 

431 ) 

432 if row_header != "": 

433 form.raw_tags.append(row_header) 

434 for col_header in col_headers: 

435 if ( 

436 col_index >= col_header.col_index 

437 and col_index 

438 < col_header.col_index + col_header.colspan 

439 ): 

440 form.raw_tags.append(col_header.text) 

441 translate_raw_tags(form) 

442 word_entry.forms.append(form) 

443 col_index += cell_colspan 

444 last_row_all_header = current_row_all_header 

445 

446 

447LINK_BREAK = "__LINK_BREAK__" 

448LINK_PAIR_RE = re.compile( 

449 rf"(.*?)(\[\[[^\]]+\]\]){LINK_BREAK}(\[\[[^\]]+\]\])(.*)", re.S 

450) 

451 

452 

453def nl_cell_variants(wxr: WiktextractContext, cell_node: WikiNode) -> list[str]: 

454 """Split a cell into texts. 

455 

456 A `<br>` between two links separates variants that share surrounding text: 

457 "zal [[getreinsurfd]]<br>[[getreinsurft]] hebben" 

458 -> ["zal getreinsurfd hebben", "zal getreinsurft hebben"] 

459 Any other `<br>` separates lines. 

460 """ 

461 wikitext = wxr.wtp.node_to_wikitext(cell_node.children) 

462 wikitext = re.sub(r"(?<=\]\])\s*<br\s*/?>\s*(?=\[\[)", LINK_BREAK, wikitext) 

463 if LINK_BREAK not in wikitext: 

464 return [clean_node(wxr, None, cell_node).strip("| ")] 

465 variants = [] 

466 for line in re.split(r"<br\s*/?>", wikitext): 

467 if m := LINK_PAIR_RE.fullmatch(line): 

468 prefix, link1, link2, suffix = m.groups() 

469 variants.extend( 

470 clean_node(wxr, None, f"{prefix} {link} {suffix}") 

471 for link in (link1, link2) 

472 ) 

473 else: 

474 variants.append( 

475 clean_node(wxr, None, line.replace(LINK_BREAK, "<br>")) 

476 ) 

477 return [v for v in variants if v] 

478 

479 

480def nl_split_cell(text: str) -> list[str]: 

481 if not text: 

482 return [] 

483 if ("/" in text) + ("\n" in text) + ("(" in text) > 1: 

484 # Leave messy entries alone; remove this when not applicable anymore 

485 return [text] 

486 if "/ " in text: # "zweerde/ zwoor" 

487 form_texts = [s.strip() for s in text.split("/")] 

488 elif "/" in text and " " in text: 

489 # "zult/zal zweren" -> ["zult zweren", "zal zweren"] 

490 space_index = text.index(" ") 

491 second_part = text[space_index:] 

492 form_texts = [ 

493 (f_str + second_part).strip() 

494 for f_str in text[:space_index].split("/") 

495 ] 

496 elif m := re.match(r"([^()]+)\(([^)]+)\)(.+)", text): 

497 # "zou(dt) treinsurfen" -> ["zou treinsurfen", "zoudt treinsurfen"] 

498 form_texts = [ 

499 m.group(1) + m.group(3), 

500 m.group(1) + m.group(2) + m.group(3), 

501 ] 

502 elif "\n" in text: 

503 form_texts = [s.strip() for s in text.split("\n")] 

504 else: 

505 form_texts = [text] 

506 return form_texts