Coverage for src / rendercv / renderer / templater / entry_templates_from_input.py: 100%

112 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-25 13:49 +0000

1import re 

2import textwrap 

3from datetime import date as Date 

4 

5from rendercv.exception import RenderCVInternalError 

6from rendercv.schema.models.cv.entries.publication import PublicationEntry 

7from rendercv.schema.models.cv.section import Entry 

8from rendercv.schema.models.design.classic_theme import Templates 

9from rendercv.schema.models.locale.locale import Locale 

10 

11from .date import compute_time_span_string, format_date_range, format_single_date 

12from .string_processor import clean_url, substitute_placeholders 

13 

14uppercase_word_pattern = re.compile(r"\b[A-Z_]+\b") 

15 

16 

17# Matches a bare connector word between placeholders: starts with a non-uppercase 

18# Unicode letter, followed by any non-whitespace chars. Surrounded by whitespace 

19# so formatting like "*in*" and punctuation-only separators like "--" are skipped. 

20connector_word_pattern = re.compile(r"(?<=\s)(?![A-Z])[^\W\d_]\S*(?=\s)") 

21 

22 

23def remove_connectors_of_missing_placeholders( 

24 template: str, not_provided_placeholders: set[str] 

25) -> str: 

26 """Remove connector words between placeholders when an adjacent placeholder is missing. 

27 

28 Why: 

29 Templates contain connector words between placeholders (e.g., "in" between 

30 DEGREE and AREA, "at" between JOB_TITLE and COMPANY). When a placeholder is 

31 removed, these connectors must be dropped too — otherwise orphaned words like 

32 "in Computer Science" or "Engineer at" appear in the output. 

33 

34 Example: 

35 ```py 

36 # "in" removed because DEGREE is missing 

37 remove_connectors_of_missing_placeholders( 

38 "**INSTITUTION**, DEGREE in AREA", {"DEGREE"} 

39 ) 

40 # Returns: "**INSTITUTION**, DEGREE AREA" 

41 

42 # "at" removed because COMPANY_NAME is missing 

43 remove_connectors_of_missing_placeholders( 

44 "**JOB_TITLE** at COMPANY_NAME", {"COMPANY_NAME"} 

45 ) 

46 # Returns: "**JOB_TITLE** COMPANY_NAME" 

47 ``` 

48 

49 Args: 

50 template: Template string with uppercase placeholders and connector text. 

51 not_provided_placeholders: Set of placeholder names that are missing. 

52 

53 Returns: 

54 Template with connector words adjacent to missing placeholders removed. 

55 """ 

56 tokens = re.split(r"(\b[A-Z_]+\b)", template) 

57 

58 for i, token in enumerate(tokens): 

59 if uppercase_word_pattern.fullmatch(token): 

60 continue 

61 

62 # Find the nearest placeholder on each side of this separator 

63 prev_ph = next( 

64 ( 

65 tokens[j] 

66 for j in range(i - 1, -1, -1) 

67 if uppercase_word_pattern.fullmatch(tokens[j]) 

68 ), 

69 None, 

70 ) 

71 next_ph = next( 

72 ( 

73 tokens[j] 

74 for j in range(i + 1, len(tokens)) 

75 if uppercase_word_pattern.fullmatch(tokens[j]) 

76 ), 

77 None, 

78 ) 

79 

80 # Only strip connectors from separators between two placeholders 

81 # where at least one side is missing: 

82 if ( 

83 prev_ph is not None 

84 and next_ph is not None 

85 and ( 

86 prev_ph in not_provided_placeholders 

87 or next_ph in not_provided_placeholders 

88 ) 

89 ): 

90 tokens[i] = connector_word_pattern.sub("", token) 

91 

92 return "".join(tokens) 

93 

94 

95def render_entry_templates[EntryType: Entry]( 

96 entry: EntryType, 

97 *, 

98 templates: Templates, 

99 locale: Locale, 

100 show_time_span: bool, 

101 current_date: Date, 

102) -> EntryType: 

