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
« 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"""
6import functools
7import pathlib
8import re
9from typing import Annotated, Any, Literal, Optional, get_args
11import pydantic
12import pydantic_extra_types.phone_numbers as pydantic_phone_numbers
14from . import computers, entry_types
15from .base import RenderCVBaseModelWithExtraKeys, RenderCVBaseModelWithoutExtraKeys
17# ======================================================================================
18# Create validator functions: ==========================================================
19# ======================================================================================
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 """
29 title: str
30 entry_type: str
31 entries: list[Any]
34# Create a URL validator:
35url_validator = pydantic.TypeAdapter(pydantic.HttpUrl)
38def validate_url(url: str) -> str:
39 """Validate a URL.
41 Args:
42 url: The URL to validate.
44 Returns:
45 The validated URL.
46 """
47 url_validator.validate_strings(url)
48 return url
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.
57 The section model is used to validate a section.
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.
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__
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 )
81def get_characteristic_entry_attributes(
82 entry_types: tuple[type],
83) -> dict[type, set[str]]:
84 """Get the characteristic attributes of the entry types.
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.
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())
100 common_attributes = {
101 attribute for attribute in all_attributes if all_attributes.count(attribute) > 1
102 }
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 )
111 return characteristic_entry_attributes
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.
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.
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.
129 Returns:
130 The entry type name and the section validator.
131 """
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 )
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
150 if entry_type_name is None:
151 message = "The entry is not provided correctly."
152 raise ValueError(message)
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: tuple[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: 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.
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
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 )
212 section = {
213 "title": "Test Section",
214 "entry_type": entry_type_name,
215 "entries": sections_input,
216 }
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
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
242def validate_a_social_network_username(username: str, network: str) -> str:
243 """Check if the `username` field in the `SocialNetwork` model is provided correctly.
245 Args:
246 username: The username to validate.
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)
271 return username
274# ======================================================================================
275# Create custom types: =================================================================
276# ======================================================================================
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]
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]]
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]
310available_social_networks = get_args(SocialNetworkName)
312# ======================================================================================
313# Create the models: ===================================================================
314# ======================================================================================
317class SocialNetwork(RenderCVBaseModelWithoutExtraKeys):
318 """This class is the data model of a social network."""
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 )
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
339 network = info.data["network"]
341 return validate_a_social_network_username(username, network)
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)
351 return self
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
377 return url
380class CurriculumVitae(RenderCVBaseModelWithExtraKeys):
381 """This class is the data model of the `cv` field."""
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 )
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
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()
439 return profile_picture_parent_folder / str(value)
441 return value
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
450 return value
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.
458 Returns:
459 The connections of the person.
460 """
462 connections: list[dict[str, Optional[str]]] = []
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 )
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 )
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 )
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 )
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 }
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"
535 connections.append(connection) # type: ignore
537 return connections
539 @functools.cached_property
540 def sections(self) -> list[SectionBase]:
541 """Compute the sections of the CV based on the input sections.
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.
548 Returns:
549 The computed sections.
550 """
551 sections: list[SectionBase] = []
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 )
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 )
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)
574 return sections
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] = {}