Coverage for src / rendercv / schema / rendercv_model_builder.py: 100%

73 statements  

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

1import pathlib 

2from typing import Any, TypedDict, Unpack 

3 

4import pydantic 

5import ruamel.yaml 

6from ruamel.yaml.comments import CommentedMap 

7 

8from rendercv.exception import ( 

9 OVERLAY_SOURCE_TO_YAML_SOURCE, 

10 OverlaySourceKey, 

11 RenderCVUserValidationError, 

12 RenderCVValidationError, 

13 YamlLocation, 

14 YamlSource, 

15) 

16 

17from .models.rendercv_model import RenderCVModel 

18from .models.validation_context import ValidationContext 

19from .override_dictionary import apply_overrides_to_dictionary 

20from .pydantic_error_handling import parse_validation_errors 

21from .yaml_reader import read_yaml 

22 

23 

24class BuildRendercvModelArguments(TypedDict, total=False): 

25 design_yaml_file: str | None 

26 locale_yaml_file: str | None 

27 settings_yaml_file: str | None 

28 output_folder: pathlib.Path | str | None 

29 typst_path: pathlib.Path | str | None 

30 pdf_path: pathlib.Path | str | None 

31 markdown_path: pathlib.Path | str | None 

32 html_path: pathlib.Path | str | None 

33 png_path: pathlib.Path | str | None 

34 dont_generate_typst: bool | None 

35 dont_generate_html: bool | None 

36 dont_generate_markdown: bool | None 

37 dont_generate_pdf: bool | None 

38 dont_generate_png: bool | None 

39 overrides: dict[str, str] | None 

40 

41 

42def get_yaml_error_location(error: ruamel.yaml.YAMLError) -> YamlLocation | None: 

43 """Extract 1-indexed line/column coordinates from ruamel parser errors. 

44 

45 Args: 

46 error: YAML parsing exception raised by ruamel. 

47 

48 Returns: 

49 Start/end coordinates when available, otherwise None. 

50 """ 

51 context_mark = getattr(error, "context_mark", None) 

52 problem_mark = getattr(error, "problem_mark", None) 

53 start_mark = context_mark or problem_mark 

54 end_mark = problem_mark or context_mark 

55 

56 if start_mark is None or end_mark is None: 

57 return None 

58 

59 return ( 

60 (start_mark.line + 1, start_mark.column + 1), 

61 (end_mark.line + 1, end_mark.column + 1), 

62 ) 

63 

64 

65def read_yaml_with_validation_errors( 

66 yaml_content: str, yaml_source: YamlSource 

67) -> CommentedMap: 

68 """Parse YAML content and convert parser failures into validation errors. 

69 

70 Why: 

71 YAML syntax errors should use the same error pipeline as schema validation, 

72 so the CLI can display all input issues through one consistent path. 

73 

74 Args: 

75 yaml_content: YAML string content. 

76 yaml_source: Which input file this YAML content came from. 

77 

78 Returns: 

79 Parsed YAML map preserving source coordinates. 

80 

81 Raises: 

82 RenderCVUserValidationError: If YAML cannot be parsed. 

83 """ 

84 try: 

85 return read_yaml(yaml_content) 

86 except ruamel.yaml.YAMLError as e: 

87 parser_message = str(e).splitlines()[0].strip() 

88 if not parser_message.endswith("."): 

89 parser_message += "." 

90 

91 raise RenderCVUserValidationError( 

92 validation_errors=[ 

93 RenderCVValidationError( 

94 schema_location=None, 

95 yaml_location=get_yaml_error_location(e), 

96 yaml_source=yaml_source, 

97 message=f"This is not a valid YAML file. {parser_message}", 

98 input="...", 

99 ) 

100 ] 

101 ) from e 

102 

103 

104def build_rendercv_dictionary( 

105 main_yaml_file: str, 

106 **kwargs: Unpack[BuildRendercvModelArguments], 

107) -> tuple[CommentedMap, dict[str, CommentedMap]]: 

108 """Merge main YAML with overlays and CLI overrides into final dictionary. 

109 

110 Args: 

111 main_yaml_file: Primary CV YAML content string. 

112 kwargs: Optional YAML overlay strings, output paths, generation flags, and CLI overrides. 

113 

114 Returns: 

115 Tuple of merged dictionary and overlay source CommentedMaps (for error reporting). 

116 """ 

