Coverage for rendercv/data/models/curriculum_vitae.py: 99%

161 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-07 17:51 +0000

1""" 

2The `rendercv.data.models.curriculum_vitae` module contains the data model of the `cv` 

3field of the input file. 

4""" 

5 

6import functools 

7import re 

8from typing import Annotated, Any, Literal, Optional, Type, get_args 

9 

10import pydantic 

11import pydantic_extra_types.phone_numbers as pydantic_phone_numbers 

12 

13from . import computers, entry_types 

14from .base import RenderCVBaseModelWithExtraKeys, RenderCVBaseModelWithoutExtraKeys 

15 

16# ====================================================================================== 

17# Create validator functions: ========================================================== 

18# ====================================================================================== 

19 

20 

21class SectionBase(RenderCVBaseModelWithoutExtraKeys): 

22 """This class is the parent class of all the section types. It is being used 

23 in RenderCV internally, and it is not meant to be used directly by the users. 

24 It is used by `rendercv.data_models.utilities.create_a_section_model` function to 

25 create a section model based on any entry type. 

26 """ 

27 

28 title: str 

29 entry_type: str 

30 entries: list[Any] 

31 

32 

33# Create a URL validator: 

34url_validator = pydantic.TypeAdapter(pydantic.HttpUrl) 

35 

36 

37def validate_url(url: str) -> str: 

38 """Validate a URL. 

39 

40 Args: 

41 url (str): The URL to validate. 

42 

43 Returns: 

44 str: The validated URL. 

45 """ 

46 url_validator.validate_strings(url) 

47 return url 

48 

49 

50def create_a_section_validator(entry_type: Type) -> Type[SectionBase]: 

51 """Create a section model based on the entry type. See [Pydantic's documentation 

52 about dynamic model 

53 creation](https://pydantic-docs.helpmanual.io/usage/models/#dynamic-model-creation) 

54 for more information. 

55 

56 The section model is used to validate a section. 

57 

58 Args: 

59 entry_type (Type): The entry type to create the section model. It's not an 

60 instance of the entry type, but the entry type itself. 

61 

62 Returns: 

63 Type[SectionBase]: The section validator (a Pydantic model). 

64 """ 

65 if entry_type is str: 

66 model_name = "SectionWithTextEntries" 

67 entry_type_name = "TextEntry" 

68 else: 

69 model_name = "SectionWith" + entry_type.__name__.replace("Entry", "Entries") 

70 entry_type_name = entry_type.__name__ 

71 

72 SectionModel = pydantic.create_model( 

73 model_name, 

74 entry_type=(Literal[entry_type_name], ...), # type: ignore 

75 entries=(list[entry_type], ...), 

76 __base__=SectionBase, 

77 ) 

78 

79 return SectionModel 

80 

81 

82def get_characteristic_entry_attributes( 

83 entry_types: list[Type], 

84) -> dict[Type, set[str]]: 

85 """Get the characteristic attributes of the entry types. 

86 

87 Args: 

88 entry_types (list[Type]): The entry types to get their characteristic 

89 attributes. These are not instances of the entry types, but the entry 

90 types themselves. `str` type should not be included in this list. 

91 

92 Returns: 

93 dict[Type, list[str]]: The characteristic attributes of the entry types. 

94 """ 

95 # Look at all the entry types, collect their attributes with 

96 # EntryType.model_fields.keys() and find the common ones. 

97 all_attributes = [] 

98 for EntryType in entry_types: 

99 all_attributes.extend(EntryType.model_fields.keys()) 

100 

101 common_attributes = set( 

102 attribute for attribute in all_attributes if all_attributes.count(attribute) > 1 

103 ) 

104 

105 # Store each entry type's characteristic attributes in a dictionary: 

106 characteristic_entry_attributes = {} 

107 for EntryType in entry_types: 

