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
« 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
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
11from .date import compute_time_span_string, format_date_range, format_single_date
12from .string_processor import clean_url, substitute_placeholders
14uppercase_word_pattern = re.compile(r"\b[A-Z_]+\b")
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)")
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.
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.
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"
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 ```
49 Args:
50 template: Template string with uppercase placeholders and connector text.
51 not_provided_placeholders: Set of placeholder names that are missing.
53 Returns:
54 Template with connector words adjacent to missing placeholders removed.
55 """
56 tokens = re.split(r"(\b[A-Z_]+\b)", template)
58 for i, token in enumerate(tokens):
59 if uppercase_word_pattern.fullmatch(token):
60 continue
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 )
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)
92 return "".join(tokens)
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.
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.
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.
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
124 entry_templates: dict[str, str] = getattr(
125 templates, entry.entry_type_in_snake_case
126 ).model_dump(exclude_none=True)
128 entry_fields: dict[str, str] = {
129 key.upper(): value for key, value in entry.model_dump(exclude_none=True).items()
130 }
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 != ""}
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 }
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)
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)
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 )
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 )
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 )
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]
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]
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"])
217 entry_templates = remove_not_provided_placeholders(entry_templates, entry_fields)
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 )
226 return entry
229def process_highlights(highlights: list[str]) -> str:
230 """Convert highlight list to Markdown unordered list with nested items.
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 ```
247 Args:
248 highlights: Highlight strings with optional " - " for sub-bullets.
250 Returns:
251 Markdown list string with nested indentation.
252 """
253 highlights = ["- " + highlight.replace(" - ", "\n - ") for highlight in highlights]
254 return "\n".join(highlights)
257def process_authors(authors: list[str]) -> str:
258 """Join author names with comma separation.
260 Args:
261 authors: Author names for publication entry.
263 Returns:
264 Comma-separated author string.
265 """
266 return ", ".join(authors)
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.
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.
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"
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 ```
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.
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}"
355 return date_range
357 raise RenderCVInternalError("Date is not provided for this entry.")
360def process_url(entry: Entry) -> str:
361 """Format entry URL as Markdown link with cleaned display text.
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 ```
370 Args:
371 entry: Entry with url or doi field.
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.")
384def process_doi(entry: Entry) -> str:
385 """Format publication DOI as Markdown link to DOI URL.
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 ```
394 Args:
395 entry: Publication entry with DOI.
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.")
405def process_summary(summary: str) -> str:
406 """Wrap summary text in Markdown admonition syntax for special rendering in Typst.
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 ```
417 Args:
418 summary: Summary text to wrap.
420 Returns:
421 Markdown admonition block with indented summary.
422 """
423 return f"!!! summary\n{textwrap.indent(summary, ' ')}"
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.
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.
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 ```
444 Args:
445 entry_templates: Template strings with uppercase placeholders.
446 entry_fields: Available field values with uppercase keys.
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 }
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 }
486 return entry_templates
489unwanted_trailing_parts_pattern = re.compile(r"[^A-Za-z0-9.!?\[\]\(\)\*_%]+$")
492def clean_trailing_parts(text: str) -> str:
493 """Remove trailing characters except alphanumeric and markdown formatting chars.
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.
500 Example:
501 ```py
502 result = clean_trailing_parts("Position at Company, \\nLink: ")
503 # Returns: "Position at Company\\nLink"
504 # Removes ", " and ": "
505 ```
507 Args:
508 text: Text with potential trailing characters.
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)