117 input_dict = read_yaml_with_validation_errors(main_yaml_file, "main_yaml_file") 

118 input_dict.setdefault("settings", {}).setdefault("render_command", {}) 

119 

120 yaml_overlays: dict[OverlaySourceKey, str | None] = { 

121 "settings": kwargs.get("settings_yaml_file"), 

122 "design": kwargs.get("design_yaml_file"), 

123 "locale": kwargs.get("locale_yaml_file"), 

124 } 

125 

126 overlay_sources: dict[str, CommentedMap] = {} 

127 for key, yaml_content in yaml_overlays.items(): 

128 if yaml_content: 

129 overlay_cm = read_yaml_with_validation_errors( 

130 yaml_content, OVERLAY_SOURCE_TO_YAML_SOURCE[key] 

131 ) 

132 input_dict[key] = overlay_cm[key] 

133 overlay_sources[key] = overlay_cm 

134 

135 render_overrides: dict[str, pathlib.Path | str | bool | None] = { 

136 "output_folder": kwargs.get("output_folder"), 

137 "typst_path": kwargs.get("typst_path"), 

138 "pdf_path": kwargs.get("pdf_path"), 

139 "markdown_path": kwargs.get("markdown_path"), 

140 "html_path": kwargs.get("html_path"), 

141 "png_path": kwargs.get("png_path"), 

142 "dont_generate_typst": kwargs.get("dont_generate_typst"), 

143 "dont_generate_html": kwargs.get("dont_generate_html"), 

144 "dont_generate_markdown": kwargs.get("dont_generate_markdown"), 

145 "dont_generate_pdf": kwargs.get("dont_generate_pdf"), 

146 "dont_generate_png": kwargs.get("dont_generate_png"), 

147 } 

148 

149 for key, value in render_overrides.items(): 

150 if value: 

151 input_dict["settings"]["render_command"][key] = value 

152 

153 overrides = kwargs.get("overrides") 

154 if overrides: 

155 input_dict = apply_overrides_to_dictionary(input_dict, overrides) 

156 

157 return input_dict, overlay_sources 

158 

159 

160def build_rendercv_model_from_commented_map( 

161 commented_map: CommentedMap | dict[str, Any], 

162 input_file_path: pathlib.Path | None = None, 

163 overlay_sources: dict[str, CommentedMap] | None = None, 

164) -> RenderCVModel: 

165 """Validate merged dictionary and build Pydantic model with error mapping. 

166 

167 Args: 

168 commented_map: Merged dictionary with line/column metadata. 

169 input_file_path: Source file path for context and photo resolution. 

170 overlay_sources: Per-section CommentedMaps from overlays (for correct error coordinates). 

171 

172 Returns: 

173 Validated RenderCVModel instance. 

174 """ 

175 try: 

176 validation_context = { 

177 "context": ValidationContext( 

178 input_file_path=input_file_path, 

179 current_date=commented_map.get("settings", {}).get( 

180 "current_date", "today" 

181 ), 

182 ) 

183 } 

184 model = RenderCVModel.model_validate(commented_map, context=validation_context) 

185 except pydantic.ValidationError as e: 

186 validation_errors = parse_validation_errors(e, commented_map, overlay_sources) 

187 raise RenderCVUserValidationError(validation_errors) from e 

188 

189 return model 

190 

191 

192def build_rendercv_dictionary_and_model( 

193 main_yaml_file: str, 

194 *, 

195 input_file_path: pathlib.Path | None = None, 

196 **kwargs: Unpack[BuildRendercvModelArguments], 

197) -> tuple[CommentedMap, RenderCVModel]: 

198 """Complete pipeline from raw YAML string to validated model. 

199 

200 Args: 

201 main_yaml_file: Primary CV YAML content string. 

202 input_file_path: Source file path for validation context (path resolution). 

203 kwargs: Optional YAML overlay strings, output paths, generation flags, and CLI overrides. 

204 

205 Returns: 

206 Tuple of merged dictionary and validated model. 

207 """ 

208 d, overlay_sources = build_rendercv_dictionary(main_yaml_file, **kwargs) 

209 m = build_rendercv_model_from_commented_map(d, input_file_path, overlay_sources) 

210 return d, m