From b642cb4b191f62f8605c38cd9b4658ffec3eb1d4 Mon Sep 17 00:00:00 2001 From: Sina Atalay Date: Sun, 28 Jan 2024 19:15:26 +0100 Subject: [PATCH] document and enhance data_models.py --- rendercv/data_models.py | 427 +++++++++++++++++----------------------- 1 file changed, 181 insertions(+), 246 deletions(-) diff --git a/rendercv/data_models.py b/rendercv/data_models.py index 05f5d43..38d361b 100644 --- a/rendercv/data_models.py +++ b/rendercv/data_models.py @@ -1,5 +1,14 @@ """ -in the end: document the whole code! +This module contains all the necessary classes to store CV data. The YAML input file is +transformed into instances of these classes (i.e., the input file is read) in the +[input_reader](https://sinaatalay.github.io/rendercv/code_documentation/input_reader/) +module. RenderCV utilizes these instances to generate a CV. These classes are called +data models. + +The data models are initialized with data validation to prevent unexpected bugs. During +the initialization, we ensure that everything is in the correct place and that the user +has provided a valid RenderCV input. This is achieved through the use of +[Pydantic](https://pypi.org/project/pydantic/). """ from datetime import date as Date @@ -16,40 +25,39 @@ import pydantic.functional_validators as pydantic_functional_validators from . import utilities from .terminal_reporter import warning - -# To understand how to create custom data types, see: -# https://docs.pydantic.dev/latest/usage/types/custom/ # use links with pydantic version tags! +from .templates.classic import ClassicThemeOptions -# LaTeXDimension = Annotated[ -# str, -# pydantic.Field( -# pattern=r"\d+\.?\d* *(cm|in|pt|mm|ex|em)", -# ), -# ] -LaTeXString = Annotated[ - str, - pydantic_functional_validators.AfterValidator(utilities.escape_latex_characters), -] +# Create a custom type called PastDate that accepts a string in YYYY-MM-DD format and +# returns a Date object. It also checks if the date is in the past. +# See https://docs.pydantic.dev/2.5/concepts/types/#custom-types for more information +# about custom types. PastDate = Annotated[ str, pydantic.Field(pattern=r"\d{4}-?(\d{2})?-?(\d{2})?"), pydantic_functional_validators.AfterValidator(utilities.parse_date_string), ] -PastDateAdapter = pydantic.TypeAdapter(PastDate) + +class RenderCVBaseModel(pydantic.BaseModel): + """ + This class is the parent class of all the data models in RenderCV. It has only one + difference from the default `pydantic.BaseModel`: It raises an error if an unknown + key is provided in the input file. + """ + + model_config = pydantic.ConfigDict(extra="forbid") + # ====================================================================================== # Entry models: ======================================================================== # ====================================================================================== -class EntryBase(pydantic.BaseModel): - """This class is the parent class for classes like `#!python EducationEntry`, - `#!python ExperienceEntry`, `#!python NormalEntry`, and `#!python OneLineEntry`. - - It stores the common fields between these classes like dates, location, highlights, - and URL. +class EntryBase(RenderCVBaseModel): + """This class is the parent class of some of the entry types. It is being used + because some of the entry types have common fields like dates, highlights, location, + etc. """ start_date: Optional[PastDate] = pydantic.Field( @@ -67,7 +75,7 @@ class EntryBase(pydantic.BaseModel): ), examples=["2020-09-24", "present"], ) - date: Optional[PastDate | int | LaTeXString] = pydantic.Field( + date: Optional[PastDate | int | str] = pydantic.Field( default=None, title="Date", description=( @@ -78,45 +86,27 @@ class EntryBase(pydantic.BaseModel): ), examples=["2020-09-24", "My Custom Date"], ) - highlights: Optional[list[LaTeXString]] = pydantic.Field( + highlights: Optional[list[str]] = pydantic.Field( default=[], title="Highlights", - description=( - "The highlights of the event. It will be rendered as bullet points." - ), + description="The highlights of the event as a list of strings.", examples=["Did this.", "Did that."], ) - location: Optional[LaTeXString] = pydantic.Field( + location: Optional[str] = pydantic.Field( default=None, title="Location", - description=( - "The location of the event. It will be shown with the date in the" - " same column." - ), + description="The location of the event.", examples=["Istanbul, Turkey"], ) url: Optional[pydantic.HttpUrl] = None - - @pydantic.field_validator("date") - @classmethod - def check_date( - cls, date: PastDate | LaTeXString - ) -> Optional[PastDate | int | LaTeXString]: - """Check if the date is a string or a Date object and return accordingly.""" - if date is None: - new_date = None - elif isinstance(date, Date): - new_date = date - else: - raise TypeError(f"{date} is an invalid date.") - - return new_date + url_text_input: Optional[str] = pydantic.Field(default=None, alias="url_text") @pydantic.model_validator(mode="after") @classmethod def check_dates(cls, model): - """Make sure that either `#!python start_date` and `#!python end_date` or only - `#!python date` is provided. + """ + Check if the dates are provided correctly and convert them to `Date` objects if + they are provided in YYYY-MM-DD format. """ date_is_provided = False start_date_is_provided = False @@ -192,7 +182,18 @@ class EntryBase(pydantic.BaseModel): @pydantic.computed_field @cached_property - def date_string(self) -> Optional[LaTeXString]: + def date_string(self) -> Optional[str]: + """ + Return a date string based on the `date`, `start_date`, and `end_date` fields. + + Example: + ```python + entry = dm.EntryBase(start_date=2020-10-11, end_date=2021-04-04) + entry.date_string + ``` + will return: + `#!python "2020-10-11 to 2021-04-04"` + """ if self.date is not None: if isinstance(self.date, str): date_string = self.date @@ -228,7 +229,19 @@ class EntryBase(pydantic.BaseModel): @pydantic.computed_field @cached_property - def time_span(self) -> Optional[LaTeXString]: + def time_span(self) -> Optional[str]: + """ + Return a time span string based on the `date`, `start_date`, and `end_date` + fields. + + Example: + ```python + entry = dm.EntryBase(start_date=2020-01-01, end_date=2020-04-20) + entry.time_span + ``` + will return: + `#!python "4 months"` + """ if self.date is not None: time_span = "" elif self.start_date is not None and self.end_date is not None: @@ -254,96 +267,77 @@ class EntryBase(pydantic.BaseModel): @pydantic.computed_field @cached_property - def markdown_url(self) -> Optional[str]: - if self.url is None: - return None - else: - url = str(self.url) + def url_text(self) -> Optional[str]: + """ + Return a URL text based on the `url_text_input` and `url` fields. + """ + url_text = None + if self.url_text_input is not None: + url_text = self.url_text_input + elif self.url is not None: + url_text_dictionary = { + "gitub": "view on GitHub", + "linkedin": "view on LinkedIn", + "instagram": "view on Instagram", + "youtube": "view on YouTube", + } + url_text = "view on my website" + for key in url_text_dictionary: + if key in str(self.url): + url_text = url_text_dictionary[key] + break - if "github" in url: - link_text = "view on GitHub" - elif "linkedin" in url: - link_text = "view on LinkedIn" - elif "instagram" in url: - link_text = "view on Instagram" - elif "youtube" in url: - link_text = "view on YouTube" - else: - link_text = "view on my website" - - markdown_url = f"[{link_text}]({url})" - - return markdown_url - - @pydantic.computed_field - @cached_property - def month_and_year(self) -> Optional[LaTeXString]: - if self.date is not None: - # Then it means start_date and end_date are not provided. - try: - # If this runs, it means the date is an ISO format string, and it can be - # parsed - month_and_year = utilities.format_date(self.date) # type: ignore - except TypeError: - month_and_year = str(self.date) - else: - # Then it means start_date and end_date are provided and month_and_year - # doesn't make sense. - month_and_year = None - - return month_and_year + return url_text -class OneLineEntry(pydantic.BaseModel): - """This class stores [OneLineEntry](../user_guide.md#onelineentry) information.""" +class OneLineEntry(RenderCVBaseModel): + """This class is the data model of `OneLineEntry`.""" - name: LaTeXString = pydantic.Field( + name: str = pydantic.Field( title="Name", description="The name of the entry. It will be shown as bold text.", ) - details: LaTeXString = pydantic.Field( + details: str = pydantic.Field( title="Details", description="The details of the entry. It will be shown as normal text.", ) class NormalEntry(EntryBase): - """This class stores [NormalEntry](../user_guide.md#normalentry) information.""" + """This class is the data model of `NormalEntry`.""" - name: LaTeXString = pydantic.Field( + name: str = pydantic.Field( title="Name", description="The name of the entry. It will be shown as bold text.", ) class ExperienceEntry(EntryBase): - """This class stores [ExperienceEntry](../user_guide.md#experienceentry) - information. - """ + """This class is the data model of `ExperienceEntry`.""" - company: LaTeXString = pydantic.Field( + company: str = pydantic.Field( title="Company", description="The company name. It will be shown as bold text.", ) - position: LaTeXString = pydantic.Field( + position: str = pydantic.Field( title="Position", description="The position. It will be shown as normal text.", ) class EducationEntry(EntryBase): - """This class stores [EducationEntry](../user_guide.md#educationentry) information.""" + """This class is the data model of `EducationEntry`.""" - institution: LaTeXString = pydantic.Field( + institution: str = pydantic.Field( title="Institution", description="The institution name. It will be shown as bold text.", examples=["Bogazici University"], ) - area: LaTeXString = pydantic.Field( + area: str = pydantic.Field( title="Area", description="The area of study. It will be shown as normal text.", ) - study_type: Optional[LaTeXString] = pydantic.Field( + study_type: Optional[str] = pydantic.Field( default=None, title="Study Type", description="The type of the degree.", @@ -351,16 +345,14 @@ class EducationEntry(EntryBase): ) -class PublicationEntry(pydantic.BaseModel): - """This class stores [PublicationEntry](../user_guide.md#publicationentry) - information. - """ +class PublicationEntry(RenderCVBaseModel): + """THis class is the data model of `PublicationEntry`.""" - title: LaTeXString = pydantic.Field( + title: str = pydantic.Field( title="Title of the Publication", description="The title of the publication. It will be shown as bold text.", ) - authors: list[LaTeXString] = pydantic.Field( + authors: list[str] = pydantic.Field( title="Authors", description="The authors of the publication in order as a list of strings.", ) @@ -369,12 +361,12 @@ class PublicationEntry(pydantic.BaseModel): description="The DOI of the publication.", examples=["10.48550/arXiv.2310.03138"], ) - date: LaTeXString = pydantic.Field( + date: str = pydantic.Field( title="Publication Date", description="The date of the publication.", examples=["2021-10-31"], ) - journal: Optional[LaTeXString] = pydantic.Field( + journal: Optional[str] = pydantic.Field( default=None, title="Journal", description="The journal or the conference name.", @@ -414,99 +406,62 @@ entries_field_of_section_model = pydantic.Field( ) -class SectionBase(pydantic.BaseModel): - """This class stores a section information. - - It is the parent class of all the section classes like - `#!python SectionWithEducationEntries`, `#!python SectionWithExperienceEntries`, - `#!python SectionWithNormalEntries`, `#!python SectionWithOneLineEntries`, and - `#!python SectionWithPublicationEntries`. +class SectionBase(RenderCVBaseModel): + """This class is the parent class of all the section types. It is being used + because all of the section types have a common field called `title`. """ - title: Optional[LaTeXString] = pydantic.Field(default=None) - link_text: Optional[LaTeXString] = pydantic.Field( - default=None, - title="Link Text", - description=( - "If the section has a link, then what should be the text of the link? If" - " this field is not provided, then the link text will be generated" - " automatically based on the URL." - ), - examples=["view on GitHub", "view on LinkedIn"], - ) + # title is excluded from the JSON schema because this will be written by RenderCV + # depending on the key in the input file. + title: Optional[str] = pydantic.Field(default=None, exclude=True) class SectionWithEducationEntries(SectionBase): - """This class stores a section with - [EducationEntry](../user_guide.md#educationentry)s. - """ + """This class is the data model of the section with `EducationEntry`s.""" entry_type: Literal["EducationEntry"] = entry_type_field_of_section_model entries: list[EducationEntry] = entries_field_of_section_model class SectionWithExperienceEntries(SectionBase): - """This class stores a section with - [ExperienceEntry](../user_guide.md#experienceentry)s. - """ + """This class is the data model of the section with `ExperienceEntry`s.""" entry_type: Literal["ExperienceEntry"] = entry_type_field_of_section_model entries: list[ExperienceEntry] = entries_field_of_section_model class SectionWithNormalEntries(SectionBase): - """This class stores a section with - [NormalEntry](../user_guide.md#normalentry)s. - """ + """This class is the data model of the section with `NormalEntry`s.""" entry_type: Literal["NormalEntry"] = entry_type_field_of_section_model entries: list[NormalEntry] = entries_field_of_section_model class SectionWithOneLineEntries(SectionBase): - """This class stores a section with - [OneLineEntry](../user_guide.md#onelineentry)s. - """ + """This class is the data model of the section with `OneLineEntry`s.""" entry_type: Literal["OneLineEntry"] = entry_type_field_of_section_model entries: list[OneLineEntry] = entries_field_of_section_model class SectionWithPublicationEntries(SectionBase): - """This class stores a section with - [PublicationEntry](../user_guide.md#publicationentry)s. - """ + """This class is the data model of the section with `PublicationEntry`s.""" entry_type: Literal["PublicationEntry"] = entry_type_field_of_section_model entries: list[PublicationEntry] = entries_field_of_section_model class SectionWithTextEntries(SectionBase): - """This class stores a section with - [TextEntry](../user_guide.md#textentry)s. - """ + """This class is the data model of the section with `TextEntry`s.""" entry_type: Literal["TextEntry"] = entry_type_field_of_section_model - entries: list[LaTeXString] = entries_field_of_section_model + entries: list[str] = entries_field_of_section_model -class SocialNetwork(pydantic.BaseModel): - """This class stores a social network information. - - Currently, only LinkedIn, Github, and Instagram are supported. - """ - - network: Literal["LinkedIn", "GitHub", "Instagram", "Orcid"] = pydantic.Field( - title="Social Network", - description="The social network name.", - ) - username: str = pydantic.Field( - title="Username", - description="The username of the social network. The link will be generated.", - ) - - -# Section type +# A custom type Section. It is a union of all the section types and the correct section +# type is determined by the entry_type field. +# See https://docs.pydantic.dev/2.5/concepts/fields/#discriminator for more information +# about discriminators. Section = Annotated[ SectionWithEducationEntries | SectionWithExperienceEntries @@ -523,7 +478,11 @@ Section = Annotated[ # Full RenderCV data models: =========================================================== # ====================================================================================== -# Default entry types for a given section title +# RenderCV requires users to specify the entry type for each section in their CV in +# order to render the correct thing in the CV. However, for certain sections, specifying +# the entry type can be redundant. To simplify this process for users, default entry +# types are stored in a dictionary for certain section titles so that users do not have +# to specify them. default_entry_types_for_a_given_title: dict[ str, tuple[type[EducationEntry], type[SectionWithEducationEntries]] @@ -531,7 +490,7 @@ default_entry_types_for_a_given_title: dict[ | tuple[type[PublicationEntry], type[SectionWithPublicationEntries]] | tuple[type[NormalEntry], type[SectionWithNormalEntries]] | tuple[type[OneLineEntry], type[SectionWithOneLineEntries]] - | tuple[type[LaTeXString], type[SectionWithTextEntries]], + | tuple[type[str], type[SectionWithTextEntries]], ] = { "Education": (EducationEntry, SectionWithEducationEntries), "Experience": (ExperienceEntry, SectionWithExperienceEntries), @@ -551,68 +510,53 @@ default_entry_types_for_a_given_title: dict[ "Other Skills": (OneLineEntry, SectionWithOneLineEntries), "Awards": (OneLineEntry, SectionWithOneLineEntries), "Interests": (OneLineEntry, SectionWithOneLineEntries), - "Summary": (LaTeXString, SectionWithTextEntries), + "Summary": (str, SectionWithTextEntries), } -class Connection(pydantic.BaseModel): - """This class stores a connection/communication information. +class SocialNetwork(RenderCVBaseModel): + """This class is the data model of a social network.""" - Warning: - This class isn't designed for users to use, but it is used by RenderCV to make - the $\\LaTeX$ templating easier. - """ - - name: Literal[ - "LinkedIn", - "GitHub", - "Instagram", - "Orcid", - "phone", - "email", - "website", - "location", - ] - value: str + network: Literal["LinkedIn", "GitHub", "Instagram", "Orcid"] = pydantic.Field( + title="Social Network", + description="The social network name.", + ) + username: str = pydantic.Field( + title="Username", + description="The username of the social network. The link will be generated.", + ) @pydantic.computed_field @cached_property - def url(self) -> Optional[pydantic.HttpUrl | str]: - if self.name == "LinkedIn": - url = f"https://www.linkedin.com/in/{self.value}" - elif self.name == "GitHub": - url = f"https://www.github.com/{self.value}" - elif self.name == "Instagram": - url = f"https://www.instagram.com/{self.value}" - elif self.name == "Orcid": - url = f"https://orcid.org/{self.value}" - elif self.name == "email": - url = f"mailto:{self.value}" - elif self.name == "website": - url = self.value - elif self.name == "phone": - url = self.value - elif self.name == "location": - url = None - else: - raise RuntimeError(f'"{self.name}" is not a valid connection.') + def url(self) -> pydantic.HttpUrl: + """Return the URL of the social network.""" + url_dictionary = { + "LinkedIn": "https://linkedin.com/in/", + "GitHub": "https://github.com/", + "Instagram": "https://instagram.com/", + "Orcid": "https://orcid.org/", + } + url = url_dictionary[self.network] + self.username + + HttpUrlAdapter = pydantic.TypeAdapter(pydantic.HttpUrl) + url = HttpUrlAdapter.validate_python(url) return url -class CurriculumVitae(pydantic.BaseModel): - """This class binds all the information of a CV together.""" +class CurriculumVitae(RenderCVBaseModel): + """This class is the data model of the CV.""" - name: LaTeXString = pydantic.Field( + name: str = pydantic.Field( title="Name", description="The name of the person.", ) - label: Optional[LaTeXString] = pydantic.Field( + label: Optional[str] = pydantic.Field( default=None, title="Label", description="The label of the person.", ) - location: Optional[LaTeXString] = pydantic.Field( + location: Optional[str] = pydantic.Field( default=None, title="Location", description="The location of the person. This is not rendered currently.", @@ -646,7 +590,7 @@ class CurriculumVitae(pydantic.BaseModel): | list[OneLineEntry] | list[PublicationEntry] | list[ExperienceEntry] - | list[LaTeXString], + | list[str], ] = pydantic.Field( default=None, title="Sections", @@ -666,7 +610,7 @@ class CurriculumVitae(pydantic.BaseModel): | list[OneLineEntry] | list[PublicationEntry] | list[ExperienceEntry] - | list[LaTeXString], + | list[str], ], ) -> dict[ str, @@ -676,9 +620,11 @@ class CurriculumVitae(pydantic.BaseModel): | list[OneLineEntry] | list[PublicationEntry] | list[ExperienceEntry] - | list[LaTeXString], + | list[str], ]: - """""" + """ + Parse and validate the sections of the CV. + """ if sections_input is not None: # check if the section names are unique, get the keys of the sections: @@ -692,6 +638,7 @@ class CurriculumVitae(pydantic.BaseModel): ) for title, section_or_entries in sections_input.items(): + title = title.replace("_", " ").title() if isinstance(section_or_entries, list): # Then it means the user provided entries directly. Then it means # the section title should have a default entry type. @@ -704,7 +651,7 @@ class CurriculumVitae(pydantic.BaseModel): # try if the entries are of the correct type by casting them # to the entry type one by one for entry in section_or_entries: - if entry_type is LaTeXString: + if entry_type is str: if not isinstance(entry, str): raise pydantic.ValidationError( f'"{entry}" is not a valid string.' @@ -720,8 +667,8 @@ class CurriculumVitae(pydantic.BaseModel): else: raise ValueError( - f'"{title}" is a custom section and it doesn\'t have' - " a default entry type. Please provide the entry type." + f'"{title}" doesn\'t have a default entry type. Please' + " provide the entry type." ) return sections_input @@ -729,14 +676,11 @@ class CurriculumVitae(pydantic.BaseModel): @pydantic.computed_field @cached_property def sections(self) -> list[Section]: - """Compute the sections of the CV. - - Returns: - list[Section]: The sections of the CV. - """ + """Return all the sections of the CV with their titles.""" sections = [] if self.sections_input is not None: for title, section_or_entries in self.sections_input.items(): + title = title.replace("_", " ").title() if isinstance( section_or_entries, ( @@ -750,16 +694,24 @@ class CurriculumVitae(pydantic.BaseModel): ): if section_or_entries.title is None: section_or_entries.title = title + sections.append(section_or_entries) + elif isinstance(section_or_entries, list): if title in default_entry_types_for_a_given_title: ( entry_type, section_type, ) = default_entry_types_for_a_given_title[title] + + if entry_type is str: + entry_type = "TextEntry" + else: + entry_type = entry_type.__name__ + section = section_type( title=title, - entry_type=entry_type.__name__, # type: ignore + entry_type=entry_type, # type: ignore entries=section_or_entries, # type: ignore ) sections.append(section) @@ -768,6 +720,7 @@ class CurriculumVitae(pydantic.BaseModel): "This error shouldn't have been raised. Please open an" " issue on GitHub." ) + else: raise RuntimeError( "This error shouldn't have been raised. Please open an" @@ -776,42 +729,24 @@ class CurriculumVitae(pydantic.BaseModel): return sections - @pydantic.computed_field - @cached_property - def connections(self) -> list[Connection]: - connections = [] - if self.location is not None: - connections.append(Connection(name="location", value=self.location)) - if self.phone is not None: - connections.append(Connection(name="phone", value=self.phone)) - if self.email is not None: - connections.append(Connection(name="email", value=self.email)) - if self.website is not None: - connections.append(Connection(name="website", value=str(self.website))) - if self.social_networks is not None: - for social_network in self.social_networks: - connections.append( - Connection( - name=social_network.network, value=social_network.username - ) - ) - - return connections - # ====================================================================================== # ====================================================================================== # ====================================================================================== -class RenderCVDataModel(pydantic.BaseModel): +class RenderCVDataModel(RenderCVBaseModel): """This class binds both the CV and the design information together.""" cv: CurriculumVitae = pydantic.Field( - default=CurriculumVitae(name="John Doe"), title="Curriculum Vitae", description="The data of the CV.", ) + design: ClassicThemeOptions = pydantic.Field( + title="Design", + description="The design information.", + discriminator="theme", + ) def generate_json_schema(output_directory: str) -> str: