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

1import pathlib 

2import urllib.error 

3import urllib.parse 

4import urllib.request 

5from collections.abc import Callable 

6from typing import Literal 

7 

8from rendercv.exception import RenderCVUserError 

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

10from rendercv.schema.models.rendercv_model import RenderCVModel 

11 

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) 

22 

23 

24def download_photo_from_url(rendercv_model: RenderCVModel) -> None: 

25 """Download photo from URL to output directory and update model to local path. 

26 

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. 

31 

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 

39 

40 url_str = str(rendercv_model.cv.photo) 

41 

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" 

46 

47 output_dir = rendercv_model.settings.render_command.output_folder 

48 output_dir.mkdir(parents=True, exist_ok=True) 

49 destination = output_dir / filename 

50 

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 

59 

60 rendercv_model.cv.photo = destination 

61 

62 

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. 

67 

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. 

72 

73 Args: 

74 rendercv_model: Validated CV model. 

75 file_type: Target format for format-specific processors. 

76 

77 Returns: 

78 Processed model ready for templates. 

79 """ 

80 rendercv_model = rendercv_model.model_copy(deep=True) 

81 

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]) 

87 

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 ) 

104 

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 ) 

113 

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 ) 

128 

129 if rendercv_model.cv.sections is None: 

130 return rendercv_model 

131 

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) 

147 

148 return rendercv_model 

149 

150 

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. 

155 

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. 

160 

161 Args: 

162 entry: Entry to process (model or string). 

163 string_processors: Transformation functions to apply. 

164 

165 Returns: 

166 Entry with processed fields. 

167 """ 

168 skipped = {"start_date", "end_date", "doi", "url"} 

169 

170 if isinstance(entry, str): 

171 return apply_string_processors(entry, string_processors) 

172 

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 

177 

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 ) 

190 

191 return entry