Coverage for src / rendercv / schema / models / cv / section.py: 100%
109 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
1from collections import Counter
2from functools import reduce
3from operator import or_
4from typing import Annotated, Any, Literal, get_args
6import pydantic
7import pydantic_core
9from ...pydantic_error_handling import CustomPydanticErrorTypes
10from ..base import BaseModelWithoutExtraKeys
11from .entries.bullet import BulletEntry
12from .entries.education import EducationEntry
13from .entries.experience import ExperienceEntry
14from .entries.normal import NormalEntry
15from .entries.numbered import NumberedEntry
16from .entries.one_line import OneLineEntry
17from .entries.publication import PublicationEntry
18from .entries.reversed_numbered import ReversedNumberedEntry
20########################################################################################
21# Below needs to be updated when new entry types are added.
23# str is an entry type (TextEntry) but not a model, so it's not included in EntryModel.
24type EntryModel = (
25 OneLineEntry
26 | NormalEntry
27 | ExperienceEntry
28 | EducationEntry
29 | PublicationEntry
30 | BulletEntry
31 | NumberedEntry
32 | ReversedNumberedEntry
33)
34type Entry = EntryModel | str
35########################################################################################
36# __value__ is standard PEP 695 type alias access (not a private API):
37available_entry_models: tuple[type[EntryModel], ...] = get_args(EntryModel.__value__)
38available_entry_type_names: tuple[str, ...] = tuple(
39 [entry_type.__name__ for entry_type in available_entry_models] + ["TextEntry"]
40)
41# reduce() constructs a union type at runtime that type checkers cannot verify
42# statically, so the ty:ignore below is unavoidable:
43type ListOfEntries = list[str] | reduce( # ty: ignore[invalid-type-form]
44 or_, [list[entry_type] for entry_type in available_entry_models]
45)
48def get_characteristic_entry_fields(
49 entry_types: tuple[type[EntryModel], ...],
50) -> dict[type[EntryModel], set[str]]:
51 """Calculate unique fields per entry type for automatic type detection.
53 Why:
54 Users provide entries without explicit type declarations. Detecting
55 entry type by unique fields (e.g., `degree` for EducationEntry)
56 enables automatic routing to correct validators.
58 Args:
59 entry_types: Entry type classes to analyze.
61 Returns:
62 Map of entry types to their unique field names.
63 """
64 all_attributes: list[str] = []
65 for EntryType in entry_types:
66 all_attributes.extend(EntryType.model_fields.keys())
68 attribute_counts = Counter(all_attributes)
69 common_attributes = {attr for attr, count in attribute_counts.items() if count > 1}
71 characteristic_entry_fields: dict[type[EntryModel], set[str]] = {}
72 for EntryType in entry_types:
73 characteristic_entry_fields[EntryType] = (
74 set(EntryType.model_fields.keys()) - common_attributes
75 )
77 return characteristic_entry_fields
80characteristic_entry_fields = get_characteristic_entry_fields(available_entry_models)
83class BaseRenderCVSection(BaseModelWithoutExtraKeys):
84 title: str
85 entry_type: str
86 entries: list[Any]
88 @property
89 def snake_case_title(self) -> str:
90 return self.title.lower().replace(" ", "_")
93def create_section_models(
94 entry_type: type[EntryModel] | type[str],
95) -> type[BaseRenderCVSection]:
96 """Generate Pydantic model for section containing specific entry type.
98 Why:
99 Each section validates that all entries match a single type. Dynamic
100 model generation creates type-safe section models with proper validation
101 constraints for each entry type.
103 Args:
104 entry_type: Entry class or str for TextEntry.
106 Returns:
107 Pydantic section model class.
108 """
109 if entry_type is str:
110 model_name = "SectionWithTextEntries"
111 entry_type_name = "TextEntry"
112 else:
113 model_name = "SectionWith" + entry_type.__name__.replace("Entry", "Entries")
114 entry_type_name = entry_type.__name__
116 return pydantic.create_model(
117 model_name,
118 entry_type=(Literal[entry_type_name], ...), # ty: ignore[invalid-type-form]
119 entries=(list[entry_type], ...), # ty: ignore[invalid-type-form]
120 __base__=BaseRenderCVSection,
121 )
124section_models: dict[type[EntryModel] | type[str], type[BaseRenderCVSection]] = {
125 entry_type: create_section_models(entry_type)
126 for entry_type in available_entry_models
127}
128section_models[str] = create_section_models(str)
131def get_entry_type_name_and_section_model(
132 entry: dict[str, str | list[str]] | str | EntryModel | None,
133) -> tuple[str, type[BaseRenderCVSection]]:
134 """Infer entry type from entry data and return corresponding section model.
136 Why:
137 Sections contain mixed raw entry data (dicts/strings) before validation.
138 Type inference via characteristic fields enables routing each entry to
139 its correct validator model.
141 Args:
142 entry: Raw or validated entry data.
144 Returns:
145 Tuple of entry type name and section model class.
146 """
148 if isinstance(entry, dict):
149 entry_type_name = None
150 section_model = None
151 for EntryType, characteristic_fields in characteristic_entry_fields.items():
152 # If at least one of the characteristic_fields is in the entry,
153 # then it means the entry is of this type:
154 if characteristic_fields & set(entry.keys()):
155 entry_type_name = EntryType.__name__
156 section_model = section_models[EntryType]
157 break
159 if section_model is None or entry_type_name is None:
160 raise pydantic_core.PydanticCustomError(
161 CustomPydanticErrorTypes.other.value,
162 "The entry does not match any entry type.",
163 )
165 elif isinstance(entry, str):
166 # Then it is a TextEntry
167 entry_type_name = "TextEntry"
168 section_model = section_models[str]
170 elif entry is None:
171 raise pydantic_core.PydanticCustomError(
172 CustomPydanticErrorTypes.other.value,
173 "The entry cannot be None.",
174 )
176 else:
177 # Then the entry is already initialized with a data model:
178 entry_type_name = entry.__class__.__name__
179 section_model = section_models[entry.__class__]
181 return entry_type_name, section_model
184def validate_section(sections_input: Any) -> Any:
185 """Validate section entries with automatic type detection and error reporting.
187 Why:
188 Section validation must infer entry type from first valid entry,
189 then validate all entries against that type. Custom error messages
190 identify detected type and aggregate nested validation errors.
192 Args:
193 sections_input: Raw section data (list of entries).
195 Returns:
196 Validated list of entry instances.
197 """
198 if isinstance(sections_input, list):
199 if len(sections_input) == 0:
200 return sections_input
202 # Find the entry type based on the first identifiable entry:
203 entry_type_name = None
204 section_type = None
205 for entry in sections_input:
206 try:
207 entry_type_name, section_type = get_entry_type_name_and_section_model(
208 entry
209 )
210 break
211 except pydantic_core.PydanticCustomError:
212 # If the entry type cannot be determined, try the next entry:
213 continue
215 if entry_type_name is None or section_type is None:
216 raise pydantic_core.PydanticCustomError(
217 CustomPydanticErrorTypes.other.value,
218 "RenderCV couldn't match this section with any entry types. Please"
219 " check the entries and make sure they are provided correctly.",
220 )
222 section = {
223 "title": "Dummy Section for Validation",
224 "entry_type": entry_type_name,
225 "entries": sections_input,
226 }
228 try:
229 section_object = section_type.model_validate(section)
230 sections_input = section_object.entries
231 except pydantic.ValidationError as e:
232 new_error = pydantic_core.PydanticCustomError(
233 CustomPydanticErrorTypes.entry_validation.value,
234 "There are problems with the entries. RenderCV detected the entry type"
235 " of this section to be {entry_type_name}. The problems are shown"
236 " below.",
237 {"entry_type_name": entry_type_name, "caused_by": e.errors()},
238 )
239 raise new_error from e
241 else:
242 raise pydantic_core.PydanticCustomError(
243 CustomPydanticErrorTypes.other.value,
244 "Each section should be a list of entries! This is not a list.",
245 )
247 return sections_input
250# Create a custom type named Section, which is a list of entries. The entries can be any
251# of the available entry types. The section is validated with the `validate_section`
252# function.
253type Section = Annotated[
254 pydantic.json_schema.SkipJsonSchema[Any] | ListOfEntries,
255 pydantic.BeforeValidator(validate_section),
256]
259def dictionary_key_to_proper_section_title(key: str) -> str:
260 """Convert snake_case section key to title case with proper capitalization.
262 Why:
263 Users write `education_and_training` in YAML for readability. Rendering
264 requires "Education and Training" with proper title case rules (lowercase
265 articles/prepositions).
267 Example:
268 ```py
269 title = dictionary_key_to_proper_section_title("education_and_training")
270 # Returns "Education and Training"
271 ```
273 Args:
274 key: Section key from YAML.
276 Returns:
277 Properly capitalized section title.
278 """
279 # If there is either a space or an uppercase letter in the key, return it as is.
280 if " " in key or any(letter.isupper() for letter in key):
281 return key
283 title = key.replace("_", " ")
284 words = title.split(" ")
286 words_not_capitalized_in_a_title = [
287 "a",
288 "and",
289 "as",
290 "at",
291 "but",
292 "by",
293 "for",
294 "from",
295 "if",
296 "in",
297 "into",
298 "like",
299 "near",
300 "nor",
301 "of",
302 "off",
303 "on",
304 "onto",
305 "or",
306 "over",
307 "so",
308 "than",
309 "that",
310 "to",
311 "upon",
312 "when",
313 "with",
314 "yet",
315 ]
317 return " ".join(
318 (word.capitalize() if (word not in words_not_capitalized_in_a_title) else word)
319 for word in words
320 )
323def get_rendercv_sections(
324 sections: dict[str, list[Any]] | None,
325) -> list[BaseRenderCVSection]:
326 """Transform user's section dictionary into list of typed section objects.
328 Why:
329 YAML sections are dicts for user convenience (e.g., `{education: [...]}`).
330 Template rendering requires list of section objects with title and
331 entry_type fields. This conversion happens after validation.
333 Args:
334 sections: User's section dictionary with titles as keys.
336 Returns:
337 List of section objects ready for template rendering.
338 """
339 sections_rendercv: list[BaseRenderCVSection] = []
341 if sections is not None:
342 for title, entries in sections.items():
343 formatted_title = dictionary_key_to_proper_section_title(title)
345 if len(entries) == 0:
346 entry_type_name = "TextEntry"
347 else:
348 # The first entry can be used because all the entries in the section
349 # are already validated with the `validate_a_section` function:
350 entry_type_name, _ = get_entry_type_name_and_section_model(
351 entries[0],
352 )
354 # SectionBase is used so that entries are not validated again:
355 section = BaseRenderCVSection(
356 title=formatted_title,
357 entry_type=entry_type_name,
358 entries=entries,
359 )
360 sections_rendercv.append(section)
362 return sections_rendercv