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[
|
input_file: Annotated[
|
||||||
str,
|
str,
|
||||||
typer.Argument(help="Name of the YAML input file"),
|
typer.Argument(help="Name of the YAML input file"),
|
||||||
]
|
],
|
||||||
):
|
):
|
||||||
"""Generate a LaTeX CV from a 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