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

1from collections import Counter 

2from functools import reduce 

3from operator import or_ 

4from typing import Annotated, Any, Literal, get_args 

5 

6import pydantic 

7import pydantic_core 

8 

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 

19 

20######################################################################################## 

21# Below needs to be updated when new entry types are added. 

22 

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) 

46 

47 

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. 

52 

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. 

57 

58 Args: 

59 entry_types: Entry type classes to analyze. 

60 

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

67 

68 attribute_counts = Counter(all_attributes) 

69 common_attributes = {attr for attr, count in attribute_counts.items() if count > 1} 

70 

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 ) 

76 

77 return characteristic_entry_fields 

78 

79 

80characteristic_entry_fields = get_characteristic_entry_fields(available_entry_models) 

81 

82 

83class BaseRenderCVSection(BaseModelWithoutExtraKeys): 

84 title: str 

85 entry_type: str 

86 entries: list[Any] 

87 

88 @property 

89 def snake_case_title(self) -> str: 

90 return self.title.lower().replace(" ", "_") 

91 

92 

93def create_section_models( 

94 entry_type: type[EntryModel] | type[str], 

95) -> type[BaseRenderCVSection]: 

96 """Generate Pydantic model for section containing specific entry type. 

97 

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. 

102 

103 Args: 

104 entry_type: Entry class or str for TextEntry. 

105 

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__ 

115 

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 ) 

122 

123 

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) 

129 

130 

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. 

135 

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. 

140 

141 Args: 

142 entry: Raw or validated entry data. 

143 

144 Returns: 

145 Tuple of entry type name and section model class. 

146 """ 

147 

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 

158 

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 ) 

164 

165 elif isinstance(entry, str): 

166 # Then it is a TextEntry 

167 entry_type_name = "TextEntry" 

168 section_model = section_models[str] 

169 

170 elif entry is None: 

171 raise pydantic_core.PydanticCustomError( 

172 CustomPydanticErrorTypes.other.value, 

173 "The entry cannot be None.", 

174 ) 

175 

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

180 

181 return entry_type_name, section_model 

182 

183 

184def validate_section(sections_input: Any) -> Any: 

185 """Validate section entries with automatic type detection and error reporting. 

186 

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. 

191 

192 Args: 

193 sections_input: Raw section data (list of entries). 

194 

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 

201 

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 

214 

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 ) 

221 

222 section = { 

223 "title": "Dummy Section for Validation", 

224 "entry_type": entry_type_name, 

225 "entries": sections_input, 

226 } 

227 

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 

240 

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 ) 

246 

247 return sections_input 

248 

249 

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] 

257 

258 

259def dictionary_key_to_proper_section_title(key: str) -> str: 

260 """Convert snake_case section key to title case with proper capitalization. 

261 

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

266 

267 Example: 

268 ```py 

269 title = dictionary_key_to_proper_section_title("education_and_training") 

270 # Returns "Education and Training" 

271 ``` 

272 

273 Args: 

274 key: Section key from YAML. 

275 

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 

282 

283 title = key.replace("_", " ") 

284 words = title.split(" ") 

285 

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 ] 

316 

317 return " ".join( 

318 (word.capitalize() if (word not in words_not_capitalized_in_a_title) else word) 

319 for word in words 

320 ) 

321 

322 

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. 

327 

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. 

332 

333 Args: 

334 sections: User's section dictionary with titles as keys. 

335 

336 Returns: 

337 List of section objects ready for template rendering. 

338 """ 

339 sections_rendercv: list[BaseRenderCVSection] = [] 

340 

341 if sections is not None: 

342 for title, entries in sections.items(): 

343 formatted_title = dictionary_key_to_proper_section_title(title) 

344 

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 ) 

353 

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) 

361 

362 return sections_rendercv