103 """Expand entry templates by substituting field placeholders with processed values. 

104 

105 Why: 

106 Entry display is user-customizable through YAML templates. This applies 

107 templates to entries, processing special fields (dates, highlights, URLs) 

108 and removing placeholders for missing optional fields. 

109 

110 Args: 

111 entry: Entry to process with templates. 

112 templates: Template collection for entry types and dates. 

113 locale: Locale for date and text formatting. 

114 show_time_span: Whether to include duration calculation in dates. 

115 current_date: Reference date for "present" and time span calculations. 

116 

117 Returns: 

118 Entry with template-generated display fields. 

119 """ 

120 if isinstance(entry, str) or not hasattr(templates, entry.entry_type_in_snake_case): 

121 # It's a TextEntry, or an entry type without templates. Return it as is: 

122 return entry 

123 

124 entry_templates: dict[str, str] = getattr( 

125 templates, entry.entry_type_in_snake_case 

126 ).model_dump(exclude_none=True) 

127 

128 entry_fields: dict[str, str] = { 

129 key.upper(): value for key, value in entry.model_dump(exclude_none=True).items() 

130 } 

131 

132 # Treat empty-string values as not provided so their surrounding 

133 # formatting characters (like ** for bold, commas) are cleaned up: 

134 entry_fields = {k: v for k, v in entry_fields.items() if v != ""} 

135 

136 # Expand locale phrases into templates by replacing phrase placeholders 

137 # (e.g., DEGREE_WITH_AREA) with their locale-specific template text 

138 # (e.g., "DEGREE in AREA" for English, "DEGREE en AREA" for French). 

139 # The sub-placeholders (DEGREE, AREA) remain as normal placeholders for the 

140 # rest of the pipeline to handle, preserving identical behavior for English. 

141 for phrase_name, phrase_template in locale.phrases.model_dump().items(): 

142 phrase_placeholder = phrase_name.upper() 

143 entry_templates = { 

144 key: template.replace(phrase_placeholder, phrase_template) 

145 for key, template in entry_templates.items() 

146 } 

147 

148 # Handle special placeholders: 

149 if "HIGHLIGHTS" in entry_fields: 

150 highlights = getattr(entry, "highlights", None) 

151 if highlights is None: 

152 raise RenderCVInternalError("HIGHLIGHTS in fields but highlights is None") 

153 entry_fields["HIGHLIGHTS"] = process_highlights(highlights) 

154 

155 if "AUTHORS" in entry_fields: 

156 authors = getattr(entry, "authors", None) 

157 if authors is None: 

158 raise RenderCVInternalError("AUTHORS in fields but authors is None") 

159 entry_fields["AUTHORS"] = process_authors(authors) 

160 

161 if ( 

162 "DATE" in entry_fields 

163 or "START_DATE" in entry_fields 

164 or "END_DATE" in entry_fields 

165 ): 

166 entry_fields["DATE"] = process_date( 

167 date=getattr(entry, "date", None), 

168 start_date=getattr(entry, "start_date", None), 

169 end_date=getattr(entry, "end_date", None), 

170 locale=locale, 

171 show_time_span=show_time_span, 

172 current_date=current_date, 

173 single_date_template=templates.single_date, 

174 date_range_template=templates.date_range, 

175 time_span_template=templates.time_span, 

176 ) 

177 

178 if "START_DATE" in entry_fields: 

179 start_date = getattr(entry, "start_date", None) 

180 if start_date is None: 

181 raise RenderCVInternalError("START_DATE in fields but start_date is None") 

182 entry_fields["START_DATE"] = format_single_date( 

183 start_date, 

184 locale=locale, 

185 single_date_template=templates.single_date, 

186 ) 

187 

188 if "END_DATE" in entry_fields: 

189 end_date = getattr(entry, "end_date", None) 

