mirror of https://github.com/eyhc1/rendercv.git
rewrite logic
This commit is contained in:
parent
7093b309d4
commit
591550e5f4
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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")
|
|
@ -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")
|
|
@ -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"
|
|
@ -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)
|
Loading…
Reference in New Issue