From 591550e5f47ef168d7d7322d7f52d46610b6cc4a Mon Sep 17 00:00:00 2001 From: Sina Atalay Date: Thu, 18 Jan 2024 18:24:30 +0100 Subject: [PATCH] rewrite logic --- rendercv/__main__.py | 2 +- rendercv/data_models.py | 808 ++++++++++++++++++++++++++++++++ rendercv/parser.py | 278 +++++++++++ rendercv/renderer.py | 0 tests/test_cli.py | 76 --- tests/test_data_model.py | 947 -------------------------------------- tests/test_data_models.py | 33 ++ tests/test_rendering.py | 303 ------------ 8 files changed, 1120 insertions(+), 1327 deletions(-) create mode 100644 rendercv/data_models.py create mode 100644 rendercv/parser.py create mode 100644 rendercv/renderer.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_data_model.py create mode 100644 tests/test_data_models.py delete mode 100644 tests/test_rendering.py diff --git a/rendercv/__main__.py b/rendercv/__main__.py index f659494..ad44f57 100644 --- a/rendercv/__main__.py +++ b/rendercv/__main__.py @@ -138,7 +138,7 @@ def render( input_file: Annotated[ str, typer.Argument(help="Name of the YAML input file"), - ] + ], ): """Generate a LaTeX CV from a YAML input file. diff --git a/rendercv/data_models.py b/rendercv/data_models.py new file mode 100644 index 0000000..ff955bd --- /dev/null +++ b/rendercv/data_models.py @@ -0,0 +1,808 @@ +""" +finally document the whole code! +""" + +from datetime import date as Date +from typing import Literal +from typing_extensions import Annotated, Optional, Union +import logging +from functools import cached_property +import urllib.request +import os +import json + +from pydantic import ( + BaseModel, + RootModel, + HttpUrl, + Field, + field_validator, + model_validator, + computed_field, + EmailStr, + TypeAdapter, +) +from pydantic.json_schema import GenerateJsonSchema +from pydantic.functional_validators import AfterValidator +from pydantic_extra_types.phone_numbers import PhoneNumber + +from . import parser + +logger = logging.getLogger(__name__) + + +# To understand how to create custom data types, see: +# https://docs.pydantic.dev/latest/usage/types/custom/ + + +LaTeXDimension = Annotated[ + str, + Field( + pattern=r"\d+\.?\d* *(cm|in|pt|mm|ex|em)", + ), +] + +LaTeXString = Annotated[str, AfterValidator(parser.escape_latex_characters)] +PastDate = Annotated[ + str, + Field(pattern=r"\d{4}-?(\d{2})?-?(\d{2})?"), + AfterValidator(parser.parse_date_string), +] + + +class Event(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. + """ + + start_date: Optional[PastDate] = Field( + default=None, + title="Start Date", + description="The start date of the event in YYYY-MM-DD format.", + examples=["2020-09-24"], + ) + end_date: Optional[Literal["present"] | PastDate] = Field( + default=None, + title="End Date", + description=( + "The end date of the event in YYYY-MM-DD format. If the event is still" + ' ongoing, then the value should be "present".' + ), + examples=["2020-09-24", "present"], + ) + date: Optional[PastDate | int | LaTeXString] = Field( + default=None, + title="Date", + description=( + "If the event is a one-day event, then this field should be filled in" + " YYYY-MM-DD format. If the event is a multi-day event, then the start date" + " and end date should be provided instead. All of them can't be provided at" + " the same time." + ), + examples=["2020-09-24", "My Custom Date"], + ) + highlights: Optional[list[LaTeXString]] = Field( + default=[], + title="Highlights", + description=( + "The highlights of the event. It will be rendered as bullet points." + ), + examples=["Did this.", "Did that."], + ) + location: Optional[LaTeXString] = Field( + default=None, + title="Location", + description=( + "The location of the event. It will be shown with the date in the" + " same column." + ), + examples=["Istanbul, Turkey"], + ) + url: Optional[HttpUrl] = None + + @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 isinstance(date, str): + try: + # If this runs, it means the date is an ISO format string, and it can be + # parsed + new_date = parser.parse_date_string(date) + except ValueError: + # Then it means it is a custom string like "Fall 2023" + new_date = date + elif date is None: + new_date = None + else: + raise TypeError(f"Date ({date}) is neither a string nor a Date object.") + + return new_date + + @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. + """ + date_is_provided = False + start_date_is_provided = False + end_date_is_provided = False + if model.date is not None: + date_is_provided = True + if model.start_date is not None: + start_date_is_provided = True + if model.end_date is not None: + end_date_is_provided = True + + if date_is_provided and start_date_is_provided and end_date_is_provided: + logger.warning( + '"start_date", "end_date" and "date" are all provided in of the' + " entries. Therefore, date will be ignored." + ) + model.date = None + + elif date_is_provided and start_date_is_provided and not end_date_is_provided: + logger.warning( + 'Both "date" and "start_date" is provided in of the entries.' + ' "start_date" will be ignored.' + ) + model.start_date = None + model.end_date = None + + elif date_is_provided and end_date_is_provided and not start_date_is_provided: + logger.warning( + 'Both "date" and "end_date" is provided in of the entries. "end_date"' + " will be ignored." + ) + model.start_date = None + model.end_date = None + + elif start_date_is_provided and not end_date_is_provided: + logger.warning( + '"start_date" is provided in of the entries, but "end_date" is not.' + ' "end_date" will be set to "present".' + ) + model.end_date = "present" + + if model.start_date is not None and model.end_date is not None: + if model.end_date == "present": + end_date = Date.today() + elif isinstance(model.end_date, int): + # Then it means user only provided the year, so convert it to a Date + # object with the first day of the year (just for the date comparison) + end_date = Date(model.end_date, 1, 1) + elif isinstance(model.end_date, Date): + # Then it means user provided either YYYY-MM-DD or YYYY-MM + end_date = model.end_date + else: + raise RuntimeError("end_date is neither an integer nor a Date object.") + + if isinstance(model.start_date, int): + # Then it means user only provided the year, so convert it to a Date + # object with the first day of the year (just for the date comparison) + start_date = Date(model.start_date, 1, 1) + elif isinstance(model.start_date, Date): + # Then it means user provided either YYYY-MM-DD or YYYY-MM + start_date = model.start_date + else: + raise RuntimeError( + "start_date is neither an integer nor a Date object." + ) + + if start_date > end_date: + raise ValueError( + '"start_date" can not be after "end_date". Please check the dates.' + ) + + return model + + @computed_field + @cached_property + def date_string(self) -> Optional[LaTeXString]: + if self.date is not None: + if isinstance(self.date, str): + date_string = self.date + elif isinstance(self.date, Date): + date_string = parser.format_date(self.date) + else: + raise RuntimeError("Date is neither a string nor a Date object.") + + elif self.start_date is not None and self.end_date is not None: + start_date = parser.format_date(self.start_date) + + if self.end_date == "present": + end_date = "present" + else: + end_date = parser.format_date(self.end_date) + + date_string = f"{start_date} to {end_date}" + + else: + date_string = None + + return date_string + + @computed_field + @cached_property + def time_span(self) -> Optional[LaTeXString]: + if self.date is not None: + time_span = "" + elif self.start_date is not None and self.end_date is not None: + if self.end_date == "present": + time_span = parser.compute_time_span_string( + self.start_date, PastDate(Date.today()) + ) + else: + time_span = parser.compute_time_span_string( + self.start_date, self.end_date + ) + else: + time_span = None + + return time_span + + @computed_field + @cached_property + def markdown_url(self) -> Optional[str]: + if self.url is None: + return None + else: + url = str(self.url) + + 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 + + @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 = parser.format_date(self.date) + 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 + + +class OneLineEntry(Event): + """This class stores [OneLineEntry](../user_guide.md#onelineentry) information.""" + + name: LaTeXString = Field( + title="Name", + description="The name of the entry. It will be shown as bold text.", + ) + details: LaTeXString = Field( + title="Details", + description="The details of the entry. It will be shown as normal text.", + ) + + +class NormalEntry(Event): + """This class stores [NormalEntry](../user_guide.md#normalentry) information.""" + + name: LaTeXString = Field( + title="Name", + description="The name of the entry. It will be shown as bold text.", + ) + + +class ExperienceEntry(Event): + """This class stores [ExperienceEntry](../user_guide.md#experienceentry) + information. + """ + + company: LaTeXString = Field( + title="Company", + description="The company name. It will be shown as bold text.", + ) + position: LaTeXString = Field( + title="Position", + description="The position. It will be shown as normal text.", + ) + + +class EducationEntry(Event): + """This class stores [EducationEntry](../user_guide.md#educationentry) information.""" + + institution: LaTeXString = Field( + title="Institution", + description="The institution name. It will be shown as bold text.", + examples=["Bogazici University"], + ) + area: LaTeXString = Field( + title="Area", + description="The area of study. It will be shown as normal text.", + ) + study_type: Optional[LaTeXString] = Field( + default=None, + title="Study Type", + description="The type of the degree.", + examples=["BS", "BA", "PhD", "MS"], + ) + + +class PublicationEntry(Event): + """This class stores [PublicationEntry](../user_guide.md#publicationentry) + information. + """ + + title: LaTeXString = Field( + title="Title of the Publication", + description="The title of the publication. It will be shown as bold text.", + ) + authors: list[LaTeXString] = Field( + title="Authors", + description="The authors of the publication in order as a list of strings.", + ) + doi: str = Field( + title="DOI", + description="The DOI of the publication.", + examples=["10.48550/arXiv.2310.03138"], + ) + date: LaTeXString = Field( + title="Publication Date", + description="The date of the publication.", + examples=["2021-10-31"], + ) + journal: Optional[LaTeXString] = Field( + default=None, + title="Journal", + description="The journal or the conference name.", + ) + + @field_validator("doi") + @classmethod + def check_doi(cls, doi: str) -> str: + """Check if the DOI exists in the DOI System.""" + doi_url = f"https://doi.org/{doi}" + + try: + urllib.request.urlopen(doi_url) + except urllib.request.HTTPError as err: + if err.code == 404: + raise ValueError(f"{doi} cannot be found in the DOI System.") + + return doi + + @computed_field + @cached_property + def doi_url(self) -> str: + return f"https://doi.org/{self.doi}" + + +default_entry_types = { + "Education": EducationEntry, + "Experience": ExperienceEntry, + "Work Experience": ExperienceEntry, + "Research Experience": ExperienceEntry, + "Publications": PublicationEntry, + "Papers": PublicationEntry, + "Projects": NormalEntry, + "Academic Projects": NormalEntry, + "University Projects": NormalEntry, + "Personal Projects": NormalEntry, + "Certificates": NormalEntry, + "Extracurricular Activities": ExperienceEntry, + "Test Scores": OneLineEntry, + "Skills": OneLineEntry, + "Programming Skills": OneLineEntry, + "Other Skills": OneLineEntry, + "Awards": OneLineEntry, + "Interests": OneLineEntry, +} + + +class SocialNetwork(BaseModel): + """This class stores a social network information. + + Currently, only LinkedIn, Github, and Instagram are supported. + """ + + network: Literal["LinkedIn", "GitHub", "Instagram", "Orcid"] = Field( + title="Social Network", + description="The social network name.", + ) + username: str = Field( + title="Username", + description="The username of the social network. The link will be generated.", + ) + + +class Connection(BaseModel): + """This class stores a connection/communication information. + + 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 + + @computed_field + @cached_property + def url(self) -> Optional[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.') + + return url + + +class SectionBase(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`. + """ + + title: Optional[LaTeXString] + link_text: Optional[LaTeXString] = 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"], + ) + + +entry_type_field = Field( + title="Entry Type", + description="The type of the entries in the section.", +) +entries_field = Field( + title="Entries", + description="The entries of the section. The format depends on the entry type.", +) + + +class SectionWithEducationEntries(SectionBase): + """This class stores a section with + [EducationEntry](../user_guide.md#educationentry)s. + """ + + entry_type: Literal["EducationEntry"] = entry_type_field + entries: list[EducationEntry] = entries_field + + +class SectionWithExperienceEntries(SectionBase): + """This class stores a section with + [ExperienceEntry](../user_guide.md#experienceentry)s. + """ + + entry_type: Literal["ExperienceEntry"] = entry_type_field + entries: list[ExperienceEntry] = entries_field + + +class SectionWithNormalEntries(SectionBase): + """This class stores a section with + [NormalEntry](../user_guide.md#normalentry)s. + """ + + entry_type: Literal["NormalEntry"] = entry_type_field + entries: list[NormalEntry] = entries_field + + +class SectionWithOneLineEntries(SectionBase): + """This class stores a section with + [OneLineEntry](../user_guide.md#onelineentry)s. + """ + + entry_type: Literal["OneLineEntry"] = entry_type_field + entries: list[OneLineEntry] = entries_field + + +class SectionWithPublicationEntries(SectionBase): + """This class stores a section with + [PublicationEntry](../user_guide.md#publicationentry)s. + """ + + entry_type: Literal["PublicationEntry"] = entry_type_field + entries: list[PublicationEntry] = entries_field + + +class SectionWithTextEntries(SectionBase): + """This class stores a section with + [TextEntry](../user_guide.md#textentry)s. + """ + + entry_type: Literal["TextEntry"] = entry_type_field + entries: list[LaTeXString] = entries_field + + +section_types = ( + SectionWithEducationEntries, + SectionWithExperienceEntries, + SectionWithNormalEntries, + SectionWithOneLineEntries, + SectionWithPublicationEntries, + SectionWithTextEntries, +) + +Section = Annotated[ + Union[section_types], + Field( + discriminator="entry_type", + ), +] + + +class CurriculumVitae(BaseModel): + """This class binds all the information of a CV together.""" + + name: LaTeXString = Field( + title="Name", + description="The name of the person.", + ) + label: Optional[LaTeXString] = Field( + default=None, + title="Label", + description="The label of the person.", + ) + location: Optional[LaTeXString] = Field( + default=None, + title="Location", + description="The location of the person. This is not rendered currently.", + ) + email: Optional[EmailStr] = Field( + default=None, + title="Email", + description="The email of the person. It will be rendered in the heading.", + ) + phone: Optional[PhoneNumber] = None + website: Optional[HttpUrl] = None + social_networks: Optional[list[SocialNetwork]] = Field( + default=None, + title="Social Networks", + description=( + "The social networks of the person. They will be rendered in the heading." + ), + ) + section_order: Optional[list[str]] = Field( + default=None, + title="Section Order", + description=( + "The order of sections in the CV. The section title should be used." + ), + ) + sections_input: dict[str, Section] = Field( + default=None, + title="Sections", + description="The sections of the CV.", + alias="sections", + ) + + @field_validator("sections_input") + @classmethod + def parse_and_check_sections( + cls, sections_input: dict[str, Section] + ) -> dict[str, Section]: + """Check if the sections are provided.""" + + if sections_input is not None: + # check if the section names are unique, get the keys of the sections: + keys = list(sections_input.keys()) + unique_keys = list(set(keys)) + duplicate_keys = list(set([key for key in keys if keys.count(key) > 1])) + if len(keys) != len(unique_keys): + raise ValueError( + "The section names should be unique. The following section names" + f" are duplicated: {duplicate_keys}" + ) + + for title, section in sections_input.items(): + parsed_title = title.replace("_", " ").title() + if isinstance(section, section_types): + section.title = parsed_title + elif isinstance(section, list): + if parsed_title not in default_entry_types: + raise ValueError( + f'"{parsed_title}" is a custom section and it doesn\'t have' + " a default entry type. Please provide the entry type." + ) + else: + raise TypeError(f'"{section}" is not a valid section.') + + return sections_input + + @computed_field + @cached_property + def sections(self) -> list[Section]: + """Compute the sections of the CV. + + Returns: + list[Section]: The sections of the CV. + """ + sections = [] + if self.sections_input is not None: + for title, section in self.sections_input.items(): + if isinstance(section, section_types): + sections.append(section) + elif isinstance(section, list): + if title in default_entry_types: + entry_type = default_entry_types[title] + section = entry_type( + title=title, entry_type=entry_type.__name__, entries=section + ) + sections.append(section) + else: + raise RuntimeError( + "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" + " issue on GitHub." + ) + + return sections + + @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(BaseModel): + """This class binds both the CV and the design information together.""" + + cv: CurriculumVitae = Field( + default=CurriculumVitae(name="John Doe"), + title="Curriculum Vitae", + description="The data of the CV.", + ) + + +def generate_json_schema(output_directory: str) -> str: + """Generate the JSON schema of the data model and save it to a file. + + Args: + output_directory (str): The output directory to save the schema. + """ + + class RenderCVSchemaGenerator(GenerateJsonSchema): + def generate(self, schema, mode="validation"): + json_schema = super().generate(schema, mode=mode) + json_schema["title"] = "RenderCV Input" + + # remove the description of the class (RenderCVDataModel) + del json_schema["description"] + + # add $id + json_schema[ + "$id" + ] = "https://raw.githubusercontent.com/sinaatalay/rendercv/main/schema.json" + + # add $schema + json_schema["$schema"] = "http://json-schema.org/draft-07/schema#" + + # Loop through $defs and remove docstring descriptions and fix optional + # fields + for key, value in json_schema["$defs"].items(): + # Don't allow additional properties + value["additionalProperties"] = False + + # I don't want the docstrings in the schema, so remove them: + if "This class" in value["description"]: + del value["description"] + + # If a type is optional, then Pydantic sets the type to a list of two + # types, one of which is null. The null type can be removed since we + # already have the required field. Moreover, we would like to warn + # users if they provide null values. They can remove the fields if they + # don't want to provide them. + null_type_dict = {} + null_type_dict["type"] = "null" + for field in value["properties"].values(): + if "anyOf" in field: + if ( + len(field["anyOf"]) == 2 + and null_type_dict in field["anyOf"] + ): + field["allOf"] = [field["anyOf"][0]] + del field["anyOf"] + + # In date field, we both accept normal strings and Date objects. They + # are both strings, therefore, if user provides a Date object, then + # JSON schema will complain that it matches two different types. + # Remember that all of the anyOfs are changed to oneOfs. Only one of + # the types can be matched. Therefore, we remove the first type, which + # is the string with the YYYY-MM-DD format. + if ( + "date" in value["properties"] + and "anyOf" in value["properties"]["date"] + ): + del value["properties"]["date"]["anyOf"][0] + + return json_schema + + schema = RenderCVDataModel.model_json_schema( + schema_generator=RenderCVSchemaGenerator + ) + schema = json.dumps(schema, indent=2) + + # Change all anyOf to oneOf + schema = schema.replace('"anyOf"', '"oneOf"') + + path_to_schema = os.path.join(output_directory, "schema.json") + with open(path_to_schema, "w") as f: + f.write(schema) + + return path_to_schema diff --git a/rendercv/parser.py b/rendercv/parser.py new file mode 100644 index 0000000..e4ccff3 --- /dev/null +++ b/rendercv/parser.py @@ -0,0 +1,278 @@ +import re +from datetime import date as Date +import time +import logging +import os + + +from ruamel.yaml import YAML + + +from pydantic import Field, AfterValidator + +from typing import Annotated + + +logger = logging.getLogger(__name__) + + +def escape_latex_characters(sentence: str) -> str: + """Escape LaTeX characters in a sentence. + + Example: + ```python + escape_latex_characters("This is a # sentence.") + ``` + will return: + `#!python "This is a \\# sentence."` + """ + + # Dictionary of escape characters: + escape_characters = { + "#": r"\#", + # "$": r"\$", # Don't escape $ as it is used for math mode + "%": r"\%", + "&": r"\&", + "~": r"\textasciitilde{}", + # "_": r"\_", # Don't escape _ as it is used for math mode + # "^": r"\textasciicircum{}", # Don't escape ^ as it is used for math mode + } + + # Don't escape links as hyperref will do it automatically: + + # Find all the links in the sentence: + links = re.findall(r"\[.*?\]\(.*?\)", sentence) + + # Replace the links with a placeholder: + for link in links: + sentence = sentence.replace(link, "!!-link-!!") + + # Handle backslash and curly braces separately because the other characters are + # escaped with backslash and curly braces: + # --don't escape curly braces as they are used heavily in LaTeX--: + # sentence = sentence.replace("{", ">>{") + # sentence = sentence.replace("}", ">>}") + # --don't escape backslash as it is used heavily in LaTeX--: + # sentence = sentence.replace("\\", "\\textbackslash{}") + # sentence = sentence.replace(">>{", "\\{") + # sentence = sentence.replace(">>}", "\\}") + + # Loop through the letters of the sentence and if you find an escape character, + # replace it with its LaTeX equivalent: + copy_of_the_sentence = sentence + for character in copy_of_the_sentence: + if character in escape_characters: + sentence = sentence.replace(character, escape_characters[character]) + + # Replace the links with the original links: + for link in links: + sentence = sentence.replace("!!-link-!!", link) + + return sentence + + +def parse_date_string(date_string: str) -> Date | int: + """Parse a date string in YYYY-MM-DD, YYYY-MM, or YYYY format and return a + datetime.date object. + + Args: + date_string (str): The date string to parse. + Returns: + datetime.date: The parsed date. + """ + if re.match(r"\d{4}-\d{2}-\d{2}", date_string): + # Then it is in YYYY-MM-DD format + date = Date.fromisoformat(date_string) + elif re.match(r"\d{4}-\d{2}", date_string): + # Then it is in YYYY-MM format + # Assign a random day since days are not rendered in the CV + date = Date.fromisoformat(f"{date_string}-01") + elif re.match(r"\d{4}", date_string): + # Then it is in YYYY format + # Then keep it as an integer + date = int(date_string) + else: + raise ValueError( + f'The date string "{date_string}" is not in YYYY-MM-DD, YYYY-MM, or YYYY' + " format." + ) + + if isinstance(date, Date): + # Then it means the date is a Date object, so check if it is a past date: + if date > Date.today(): + raise ValueError( + f'The date "{date_string}" is in the future. Please check the dates.' + ) + date = PastDate(date) + + return date + + +PastDate = Annotated[ + str, + Field(pattern=r"\d{4}-?(\d{2})?-?(\d{2})?"), + AfterValidator(parse_date_string), +] + + +def compute_time_span_string( + start_date: PastDate | int, end_date: PastDate | int +) -> str: + """Compute the time span between two dates and return a string that represents it. + + Example: + ```python + compute_time_span_string(Date(2022, 9, 24), Date(2025, 2, 12)) + ``` + + will return: + + `#!python "2 years 5 months"` + + Args: + start_date (Date | int): The start date. + end_date (Date | int): The end date. + + Returns: + str: The time span string. + """ + # check if the types of start_date and end_date are correct: + if not isinstance(start_date, (Date, int)): + raise TypeError("start_date is not a Date object or an integer!") + if not isinstance(end_date, (Date, int)): + raise TypeError("end_date is not a Date object or an integer!") + + # calculate the number of days between start_date and end_date: + if isinstance(start_date, Date) and isinstance(end_date, Date): + timespan_in_days = (end_date - start_date).days # type: ignore + elif isinstance(start_date, Date) and isinstance(end_date, int): + timespan_in_days = (Date(end_date, 1, 1) - start_date).days + elif isinstance(start_date, int) and isinstance(end_date, Date): + timespan_in_days = (end_date - Date(start_date, 1, 1)).days # type: ignore + elif isinstance(start_date, int) and isinstance(end_date, int): + timespan_in_days = (end_date - start_date) * 365 + else: + raise TypeError( + f"start_date ({start_date}) and end_date ({end_date}) are not valid to" + " compute the time span." + ) + + if timespan_in_days < 0: + raise ValueError( + '"start_date" can not be after "end_date". Please check the dates.' + ) + + # calculate the number of years between start_date and end_date: + how_many_years = timespan_in_days // 365 + if how_many_years == 0: + how_many_years_string = None + elif how_many_years == 1: + how_many_years_string = "1 year" + else: + how_many_years_string = f"{how_many_years} years" + + # calculate the number of months between start_date and end_date: + how_many_months = round((timespan_in_days % 365) / 30) + if how_many_months <= 1: + how_many_months_string = "1 month" + else: + how_many_months_string = f"{how_many_months} months" + + # combine howManyYearsString and howManyMonthsString: + if how_many_years_string is None: + timespan_string = how_many_months_string + else: + timespan_string = f"{how_many_years_string} {how_many_months_string}" + + return timespan_string + + +def format_date(date: PastDate | int) -> str: + """Formats a date to a string in the following format: "Jan. 2021". + + It uses month abbreviations, taken from + [Yale University Library](https://web.library.yale.edu/cataloging/months). + + Example: + ```python + format_date(Date(2024, 5, 1)) + ``` + will return + + `#!python "May 2024"` + + Args: + date (Date): The date to format. + + Returns: + str: The formatted date. + """ + if not isinstance(date, (Date, int)): + raise TypeError("date is not a Date object or an integer!") + + if isinstance(date, int): + # Then it means the user only provided the year, so just return the year + return str(date) + + # Month abbreviations, + # taken from: https://web.library.yale.edu/cataloging/months + abbreviations_of_months = [ + "Jan.", + "Feb.", + "Mar.", + "Apr.", + "May", + "June", + "July", + "Aug.", + "Sept.", + "Oct.", + "Nov.", + "Dec.", + ] + + month = int(date.strftime("%m")) + monthAbbreviation = abbreviations_of_months[month - 1] + year = date.strftime("%Y") + date_string = f"{monthAbbreviation} {year}" + + return date_string + + +def read_input_file(file_path: str): + """Read the input file. + + Args: + file_path (str): The path to the input file. + + Returns: + str: The input file as a string. + """ + start_time = time.time() + logger.info(f"Reading and validating the input file {file_path} has started.") + + # check if the file exists: + if not os.path.exists(file_path): + raise FileNotFoundError(f"The file {file_path} doesn't exist.") + + # check the file extension: + accepted_extensions = [".yaml", ".yml", ".json", ".json5"] + if not any(file_path.endswith(extension) for extension in accepted_extensions): + raise ValueError( + f"The file {file_path} doesn't have an accepted extension!" + f" Accepted extensions are: {accepted_extensions}" + ) + + with open(file_path) as file: + yaml = YAML() + raw_json = yaml.load(file) + + # data = RenderCVDataModel(**raw_json) + + end_time = time.time() + time_taken = end_time - start_time + logger.info( + f"Reading and validating the input file {file_path} has finished in" + f" {time_taken:.2f} s." + ) + return data diff --git a/rendercv/renderer.py b/rendercv/renderer.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 3c516ed..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,76 +0,0 @@ -import unittest -import os -import shutil -import subprocess -import sys - -from rendercv import data_model - - -class TestCLI(unittest.TestCase): - def test_render(self): - # Change the working directory to the root of the project: - workspace_path = os.path.dirname(os.path.dirname(__file__)) - - with self.subTest(msg="Correct input"): - test_input_file_path = os.path.join( - workspace_path, - "tests", - "reference_files", - "John_Doe_CV_yaml_reference.yaml", - ) - subprocess.run( - [sys.executable, "-m", "rendercv", "render", test_input_file_path], - check=True, - ) - - # Read the necessary information and remove the output directory: - output_file_path = os.path.join(workspace_path, "output", "John_Doe_CV.pdf") - pdf_file_size = os.path.getsize(output_file_path) - file_exists = os.path.exists(output_file_path) - shutil.rmtree(os.path.join(workspace_path, "output")) - - # Check if the output file exists: - self.assertTrue(file_exists, msg="PDF file couldn't be generated.") - - # Compare the pdf file with the reference pdf file: - reference_pdf_file = os.path.join( - workspace_path, - "tests", - "reference_files", - "John_Doe_CV_pdf_reference.pdf", - ) - reference_pdf_file_size = os.path.getsize(reference_pdf_file) - ratio = min(reference_pdf_file_size, pdf_file_size) / max( - reference_pdf_file_size, pdf_file_size - ) - self.assertTrue(ratio > 0.98, msg="PDF file didn't match the reference.") - - # Wrong input: - with self.subTest(msg="Wrong input"): - with self.assertRaises(subprocess.CalledProcessError): - subprocess.run( - [ - sys.executable, - "-m", - "rendercv", - "wrong_input.yaml", - ], - check=True, - ) - - def test_new(self): - # Change the working directory to the root of the project: - workspace_path = os.path.dirname(os.path.dirname(__file__)) - - subprocess.run( - [sys.executable, "-m", "rendercv", "new", "John Doe"], - check=True, - ) - output_file_path = os.path.join(workspace_path, "John_Doe_CV.yaml") - - model: data_model.RenderCVDataModel = data_model.read_input_file( - output_file_path - ) - - self.assertTrue(model.cv.name == "John Doe") diff --git a/tests/test_data_model.py b/tests/test_data_model.py deleted file mode 100644 index 3c5ef79..0000000 --- a/tests/test_data_model.py +++ /dev/null @@ -1,947 +0,0 @@ -import unittest -import os -import json - -from rendercv import data_model - -from datetime import date as Date -from pydantic import ValidationError - - -class TestDataModel(unittest.TestCase): - def test_escape_latex_characters(self): - tests = [ - { - "input": "This is a string without LaTeX characters.", - "expected": "This is a string without LaTeX characters.", - "msg": "string without LaTeX characters", - }, - { - "input": r"asdf#asdf$asdf%asdf& ~ fd_ \ ^aa aa{ bb}", - "expected": ( - r"asdf\#asdf$asdf\%asdf\& \textasciitilde{} fd_ \ ^aa aa{ bb}" - ), - "msg": "string with LaTeX characters", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - result = data_model.escape_latex_characters(test["input"]) - self.assertEqual(result, test["expected"]) - - def test_compute_time_span_string(self): - # Valid inputs: - tests = [ - { - "start_date": Date(year=2020, month=1, day=1), - "end_date": Date(year=2021, month=1, day=1), - "expected": "1 year 1 month", - "msg": "1 year 1 month", - }, - { - "start_date": Date(year=2020, month=1, day=1), - "end_date": Date(year=2020, month=2, day=1), - "expected": "1 month", - "msg": "1 month", - }, - { - "start_date": Date(year=2020, month=1, day=1), - "end_date": Date(year=2023, month=3, day=2), - "expected": "3 years 2 months", - "msg": "3 years 2 months", - }, - { - "start_date": Date(year=2020, month=1, day=1), - "end_date": 2021, - "expected": "1 year 1 month", - "msg": "start_date and YYYY end_date", - }, - { - "start_date": 2020, - "end_date": Date(year=2021, month=1, day=1), - "expected": "1 year 1 month", - "msg": "YYYY start_date and end_date", - }, - { - "start_date": 2020, - "end_date": 2021, - "expected": "1 year 1 month", - "msg": "YYYY start_date and YYYY end_date", - }, - { - "start_date": None, - "end_date": Date(year=2023, month=3, day=2), - "expected": TypeError, - "msg": "start_date is None", - }, - { - "start_date": Date(year=2020, month=1, day=1), - "end_date": None, - "expected": TypeError, - "msg": "end_date is None", - }, - { - "start_date": 324, - "end_date": "test", - "expected": TypeError, - "msg": "start_date and end_date are not dates", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - if isinstance(test["expected"], type): - if issubclass(test["expected"], Exception): - with self.assertRaises(test["expected"]): - data_model.compute_time_span_string( - test["start_date"], test["end_date"] - ) - else: - result = data_model.compute_time_span_string( - test["start_date"], test["end_date"] - ) - self.assertEqual(result, test["expected"]) - - def test_format_date(self): - tests = [ - { - "date": Date(year=2020, month=1, day=1), - "expected": "Jan. 2020", - "msg": "Jan. 2020", - }, - { - "date": Date(year=1983, month=12, day=1), - "expected": "Dec. 1983", - "msg": "Dec. 1983", - }, - { - "date": Date(year=2045, month=6, day=1), - "expected": "June 2045", - "msg": "June 2045", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - result = data_model.format_date(test["date"]) - self.assertEqual(result, test["expected"]) - - def test_data_design_font(self): - tests = [ - {"input": "SourceSans3", "expected": "SourceSans3", "msg": "valid font"}, - { - "input": "InvalidFont", - "expected": ValidationError, - "msg": "invalid font", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - if isinstance(test["expected"], type): - if issubclass(test["expected"], Exception): - with self.assertRaises(test["expected"]): - data_model.Design(font=test["input"]) - else: - design = data_model.Design(font=test["input"]) - self.assertEqual(design.font, test["expected"]) - - def test_data_design_theme(self): - tests = [ - {"input": "classic", "expected": "classic", "msg": "valid theme"}, - { - "input": "InvalidTheme", - "expected": ValidationError, - "msg": "invalid theme", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - if isinstance(test["expected"], type): - if issubclass(test["expected"], Exception): - with self.assertRaises(test["expected"]): - data_model.Design(theme=test["input"]) - else: - design = data_model.Design(theme=test["input"]) - self.assertEqual(design.theme, test["expected"]) - - def test_data_design_show_timespan_in(self): - # Valid show_timespan_in: - input = { - "design": { - "options": { - "show_timespan_in": ["Work Experience"], - } - }, - "cv": { - "name": "John Doe", - "work_experience": [ - { - "company": "My Company", - "position": "My Position", - "start_date": "2020-01-01", - "end_date": "2021-01-01", - } - ], - }, - } - with self.subTest(msg="valid show_timespan_in"): - data_model.RenderCVDataModel(**input) - - # Nonexistent show_timespan_in: - del input["cv"]["work_experience"] - with self.subTest(msg="nonexistent show_timespan_in"): - with self.assertRaises(ValidationError): - data_model.RenderCVDataModel(**input) - - def test_data_event_check_dates(self): - # Inputs with valid dates: - # All the combinations are tried. In valid dates: - # Start dates can be 4 different things: YYYY-MM-DD, YYYY-MM, YYYY. - # End dates can be 5 different things: YYYY-MM-DD, YYYY-MM, YYYY, or "present" or None. - start_dates = [ - { - "input": "2020-01-01", - "expected": Date.fromisoformat("2020-01-01"), - }, - { - "input": "2020-01", - "expected": Date.fromisoformat("2020-01-01"), - }, - { - "input": "2020", - "expected": 2020, - }, - ] - - end_dates = [ - { - "input": "2021-01-01", - "expected": Date.fromisoformat("2021-01-01"), - }, - { - "input": "2021-01", - "expected": Date.fromisoformat("2021-01-01"), - }, - { - "input": "2021", - "expected": 2021, - }, - { - "input": "present", - "expected": "present", - }, - { - "input": None, - "expected": "present", - }, - ] - - combinations = [ - (start_date, end_date) - for start_date in start_dates - for end_date in end_dates - ] - for start_date, end_date in combinations: - with self.subTest( - msg=f"valid: {start_date['expected']} to {end_date['expected']}" - ): - event = data_model.Event( - start_date=start_date["input"], end_date=end_date["input"] - ) - self.assertEqual(event.start_date, start_date["expected"]) - self.assertEqual(event.end_date, end_date["expected"]) - - # Valid dates but edge cases: - tests = [ - { - "input": { - "start_date": None, - "end_date": None, - "date": "My Birthday", - }, - "expected": { - "start_date": None, - "end_date": None, - "date": "My Birthday", - }, - "msg": "valid: custom date only", - }, - { - "input": { - "start_date": None, - "end_date": None, - "date": "2020-01-01", - }, - "expected": { - "start_date": None, - "end_date": None, - "date": Date.fromisoformat("2020-01-01"), - }, - "msg": "valid: YYYY-MM-DD date only", - }, - { - "input": { - "start_date": "2020-01-01", - "end_date": "present", - "date": "My Birthday", - }, - "expected": { - "start_date": Date.fromisoformat("2020-01-01"), - "end_date": "present", - "date": None, - }, - "msg": "valid: start_date, end_date, and date", - }, - { - "input": { - "start_date": "2020-01-01", - "end_date": None, - "date": "My Birthday", - }, - "expected": { - "start_date": None, - "end_date": None, - "date": "My Birthday", - }, - "msg": "valid: start_date and date", - }, - { - "input": { - "start_date": None, - "end_date": "2020-01-01", - "date": "My Birthday", - }, - "expected": { - "start_date": None, - "end_date": None, - "date": "My Birthday", - }, - "msg": "valid: end_date and date", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - event = data_model.Event(**test["input"]) - self.assertEqual(event.start_date, test["expected"]["start_date"]) - self.assertEqual(event.end_date, test["expected"]["end_date"]) - self.assertEqual(event.date, test["expected"]["date"]) - - # Inputs without dates: - with self.subTest(msg="no dates"): - event = data_model.Event(**{}) - self.assertEqual(event.start_date, None, msg="Start date is not correct.") - self.assertEqual(event.end_date, None, msg="End date is not correct.") - self.assertEqual(event.date, None, msg="Date is not correct.") - - # Invalid dates: - tests = [ - { - "input": { - "start_date": "2020-01-01", - "end_date": "2019-01-01", - }, - "expected": ValidationError, - "msg": "start_date > end_date", - }, - { - "input": { - "start_date": "2020-01-01", - "end_date": "2900-01-01", - }, - "expected": ValidationError, - "msg": "end_date > present", - }, - { - "input": { - "start_date": "invalid date", - "end_date": "invalid date", - }, - "expected": ValidationError, - "msg": "invalid start_date and end_date", - }, - { - "input": { - "start_date": "invalid date", - "end_date": "2020-01-01", - }, - "expected": ValidationError, - "msg": "invalid start_date", - }, - { - "input": { - "start_date": "2020-01-01", - "end_date": "invalid date", - }, - "expected": ValidationError, - "msg": "invalid end_date", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - with self.assertRaises(test["expected"]): - data_model.Event(**test["input"]) - - def test_data_event_date_and_location_strings(self): - tests = [ - { - "input": { - "start_date": "2020-01-01", - "end_date": "2021-01-16", - "location": "My Location", - }, - "expected_with_time_span": [ - "My Location", - "Jan. 2020 to Jan. 2021", - "1 year 1 month", - ], - "expected_without_time_span": [ - "My Location", - "Jan. 2020 to Jan. 2021", - ], - "msg": "start_date, end_date, and location are provided", - }, - { - "input": { - "date": "My Birthday", - "location": "My Location", - }, - "expected_with_time_span": [ - "My Location", - "My Birthday", - ], - "expected_without_time_span": [ - "My Location", - "My Birthday", - ], - "msg": "date and location are provided", - }, - { - "input": { - "date": "2020-01-01", - }, - "expected_with_time_span": [ - "Jan. 2020", - ], - "expected_without_time_span": [ - "Jan. 2020", - ], - "msg": "date is provided", - }, - { - "input": { - "start_date": "2020-01-01", - "end_date": "2021-01-16", - }, - "expected_with_time_span": [ - "Jan. 2020 to Jan. 2021", - "1 year 1 month", - ], - "expected_without_time_span": [ - "Jan. 2020 to Jan. 2021", - ], - "msg": "start_date and end_date are provided", - }, - { - "input": { - "location": "My Location", - }, - "expected_with_time_span": [ - "My Location", - ], - "expected_without_time_span": [ - "My Location", - ], - "msg": "location is provided", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - event = data_model.Event(**test["input"]) - result = event.date_and_location_strings_with_timespan - self.assertEqual(result, test["expected_with_time_span"]) - - result = event.date_and_location_strings_without_timespan - self.assertEqual(result, test["expected_without_time_span"]) - - def test_data_event_highlight_strings(self): - tests = [ - { - "highlights": [ - "My Highlight 1", - "My Highlight 2", - ], - "expected": [ - "My Highlight 1", - "My Highlight 2", - ], - "msg": "highlights are provided", - }, - { - "highlights": [], - "expected": [], - "msg": "highlights are not provided", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - event = data_model.Event(highlights=test["highlights"]) - result = event.highlight_strings - self.assertEqual(result, test["expected"]) - - def test_data_event_markdown_url(self): - tests = [ - { - "url": "https://www.linkedin.com/in/username", - "expected": "[view on LinkedIn](https://www.linkedin.com/in/username)", - "msg": "LinkedIn link", - }, - { - "url": "https://www.github.com/sinaatalay", - "expected": "[view on GitHub](https://www.github.com/sinaatalay)", - "msg": "Github link", - }, - { - "url": "https://www.instagram.com/username", - "expected": "[view on Instagram](https://www.instagram.com/username)", - "msg": "Instagram link", - }, - { - "url": "https://www.youtube.com/", - "expected": "[view on YouTube](https://www.youtube.com/)", - "msg": "Youtube link", - }, - { - "url": "https://www.google.com/", - "expected": "[view on my website](https://www.google.com/)", - "msg": "Other links", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - event = data_model.Event(url=test["url"]) - result = event.markdown_url - self.assertEqual(result, test["expected"]) - - def test_data_event_month_and_year(self): - tests = [ - { - "input": { - "start_date": "2020-01-01", - "end_date": "2021-01-16", - }, - "expected": None, - "msg": "start_date and end_date are provided", - }, - { - "input": { - "date": "My Birthday", - }, - "expected": "My Birthday", - "msg": "custom date is provided", - }, - { - "input": { - "date": "2020-01-01", - }, - "expected": "Jan. 2020", - "msg": "date is provided", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - event = data_model.Event(**test["input"]) - result = event.month_and_year - self.assertEqual(result, test["expected"]) - - def test_data_education_entry_highlight_strings(self): - tests = [ - { - "input": { - "institution": "My Institution", - "area": "My Area", - "gpa": 3.5, - "highlights": [ - "My Highlight 1", - "My Highlight 2", - ], - }, - "expected": [ - "GPA: 3.5", - "My Highlight 1", - "My Highlight 2", - ], - "msg": "gpa and highlights are provided", - }, - { - "input": { - "institution": "My Institution", - "area": "My Area", - "gpa": None, - "highlights": [ - "My Highlight 1", - "My Highlight 2", - ], - }, - "expected": [ - "My Highlight 1", - "My Highlight 2", - ], - "msg": "gpa is not provided, but highlights are", - }, - { - "input": { - "institution": "My Institution", - "area": "My Area", - "gpa": 3.5, - "highlights": [], - }, - "expected": [ - "GPA: 3.5", - ], - "msg": "gpa is provided, but highlights are not", - }, - { - "input": { - "institution": "My Institution", - "area": "My Area", - "gpa": None, - "highlights": [], - }, - "expected": [], - "msg": "neither gpa nor highlights are provided", - }, - { - "input": { - "institution": "My Institution", - "area": "My Area", - "gpa": 3.5, - "transcript_url": "https://www.example.com/", - "highlights": None, - }, - "expected": [ - "GPA: 3.5 ([Transcript](https://www.example.com/))", - ], - "msg": "gpa and transcript_url are provided, but highlights are not", - }, - { - "input": { - "institution": "My Institution", - "area": "My Area", - "gpa": "3.5", - "transcript_url": "https://www.example.com/", - "highlights": [ - "My Highlight 1", - "My Highlight 2", - ], - }, - "expected": [ - "GPA: 3.5 ([Transcript](https://www.example.com/))", - "My Highlight 1", - "My Highlight 2", - ], - "msg": "gpa, transcript_url, and highlights are provided", - }, - ] - - for test in tests: - with self.subTest(msg=test["msg"]): - education = data_model.EducationEntry(**test["input"]) - result = education.highlight_strings - self.assertEqual(result, test["expected"]) - - def test_data_publication_entry_check_doi(self): - # Invalid DOI: - input = { - "title": "My Publication", - "authors": [ - "Author 1", - "Author 2", - ], - "doi": "invalidDoi", - "date": "2020-01-01", - } - with self.subTest(msg="invalid doi"): - with self.assertRaises(ValidationError): - data_model.PublicationEntry(**input) - - # Valid DOI: - input = { - "title": "My Publication", - "authors": [ - "Author 1", - "Author 2", - ], - "doi": "10.1103/PhysRevB.76.054309", - "date": "2007-08-01", - } - with self.subTest(msg="valid doi"): - publication_entry = data_model.PublicationEntry(**input) - self.assertEqual(publication_entry.doi, input["doi"]) - - def test_data_publication_entry_doi_url(self): - input = { - "title": "My Publication", - "authors": [ - "Author 1", - "Author 2", - ], - "doi": "10.1103/PhysRevB.76.054309", - "date": "2007-08-01", - } - expected = "https://doi.org/10.1103/PhysRevB.76.054309" - publication = data_model.PublicationEntry(**input) - result = publication.doi_url - self.assertEqual(result, expected) - - def test_data_connection_url(self): - tests = [ - { - "input": { - "name": "LinkedIn", - "value": "username", - }, - "expected": "https://www.linkedin.com/in/username", - }, - { - "input": { - "name": "GitHub", - "value": "sinaatalay", - }, - "expected": "https://www.github.com/sinaatalay", - }, - { - "input": { - "name": "Instagram", - "value": "username", - }, - "expected": "https://www.instagram.com/username", - }, - { - "input": { - "name": "phone", - "value": "+909999999999", - }, - "expected": "+909999999999", - }, - { - "input": { - "name": "email", - "value": "example@example.com", - }, - "expected": "mailto:example@example.com", - }, - { - "input": { - "name": "website", - "value": "https://www.example.com/", - }, - "expected": "https://www.example.com/", - }, - { - "input": { - "name": "location", - "value": "My Location", - }, - "expected": None, - }, - ] - - for test in tests: - with self.subTest(msg=test["input"]["name"]): - connection = data_model.Connection(**test["input"]) - result = connection.url - self.assertEqual(result, test["expected"]) - - def test_data_curriculum_vitae_connections(self): - input = { - "name": "John Doe", - "location": "My Location", - "phone": "+905559876543", - "email": "john@doe.com", - "website": "https://www.example.com/", - } - exptected_length = 4 - cv = data_model.CurriculumVitae(**input) # type: ignore - result = len(cv.connections) - with self.subTest(msg="without social networks"): - self.assertEqual(result, exptected_length) - - input = { - "name": "John Doe", - "location": "My Location", - "phone": "+905559876543", - "email": "john@doe.com", - "website": "https://www.example.com/", - "social_networks": [ - {"network": "LinkedIn", "username": "username"}, - {"network": "GitHub", "username": "sinaatalay"}, - {"network": "Instagram", "username": "username"}, - ], - } - exptected_length = 7 - cv = data_model.CurriculumVitae(**input) - result = len(cv.connections) - with self.subTest(msg="with social networks"): - self.assertEqual(result, exptected_length) - - def test_data_curriculum_vitae_custom_sections(self): - # Valid custom sections: - input = { - "name": "John Doe", - "custom_sections": [ - { - "title": "My Custom Section 1", - "entry_type": "OneLineEntry", - "entries": [ - { - "name": "My Custom Entry Name", - "details": "My Custom Entry Value", - }, - { - "name": "My Custom Entry Name", - "details": "My Custom Entry Value", - }, - ], - }, - { - "title": "My Custom Section 2", - "entry_type": "NormalEntry", - "link_text": "My Custom Link Text", - "entries": [ - {"name": "My Custom Entry Name"}, - {"name": "My Custom Entry Name"}, - ], - }, - { - "title": "My Custom Section 3", - "entry_type": "ExperienceEntry", - "entries": [ - { - "company": "My Custom Entry Name", - "position": "My Custom Entry Value", - }, - { - "company": "My Custom Entry Name", - "position": "My Custom Entry Value", - }, - ], - }, - { - "title": "My Custom Section 4", - "entry_type": "EducationEntry", - "entries": [ - { - "institution": "My Custom Entry Name", - "area": "My Custom Entry Value", - }, - { - "institution": "My Custom Entry Name", - "area": "My Custom Entry Value", - }, - ], - }, - { - "title": "My Custom Section 5", - "entry_type": "PublicationEntry", - "entries": [ - { - "title": "My Publication", - "authors": [ - "Author 1", - "Author 2", - ], - "doi": "10.1103/PhysRevB.76.054309", - "date": "2020-01-01", - }, - { - "title": "My Publication", - "authors": [ - "Author 1", - "Author 2", - ], - "doi": "10.1103/PhysRevB.76.054309", - "date": "2020-01-01", - }, - ], - }, - ], - } - - with self.subTest(msg="valid custom sections"): - cv = data_model.CurriculumVitae(**input) - self.assertEqual(len(cv.sections), 5) - - with self.subTest(msg="check link_text"): - cv = data_model.CurriculumVitae(**input) - self.assertEqual(cv.sections[1].link_text, "My Custom Link Text") - - # Invalid section_order: - input["section_order"] = ["invalid section"] - with self.subTest(msg="invalid section_order"): - data = data_model.CurriculumVitae(**input) - with self.assertRaises(ValueError): - data.sections - del input["section_order"] - - # Custom sections with duplicate titles: - input["custom_sections"][1]["title"] = "My Custom Section 1" - with self.subTest(msg="custom sections with duplicate titles"): - with self.assertRaises(ValidationError): - data_model.CurriculumVitae(**input) - - def test_if_json_schema_is_the_latest(self): - tests_directory = os.path.dirname(__file__) - path_to_generated_schema = data_model.generate_json_schema(tests_directory) - - # Read the generated JSON schema: - with open(path_to_generated_schema, "r") as f: - generated_json_schema = json.load(f) - - # Remove the generated JSON schema: - os.remove(path_to_generated_schema) - - # Read the repository's current JSON schema: - path_to_schema = os.path.join(os.path.dirname(tests_directory), "schema.json") - with open(path_to_schema, "r") as f: - current_json_schema = json.load(f) - - # Compare the two JSON schemas: - self.assertEqual(generated_json_schema, current_json_schema) - - def test_read_input_file(self): - test_input = { - "cv": { - "name": "John Doe", - } - } - - # write dictionary to a file as json: - input_file_path = os.path.join(os.path.dirname(__file__), "test_input.json") - json_string = json.dumps(test_input) - with open(input_file_path, "w") as file: - file.write(json_string) - - # read the file: - result = data_model.read_input_file(input_file_path) - - # remove the file: - os.remove(input_file_path) - - with self.subTest(msg="read input file"): - self.assertEqual( - result.cv.name, - test_input["cv"]["name"], - ) - - with self.subTest(msg="nonexistent file"): - with self.assertRaises(FileNotFoundError): - data_model.read_input_file("nonexistent.json") diff --git a/tests/test_data_models.py b/tests/test_data_models.py new file mode 100644 index 0000000..0b05b94 --- /dev/null +++ b/tests/test_data_models.py @@ -0,0 +1,33 @@ +import unittest +import os +import json + +from rendercv import data_models + +from datetime import date as Date +from pydantic import ValidationError + + +def test_sections(): + input = { + "name": "John Doe", + "sections": { + "my_section": { + "title": "test", + "entry_type": "EducationEntry", + "entries": [ + { + "institution": "Boğaziçi University", + "area": "Mechanical Engineering", + "date": "My Date", + } + ], + } + }, + } + + cv = data_models.CurriculumVitae(**input) + assert cv is not None + assert cv.sections[0].entry_type == "EducationEntry" + assert len(cv.sections_input["my_section"].entries) == 1 + assert cv.sections[0].title == "My Section" diff --git a/tests/test_rendering.py b/tests/test_rendering.py deleted file mode 100644 index 61fea74..0000000 --- a/tests/test_rendering.py +++ /dev/null @@ -1,303 +0,0 @@ -import unittest -import os -from datetime import date -import shutil - -from rendercv import rendering, data_model - - -class TestRendering(unittest.TestCase): - def test_markdown_to_latex(self): - input = "[link](www.example.com)" - expected = r"\href{www.example.com}{link}" - output = rendering.markdown_to_latex(input) - with self.subTest(msg="only one link"): - self.assertEqual(output, expected) - - input = "[link](www.example.com) and [link2](www.example2.com)" - expected = ( - r"\href{www.example.com}{link} and" r" \href{www.example2.com}{link2}" - ) - output = rendering.markdown_to_latex(input) - with self.subTest(msg="two links"): - self.assertEqual(output, expected) - - input = "[**link**](www.example.com)" - expected = r"\href{www.example.com}{\textbf{link}}" - output = rendering.markdown_to_latex(input) - with self.subTest(msg="bold link"): - self.assertEqual(output, expected) - - input = "[*link*](www.example.com)" - expected = r"\href{www.example.com}{\textit{link}}" - output = rendering.markdown_to_latex(input) - with self.subTest(msg="italic link"): - self.assertEqual(output, expected) - - input = "[*link*](www.example.com) and [**link2**](www.example2.com)" - expected = ( - r"\href{www.example.com}{\textit{link}} and" - r" \href{www.example2.com}{\textbf{link2}}" - ) - output = rendering.markdown_to_latex(input) - with self.subTest(msg="italic and bold links"): - self.assertEqual(output, expected) - - input = "**bold**, *italic*, and [link](www.example.com)" - expected = ( - r"\textbf{bold}, \textit{italic}, and" r" \href{www.example.com}{link}" - ) - output = rendering.markdown_to_latex(input) - with self.subTest(msg="bold, italic, and link"): - self.assertEqual(output, expected) - - # invalid input: - input = 20 - with self.subTest(msg="float input"): - with self.assertRaises(ValueError): - rendering.markdown_to_latex(input) # type: ignore - - def test_markdown_link_to_url(self): - input = "[link](www.example.com)" - expected = "www.example.com" - output = rendering.markdown_link_to_url(input) - with self.subTest(msg="only one link"): - self.assertEqual(output, expected) - - input = "[**link**](www.example.com)" - expected = "www.example.com" - output = rendering.markdown_link_to_url(input) - with self.subTest(msg="bold link"): - self.assertEqual(output, expected) - - input = "[*link*](www.example.com)" - expected = "www.example.com" - output = rendering.markdown_link_to_url(input) - with self.subTest(msg="italic link"): - self.assertEqual(output, expected) - - # invalid input: - input = 20 - with self.subTest(msg="float input"): - with self.assertRaises(ValueError): - rendering.markdown_link_to_url(input) # type: ignore - - input = "not a markdown link" - with self.subTest(msg="invalid input"): - with self.assertRaises(ValueError): - rendering.markdown_link_to_url(input) - - input = "[]()" - with self.subTest(msg="empty link"): - with self.assertRaises(ValueError): - rendering.markdown_link_to_url(input) - - def test_make_it_something(self): - # invalid input: - input = "test" - something = "haha" - result = rendering.make_it_something(input, something) - with self.subTest(msg="match_str is none"): - self.assertEqual(result, "\\haha{test}") - - result = rendering.make_it_something(input, something, match_str="te") - with self.subTest(msg="match_str is not none"): - self.assertEqual(result, "\\haha{te}st") - - def test_make_it_bold(self): - input = "some text" - expected = r"\textbf{some text}" - output = rendering.make_it_bold(input) - with self.subTest(msg="without match_str input"): - self.assertEqual(output, expected) - - match_str = "text" - expected = r"some \textbf{text}" - output = rendering.make_it_bold(input, match_str) - with self.subTest(msg="with match_str input"): - self.assertEqual(output, expected) - - match_str = 2423 - with self.subTest(msg="invalid match_str input"): - with self.assertRaises(ValueError): - rendering.make_it_bold(input, match_str) # type: ignore - - input = 20 - with self.subTest(msg="float input"): - with self.assertRaises(ValueError): - rendering.make_it_bold(input) # type: ignore - - def test_make_it_underlined(self): - input = "some text" - expected = r"\underline{some text}" - output = rendering.make_it_underlined(input) - with self.subTest(msg="without match_str input"): - self.assertEqual(output, expected) - - input = "some text" - match_str = "text" - expected = r"some \underline{text}" - output = rendering.make_it_underlined(input, match_str) - with self.subTest(msg="with match_str input"): - self.assertEqual(output, expected) - - input = 20 - with self.subTest(msg="float input"): - with self.assertRaises(ValueError): - rendering.make_it_underlined(input) # type: ignore - - def test_make_it_italic(self): - input = "some text" - expected = r"\textit{some text}" - output = rendering.make_it_italic(input) - with self.subTest(msg="without match_str input"): - self.assertEqual(output, expected) - - input = "some text" - match_str = "text" - expected = r"some \textit{text}" - output = rendering.make_it_italic(input, match_str) - with self.subTest(msg="with match_str input"): - self.assertEqual(output, expected) - - input = 20 - with self.subTest(msg="float input"): - with self.assertRaises(ValueError): - rendering.make_it_italic(input) # type: ignore - - def test_divide_length_by(self): - lengths = [ - "10cm", - "10.24in", - "10 pt", - "10.24 mm", - "10.24 em", - "1024 ex", - ] - divider = 10 - expected = [ - "1.0 cm", - "1.024 in", - "1.0 pt", - "1.024 mm", - "1.024 em", - "102.4 ex", - ] - for length, exp in zip(lengths, expected): - with self.subTest(length=length, msg="valid input"): - self.assertEqual(rendering.divide_length_by(length, divider), exp) - - def test_get_today(self): - expected = date.today().strftime("%B %Y") - result = rendering.get_today() - self.assertEqual(expected, result, msg="Today's date is not correct.") - - def test_get_path_to_font_directory(self): - font_name = "test" - expected = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - "rendercv", - "templates", - "fonts", - font_name, - ) - result = rendering.get_path_to_font_directory(font_name) - self.assertEqual(expected, result, msg="Font directory path is not correct.") - - def test_render_template(self): - # Read the reference YAML file: - input_file_path = os.path.join( - os.path.dirname(__file__), - "reference_files", - "John_Doe_CV_yaml_reference.yaml", - ) - data = data_model.read_input_file(input_file_path) - output_file_path = rendering.render_template( - data=data, output_path=os.path.dirname(__file__) - ) - - # Check if the output file exists: - self.assertTrue( - os.path.exists(output_file_path), msg="LaTeX file couldn't be generated." - ) - - # Compare the output file with the reference file: - reference_file_path = os.path.join( - os.path.dirname(__file__), - "reference_files", - "John_Doe_CV_tex_reference.tex", - ) - with open(output_file_path, "r") as file: - output = file.read() - with open(reference_file_path, "r") as file: - reference = file.read() - reference = reference.replace("REPLACETHISWITHTODAY", rendering.get_today()) - - self.assertEqual( - output, reference, msg="LaTeX file didn't match the reference." - ) - - # Check if the font directory exists: - output_folder_path = os.path.dirname(output_file_path) - font_directory_path = os.path.join(output_folder_path, "fonts") - self.assertTrue( - os.path.exists(font_directory_path), msg="Font directory doesn't exist." - ) - - required_files = [ - f"{data.design.font}-Italic.ttf", - f"{data.design.font}-Regular.ttf", - f"{data.design.font}-Bold.ttf", - f"{data.design.font}-BoldItalic.ttf", - ] - font_files = os.listdir(font_directory_path) - for required_file in required_files: - with self.subTest(required_file=required_file): - self.assertIn( - required_file, - font_files, - msg=f"Font file ({required_file}) is missing.", - ) - - # Remove the output directory: - shutil.rmtree(output_folder_path) - - def test_run_latex(self): - latex_file_path = os.path.join( - os.path.dirname(__file__), - "reference_files", - "John_Doe_CV_tex_reference.tex", - ) - - with self.subTest(msg="Existent file name"): - pdf_file = rendering.run_latex(latex_file_path) - - # Check if the output file exists: - self.assertTrue( - os.path.exists(pdf_file), msg="PDF file couldn't be generated." - ) - - # Compare the pdf file with the reference pdf file: - reference_pdf_file = pdf_file.replace( - "_tex_reference.pdf", "_pdf_reference.pdf" - ) - reference_pdf_file_size = os.path.getsize(reference_pdf_file) - pdf_file_size = os.path.getsize(pdf_file) - - # Remove the output file: - os.remove(pdf_file) - - ratio = min(reference_pdf_file_size, pdf_file_size) / max( - reference_pdf_file_size, pdf_file_size - ) - self.assertTrue(ratio > 0.98, msg="PDF file didn't match the reference.") - - nonexistent_latex_file_path = os.path.join( - os.path.dirname(__file__), "reference_files", "nonexistent.tex" - ) - - with self.subTest(msg="Nonexistent file name"): - with self.assertRaises( - FileNotFoundError, msg="File not found error didn't raise." - ): - rendering.run_latex(nonexistent_latex_file_path)