108 characteristic_entry_attributes[EntryType] = ( 

109 set(EntryType.model_fields.keys()) - common_attributes 

110 ) 

111 

112 return characteristic_entry_attributes 

113 

114 

115def get_entry_type_name_and_section_validator( 

116 entry: dict[str, str | list[str]] | str | Type, entry_types: list[Type] 

117) -> tuple[str, Type[SectionBase]]: 

118 """Get the entry type name and the section validator based on the entry. 

119 

120 It takes an entry (as a dictionary or a string) and a list of entry types. Then 

121 it determines the entry type and creates a section validator based on the entry 

122 type. 

123 

124 Args: 

125 entry (dict[str, str | list[str]] | str): The entry to determine its type. 

126 entry_types (list[Type]): The entry types to determine the entry type. These 

127 are not instances of the entry types, but the entry types themselves. `str` 

128 type should not be included in this list. 

129 

130 Returns: 

131 tuple[str, Type[SectionBase]]: The entry type name and the section validator. 

132 """ 

133 

134 if isinstance(entry, dict): 

135 entry_type_name = None # the entry type is not determined yet 

136 characteristic_entry_attributes = get_characteristic_entry_attributes( 

137 entry_types 

138 ) 

139 

140 for ( 

141 EntryType, 

142 characteristic_attributes, 

143 ) in characteristic_entry_attributes.items(): 

144 # If at least one of the characteristic_entry_attributes is in the entry, 

145 # then it means the entry is of this type: 

146 if characteristic_attributes & set(entry.keys()): 

147 entry_type_name = EntryType.__name__ 

148 section_type = create_a_section_validator(EntryType) 

149 break 

150 

151 if entry_type_name is None: 

152 raise ValueError("The entry is not provided correctly.") 

153 

154 elif isinstance(entry, str): 

155 # Then it is a TextEntry 

156 entry_type_name = "TextEntry" 

157 section_type = create_a_section_validator(str) 

158 

159 else: 

160 # Then the entry is already initialized with a data model: 

161 entry_type_name = entry.__class__.__name__ 

162 section_type = create_a_section_validator(entry.__class__) 

163 

164 return entry_type_name, section_type # type: ignore 

165 

166 

167def validate_a_section( 

168 sections_input: list[Any], entry_types: list[Type] 

169) -> list[entry_types.Entry]: 

170 """Validate a list of entries (a section) based on the entry types. 

171 

172 Sections input is a list of entries. Since there are multiple entry types, it is not 

173 possible to validate it directly. Firstly, the entry type is determined with the 

174 `get_entry_type_name_and_section_validator` function. If the entry type cannot be 

175 determined, an error is raised. If the entry type is determined, the rest of the 

176 list is validated with the section validator. 

177 

178 Args: 

179 sections_input (list[Any]): The sections input to validate. 

180 entry_types (list[Type]): The entry types to determine the entry type. These 

181 are not instances of the entry types, but the entry types themselves. `str` 

182 type should not be included in this list. 

183 

184 Returns: 

185 list[Any]: The validated sections input. 

186 """ 

187 if isinstance(sections_input, list): 

188 # Find the entry type based on the first identifiable entry: 

189 entry_type_name = None 

190 section_type = None 

191 for entry in sections_input: 

192 try: 

193 entry_type_name, section_type = ( 

194 get_entry_type_name_and_section_validator(entry, entry_types) 

195 ) 

196 break 

197 except ValueError: 

198 # If the entry type cannot be determined, try the next entry: 

199 pass 

200 

201 if entry_type_name is None or section_type is None: 

202 raise ValueError( 

203 "RenderCV couldn't match this section with any entry types! Please" 

204 " check the entries and make sure they are provided correctly.", 

205 "", # This is the location of the error 

206 "", # This is value of the error 

207 ) 

208 

209 section = { 

210 "title": "Test Section", 

211 "entry_type": entry_type_name, 

212 "entries": sections_input, 

213 } 

214 