190 if end_date is None: 

191 raise RenderCVInternalError("END_DATE in fields but end_date is None") 

192 entry_fields["END_DATE"] = format_single_date( 

193 end_date, 

194 locale=locale, 

195 single_date_template=templates.single_date, 

196 ) 

197 

198 if "URL" in entry_fields: 

199 # entry is guaranteed to be an EntryModel (not str) here because str entries 

200 # have no URL field. The ty:ignore is due to Entry = EntryModel | str union. 

201 entry_fields["URL"] = process_url(entry) # ty: ignore[invalid-argument-type] 

202 

203 if "DOI" in entry_fields: 

204 # Same as above: entry is an EntryModel with doi/url fields. 

205 entry_fields["URL"] = process_url(entry) # ty: ignore[invalid-argument-type] 

206 entry_fields["DOI"] = process_doi(entry) # ty: ignore[invalid-argument-type] 

207 

208 if "SUMMARY" in entry_fields: 

209 summary_is_standalone = any( 

210 line.strip() == "SUMMARY" 

211 for template in entry_templates.values() 

212 for line in template.split("\n") 

213 ) 

214 if summary_is_standalone: 

215 entry_fields["SUMMARY"] = process_summary(entry_fields["SUMMARY"]) 

216 

217 entry_templates = remove_not_provided_placeholders(entry_templates, entry_fields) 

218 

219 for template_name, template in (entry_templates | entry_fields).items(): 

220 setattr( 

221 entry, 

222 template_name, 

223 substitute_placeholders(template, entry_fields), 

224 ) 

225 

226 return entry 

227 

228 

229def process_highlights(highlights: list[str]) -> str: 

230 """Convert highlight list to Markdown unordered list with nested items. 

231 

232 Example: 

233 ```py 

234 result = process_highlights( 

235 [ 

236 "Led team of 5 engineers", 

237 "Reduced costs - Server optimization - Database indexing", 

238 ] 

239 ) 

240 # Returns: 

241 # - Led team of 5 engineers 

242 # - Reduced costs 

243 # - Server optimization 

244 # - Database indexing 

245 ``` 

246 

247 Args: 

248 highlights: Highlight strings with optional " - " for sub-bullets. 

249 

250 Returns: 

251 Markdown list string with nested indentation. 

252 """ 

253 highlights = ["- " + highlight.replace(" - ", "\n - ") for highlight in highlights] 

254 return "\n".join(highlights) 

255 

256 

257def process_authors(authors: list[str]) -> str: 

258 """Join author names with comma separation. 

259 

260 Args: 

261 authors: Author names for publication entry. 

262 

263 Returns: 

264 Comma-separated author string. 

265 """ 

266 return ", ".join(authors) 

267 

268 

269def process_date( 

270 *, 

271 date: str | int | None, 

272 start_date: str | int | None, 

273 end_date: str | int | None, 

274 locale: Locale, 

275 current_date: Date, 

276 show_time_span: bool, 

277 single_date_template: str, 

278 date_range_template: str, 

279 time_span_template: str, 

280) -> str: 

