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

87 statements  

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

1import pathlib 

2from typing import Any, cast 

3 

4import pydantic 

5import pydantic_core 

6from ruamel.yaml.comments import CommentedMap 

7 

8from rendercv.exception import ( 

9 OVERLAY_SOURCE_TO_YAML_SOURCE, 

10 OverlaySourceKey, 

11 RenderCVInternalError, 

12 RenderCVValidationError, 

13 YamlSource, 

14) 

15 

16from .models.custom_error_types import CustomPydanticErrorTypes 

17from .yaml_reader import read_yaml 

18 

19error_dictionary = cast( 

20 dict[str, str], 

21 read_yaml(pathlib.Path(__file__).parent / "error_dictionary.yaml"), 

22) 

23unwanted_texts = ("value is not a valid email address: ", "Value error, ") 

24unwanted_locations = ( 

25 "tagged-union", 

26 "list", 

27 "literal", 

28 "int", 

29 "str", 

30 "constrained-str", 

31 "function-", 

32) 

33 

34 

35def parse_plain_pydantic_error( 

36 plain_error: pydantic_core.ErrorDetails, 

37 input_dictionary: CommentedMap | dict[str, Any], 

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

39) -> RenderCVValidationError: 

40 """Transform raw Pydantic error into user-friendly validation error with YAML coordinates. 

41 

42 Args: 

43 plain_error: Raw Pydantic validation error. 

44 input_dictionary: YAML dict with line/column metadata. 

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

46 

47 Returns: 

48 Structured error with location tuple, friendly message, and YAML coordinates. 

49 """ 

50 for unwanted_text in unwanted_texts: 

51 plain_error["msg"] = plain_error["msg"].replace(unwanted_text, "") 

52 

53 if plain_error["loc"][0] in ["design", "locale"]: 

54 # Skip the second key because it's the discriminator value. 

55 plain_error["loc"] = plain_error["loc"][:1] + plain_error["loc"][2:] 

56 

57 if "ctx" in plain_error: 

58 if "input" in plain_error["ctx"]: 

59 plain_error["input"] = plain_error["ctx"]["input"] 

60 

61 if "loc" in plain_error["ctx"]: 

62 plain_error["loc"] = plain_error["ctx"]["loc"] 

63 

64 location = tuple( 

65 str(location_element) 

66 for location_element in plain_error["loc"] 

67 if not any(item in str(location_element) for item in unwanted_locations) 

68 ) 

69 # Special case for end_date because Pydantic returns multiple end_date errors 

70 # since it has multiple valid formats: 

71 if location and "end_date" in location[-1]: 

72 plain_error["msg"] = ( 

73 "This is not a valid `end_date`! Please use either YYYY-MM-DD, YYYY-MM," 

74 ' or YYYY format or "present"!' 

75 ) 

76 

77 # Special case for current_date: the field is typed as datetime.date | 

78 # Literal["today"]. Pydantic appends "date" to the loc for the datetime.date 

79 # union branch (e.g. ("settings", "current_date", "date")). Strip that suffix 

80 # first so the field name lands at location[-1], matching the end_date pattern. 

81 if len(location) >= 2 and location[-1] == "date" and location[-2] == "current_date": 

82 location = location[:-1] 

83 if location and "current_date" in location[-1]: 

84 plain_error["msg"] = ( 

85 "This is not a valid `current_date`! Please use YYYY-MM-DD format or" 

86 ' "today".' 

87 ) 

88 

89 for old_error_message, new_error_message in error_dictionary.items(): 

90 if old_error_message in plain_error["msg"]: 

91 plain_error["msg"] = new_error_message 

92 break 

93 

94 if not plain_error["msg"].endswith("."): 

95 plain_error["msg"] += "." 

96 

97 # Determine which YAML source this error came from and use the correct 

98 # CommentedMap for coordinate lookup 

99 yaml_source: YamlSource = "main_yaml_file" 

100 coord_dict: CommentedMap | dict[str, Any] = input_dictionary 

101 if overlay_sources and location and location[0] in overlay_sources: 

102 source_key = cast(OverlaySourceKey, location[0]) 

103 yaml_source = OVERLAY_SOURCE_TO_YAML_SOURCE[source_key] 

104 coord_dict = overlay_sources[source_key] 

105 

106 location_for_coords = ( 

107 location if plain_error["type"] != "missing" else location[:-1] 

108 ) 

109 

110 return RenderCVValidationError( 

111 schema_location=location, 

112 yaml_location=( 

113 get_coordinates_of_a_key_in_a_yaml_object( 

114 coord_dict, 

115 location_for_coords, 

116 ) 

117 if isinstance(coord_dict, CommentedMap) 

118 else None 

119 ), 

120 yaml_source=yaml_source, 

121 message=plain_error["msg"], 

122 input=( 

123 str(plain_error["input"]) 

124 if not isinstance(plain_error["input"], dict | list) 

125 else "..." 

126 ), 

127 ) 

128 

129 

130def parse_validation_errors( 

131 exception: pydantic.ValidationError, 

132 input_dictionary: CommentedMap | dict[str, Any], 

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

134) -> list[RenderCVValidationError]: 

