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

177 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 00:25 +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 pathlib 

8import re 

9from typing import Annotated, Any, Literal, Optional, get_args 

10 

11import pydantic 

12import pydantic_extra_types.phone_numbers as pydantic_phone_numbers 

13 

14from . import computers, entry_types 

15from .base import RenderCVBaseModelWithExtraKeys, RenderCVBaseModelWithoutExtraKeys 

16 

17# ====================================================================================== 

18# Create validator functions: ========================================================== 

19# ====================================================================================== 

20 

21 

22class SectionBase(RenderCVBaseModelWithoutExtraKeys): 

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

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

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

26 create a section model based on any entry type. 

27 """ 

28 

29 title: str 

30 entry_type: str 

31 entries: list[Any] 

32 

33 

34# Create a URL validator: 

35url_validator = pydantic.TypeAdapter(pydantic.HttpUrl) 

36 

37 

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

39 """Validate a URL. 

40 

41 Args: 

42 url: The URL to validate. 

43 

44 Returns: 

45 The validated URL. 

46 """ 

47 url_validator.validate_strings(url) 

48 return url 

49 

50 

51def create_a_section_validator(entry_type: type) -> type[SectionBase]: 

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

53 about dynamic model 

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

55 for more information. 

56 

57 The section model is used to validate a section. 

58 

59 Args: 

60 entry_type: The entry type to create the section model. It's not an instance of 

61 the entry type, but the entry type itself. 

62 

63 Returns: 

64 The section validator (a Pydantic model). 

65 """ 

66 if entry_type is str: 

67 model_name = "SectionWithTextEntries" 

68 entry_type_name = "TextEntry" 

69 else: 

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

71 entry_type_name = entry_type.__name__ 

72 

73 return pydantic.create_model( 

74 model_name, 

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

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

77 __base__=SectionBase, 

78 ) 

79 

80 

81def get_characteristic_entry_attributes( 

82 entry_types: tuple[type], 

83) -> dict[type, set[str]]: 

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

85 

86 Args: 

87 entry_types: The entry types to get their characteristic attributes. These are 

88 not instances of the entry types, but the entry types themselves. `str` type 

89 should not be included in this list. 

90 

91 Returns: 

92 The characteristic attributes of the entry types. 

93 """ 

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

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

96 all_attributes = [] 

97 for EntryType in entry_types: 

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

99 

100 common_attributes = { 

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

102 } 

103 

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

105 characteristic_entry_attributes = {} 

106 for EntryType in entry_types: 

107 characteristic_entry_attributes[EntryType] = ( 

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

109 ) 

110 

111 return characteristic_entry_attributes 

112 

113 

114def get_entry_type_name_and_section_validator( 

115 entry: dict[str, str | list[str]] | str | type, entry_types: tuple[type] 

116) -> tuple[str, type[SectionBase]]: 

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

118 

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

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

121 type. 

122 

123 Args: 

124 entry: The entry to determine its type. 

125 entry_types: The entry types to determine the entry type. These are not 

126 instances of the entry types, but the entry types themselves. `str` type 

127 should not be included in this list. 

128 

129 Returns: 

130 The entry type name and the section validator. 

131 """ 

132 

133 if isinstance(entry, dict): 

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

135 characteristic_entry_attributes = get_characteristic_entry_attributes( 

136 entry_types 

137 ) 

138 

139 for ( 

140 EntryType, 

141 characteristic_attributes, 

142 ) in characteristic_entry_attributes.items(): 

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

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

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

146 entry_type_name = EntryType.__name__ 

147 section_type = create_a_section_validator(EntryType) 

148 break 

149 

150 if entry_type_name is None: 

151 message = "The entry is not provided correctly." 