281 """Format date field as single date or range with optional time span. 

282 

283 Why: 

284 Entry date fields vary by type: publications use single dates, experience 

285 uses ranges. This routes to appropriate formatter and optionally appends 

286 duration calculation for employment sections. 

287 

288 Example: 

289 ```py 

290 # Single date for publication 

291 result = process_date( 

292 date="2024-03", 

293 start_date=None, 

294 end_date=None, 

295 locale=english_locale, 

296 current_date=Date(2025, 1, 1), 

297 show_time_span=False, 

298 single_date_template="MONTH_NAME YEAR", 

299 date_range_template="", 

300 time_span_template="", 

301 ) 

302 # Returns: "March 2024" 

303 

304 # Date range with time span for employment 

305 result = process_date( 

306 date=None, 

307 start_date="2020-06", 

308 end_date="present", 

309 locale=english_locale, 

310 current_date=Date(2025, 1, 1), 

311 show_time_span=True, 

312 single_date_template="MONTH_ABBREVIATION YEAR", 

313 date_range_template="START_DATE to END_DATE", 

314 time_span_template="HOW_MANY_YEARS YEARS", 

315 ) 

316 # Returns: "Jun 2020 to present\n\n4 years" 

317 ``` 

318 

319 Args: 

320 date: Single date for publications and certifications. 

321 start_date: Range start for employment and education. 

322 end_date: Range end for employment and education. 

323 locale: Locale for date formatting. 

324 current_date: Reference date for "present" calculation. 

325 show_time_span: Whether to append duration to date range. 

326 single_date_template: Template for single date formatting. 

327 date_range_template: Template for date range formatting. 

328 time_span_template: Template for duration formatting. 

329 

330 Returns: 

331 Formatted date string, optionally with time span on new lines. 

332 """ 

333 if date and not (start_date or end_date): 

334 return format_single_date( 

335 date, locale=locale, single_date_template=single_date_template 

336 ) 

337 if start_date and end_date: 

338 date_range = format_date_range( 

339 start_date, 

340 end_date, 

341 locale=locale, 

342 single_date_template=single_date_template, 

343 date_range_template=date_range_template, 

344 ) 

345 if show_time_span: 

346 time_span = compute_time_span_string( 

347 start_date, 

348 end_date, 

349 locale=locale, 

350 current_date=current_date, 

351 time_span_template=time_span_template, 

352 ) 

353 return f"{date_range}\n\n{time_span}" 

354 

355 return date_range 

356 

357 raise RenderCVInternalError("Date is not provided for this entry.") 

358 

359 

360def process_url(entry: Entry) -> str: 

361 """Format entry URL as Markdown link with cleaned display text. 

362 

363 Example: 

364 ```py 

365 # Entry with url="https://www.example.com/project" 

366 result = process_url(entry) 

367 # Returns: "[example.com/project](https://www.example.com/project)" 

368 ``` 

369 

370 Args: 

371 entry: Entry with url or doi field. 

372 

373 Returns: 

374 Markdown link with cleaned URL as display text. 

375 """ 

376 if isinstance(entry, PublicationEntry) and entry.doi: 

377 return process_doi(entry) 

378 if hasattr(entry, "url") and entry.url: 

379 url = str(entry.url) 

380 return f"[{clean_url(url)}]({url})" 

381 raise RenderCVInternalError("URL is not provided for this entry.") 

382 

383 

384def process_doi(entry: Entry) -> str: 

385 """Format publication DOI as Markdown link to DOI URL. 

386 

387 Example: 

388 ```py 

389 # Entry with doi="10.1000/xyz123" 

390 result = process_doi(entry) 

391 # Returns: "[10.1000/xyz123](https://doi.org/10.1000/xyz123)" 

392 ``` 

393 

394 Args: 

395 entry: Publication entry with DOI. 

396 

397 Returns: 

398 Markdown link with DOI as display text and DOI URL as target. 

399 """ 

400 if isinstance(entry, PublicationEntry) and entry.doi: 

401 return f"[{entry.doi}]({entry.doi_url})" 

402 raise RenderCVInternalError("DOI is not provided for this entry.") 

403 

404 

405def process_summary(summary: str) -> str: 

406 """Wrap summary text in Markdown admonition syntax for special rendering in Typst. 

407 

408 Example: 

409 ```py 

410 result = process_summary("Key project achievements\\nand outcomes") 

411 # Returns: 

412 # !!! summary 

413 # Key project achievements 

414 # and outcomes 

415 ``` 

416 

417 Args: 

418 summary: Summary text to wrap. 

419 

420 Returns: 

421 Markdown admonition block with indented summary. 

422 """ 

