rewrite logic

This commit is contained in:
Sina Atalay 2024-01-18 18:24:30 +01:00
parent 7093b309d4
commit 591550e5f4
8 changed files with 1120 additions and 1327 deletions

View File

@ -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.

808
rendercv/data_models.py Normal file
View File

@ -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

278
rendercv/parser.py Normal file
View File

@ -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

0
rendercv/renderer.py Normal file
View File

View File

@ -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")

View File

@ -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")

33
tests/test_data_models.py Normal file
View File

@ -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"

View File

@ -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)