215 try: 

216 section_object = section_type.model_validate( 

217 section, 

218 ) 

219 sections_input = section_object.entries 

220 except pydantic.ValidationError as e: 

221 new_error = ValueError( 

222 "There are problems with the entries. RenderCV detected the entry type" 

223 f" of this section to be {entry_type_name}! The problems are shown" 

224 " below.", 

225 "", # This is the location of the error 

226 "", # This is value of the error 

227 ) 

228 raise new_error from e 

229 

230 else: 

231 raise ValueError( 

232 "Each section should be a list of entries! Please see the documentation for" 

233 " more information about the sections.", 

234 ) 

235 return sections_input 

236 

237 

238def validate_a_social_network_username(username: str, network: str) -> str: 

239 """Check if the `username` field in the `SocialNetwork` model is provided correctly. 

240 

241 Args: 

242 username (str): The username to validate. 

243 

244 Returns: 

245 str: The validated username. 

246 """ 

247 if network == "Mastodon": 

248 mastodon_username_pattern = r"@[^@]+@[^@]+" 

249 if not re.fullmatch(mastodon_username_pattern, username): 

250 raise ValueError( 

251 'Mastodon username should be in the format "@username@domain"!' 

252 ) 

253 if network == "StackOverflow": 

254 stackoverflow_username_pattern = r"\d+\/[^\/]+" 

255 if not re.fullmatch(stackoverflow_username_pattern, username): 

256 raise ValueError( 

257 'StackOverflow username should be in the format "user_id/username"!' 

258 ) 

259 if network == "YouTube": 

260 if username.startswith("@"): 

261 raise ValueError( 

262 'YouTube username should not start with "@"! Remove "@" from the' 

263 " beginning of the username." 

264 ) 

265 

266 return username 

267 

268 

269# ====================================================================================== 

270# Create custom types: ================================================================= 

271# ====================================================================================== 

272 

273# Create a custom type named SectionContents, which is a list of entries. The entries 

274# can be any of the available entry types. The section is validated with the 

275# `validate_a_section` function. 

276SectionContents = Annotated[ 

277 pydantic.json_schema.SkipJsonSchema[Any] | entry_types.ListOfEntries, 

278 pydantic.BeforeValidator( 

279 lambda entries: validate_a_section( 

280 entries, entry_types=entry_types.available_entry_models 

281 ) 

282 ), 

283] 

284 

285# Create a custom type named SectionInput, which is a dictionary where the keys are the 

286# section titles and the values are the list of entries in that section. 

287Sections = Optional[dict[str, SectionContents]] 

288 

289# Create a custom type named SocialNetworkName, which is a literal type of the available 

290# social networks. 

291SocialNetworkName = Literal[ 

292 "LinkedIn", 

293 "GitHub", 

294 "GitLab", 

295 "Instagram", 

296 "ORCID", 

297 "Mastodon", 

298 "StackOverflow", 

299 "ResearchGate", 

300 "YouTube", 

301 "Google Scholar", 

302] 

303 

304available_social_networks = get_args(SocialNetworkName) 

305 

306# ====================================================================================== 

307# Create the models: =================================================================== 

308# ====================================================================================== 

309 

310 

311class SocialNetwork(RenderCVBaseModelWithoutExtraKeys): 

312 """This class is the data model of a social network.""" 

313 

314 network: SocialNetworkName = pydantic.Field( 

315 title="Social Network", 

316 description="Name of the social network.", 

317 ) 

318 username: str = pydantic.Field( 

319 title="Username", 

320 description="The username of the social network. The link will be generated.", 

321 ) 

322 

323 @pydantic.field_validator("username") 

324 @classmethod 

325 def check_username(cls, username: str, info: pydantic.ValidationInfo) -> str: 

326 """Check if the username is provided correctly.""" 

327 if "network" not in info.data: 

328 # the network is either not provided or not one of the available social 