423 return f"!!! summary\n{textwrap.indent(summary, ' ')}" 

424 

425 

426def remove_not_provided_placeholders( 

427 entry_templates: dict[str, str], entry_fields: dict[str, str] 

428) -> dict[str, str]: 

429 """Remove template placeholders for missing optional fields and surrounding punctuation. 

430 

431 Why: 

432 Optional entry fields like location or URL should disappear cleanly from 

433 templates when not provided. Regex removal eliminates placeholders plus 

434 adjacent punctuation to prevent "Position at " or trailing commas. 

435 

436 Example: 

437 ```py 

438 templates = {"title": "POSITION at COMPANY, LOCATION"} 

439 fields = {"POSITION": "Engineer", "COMPANY": "Acme"} # LOCATION missing 

440 result = remove_not_provided_placeholders(templates, fields) 

441 # Returns: {"title": "POSITION at COMPANY"} 

442 ``` 

443 

444 Args: 

445 entry_templates: Template strings with uppercase placeholders. 

446 entry_fields: Available field values with uppercase keys. 

447 

448 Returns: 

449 Templates with missing placeholders and surrounding characters removed. 

450 """ 

451 # Remove the not provided placeholders from the templates, including characters 

452 # around them: 

453 used_placeholders_in_templates: set[str] = set( 

454 uppercase_word_pattern.findall(" ".join(entry_templates.values())) 

455 ) 

456 not_provided_placeholders: set[str] = used_placeholders_in_templates - set( 

457 entry_fields.keys() 

458 ) 

459 if not_provided_placeholders: 

460 # First, remove connector words (like "in", "at") between placeholders 

461 # where at least one side is missing: 

462 entry_templates = { 

463 key: re.sub( 

464 r" {2,}", 

465 " ", 

466 remove_connectors_of_missing_placeholders( 

467 value, not_provided_placeholders 

468 ), 

469 ) 

470 for key, value in entry_templates.items() 

471 } 

472 

473 # Then remove the placeholders themselves and adjacent non-space chars. 

474 # Sort longest-first so e.g. "AAA" matches before "AA": 

475 sorted_placeholders = sorted(not_provided_placeholders, key=len, reverse=True) 

476 not_provided_placeholders_pattern = re.compile( 

477 r"\S*\b(?:" + "|".join(sorted_placeholders) + r")\b\S*" # ty: ignore[no-matching-overload] 

478 ) 

479 entry_templates = { 

480 key: clean_trailing_parts( 

481 re.sub(r" {2,}", " ", not_provided_placeholders_pattern.sub("", value)) 

482 ) 

483 for key, value in entry_templates.items() 

484 } 

485 

486 return entry_templates 

487 

488 

489unwanted_trailing_parts_pattern = re.compile(r"[^A-Za-z0-9.!?\[\]\(\)\*_%]+$") 

490 

491 

492def clean_trailing_parts(text: str) -> str: 

493 """Remove trailing characters except alphanumeric and markdown formatting chars. 

494 

495 Why: 

496 Placeholder removal can leave trailing separators like commas, colons, 

497 or dashes. Regex preserves markdown formatting (brackets, asterisks) 

498 while removing unwanted trailing characters. 

499 

500 Example: 

501 ```py 

502 result = clean_trailing_parts("Position at Company, \\nLink: ") 

503 # Returns: "Position at Company\\nLink" 

504 # Removes ", " and ": " 

505 ``` 

506 

507 Args: 

508 text: Text with potential trailing characters. 

509 

510 Returns: 

511 Text with only allowed trailing characters (A-Z, a-z, 0-9, .!?[]())*_%). 

512 """ 

513 new_lines = [] 

514 for line in text.splitlines(): 

515 new_line = line.rstrip() 

516 if new_line == "": 

517 continue 

518 new_lines.append(unwanted_trailing_parts_pattern.sub("", new_line).rstrip()) 

519 return "\n".join(new_lines)