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
« 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"""
6import functools
7import re
8from typing import Annotated, Any, Literal, Optional, Type, get_args
10import pydantic
11import pydantic_extra_types.phone_numbers as pydantic_phone_numbers
13from . import computers, entry_types
14from .base import RenderCVBaseModelWithExtraKeys, RenderCVBaseModelWithoutExtraKeys
16# ======================================================================================
17# Create validator functions: ==========================================================
18# ======================================================================================
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 """
28 title: str
29 entry_type: str
30 entries: list[Any]
33# Create a URL validator:
34url_validator = pydantic.TypeAdapter(pydantic.HttpUrl)
37def validate_url(url: str) -> str:
38 """Validate a URL.
40 Args:
41 url (str): The URL to validate.
43 Returns:
44 str: The validated URL.
45 """
46 url_validator.validate_strings(url)
47 return url
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.
56 The section model is used to validate a section.
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.
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__
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 )
79 return SectionModel
82def get_characteristic_entry_attributes(
83 entry_types: list[Type],
84) -> dict[Type, set[str]]:
85 """Get the characteristic attributes of the entry types.
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.
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())
101 common_attributes = set(
102 attribute for attribute in all_attributes if all_attributes.count(attribute) > 1
103 )
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 )
112 return characteristic_entry_attributes
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.
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.
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.
130 Returns:
131 tuple[str, Type[SectionBase]]: The entry type name and the section validator.
132 """
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 )
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
151 if entry_type_name is None:
152 raise ValueError("The entry is not provided correctly.")
154 elif isinstance(entry, str):
155 # Then it is a TextEntry
156 entry_type_name = "TextEntry"
157 section_type = create_a_section_validator(str)
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__)
164 return entry_type_name, section_type # type: ignore
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.
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.
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.
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
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 )
209 section = {
210 "title": "Test Section",
211 "entry_type": entry_type_name,
212 "entries": sections_input,
213 }
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
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
238def validate_a_social_network_username(username: str, network: str) -> str:
239 """Check if the `username` field in the `SocialNetwork` model is provided correctly.
241 Args:
242 username (str): The username to validate.
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 )
266 return username
269# ======================================================================================
270# Create custom types: =================================================================
271# ======================================================================================
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]
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]]
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]
304available_social_networks = get_args(SocialNetworkName)
306# ======================================================================================
307# Create the models: ===================================================================
308# ======================================================================================
311class SocialNetwork(RenderCVBaseModelWithoutExtraKeys):
312 """This class is the data model of a social network."""
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 )
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
333 network = info.data["network"]
335 username = validate_a_social_network_username(username, network)
337 return username
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)
347 return self
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
372 return url
375class CurriculumVitae(RenderCVBaseModelWithExtraKeys):
376 """This class is the data model of the `cv` field."""
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 )
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
424 return value
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.
432 Returns:
433 list[dict[str, Optional[str]]]: The connections of the person.
434 """
436 connections: list[dict[str, Optional[str]]] = []
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 )
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 )
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 )
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 )
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 }
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"
508 connections.append(connection) # type: ignore
510 return connections
512 @functools.cached_property
513 def sections(self) -> list[SectionBase]:
514 """Compute the sections of the CV based on the input sections.
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.
521 Returns:
522 list[SectionBase]: The computed sections.
523 """
524 sections: list[SectionBase] = []
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)
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 )
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)
545 return sections
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()