152 raise ValueError(message) 

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: tuple[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: The sections input to validate. 

180 entry_types: The entry types to determine the entry type. These are not 

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

182 should not be included in this list. 

183 

184 Returns: 

185 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 message = ( 

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

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

205 ) 

206 raise ValueError( 

207 message, 

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

209 "", # This is value of the error 

210 ) 

211 

212 section = { 

213 "title": "Test Section", 

214 "entry_type": entry_type_name, 

215 "entries": sections_input, 

216 } 

217 

218 try: 

219 section_object = section_type.model_validate( 

220 section, 

221 ) 

222 sections_input = section_object.entries 

223 except pydantic.ValidationError as e: 

224 new_error = ValueError( 

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

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

227 " below.", 

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

229 "", # This is value of the error 

230 ) 

231 raise new_error from e 

232 

233 else: 

234 message = ( 

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

236 " more information about the sections." 

237 ) 

238 raise ValueError(message) 

239 return sections_input 

240 

241 

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

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

244 

245 Args: 

246 username: The username to validate. 

247 

248 Returns: 

249 The validated username. 

250 """ 

251 if network == "Mastodon": 

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

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

254 message = 'Mastodon username should be in the format "@username@domain"!' 

255 raise ValueError(message) 

256 elif network == "StackOverflow": 

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

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

259 message = ( 

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

261 ) 

262 raise ValueError(message) 

263 elif network == "YouTube": 

264 if username.startswith("@"): 

265 message = ( 

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

267 " beginning of the username." 

268 ) 

269 raise ValueError(message) 

270 

271 return username 

272 

273 

274# ====================================================================================== 

275# Create custom types: ================================================================= 

276# ====================================================================================== 

277 

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

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

280# `validate_a_section` function. 

281SectionContents = Annotated[ 

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

283 pydantic.BeforeValidator( 

284 lambda entries: validate_a_section( 

285 entries, entry_types=entry_types.available_entry_models 

286 ) 

287 ), 

288] 

289 

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

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

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

293 

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

295# social networks. 

296SocialNetworkName = Literal[ 

297 "LinkedIn", 

298 "GitHub", 

299 "GitLab", 

300 "Instagram", 

301 "ORCID", 

302 "Mastodon", 

303 "StackOverflow", 

304 "ResearchGate", 

305 "YouTube", 

306 "Google Scholar", 

307 "Telegram", 

308] 

309 

310available_social_networks = get_args(SocialNetworkName) 

311 

312# ====================================================================================== 

313# Create the models: =================================================================== 

314# ====================================================================================== 

315 

316 

317class SocialNetwork(RenderCVBaseModelWithoutExtraKeys): 

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

319 

320 network: SocialNetworkName = pydantic.Field( 

321 title="Social Network", 

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

323 ) 

324 username: str = pydantic.Field( 

325 title="Username", 

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

327 ) 

328 

329 @pydantic.field_validator("username") 

330 @classmethod 

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

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

333 if "network" not in info.data: 

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

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

336 # raise an error for the network. 

337 return username 

338 

339 network = info.data["network"] 

340 

341 return validate_a_social_network_username(username, network) 

342 

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

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

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

346 if self.network == "Mastodon": 

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

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

349 validate_url(self.url) 

350 

351 return self 

352 

353 @functools.cached_property 

354 def url(self) -> str: 

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

356 instance. 

357 """ 

358 if self.network == "Mastodon": 

359 # Split domain and username 

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

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

362 else: 

363 url_dictionary = { 

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

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

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

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

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

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

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

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

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

373 "Telegram": "https://t.me/", 

374 } 

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

376 

377 return url 

378 

379 

380class CurriculumVitae(RenderCVBaseModelWithExtraKeys): 

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

382 

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

384 default=None, 

385 title="Name", 

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

387 ) 

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

389 default=None, 

390 title="Location", 

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

392 ) 

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

394 default=None, 

395 title="Email", 

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

397 ) 

398 photo: Optional[pathlib.Path] = pydantic.Field( 

399 default=None, 

400 title="Photo", 

401 description="Path to the photo of the person, relatie to the input file.", 

402 ) 

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

404 default=None, 

405 title="Phone", 

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

407 ) 

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

409 default=None, 

410 title="Website", 

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

412 ) 

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

414 default=None, 

415 title="Social Networks", 

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

417 ) 

418 sections_input: Sections = pydantic.Field( 

419 default=None, 

420 title="Sections", 

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

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

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

424 alias="sections", 

425 ) 