329 # networks. In this case, don't check the username, since Pydantic will 

330 # raise an error for the network. 

331 return username 

332 

333 network = info.data["network"] 

334 

335 username = validate_a_social_network_username(username, network) 

336 

337 return username 

338 

339 @pydantic.model_validator(mode="after") # type: ignore 

340 def check_url(self) -> "SocialNetwork": 

341 """Validate the URL of the social network.""" 

342 if self.network == "Mastodon": 

343 # All the other social networks have valid URLs. Mastodon URLs contain both 

344 # the username and the domain. So, we need to validate if the url is valid. 

345 validate_url(self.url) 

346 

347 return self 

348 

349 @functools.cached_property 

350 def url(self) -> str: 

351 """Return the URL of the social network and cache `url` as an attribute of the 

352 instance. 

353 """ 

354 if self.network == "Mastodon": 

355 # Split domain and username 

356 _, username, domain = self.username.split("@") 

357 url = f"https://{domain}/@{username}" 

358 else: 

359 url_dictionary = { 

360 "LinkedIn": "https://linkedin.com/in/", 

361 "GitHub": "https://github.com/", 

362 "GitLab": "https://gitlab.com/", 

363 "Instagram": "https://instagram.com/", 

364 "ORCID": "https://orcid.org/", 

365 "StackOverflow": "https://stackoverflow.com/users/", 

366 "ResearchGate": "https://researchgate.net/profile/", 

367 "YouTube": "https://youtube.com/@", 

368 "Google Scholar": "https://scholar.google.com/citations?user=", 

369 } 

370 url = url_dictionary[self.network] + self.username 

371 

372 return url 

373 

374 

375class CurriculumVitae(RenderCVBaseModelWithExtraKeys): 

376 """This class is the data model of the `cv` field.""" 

377 

378 name: Optional[str] = pydantic.Field( 

379 default=None, 

380 title="Name", 

381 description="The name of the person.", 

382 ) 

383 location: Optional[str] = pydantic.Field( 

384 default=None, 

385 title="Location", 

386 description="The location of the person.", 

387 ) 

388 email: Optional[pydantic.EmailStr] = pydantic.Field( 

389 default=None, 

390 title="Email", 

391 description="The email address of the person.", 

392 ) 

393 phone: Optional[pydantic_phone_numbers.PhoneNumber] = pydantic.Field( 

394 default=None, 

395 title="Phone", 

396 description="The phone number of the person, including the country code.", 

397 ) 

398 website: Optional[pydantic.HttpUrl] = pydantic.Field( 

399 default=None, 

400 title="Website", 

401 description="The website of the person.", 

402 ) 

403 social_networks: Optional[list[SocialNetwork]] = pydantic.Field( 

404 default=None, 

405 title="Social Networks", 

406 description="The social networks of the person.", 

407 ) 

408 sections_input: Sections = pydantic.Field( 

409 default=None, 

410 title="Sections", 

411 description="The sections of the CV.", 

412 # This is an alias to allow users to use `sections` in the YAML file: 

413 # `sections` key is preserved for RenderCV's internal use. 

414 alias="sections", 

415 ) 

416 

417 @pydantic.field_validator("name") 

418 @classmethod 

419 def update_curriculum_vitae(cls, value: str, info: pydantic.ValidationInfo) -> str: 

420 """Update the `curriculum_vitae` dictionary.""" 

421 if value: 

422 curriculum_vitae[info.field_name] = value # type: ignore 

423 

424 return value 

425 

426 @functools.cached_property 

427 def connections(self) -> list[dict[str, Optional[str]]]: 

428 """Return all the connections of the person as a list of dictionaries and cache 

429 `connections` as an attribute of the instance. The connections are used in the 

430 header of the CV. 

431 

432 Returns: 

433 list[dict[str, Optional[str]]]: The connections of the person. 

434 """ 

435 

436 connections: list[dict[str, Optional[str]]] = [] 

