Coverage for src / rendercv / renderer / templater / model_processor.py: 100%
69 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 pathlib
2import urllib.error
3import urllib.parse
4import urllib.request
5from collections.abc import Callable
6from typing import Literal
8from rendercv.exception import RenderCVUserError
9from rendercv.schema.models.cv.section import Entry
10from rendercv.schema.models.rendercv_model import RenderCVModel
12from .connections import compute_connections
13from .date import build_date_placeholders, date_object_to_string
14from .entry_templates_from_input import render_entry_templates
15from .footer_and_top_note import render_footer_template, render_top_note_template
16from .markdown_parser import markdown_to_typst
17from .string_processor import (
18 apply_string_processors,
19 make_keywords_bold,
20 substitute_placeholders,
21)
24def download_photo_from_url(rendercv_model: RenderCVModel) -> None:
25 """Download photo from URL to output directory and update model to local path.
27 Why:
28 Templates and Typst compiler require cv.photo to be a local pathlib.Path.
29 When user provides a URL, this downloads the image before template
30 rendering, preserving the local-path invariant for all downstream code.
32 Args:
33 rendercv_model: CV model whose photo URL will be downloaded in-place.
34 """
35 if rendercv_model.cv.photo is None or isinstance(
36 rendercv_model.cv.photo, pathlib.Path
37 ):
38 return
40 url_str = str(rendercv_model.cv.photo)
42 parsed = urllib.parse.urlparse(url_str)
43 filename = pathlib.PurePosixPath(parsed.path).name
44 if not filename or "." not in filename:
45 filename = "photo.jpg"
47 output_dir = rendercv_model.settings.render_command.output_folder
48 output_dir.mkdir(parents=True, exist_ok=True)
49 destination = output_dir / filename
51 if not destination.exists():
52 try:
53 with urllib.request.urlopen(url_str, timeout=30) as response:
54 destination.write_bytes(response.read())
55 except (urllib.error.URLError, OSError) as e:
56 raise RenderCVUserError(
57 message=f"Failed to download photo from {url_str}: {e}"
58 ) from e
60 rendercv_model.cv.photo = destination
63def process_model(
64 rendercv_model: RenderCVModel, file_type: Literal["typst", "markdown"]
65) -> RenderCVModel:
66 """Pre-process CV model for template rendering with format-specific transformations.
68 Why:
69 Templates need processed data, not raw model. This applies markdown
70 parsing, keyword bolding, connection formatting, date rendering, and
71 entry template expansion before templates execute.
73 Args:
74 rendercv_model: Validated CV model.
75 file_type: Target format for format-specific processors.
77 Returns:
78 Processed model ready for templates.
79 """
80 rendercv_model = rendercv_model.model_copy(deep=True)
82 string_processors: list[Callable[[str], str]] = [
83 lambda string: make_keywords_bold(string, rendercv_model.settings.bold_keywords)
84 ]
85 if file_type == "typst":
86 string_processors.extend([markdown_to_typst])
88 rendercv_model.cv._plain_name = rendercv_model.cv.name
89 rendercv_model.cv.name = apply_string_processors(
90 rendercv_model.cv.name, string_processors
91 )
92 rendercv_model.cv.headline = apply_string_processors(
93 rendercv_model.cv.headline, string_processors
94 )
95 rendercv_model.cv._connections = compute_connections(rendercv_model, file_type)
96 rendercv_model.cv._top_note = render_top_note_template(
97 rendercv_model.design.templates.top_note,
98 locale=rendercv_model.locale,
99 current_date=rendercv_model.settings._resolved_current_date,
100 name=rendercv_model.cv.name,
101 single_date_template=rendercv_model.design.templates.single_date,
102 string_processors=string_processors,
103 )
105 rendercv_model.cv._footer = render_footer_template(
106 rendercv_model.design.templates.footer,
107 locale=rendercv_model.locale,
108 current_date=rendercv_model.settings._resolved_current_date,
109 name=rendercv_model.cv.name,
110 single_date_template=rendercv_model.design.templates.single_date,
111 string_processors=string_processors,
112 )
114 pdf_title_placeholders: dict[str, str] = {
115 "CURRENT_DATE": date_object_to_string(
116 rendercv_model.settings._resolved_current_date,
117 locale=rendercv_model.locale,
118 single_date_template=rendercv_model.design.templates.single_date,
119 ),
120 "NAME": rendercv_model.cv._plain_name or "",
121 **build_date_placeholders(
122 rendercv_model.settings._resolved_current_date, locale=rendercv_model.locale
123 ),
124 }
125 rendercv_model.settings.pdf_title = substitute_placeholders(
126 rendercv_model.settings.pdf_title, pdf_title_placeholders
127 )
129 if rendercv_model.cv.sections is None:
130 return rendercv_model
132 for section in rendercv_model.cv.rendercv_sections:
133 section.title = apply_string_processors(section.title, string_processors)
134 show_time_span = (
135 section.snake_case_title
136 in rendercv_model.design.sections.show_time_spans_in
137 )
138 for i, entry in enumerate(section.entries):
139 processed_entry = render_entry_templates(
140 entry,
141 templates=rendercv_model.design.templates,
142 locale=rendercv_model.locale,
143 show_time_span=show_time_span,
144 current_date=rendercv_model.settings._resolved_current_date,
145 )
146 section.entries[i] = process_fields(processed_entry, string_processors)
148 return rendercv_model
151def process_fields(
152 entry: Entry, string_processors: list[Callable[[str], str]]
153) -> Entry:
154 """Apply string processors to all entry fields except skipped technical fields.
156 Why:
157 Entry fields need markdown parsing and formatting, but dates, DOIs, and
158 URLs must remain unprocessed for correct linking and formatting. Field-
159 level processing enables selective transformation.
161 Args:
162 entry: Entry to process (model or string).
163 string_processors: Transformation functions to apply.
165 Returns:
166 Entry with processed fields.
167 """
168 skipped = {"start_date", "end_date", "doi", "url"}
170 if isinstance(entry, str):
171 return apply_string_processors(entry, string_processors)
173 data = entry.model_dump(exclude_none=True)
174 for field, value in data.items():
175 if field in skipped or field.startswith("_"):
176 continue
178 if isinstance(value, str):
179 setattr(entry, field, apply_string_processors(value, string_processors))
180 elif isinstance(value, list):
181 setattr(
182 entry,
183 field,
184 [apply_string_processors(v, string_processors) for v in value],
185 )
186 else:
187 setattr(
188 entry, field, apply_string_processors(str(value), string_processors)
189 )
191 return entry