426 

427 @pydantic.field_validator("photo") 

428 @classmethod 

429 def update_photo_path(cls, value: Optional[pathlib.Path]) -> Optional[pathlib.Path]: 

430 """Cast `photo` to Path and make the path absolute""" 

431 if value: 

432 from .rendercv_data_model import INPUT_FILE_DIRECTORY 

433 

434 if INPUT_FILE_DIRECTORY is not None: 

435 profile_picture_parent_folder = INPUT_FILE_DIRECTORY 

436 else: 

437 profile_picture_parent_folder = pathlib.Path.cwd() 

438 

439 return profile_picture_parent_folder / str(value) 

440 

441 return value 

442 

443 @pydantic.field_validator("name") 

444 @classmethod 

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

446 """Update the `curriculum_vitae` dictionary.""" 

447 if value: 

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

449 

450 return value 

451 

452 @functools.cached_property 

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

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

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

456 header of the CV. 

457 

458 Returns: 

459 The connections of the person. 

460 """ 

461 

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

463 

464 if self.location is not None: 

465 connections.append( 

466 { 

467 "typst_icon": "location-dot", 

468 "url": None, 

469 "clean_url": None, 

470 "placeholder": self.location, 

471 } 

472 ) 

473 

474 if self.email is not None: 

475 connections.append( 

476 { 

477 "typst_icon": "envelope", 

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

479 "clean_url": self.email, 

480 "placeholder": self.email, 

481 } 

482 ) 

483 

484 if self.phone is not None: 

485 phone_placeholder = computers.format_phone_number(self.phone) 

486 connections.append( 

487 { 

488 "typst_icon": "phone", 

489 "url": self.phone, 

490 "clean_url": phone_placeholder, 

491 "placeholder": phone_placeholder, 

492 } 

493 ) 

494 

495 if self.website is not None: 

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

497 connections.append( 

498 { 

499 "typst_icon": "link", 

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

501 "clean_url": website_placeholder, 

502 "placeholder": website_placeholder, 

503 } 

504 ) 

505 

506 if self.social_networks is not None: 

507 icon_dictionary = { 

508 "LinkedIn": "linkedin", 

509 "GitHub": "github", 

510 "GitLab": "gitlab", 

511 "Instagram": "instagram", 

512 "Mastodon": "mastodon", 

513 "ORCID": "orcid", 

514 "StackOverflow": "stack-overflow", 

515 "ResearchGate": "researchgate", 

516 "YouTube": "youtube", 

517 "Google Scholar": "graduation-cap", 

518 "Telegram": "telegram", 

519 } 

520 for social_network in self.social_networks: 

521 clean_url = computers.make_a_url_clean(social_network.url) 

522 connection = { 

523 "typst_icon": icon_dictionary[social_network.network], 

524 "url": social_network.url, 

525 "clean_url": clean_url, 

526 "placeholder": social_network.username, 

527 } 

528 

529 if social_network.network == "StackOverflow": 

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

531 connection["placeholder"] = username 

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

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

534 

535 connections.append(connection) # type: ignore 

536 

537 return connections 

538 

539 @functools.cached_property 

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

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

542 

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

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

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

546 the sections in the rest of the code. 

547 

548 Returns: 

549 The computed sections. 

550 """ 

551 sections: list[SectionBase] = [] 

552 

553 if self.sections_input is not None: 

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

555 formatted_title = computers.dictionary_key_to_proper_section_title( 

556 title 

557 ) 

558 

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

560 # already validated with the `validate_a_section` function: 

561 entry_type_name, _ = get_entry_type_name_and_section_validator( 

562 entries[0], # type: ignore 

563 entry_types=entry_types.available_entry_models, 

564 ) 

565 

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

567 section = SectionBase( 

568 title=formatted_title, 

569 entry_type=entry_type_name, 

570 entries=entries, 

571 ) 

572 sections.append(section) 

573 

574 return sections 

575 

576 

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

578# some important data for the CV. 

579curriculum_vitae: dict[str, str] = {}