437 

438 if self.location is not None: 

439 connections.append( 

440 { 

441 "latex_icon": "\\faMapMarker*", 

442 "url": None, 

443 "clean_url": None, 

444 "placeholder": self.location, 

445 } 

446 ) 

447 

448 if self.email is not None: 

449 connections.append( 

450 { 

451 "latex_icon": "\\faEnvelope[regular]", 

452 "url": f"mailto:{self.email}", 

453 "clean_url": self.email, 

454 "placeholder": self.email, 

455 } 

456 ) 

457 

458 if self.phone is not None: 

459 phone_placeholder = computers.format_phone_number(self.phone) 

460 connections.append( 

461 { 

462 "latex_icon": "\\faPhone*", 

463 "url": self.phone, 

464 "clean_url": phone_placeholder, 

465 "placeholder": phone_placeholder, 

466 } 

467 ) 

468 

469 if self.website is not None: 

470 website_placeholder = computers.make_a_url_clean(str(self.website)) 

471 connections.append( 

472 { 

473 "latex_icon": "\\faLink", 

474 "url": str(self.website), 

475 "clean_url": website_placeholder, 

476 "placeholder": website_placeholder, 

477 } 

478 ) 

479 

480 if self.social_networks is not None: 

481 icon_dictionary = { 

482 "LinkedIn": "\\faLinkedinIn", 

483 "GitHub": "\\faGithub", 

484 "GitLab": "\\faGitlab", 

485 "Instagram": "\\faInstagram", 

486 "Mastodon": "\\faMastodon", 

487 "ORCID": "\\faOrcid", 

488 "StackOverflow": "\\faStackOverflow", 

489 "ResearchGate": "\\faResearchgate", 

490 "YouTube": "\\faYoutube", 

491 "Google Scholar": "\\faGraduationCap", 

492 } 

493 for social_network in self.social_networks: 

494 clean_url = computers.make_a_url_clean(social_network.url) 

495 connection = { 

496 "latex_icon": icon_dictionary[social_network.network], 

497 "url": social_network.url, 

498 "clean_url": clean_url, 

499 "placeholder": social_network.username, 

500 } 

501 

502 if social_network.network == "StackOverflow": 

503 username = social_network.username.split("/")[1] 

504 connection["placeholder"] = username 

505 if social_network.network == "Google Scholar": 

506 connection["placeholder"] = "Google Scholar" 

507 

508 connections.append(connection) # type: ignore 

509 

510 return connections 

511 

512 @functools.cached_property 

513 def sections(self) -> list[SectionBase]: 

514 """Compute the sections of the CV based on the input sections. 

515 

516 The original `sections` input is a dictionary where the keys are the section titles 

517 and the values are the list of entries in that section. This function converts the 

518 input sections to a list of `SectionBase` objects. This makes it easier to work with 

519 the sections in the rest of the code. 

520 

521 Returns: 

522 list[SectionBase]: The computed sections. 

523 """ 

524 sections: list[SectionBase] = [] 

525 

526 if self.sections_input is not None: 

527 for title, entries in self.sections_input.items(): 

528 title = computers.dictionary_key_to_proper_section_title(title) 

529 

530 # The first entry can be used because all the entries in the section are 

531 # already validated with the `validate_a_section` function: 

532 entry_type_name, _ = get_entry_type_name_and_section_validator( 

533 entries[0], # type: ignore 

534 entry_types=entry_types.available_entry_models, 

535 ) 

536 

537 # SectionBase is used so that entries are not validated again: 

538 section = SectionBase( 

539 title=title, 

540 entry_type=entry_type_name, 

541 entries=entries, 

542 ) 

543 sections.append(section) 

544 

545 return sections 

546 

547 

548# The dictionary below will be overwritten by CurriculumVitae class, which will contain 

549# some important data for the CV. 

550curriculum_vitae: dict[str, str] = dict()