135 """Extract all validation errors from Pydantic exception with deduplication. 

136 

137 Args: 

138 exception: Pydantic validation exception. 

139 input_dictionary: YAML dict with location metadata. 

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

141 

142 Returns: 

143 Deduplicated list of user-friendly validation errors. 

144 """ 

145 all_plain_errors = exception.errors() 

146 all_final_errors: list[RenderCVValidationError] = [] 

147 

148 for plain_error in all_plain_errors: 

149 all_final_errors.append( 

150 parse_plain_pydantic_error(plain_error, input_dictionary, overlay_sources) 

151 ) 

152 

153 if plain_error["type"] == CustomPydanticErrorTypes.entry_validation.value: 

154 if "ctx" not in plain_error or "caused_by" not in plain_error["ctx"]: 

155 raise RenderCVInternalError( 

156 "entry_validation error missing ctx or caused_by" 

157 ) 

158 for plain_cause_error in plain_error["ctx"]["caused_by"]: 

159 loc = plain_cause_error["loc"][1:] # Omit `entries` location 

160 plain_cause_error["loc"] = plain_error["loc"] + loc 

161 all_final_errors.append( 

162 parse_plain_pydantic_error( 

163 plain_cause_error, input_dictionary, overlay_sources 

164 ) 

165 ) 

166 

167 # Remove duplicates from all_final_errors: 

168 error_locations = set() 

169 errors_without_duplicates = [] 

170 for error in all_final_errors: 

171 schema_location = error.schema_location 

172 if schema_location not in error_locations: 

173 error_locations.add(schema_location) 

174 errors_without_duplicates.append(error) 

175 

176 return errors_without_duplicates 

177 

178 

179def get_inner_yaml_object_from_its_key( 

180 yaml_object: CommentedMap, 

181 location_key: str, 

182) -> tuple[CommentedMap, tuple[tuple[int, int], tuple[int, int]]]: 

183 """Navigate one level into YAML structure and extract coordinates. 

184 

185 Why: 

186 Error locations are dotted paths like `cv.sections.education.0.degree`. 

187 Each traversal step must extract both the nested object and its exact 

188 source coordinates for error highlighting. 

189 

190 Args: 

191 yaml_object: Current YAML object being traversed. 

192 location_key: Single key or list index as string. 

193 

194 Returns: 

195 Tuple of nested object and ((start_line, start_col), (end_line, end_col)). 

196 """ 

197 # If the part is numeric, interpret it as a list index: 

198 try: 

199 index = int(location_key) 

200 try: 

201 inner_yaml_object = yaml_object[index] 

202 # Get the coordinates from the list's lc.data (which is a list of tuples). 

203 start_line, start_col = yaml_object.lc.data[index] 

204 end_line, end_col = start_line, start_col 

205 coordinates = ((start_line + 1, start_col - 1), (end_line + 1, end_col)) 

206 except IndexError as e: 

207 message = f"Index {index} is out of range in the YAML file." 

208 raise RenderCVInternalError(message) from e 

209 except ValueError as e: 

210 # Otherwise, the part is a key in a mapping. 

211 if location_key not in yaml_object: 

212 message = f"Key '{location_key}' not found in the YAML file." 

213 raise RenderCVInternalError(message) from e 

214 

215 inner_yaml_object = yaml_object[location_key] 

216 start_line, start_col, end_line, end_col = yaml_object.lc.data[location_key] 

217 coordinates = ((start_line + 1, start_col + 1), (end_line + 1, end_col)) 

218 

219 return inner_yaml_object, coordinates 

220 

221 

222def get_coordinates_of_a_key_in_a_yaml_object( 

223 yaml_object: CommentedMap, location: tuple[str, ...] 

224) -> tuple[tuple[int, int], tuple[int, int]]: 

225 """Resolve dotted location path to exact YAML source coordinates. 

226 

227 Why: 

228 Error tables must show users exactly which line/column contains 

229 the invalid value. Recursive traversal through CommentedMap's 

230 lc.data preserves coordinates at every nesting level. 

231 

232 Example: 

233 ```py 

234 data = read_yaml(pathlib.Path("cv.yaml")) 

235 coords = get_coordinates_of_a_key_in_a_yaml_object( 

236 data, ("cv", "sections", "education", "0", "degree") 

237 ) 

238 # coords = ((12, 4), (12, 10)) for line 12, columns 4-10 

239 ``` 

240 

241 Args: 

242 yaml_object: Root YAML object with location metadata. 

243 location: Path segments from root to target key. 

244 

245 Returns: 

246 ((start_line, start_col), (end_line, end_col)) in 1-indexed coordinates. 

247 """ 

248 

249 current_yaml_object = yaml_object 

250 coordinates = ((0, 0), (0, 0)) 

251 # start from the first key and move forward: 

252 for location_key in location: 

253 current_yaml_object, coordinates = get_inner_yaml_object_from_its_key( 

254 current_yaml_object, location_key 

255 ) 

256 

257 return coordinates