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
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
1import pathlib
2from typing import Any, cast
4import pydantic
5import pydantic_core
6from ruamel.yaml.comments import CommentedMap
8from rendercv.exception import (
9 OVERLAY_SOURCE_TO_YAML_SOURCE,
10 OverlaySourceKey,
11 RenderCVInternalError,
12 RenderCVValidationError,
13 YamlSource,
14)
16from .models.custom_error_types import CustomPydanticErrorTypes
17from .yaml_reader import read_yaml
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)
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.
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).
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, "")
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:]
57 if "ctx" in plain_error:
58 if "input" in plain_error["ctx"]:
59 plain_error["input"] = plain_error["ctx"]["input"]
61 if "loc" in plain_error["ctx"]:
62 plain_error["loc"] = plain_error["ctx"]["loc"]
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 )
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 )
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
94 if not plain_error["msg"].endswith("."):
95 plain_error["msg"] += "."
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]
106 location_for_coords = (
107 location if plain_error["type"] != "missing" else location[:-1]
108 )
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 )
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.
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).
142 Returns:
143 Deduplicated list of user-friendly validation errors.
144 """
145 all_plain_errors = exception.errors()
146 all_final_errors: list[RenderCVValidationError] = []
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 )
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 )
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)
176 return errors_without_duplicates
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.
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.
190 Args:
191 yaml_object: Current YAML object being traversed.
192 location_key: Single key or list index as string.
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
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))
219 return inner_yaml_object, coordinates
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.
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.
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 ```
241 Args:
242 yaml_object: Root YAML object with location metadata.
243 location: Path segments from root to target key.
245 Returns:
246 ((start_line, start_col), (end_line, end_col)) in 1-indexed coordinates.
247 """
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 )
257 return coordinates