refactor completely (#103)

This commit is contained in:
Sina Atalay committed 2024-07-01 00:05:02 +03:00
1 parent c0f501df21
commit 1b0ede6ea6
23 files changed
+3409 -3064

No files matched your search

+3 -3
View File
@@ -194,10 +194,10 @@ def generate_entry_figures():
)
# Render:
latex_file_path = r.generate_latex_file_and_copy_theme_files(
latex_file_path = r.render_a_latex_file_and_copy_theme_files(
data_model, temporary_directory_path
)
pdf_file_path = r.latex_to_pdf(latex_file_path)
pdf_file_path = r.render_pdf_from_latex(latex_file_path)
# Prepare the output directory and file path:
output_directory = image_assets_directory / theme
@@ -228,7 +228,7 @@ def generate_entry_figures():
)
# Convert pdf to an image
png_file_path = r.pdf_to_pngs(output_pdf_file_path)[0]
png_file_path = r.render_a_markdown_file(output_pdf_file_path)[0]
desired_png_file_path = output_pdf_file_path.with_suffix(".png")
# If the image exists, remove it
+1 -1
View File
@@ -61,7 +61,7 @@ def generate_examples():
shutil.rmtree(rendercv_output_directory)
# convert first page of the pdf to an image:
png_file_paths = r.pdf_to_pngs(new_pdf_file_path)
png_file_paths = r.render_a_markdown_file(new_pdf_file_path)
firt_page_png_file_path = png_file_paths[0]
if len(png_file_paths) > 1:
# remove the other pages
+3 -3
View File
@@ -141,11 +141,11 @@ path = ".venv"
# Hatch allows us to define scripts that can be run in the activated virtual environment
# with `hatch run ENV_NAME:SCRIPT_NAME`.
# Format all the code in the `rendercv` package with `black`:
format = "black ." # hatch run format
format = "black rendercv && black docs && black tests" # hatch run format
# Lint the code in the `rendercv` package with `ruff`:
lint = "ruff check ." # hatch run lint
lint = "ruff check rendercv && ruff check docs && ruff check tests" # hatch run lint
# Sort the imports in the `rendercv` package with `isort`:
sort-imports = "isort ." # hatch run sort-imports
sort-imports = "isort rendercv && isort docs && isort tests" # hatch run sort-imports
[tool.hatch.envs.docs]
+15 -1
View File
@@ -5,4 +5,18 @@ CLI and [Rich](https://rich.readthedocs.io/en/latest/) to provide a nice-looking
terminal output.
"""
__all__ = ["commands", "printer", "utilities"]
from .commands import (
app,
cli_command_render,
cli_command_new,
cli_command_create_theme,
cli_command_no_args,
)
__all__ = [
"app",
"cli_command_render",
"cli_command_new",
"cli_command_create_theme",
"cli_command_no_args",
]
+8 -8
View File
@@ -1,6 +1,6 @@
"""
`rendercv.cli.commands` module contains all the command-line interface (CLI) commands of
RenderCV.
The `rendercv.cli.commands` module contains all the command-line interface (CLI
commands of RenderCV.
"""
import os
@@ -200,7 +200,7 @@ def cli_command_render(
progress.finish_the_current_step()
progress.start_a_step("Generating the LaTeX file")
latex_file_path_in_output_folder = r.generate_latex_file_and_copy_theme_files(
latex_file_path_in_output_folder = r.render_a_latex_file_and_copy_theme_files(
data_model, output_directory
)
if latex_path:
@@ -208,7 +208,7 @@ def cli_command_render(
progress.finish_the_current_step()
progress.start_a_step("Rendering the LaTeX file to a PDF")
pdf_file_path_in_output_folder = r.latex_to_pdf(
pdf_file_path_in_output_folder = r.render_pdf_from_latex(
latex_file_path_in_output_folder, use_local_latex_command
)
if pdf_path:
@@ -217,7 +217,7 @@ def cli_command_render(
if not dont_generate_png:
progress.start_a_step("Rendering PNG files from the PDF")
png_file_paths_in_output_folder = r.pdf_to_pngs(
png_file_paths_in_output_folder = r.render_a_markdown_file(
pdf_file_path_in_output_folder
)
if png_path:
@@ -236,7 +236,7 @@ def cli_command_render(
if not dont_generate_markdown:
progress.start_a_step("Generating the Markdown file")
markdown_file_path_in_output_folder = r.generate_markdown_file(
markdown_file_path_in_output_folder = r.render_a_markdown_file(
data_model, output_directory
)
if markdown_path:
@@ -247,7 +247,7 @@ def cli_command_render(
progress.start_a_step(
"Rendering the Markdown file to a HTML (for Grammarly)"
)
html_file_path_in_output_folder = r.markdown_to_html(
html_file_path_in_output_folder = r.render_html_from_markdown(
markdown_file_path_in_output_folder
)
if html_path:
@@ -394,7 +394,7 @@ def cli_command_create_theme(
@app.callback()
def main(
def cli_command_no_args(
version_requested: Annotated[
Optional[bool], typer.Option("--version", "-v", help="Show the version.")
] = None,
+87
View File
@@ -0,0 +1,87 @@
"""
The `rendercv.cli.handlers` contains all the functions that are used to handle all
exceptions that can be raised by `rendercv` during the execution of the CLI.
"""
import functools
from typing import Callable
import jinja2
import pydantic
import ruamel.yaml
import ruamel.yaml.parser
import typer
from .printer import error, print_validation_errors
def handle_exceptions(function: Callable) -> Callable:
"""Return a wrapper function that handles exceptions.
A decorator in Python is a syntactic convenience that allows a Python to interpret
the code below:
```python
@handle_exceptions
def my_function():
pass
```
as
```python
handle_exceptions(my_function)()
```
which is step by step equivalent to
1. Execute `#!python handle_exceptions(my_function)` which will return the
function called `wrapper`.
2. Execute `#!python wrapper()`.
Args:
function (Callable): The function to be wrapped.
Returns:
Callable: The wrapped function.
"""
@functools.wraps(function)
def wrapper(*args, **kwargs):
try:
function(*args, **kwargs)
except pydantic.ValidationError as e:
print_validation_errors(e)
except ruamel.yaml.YAMLError as e:
error(
"There is a YAML error in the input file!\n\nTry to use quotation marks"
" to make sure the YAML parser understands the field is a string.",
e,
)
except FileNotFoundError as e:
error(e)
except UnicodeDecodeError as e:
# find the problematic character that cannot be decoded with utf-8
bad_character = str(e.object[e.start : e.end])
try:
bad_character_context = str(e.object[e.start - 16 : e.end + 16])
except IndexError:
bad_character_context = ""
error(
"The input file contains a character that cannot be decoded with"
f" UTF-8 ({bad_character}):\n {bad_character_context}",
)
except ValueError as e:
error(e)
except typer.Exit:
pass
except jinja2.exceptions.TemplateSyntaxError as e:
error(
f"There is a problem with the template ({e.filename}) at line"
f" {e.lineno}!",
e,
)
except RuntimeError as e:
error(e)
return wrapper
+158 -3
View File
@@ -1,16 +1,20 @@
"""
`rendercv.cli.printer` module contains all the functions and classes that are used to
print nice-looking messages to the terminal.
The `rendercv.cli.printer` module contains all the functions and classes that are used
to print nice-looking messages to the terminal.
"""
from typing import Optional
import pydantic
import rich
import typer
from rich import print
from .. import __version__
from .utilities import get_latest_version_number_from_pypi
from .utilities import (
get_error_message_and_location_and_value_from_a_custom_error,
get_latest_version_number_from_pypi,
)
class LiveProgressReporter(rich.live.Live):
@@ -182,3 +186,154 @@ def information(text: str):
text (str): The text of the information message.
"""
print(f"[yellow]{text}")
def print_validation_errors(exception: pydantic.ValidationError):
"""Take a Pydantic validation error and print the error messages in a nice table.
Pydantic's `ValidationError` object is a complex object that contains a lot of
information about the error. This function takes a `ValidationError` object and
extracts the error messages, locations, and the input values. Then, it prints them
in a nice table with [Rich](https://rich.readthedocs.io/en/latest/).
Args:
exception (pydantic.ValidationError): The Pydantic validation error object.
"""
# This dictionary is used to convert the error messages that Pydantic returns to
# more user-friendly messages.
error_dictionary: dict[str, str] = {
"Input should be 'present'": (
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
' format or "present"!'
),
"Input should be a valid integer, unable to parse string as an integer": (
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
" format!"
),
"String should match pattern '\\d{4}-\\d{2}(-\\d{2})?'": (
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
" format!"
),
"URL scheme should be 'http' or 'https'": "This is not a valid URL!",
"Field required": "This field is required!",
"value is not a valid phone number": "This is not a valid phone number!",
"month must be in 1..12": "The month must be between 1 and 12!",
"day is out of range for month": "The day is out of range for the month!",
"Extra inputs are not permitted": (
"This field is unknown for this object! Please remove it."
),
"Input should be a valid string": "This field should be a string!",
"Input should be a valid list": (
"This field should contain a list of items but it doesn't!"
),
}
unwanted_texts = ["value is not a valid email address: ", "Value error, "]
# Check if this is a section error. If it is, we need to handle it differently.
# This is needed because how dm.validate_section_input function raises an exception.
# This is done to tell the user which which EntryType RenderCV excepts to see.
errors = exception.errors()
for error_object in errors.copy():
if (
"There are problems with the entries." in error_object["msg"]
and "ctx" in error_object
):
location = error_object["loc"]
ctx_object = error_object["ctx"]
if "error" in ctx_object:
error_object = ctx_object["error"]
if hasattr(error_object, "__cause__"):
cause_object = error_object.__cause__
cause_object_errors = cause_object.errors()
for cause_error_object in cause_object_errors:
# we use [1:] to avoid `entries` location. It is a location for
# RenderCV's own data model, not the user's data model.
cause_error_object["loc"] = tuple(
list(location) + list(cause_error_object["loc"][1:])
)
errors.extend(cause_object_errors)
# some locations are not really the locations in the input file, but some
# information about the model coming from Pydantic. We need to remove them.
# (e.g. avoid stuff like .end_date.literal['present'])
unwanted_locations = ["tagged-union", "list", "literal", "int", "constrained-str"]
for error_object in errors:
location = error_object["loc"]
new_location = [str(location_element) for location_element in location]
for location_element in location:
location_element = str(location_element)
for unwanted_location in unwanted_locations:
if unwanted_location in location_element:
new_location.remove(location_element)
error_object["loc"] = new_location # type: ignore
# Parse all the errors and create a new list of errors.
new_errors: list[dict[str, str]] = []
for error_object in errors:
message = error_object["msg"]
location = ".".join(error_object["loc"]) # type: ignore
input = error_object["input"]
# Check if this is a custom error message:
custom_message, custom_location, custom_input_value = (
get_error_message_and_location_and_value_from_a_custom_error(message)
)
if custom_message is not None:
message = custom_message
if custom_location:
# If the custom location is not empty, then add it to the location.
location = f"{location}.{custom_location}"
input = custom_input_value
# Don't show unwanted texts in the error message:
for unwanted_text in unwanted_texts:
message = message.replace(unwanted_text, "")
# Convert the error message to a more user-friendly message if it's in the
# error_dictionary:
if message in error_dictionary:
message = error_dictionary[message]
# Special case for end_date because Pydantic returns multiple end_date errors
# since it has multiple valid formats:
if "end_date" in location:
message = (
"This is not a valid end date! Please use either YYYY-MM-DD, YYYY-MM,"
' or YYYY format or "present"!'
)
# If the input is a dictionary or a list (the model itself fails to validate),
# then don't show the input. It looks confusing and it is not helpful.
if isinstance(input, (dict, list)):
input = ""
new_error = {
"loc": str(location),
"msg": message,
"input": str(input),
}
# if new_error is not in new_errors, then add it to new_errors
if new_error not in new_errors:
new_errors.append(new_error)
# Print the errors in a nice table:
table = rich.table.Table(
title="[bold red]\nThere are some errors in the data model!\n",
title_justify="left",
show_lines=True,
)
table.add_column("Location", style="cyan", no_wrap=True)
table.add_column("Input Value", style="magenta")
table.add_column("Error Message", style="orange4")
for error_object in new_errors:
table.add_row(
error_object["loc"],
error_object["input"],
error_object["msg"],
)
print(table)
error() # exit the program
+4 -236
View File
@@ -1,27 +1,15 @@
"""
`rendercv.cli.utilities` module contains utility functions that are required by CLI.
The `rendercv.cli.utilities` module contains utility functions that are required by CLI.
"""
import functools
import json
import pathlib
import re
import shutil
import urllib.request
from typing import Callable, Optional
from typing import Optional
import jinja2
import pydantic
import rich.console
import rich.live
import rich.panel
import rich.progress
import rich.table
import rich.text
import ruamel.yaml
import ruamel.yaml.parser
import typer
from rich import print
from .printer import error, warning
@@ -33,7 +21,7 @@ def get_latest_version_number_from_pypi() -> Optional[str]:
```python
get_latest_version_number_from_pypi()
```
will return:
returns
`#!python "1.1"`
Returns:
@@ -82,226 +70,6 @@ def get_error_message_and_location_and_value_from_a_custom_error(
return None, None, None
def handle_validation_error(exception: pydantic.ValidationError):
"""Take a Pydantic validation error and print the error messages in a nice table.
Pydantic's `ValidationError` object is a complex object that contains a lot of
information about the error. This function takes a `ValidationError` object and
extracts the error messages, locations, and the input values. Then, it prints them
in a nice table with [Rich](https://rich.readthedocs.io/en/latest/).
Args:
exception (pydantic.ValidationError): The Pydantic validation error object.
"""
# This dictionary is used to convert the error messages that Pydantic returns to
# more user-friendly messages.
error_dictionary: dict[str, str] = {
"Input should be 'present'": (
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
' format or "present"!'
),
"Input should be a valid integer, unable to parse string as an integer": (
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
" format!"
),
"String should match pattern '\\d{4}-\\d{2}(-\\d{2})?'": (
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
" format!"
),
"URL scheme should be 'http' or 'https'": "This is not a valid URL!",
"Field required": "This field is required!",
"value is not a valid phone number": "This is not a valid phone number!",
"month must be in 1..12": "The month must be between 1 and 12!",
"day is out of range for month": "The day is out of range for the month!",
"Extra inputs are not permitted": (
"This field is unknown for this object! Please remove it."
),
"Input should be a valid string": "This field should be a string!",
"Input should be a valid list": (
"This field should contain a list of items but it doesn't!"
),
}
unwanted_texts = ["value is not a valid email address: ", "Value error, "]
# Check if this is a section error. If it is, we need to handle it differently.
# This is needed because how dm.validate_section_input function raises an exception.
# This is done to tell the user which which EntryType RenderCV excepts to see.
errors = exception.errors()
for error_object in errors.copy():
if (
"There are problems with the entries." in error_object["msg"]
and "ctx" in error_object
):
location = error_object["loc"]
ctx_object = error_object["ctx"]
if "error" in ctx_object:
error_object = ctx_object["error"]
if hasattr(error_object, "__cause__"):
cause_object = error_object.__cause__
cause_object_errors = cause_object.errors()
for cause_error_object in cause_object_errors:
# we use [1:] to avoid `entries` location. It is a location for
# RenderCV's own data model, not the user's data model.
cause_error_object["loc"] = tuple(
list(location) + list(cause_error_object["loc"][1:])
)
errors.extend(cause_object_errors)
# some locations are not really the locations in the input file, but some
# information about the model coming from Pydantic. We need to remove them.
# (e.g. avoid stuff like .end_date.literal['present'])
unwanted_locations = ["tagged-union", "list", "literal", "int", "constrained-str"]
for error_object in errors:
location = error_object["loc"]
new_location = [str(location_element) for location_element in location]
for location_element in location:
location_element = str(location_element)
for unwanted_location in unwanted_locations:
if unwanted_location in location_element:
new_location.remove(location_element)
error_object["loc"] = new_location # type: ignore
# Parse all the errors and create a new list of errors.
new_errors: list[dict[str, str]] = []
for error_object in errors:
message = error_object["msg"]
location = ".".join(error_object["loc"]) # type: ignore
input = error_object["input"]
# Check if this is a custom error message:
custom_message, custom_location, custom_input_value = (
get_error_message_and_location_and_value_from_a_custom_error(message)
)
if custom_message is not None:
message = custom_message
if custom_location:
# If the custom location is not empty, then add it to the location.
location = f"{location}.{custom_location}"
input = custom_input_value
# Don't show unwanted texts in the error message:
for unwanted_text in unwanted_texts:
message = message.replace(unwanted_text, "")
# Convert the error message to a more user-friendly message if it's in the
# error_dictionary:
if message in error_dictionary:
message = error_dictionary[message]
# Special case for end_date because Pydantic returns multiple end_date errors
# since it has multiple valid formats:
if "end_date" in location:
message = (
"This is not a valid end date! Please use either YYYY-MM-DD, YYYY-MM,"
' or YYYY format or "present"!'
)
# If the input is a dictionary or a list (the model itself fails to validate),
# then don't show the input. It looks confusing and it is not helpful.
if isinstance(input, (dict, list)):
input = ""
new_error = {
"loc": str(location),
"msg": message,
"input": str(input),
}
# if new_error is not in new_errors, then add it to new_errors
if new_error not in new_errors:
new_errors.append(new_error)
# Print the errors in a nice table:
table = rich.table.Table(
title="[bold red]\nThere are some errors in the data model!\n",
title_justify="left",
show_lines=True,
)
table.add_column("Location", style="cyan", no_wrap=True)
table.add_column("Input Value", style="magenta")
table.add_column("Error Message", style="orange4")
for error_object in new_errors:
table.add_row(
error_object["loc"],
error_object["input"],
error_object["msg"],
)
print(table)
error() # exit the program
def handle_exceptions(function: Callable) -> Callable:
"""Return a wrapper function that handles exceptions.
A decorator in Python is a syntactic convenience that allows a Python to interpret
the code below:
```python
@handle_exceptions
def my_function():
pass
```
as
```python
handle_exceptions(my_function)()
```
which is step by step equivalent to
1. Execute `#!python handle_exceptions(my_function)` which will return the
function called `wrapper`.
2. Execute `#!python wrapper()`.
Args:
function (Callable): The function to be wrapped.
Returns:
Callable: The wrapped function.
"""
@functools.wraps(function)
def wrapper(*args, **kwargs):
try:
function(*args, **kwargs)
except pydantic.ValidationError as e:
handle_validation_error(e)
except ruamel.yaml.YAMLError as e:
error(
"There is a YAML error in the input file!\n\nTry to use quotation marks"
" to make sure the YAML parser understands the field is a string.",
e,
)
except FileNotFoundError as e:
error(e)
except UnicodeDecodeError as e:
# find the problematic character that cannot be decoded with utf-8
bad_character = str(e.object[e.start : e.end])
try:
bad_character_context = str(e.object[e.start - 16 : e.end + 16])
except IndexError:
bad_character_context = ""
error(
"The input file contains a character that cannot be decoded with"
f" UTF-8 ({bad_character}):\n {bad_character_context}",
)
except ValueError as e:
error(e)
except typer.Exit:
pass
except jinja2.exceptions.TemplateSyntaxError as e:
error(
f"There is a problem with the template ({e.filename}) at line"
f" {e.lineno}!",
e,
)
except RuntimeError as e:
error(e)
return wrapper
def copy_templates(
folder_name: str,
copy_to: pathlib.Path,
@@ -332,7 +100,7 @@ def copy_templates(
)
else:
warning(
'The folder "markdown" already exists! New markdown files are not'
'The folder "markdown" already exists! New Markdown files are not'
" created."
)
+66
View File
@@ -0,0 +1,66 @@
"""
The `rendercv.data_models` package contains the necessary classes and functions for
- Parsing and validating a YAML input file
- Computing some properties based on a YAML input file (like converting ISO dates to
plain English, URLs of social networks, etc.)
- Generating a JSON Schema for RenderCV's data format
- Generating a sample YAML input file
The validators and data format of RenderCV are written using
[Pydantic](https://github.com/pydantic/pydantic).
"""
from .models import (
OneLineEntry,
BulletEntry,
EducationEntry,
ExperienceEntry,
PublicationEntry,
NormalEntry,
SocialNetwork,
CurriculumVitae,
LocaleCatalog,
RenderCVDataModel,
locale_catalog,
read_input_file,
)
from .generators import (
generate_json_schema_file,
generate_json_schema,
create_a_sample_yaml_input_file,
get_a_sample_data_model,
)
from .types import (
available_entry_type_names,
available_themes,
available_social_networks,
)
from .utilities import set_or_update_a_value, dictionary_to_yaml
__all__ = [
"OneLineEntry",
"BulletEntry",
"EducationEntry",
"ExperienceEntry",
"PublicationEntry",
"NormalEntry",
"SocialNetwork",
"CurriculumVitae",
"LocaleCatalog",
"RenderCVDataModel",
"locale_catalog",
"generate_json_schema_file",
"generate_json_schema",
"create_a_sample_yaml_input_file",
"get_a_sample_data_model",
"available_entry_type_names",
"available_themes",
"available_social_networks",
"read_input_file",
"set_or_update_a_value",
"dictionary_to_yaml",
]
+339
View File
@@ -0,0 +1,339 @@
"""
The `rendercv.data_models.computed_fields` module contains functions that compute
some properties based on the input data. For example, it includes functions that
calculate the time span between two dates, the date string, the URL of a social network,
etc.
"""
from typing import Optional
from . import models
from . import utilities as util
from . import validators as val
def compute_time_span_string(
start_date: Optional[str],
end_date: Optional[str],
date: Optional[str],
) -> str:
"""
Return a time span string based on the provided dates.
Example:
```python
get_time_span_string("2020-01-01", "2020-05-01", None)
```
returns
`#!python "4 months"`
Args:
start_date (Optional[str]): A start date in YYYY-MM-DD, YYYY-MM, or YYYY format.
end_date (Optional[str]): An end date in YYYY-MM-DD, YYYY-MM, or YYYY format or
"present".
date (Optional[str]): A date in YYYY-MM-DD, YYYY-MM, or YYYY format or a custom
string. If provided, start_date and end_date will be ignored.
Returns:
str: The computed time span string.
"""
date_is_provided = date is not None
start_date_is_provided = start_date is not None
end_date_is_provided = end_date is not None
if date_is_provided:
# If only the date is provided, the time span is irrelevant. So, return an
# empty string.
return ""
elif not start_date_is_provided and not end_date_is_provided:
# If neither start_date nor end_date is provided, return an empty string.
return ""
elif isinstance(start_date, int) or isinstance(end_date, int):
# Then it means one of the dates is year, so time span cannot be more
# specific than years.
start_year = util.get_date_object(start_date).year # type: ignore
end_year = util.get_date_object(end_date).year # type: ignore
time_span_in_years = end_year - start_year
if time_span_in_years < 2:
time_span_string = "1 year"
else:
time_span_string = f"{time_span_in_years} years"
return time_span_string
else:
# Then it means both start_date and end_date are in YYYY-MM-DD or YYYY-MM
# format.
end_date = util.get_date_object(end_date) # type: ignore
start_date = util.get_date_object(start_date) # type: ignore
# Calculate the number of days between start_date and end_date:
timespan_in_days = (end_date - start_date).days # type: ignore
# 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 = f"1 {models.locale_catalog['year']}"
else:
how_many_years_string = f"{how_many_years} {models.locale_catalog['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 = f"1 {models.locale_catalog['month']}"
else:
how_many_months_string = (
f"{how_many_months} {models.locale_catalog['months']}"
)
# Combine howManyYearsString and howManyMonthsString:
if how_many_years_string is None:
time_span_string = how_many_months_string
else:
time_span_string = f"{how_many_years_string} {how_many_months_string}"
return time_span_string
def compute_date_string(
start_date: Optional[str],
end_date: Optional[str],
date: Optional[str],
show_only_years: bool = False,
) -> str:
"""Return a date string based on the provided dates.
Example:
```python
get_date_string("2020-01-01", "2021-01-01", None)
```
returns
```
"Jan 2020 to Jan 2021"
```
Args:
start_date (Optional[str]): A start date in YYYY-MM-DD, YYYY-MM, or YYYY
format.
end_date (Optional[str]): An end date in YYYY-MM-DD, YYYY-MM, or YYYY format
or "present".
date (Optional[str]): A date in YYYY-MM-DD, YYYY-MM, or YYYY format or
a custom string. If provided, start_date and end_date will be ignored.
show_only_years (bool): If True, only the years will be shown in the date
string.
Returns:
str: The computed date string.
"""
date_is_provided = date is not None
start_date_is_provided = start_date is not None
end_date_is_provided = end_date is not None
if date_is_provided:
if isinstance(date, int):
# Then it means only the year is provided
date_string = str(date)
else:
try:
date_object = util.get_date_object(date)
if show_only_years:
date_string = str(date_object.year)
else:
date_string = util.format_date(date_object)
except ValueError:
# Then it is a custom date string (e.g., "My Custom Date")
date_string = str(date)
elif start_date_is_provided and end_date_is_provided:
if isinstance(start_date, int):
# Then it means only the year is provided
start_date = str(start_date)
else:
# Then it means start_date is either in YYYY-MM-DD or YYYY-MM format
date_object = util.get_date_object(start_date)
if show_only_years:
start_date = date_object.year
else:
start_date = util.format_date(date_object)
if end_date == "present":
end_date = models.locale_catalog["present"]
elif isinstance(end_date, int):
# Then it means only the year is provided
end_date = str(end_date)
else:
# Then it means end_date is either in YYYY-MM-DD or YYYY-MM format
date_object = util.get_date_object(end_date)
if show_only_years:
end_date = date_object.year
else:
end_date = util.format_date(date_object)
date_string = f"{start_date} {models.locale_catalog['to']} {end_date}"
else:
# Neither date, start_date, nor end_date are provided, so return an empty
# string:
date_string = ""
return date_string
def compute_social_network_url(network: str, username: str):
"""Return the URL of a social network based on the network name and the username.
Args:
network (str): The name of the social network.
username (str): The username of the user in the social network.
Returns:
str: The URL of the social network.
"""
if network == "Mastodon":
# Split domain and username
dummy, username, domain = username.split("@")
url = f"https://{domain}/@{username}"
else:
url_dictionary = {
"LinkedIn": "https://linkedin.com/in/",
"GitHub": "https://github.com/",
"GitLab": "https://gitlab.com/",
"Instagram": "https://instagram.com/",
"ORCID": "https://orcid.org/",
"StackOverflow": "https://stackoverflow.com/users/",
"ResearchGate": "https://researchgate.net/profile/",
"YouTube": "https://youtube.com/@",
"Google Scholar": "https://scholar.google.com/citations?user=",
}
url = url_dictionary[network] + username
return url
def compute_connections(cv: models.CurriculumVitae) -> list[dict[str, str]]:
"""Bring together all the connections in the CV, such as social networks, phone
number, email, etc and return them as a list of dictionaries. Each dictionary
contains the following keys: "latex_icon", "url", "clean_url", and "placeholder."
The connections are used in the header of the CV.
Args:
cv (CurriculumVitae): The CV to compute the connections.
Returns:
list[dict[str, str]]: The computed connections.
"""
connections: list[dict[str, str]] = []
if cv.location is not None:
connections.append(
{
"latex_icon": "\\faMapMarker*",
"url": None,
"clean_url": None,
"placeholder": cv.location,
}
)
if cv.email is not None:
connections.append(
{
"latex_icon": "\\faEnvelope[regular]",
"url": f"mailto:{cv.email}",
"clean_url": cv.email,
"placeholder": cv.email,
}
)
if cv.phone is not None:
phone_placeholder = cv.phone.replace("tel:", "").replace("-", " ")
connections.append(
{
"latex_icon": "\\faPhone*",
"url": f"{cv.phone}",
"clean_url": phone_placeholder,
"placeholder": phone_placeholder,
}
)
if cv.website is not None:
website_placeholder = util.make_a_url_clean(cv.website)
connections.append(
{
"latex_icon": "\\faLink",
"url": cv.website,
"clean_url": website_placeholder,
"placeholder": website_placeholder,
}
)
if cv.social_networks is not None:
icon_dictionary = {
"LinkedIn": "\\faLinkedinIn",
"GitHub": "\\faGithub",
"GitLab": "\\faGitlab",
"Instagram": "\\faInstagram",
"Mastodon": "\\faMastodon",
"ORCID": "\\faOrcid",
"StackOverflow": "\\faStackOverflow",
"ResearchGate": "\\faResearchgate",
"YouTube": "\\faYoutube",
"Google Scholar": "\\faGraduationCap",
}
for social_network in cv.social_networks:
clean_url = util.make_a_url_clean(social_network.url)
connection = {
"latex_icon": icon_dictionary[social_network.network],
"url": social_network.url,
"clean_url": clean_url,
"placeholder": social_network.username,
}
if social_network.network == "StackOverflow":
username = social_network.username.split("/")[1]
connection["placeholder"] = username
if social_network.network == "Google Scholar":
connection["placeholder"] = "Google Scholar"
connections.append(connection)
return connections
def compute_sections(
sections_input: Optional[dict[str, models.SectionInput]],
) -> list[models.SectionBase]:
"""Compute the sections of the CV based on the input sections.
The original `sections` input is a dictionary where the keys are the section titles
and the values are the list of entries in that section. This function converts the
input sections to a list of `SectionBase` objects. This makes it easier to work with
the sections in the rest of the code.
Args:
sections_input (Optional[dict[str, SectionInput]]): The input sections.
Returns:
list[SectionBase]: The computed sections.
"""
sections: list[models.SectionBase] = []
if sections_input is not None:
for title, section_or_entries in sections_input.items():
title = util.dictionary_key_to_proper_section_title(title)
entry_type_name = val.validate_an_entry_type_and_get_entry_type_name(
section_or_entries[0]
)
section = models.SectionBase(
title=title,
entry_type=entry_type_name,
entries=section_or_entries,
)
sections.append(section)
return sections
File diff suppressed because it is too large. Load diff
+385
View File
@@ -0,0 +1,385 @@
"""
The `rendercv.data_models.generators` module contains the functions that are used to
generate a sample YAML input file and the JSON schema of RenderCV based on the data
models defined in `rendercv.data_models.models`.
"""
import json
import pathlib
from typing import Any, Optional
import pydantic
from ..themes.classic import ClassicThemeOptions
from . import models
from . import utilities as utils
from . import validators as val
def get_a_sample_data_model(
name: str = "John Doe", theme: str = "classic"
) -> models.RenderCVDataModel:
"""Return a sample data model for new users to start with.
Args:
name (str, optional): The name of the person. Defaults to "John Doe".
Returns:
RenderCVDataModel: A sample data model.
"""
# Check if the theme is valid:
if theme not in models.available_themes:
available_themes_string = ", ".join(models.available_themes)
raise ValueError(
f"The theme should be one of the following: {available_themes_string}!"
f' The provided theme is "{theme}".'
)
name = name.encode().decode("unicode-escape")
sections = {
"welcome_to_RenderCV!": [
(
"[RenderCV](https://github.com/sinaatalay/rendercv) is a LaTeX-based"
" CV/resume framework. It allows you to create a high-quality CV or"
" resume as a PDF file from a YAML file, with **Markdown syntax"
" support** and **complete control over the LaTeX code**."
),
(
"The boilerplate content is taken from"
" [here](https://github.com/dnl-blkv/mcdowell-cv), where a"
" *clean and tidy CV* pattern is proposed by"
" **[Gayle Laakmann McDowell](https://www.gayle.com/)**."
),
],
"quick_guide": [
models.BulletEntry(
bullet=(
"Each section title is arbitrary, and each section contains a list"
" of entries."
),
),
models.BulletEntry(
bullet=(
"There are 7 unique entry types: *models.BulletEntry*, *TextEntry*,"
" *models.EducationEntry*, *models.ExperienceEntry*,"
" *models.NormalEntry*, *models.PublicationEntry*, and"
" *models.OneLineEntry*."
),
),
models.BulletEntry(
bullet=(
"Select a section title, pick an entry type, and start writing your"
" section!"
)
),
models.BulletEntry(
bullet=(
"[Here](https://docs.rendercv.com/user_guide/), you can find a"
" comprehensive user guide for RenderCV."
)
),
],
"education": [
models.EducationEntry(
institution="University of Pennsylvania",
area="Computer Science",
degree="BS",
start_date="2000-09",
end_date="2005-05",
highlights=[
"GPA: 3.9/4.0 ([Transcript](https://example.com))",
(
"**Coursework:** Computer Architecture, Artificial"
" Intelligence, Comparison of Learning Algorithms,"
" Computational Theory"
),
],
),
],
"experience": [
models.ExperienceEntry(
company="Apple",
position="Software Engineer",
start_date="2005-06",
end_date="2007-08",
location="Cupertino, CA",
highlights=[
(
"Reduced time to render the user's buddy list by 75% by"
" implementing a prediction algorithm"
),
(
"Implemented iChat integration with OS X Spotlight Search by"
" creating a tool to extract metadata from saved chat"
" transcripts and provide metadata to a system-wide search"
" database"
),
(
"Redesigned chat file format and implemented backward"
" compatibility for search"
),
],
),
models.ExperienceEntry(
company="Microsoft",
position="Lead Student Ambassador",
start_date="2003-09",
end_date="2005-04",
location="Redmond, WA",
highlights=[
(
"Promoted to Lead Student Ambassador in the Fall of 2004,"
" supervised 10-15 Student Ambassadors"
),
(
"Created and taught a computer science course, CSE 099:"
" Software Design and Development"
),
],
),
models.ExperienceEntry(
company="University of Pennsylvania",
position="Head Teaching Assistant",
start_date="2001-10",
end_date="2003-05",
location="Philadelphia, PA",
highlights=[
(
"Implemented a user interface for the VS open file switcher"
" (ctrl-tab) and extended it to tool windows"
),
(
"Created a service to provide gradient across VS and VS"
" add-ins, optimized its performance via caching"
),
"Programmer Productivity Research Center (Summers 2001, 2002)",
(
"Built an app to compute the similarity of all methods in a"
" code base, reducing the time from $\\mathcal{O}(n^2)$ to"
" $\\mathcal{O}(n \\log n)$"
),
(
"Created a test case generation tool that creates random XML"
" docs from XML Schema"
),
],
),
models.ExperienceEntry(
company="Microsoft",
position="Software Engineer, Intern",
start_date="2003-06",
end_date="2003-08",
location="Redmond, WA",
highlights=[
(
"Automated the extraction and processing of large datasets from"
" legacy systems using SQL and Perl scripts"
),
],
),
],
"publications": [
models.PublicationEntry(
title=(
"Magneto-Thermal Thin Shell Approximation for 3D Finite Element"
" Analysis of No-Insulation Coils"
),
authors=[
"Albert Smith",
f"***{name}***",
"Jane Derry",
"Harry Tom",
"Frodo Baggins",
],
date="2004-01",
doi="10.1109/TASC.2023.3340648",
)
],
"projects": [
models.NormalEntry(
name="Multi-User Drawing Tool",
date="[github.com/name/repo](https://github.com/sinaatalay/rendercv)",
highlights=[
(
"Developed an electronic classroom where multiple users can"
' view and simultaneously draw on a "chalkboard" with each'
" person's edits synchronized"
),
"Tools Used: C++, MFC",
],
),
models.NormalEntry(
name="Synchronized Calendar",
date="[github.com/name/repo](https://github.com/sinaatalay/rendercv)",
highlights=[
(
"Developed a desktop calendar with globally shared and"
" synchronized calendars, allowing users to schedule meetings"
" with other users"
),
"Tools Used: C#, .NET, SQL, XML",
],
),
models.NormalEntry(
name="Operating System",
date="2002",
highlights=[
(
"Developed a UNIX-style OS with a scheduler, file system, text"
" editor, and calculator"
),
"Tools Used: C",
],
),
],
"additional_experience_and_awards": [
models.OneLineEntry(
label="Instructor (2003-2005)",
details="Taught 2 full-credit computer science courses",
),
models.OneLineEntry(
label="Third Prize, Senior Design Project",
details=(
"Awarded 3rd prize for a synchronized calendar project out of 100"
" entries"
),
),
],
"technologies": [
models.OneLineEntry(
label="Languages",
details="C++, C, Java, Objective-C, C#, SQL, JavaScript",
),
models.OneLineEntry(
label="Software",
details=".NET, Microsoft SQL Server, XCode, Interface Builder",
),
],
}
cv = models.CurriculumVitae(
name=name,
location="Your Location",
email="youremail@yourdomain.com",
phone="+905419999999", # type: ignore
website="https://yourwebsite.com", # type: ignore
social_networks=[
models.SocialNetwork(network="LinkedIn", username="yourusername"),
models.SocialNetwork(network="GitHub", username="yourusername"),
],
sections=sections, # type: ignore
)
if theme == "classic":
design = ClassicThemeOptions(theme="classic", show_timespan_in=["Experience"])
else:
design = val.rendercv_design_validator.validate_python({"theme": theme})
return models.RenderCVDataModel(cv=cv, design=design)
def create_a_sample_yaml_input_file(
input_file_path: Optional[pathlib.Path] = None,
name: str = "John Doe",
theme: str = "classic",
) -> str:
"""Create a sample YAML input file and return it as a string. If the input file path
is provided, then also save the contents to the file.
Args:
input_file_path (pathlib.Path, optional): The path to save the input file.
Defaults to None.
name (str, optional): The name of the person. Defaults to "John Doe".
theme (str, optional): The theme of the CV. Defaults to "classic".
Returns:
str: The sample YAML input file as a string.
"""
data_model = get_a_sample_data_model(name=name, theme=theme)
# Instead of getting the dictionary with data_model.model_dump() directly, we
# convert it to JSON and then to a dictionary. Because the YAML library we are
# using sometimes has problems with the dictionary returned by model_dump().
# We exclude "cv.sections" because the data model automatically generates them.
# The user's "cv.sections" input is actually "cv.sections_input" in the data
# model. It is shown as "cv.sections" in the YAML file because an alias is being
# used. If"cv.sections" were not excluded, the automatically generated
# "cv.sections" would overwrite the "cv.sections_input". "cv.sections" are
# automatically generated from "cv.sections_input" to make the templating
# process easier. "cv.sections_input" exists for the convenience of the user.
data_model_as_json = data_model.model_dump_json(
exclude_none=True, by_alias=True, exclude={"cv": {"sections"}}
)
data_model_as_dictionary = json.loads(data_model_as_json)
yaml_string = utils.dictionary_to_yaml(data_model_as_dictionary)
if input_file_path is not None:
input_file_path.write_text(yaml_string, encoding="utf-8")
return yaml_string
def generate_json_schema() -> dict[str, Any]:
"""Generate the JSON schema of RenderCV.
JSON schema is generated for the users to make it easier for them to write the input
file. The JSON Schema of RenderCV is saved in the `docs` directory of the repository
and distributed to the users with the
[JSON Schema Store](https://www.schemastore.org/).
Returns:
dict: The JSON schema of RenderCV.
"""
class RenderCVSchemaGenerator(pydantic.json_schema.GenerateJsonSchema):
def generate(self, schema, mode="validation"): # type: ignore
json_schema = super().generate(schema, mode=mode)
# Basic information about the schema:
json_schema["title"] = "RenderCV"
json_schema["description"] = "RenderCV data model."
json_schema["$id"] = (
"https://raw.githubusercontent.com/sinaatalay/rendercv/main/schema.json"
)
json_schema["$schema"] = "http://json-schema.org/draft-07/schema#"
# Loop through $defs and remove docstring descriptions and fix optional
# fields
for object_name, value in json_schema["$defs"].items():
# Don't allow additional properties
value["additionalProperties"] = False
# 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 = {
"type": "null",
}
for field_name, field in value["properties"].items():
if "anyOf" in field:
if null_type_dict in field["anyOf"]:
field["anyOf"].remove(null_type_dict)
field["oneOf"] = field["anyOf"]
del field["anyOf"]
return json_schema
schema = models.RenderCVDataModel.model_json_schema(
schema_generator=RenderCVSchemaGenerator
)
return schema
def generate_json_schema_file(json_schema_path: pathlib.Path):
"""Generate the JSON schema of RenderCV and save it to a file.
Args:
json_schema_path (pathlib.Path): The path to save the JSON schema.
"""
schema = generate_json_schema()
schema_json = json.dumps(schema, indent=2, ensure_ascii=False)
json_schema_path.write_text(schema_json, encoding="utf-8")
+615
View File
@@ -0,0 +1,615 @@
"""
The `rendercv.data_models.models` module contains all the Pydantic data models used in
RenderCV. These data models define the data format and the usage of computed fields and
the validators.
"""
import functools
from typing import Annotated, Optional
import pathlib
import annotated_types as at
import pydantic
import pydantic_extra_types.phone_numbers as pydantic_phone_numbers
from ..themes.classic import ClassicThemeOptions
from . import computed_fields as cf
from . import types
from . import utilities as util
from . import validators as val
# Disable Pydantic warnings:
# warnings.filterwarnings("ignore")
class RenderCVBaseModel(pydantic.BaseModel):
"""This class is the parent class of all the data models in RenderCV. It has only
one difference from the default `pydantic.BaseModel`: It raises an error if an
unknown key is provided in the input file.
"""
model_config = pydantic.ConfigDict(extra="forbid")
# ======================================================================================
# Entry models: ========================================================================
# ======================================================================================
class OneLineEntry(RenderCVBaseModel):
"""This class is the data model of `OneLineEntry`."""
label: str = pydantic.Field(
title="Name",
description="The label of the OneLineEntry.",
)
details: str = pydantic.Field(
title="Details",
description="The details of the OneLineEntry.",
)
class BulletEntry(RenderCVBaseModel):
"""This class is the data model of `BulletEntry`."""
bullet: str = pydantic.Field(
title="Bullet",
description="The bullet of the BulletEntry.",
)
class EntryWithDate(RenderCVBaseModel):
"""This class is the parent class of some of the entry types that have date
fields.
"""
date: types.ArbitraryDate = pydantic.Field(
default=None,
title="Date",
description=(
"The date field can be filled in YYYY-MM-DD, YYYY-MM, or YYYY formats or as"
' an arbitrary string like "Fall 2023".'
),
examples=["2020-09-24", "Fall 2023"],
)
@functools.cached_property
def date_string(self) -> str:
"""Return a date string based on the `date` field and cache `date_string` as
an attribute of the instance.
"""
return cf.compute_date_string(start_date=None, end_date=None, date=self.date)
class PublicationEntryBase(RenderCVBaseModel):
title: str = pydantic.Field(
title="Publication Title",
description="The title of the publication.",
)
authors: list[str] = pydantic.Field(
title="Authors",
description="The authors of the publication in order as a list of strings.",
)
doi: Optional[str] = pydantic.Field(
default=None,
title="DOI",
description="The DOI of the publication.",
examples=["10.48550/arXiv.2310.03138"],
)
url: Optional[pydantic.HttpUrl] = pydantic.Field(
default=None,
title="URL",
description=(
"The URL of the publication. If DOI is provided, it will be ignored."
),
)
journal: Optional[str] = pydantic.Field(
default=None,
title="Journal",
description="The journal or conference name.",
)
@pydantic.model_validator(mode="after")
def ignore_url_if_doi_is_given(self) -> "PublicationEntryBase":
"""Check if DOI is provided and ignore the URL if it is provided."""
doi_is_provided = self.doi is not None
url_is_provided = self.url is not None
if doi_is_provided and url_is_provided:
self.url = None
return self
@functools.cached_property
def doi_url(self) -> str:
"""Return the URL of the DOI and cache `doi_url` as an attribute of the
instance.
"""
doi_is_provided = self.doi is not None
if doi_is_provided:
return f"https://doi.org/{self.doi}"
else:
return ""
@functools.cached_property
def clean_url(self) -> str:
"""Return the clean URL of the publication and cache `clean_url` as an attribute
of the instance.
"""
url_is_provided = self.url is not None
if url_is_provided:
return util.make_a_url_clean(self.url)
else:
return ""
# The following class is to ensure PublicationEntryBase keys come first,
# then the keys of the EntryWithDate class. The only way to achieve this in Pydantic is
# to do this. The same thing is done for the other classes as well.
class PublicationEntry(EntryWithDate, PublicationEntryBase):
"""This class is the data model of `PublicationEntry`."""
pass
class EntryBase(EntryWithDate):
"""This class is the parent class of some of the entry types. It is being used
because some of the entry types have common fields like dates, highlights, location,
etc.
"""
location: Optional[str] = pydantic.Field(
default=None,
title="Location",
description="The location of the event.",
examples=["Istanbul, Türkiye"],
)
start_date: types.StartDate = pydantic.Field(
default=None,
title="Start Date",
description=(
"The start date of the event in YYYY-MM-DD, YYYY-MM, or YYYY format."
),
examples=["2020-09-24"],
)
end_date: types.EndDate = pydantic.Field(
default=None,
title="End Date",
description=(
"The end date of the event in YYYY-MM-DD, YYYY-MM, or YYYY format. If the"
' event is still ongoing, then type "present" or provide only the'
" start_date."
),
examples=["2020-09-24", "present"],
)
highlights: Optional[list[str]] = pydantic.Field(
default=None,
title="Highlights",
description="The highlights of the event as a list of strings.",
examples=["Did this.", "Did that."],
)
@pydantic.model_validator(mode="after")
def check_and_adjust_dates(self) -> "EntryBase":
"""Call the `validate_adjust_dates_of_an_entry` function to validate the
dates.
"""
return val.validate_and_adjust_dates_of_an_entry(self)
@functools.cached_property
def date_string(self) -> str:
"""Return a date string based on the `date`, `start_date`, and `end_date` fields
and cache `date_string` as an attribute of the instance.
Example:
```python
entry = dm.EntryBase(start_date="2020-10-11", end_date="2021-04-04").date_string
```
returns
`#!python "Nov 2020 to Apr 2021"`
"""
return cf.compute_date_string(
start_date=self.start_date, end_date=self.end_date, date=self.date
)
@functools.cached_property
def date_string_only_years(self) -> str:
"""Return a date string that only contains years based on the `date`,
`start_date`, and `end_date` fields and cache `date_string_only_years` as an
attribute of the instance.
Example:
```python
entry = dm.EntryBase(start_date="2020-10-11", end_date="2021-04-04").date_string_only_years
```
returns
`#!python "2020 to 2021"`
"""
return cf.compute_date_string(
start_date=self.start_date,
end_date=self.end_date,
date=self.date,
show_only_years=True,
)
@functools.cached_property
def time_span_string(self) -> str:
"""Return a time span string based on the `date`, `start_date`, and `end_date`
fields and cache `time_span_string` as an attribute of the instance.
"""
return cf.compute_time_span_string(
start_date=self.start_date, end_date=self.end_date, date=self.date
)
class NormalEntryBase(RenderCVBaseModel):
name: str = pydantic.Field(
title="Name",
description="The name of the NormalEntry.",
)
class NormalEntry(EntryBase, NormalEntryBase):
"""This class is the data model of `NormalEntry`."""
pass
class ExperienceEntryBase(RenderCVBaseModel):
company: str = pydantic.Field(
title="Company",
description="The company name.",
)
position: str = pydantic.Field(
title="Position",
description="The position.",
)
class ExperienceEntry(EntryBase, ExperienceEntryBase):
"""This class is the data model of `ExperienceEntry`."""
pass
class EducationEntryBase(RenderCVBaseModel):
institution: str = pydantic.Field(
title="Institution",
description="The institution name.",
)
area: str = pydantic.Field(
title="Area",
description="The area of study.",
)
degree: Optional[str] = pydantic.Field(
default=None,
title="Degree",
description="The type of the degree.",
examples=["BS", "BA", "PhD", "MS"],
)
class EducationEntry(EntryBase, EducationEntryBase):
"""This class is the data model of `EducationEntry`."""
pass
# ======================================================================================
# Section models: ======================================================================
# ======================================================================================
class SectionBase(RenderCVBaseModel):
"""This class is the parent class of all the section types. It is being used
in RenderCV internally, and it is not meant to be used directly by the users.
It is used by `rendercv.data_models.utilities.create_a_section_model` function to
create a section model based on any entry type.
"""
title: str
entry_type: str
entries: types.ListOfEntries
# ======================================================================================
# Full RenderCV data models: ===========================================================
# ======================================================================================
class SocialNetwork(RenderCVBaseModel):
"""This class is the data model of a social network."""
network: types.SocialNetworkName = pydantic.Field(
title="Social Network",
description="Name of the social network.",
)
username: str = pydantic.Field(
title="Username",
description="The username of the social network. The link will be generated.",
)
@pydantic.field_validator("username")
@classmethod
def check_username(cls, username: str, info: pydantic.ValidationInfo) -> str:
"""Check if the username is provided correctly."""
if "network" not in info.data:
# the network is either not provided or not one of the available social
# networks. In this case, don't check the username, since Pydantic will
# raise an error for the network.
return username
network = info.data["network"]
username = val.validate_a_social_network_username(username, network)
return username
@pydantic.model_validator(mode="after") # type: ignore
def check_url(self) -> "SocialNetwork":
"""Validate the URL of the social network."""
if self.network == "Mastodon":
# All the other social networks have valid URLs. Mastodon URLs contain both
# the username and the domain. So, we need to validate if the url is valid.
val.validate_url(self.url)
return self
@functools.cached_property
def url(self) -> str:
"""Return the URL of the social network and cache `url` as an attribute of the
instance.
"""
return cf.compute_social_network_url(self.network, self.username)
class CurriculumVitae(RenderCVBaseModel):
"""This class is the data model of the `cv` field."""
name: Optional[str] = pydantic.Field(
default=None,
title="Name",
description="The name of the person.",
)
label: Optional[str] = pydantic.Field(
default=None,
title="Label",
description="The label of the person.",
)
location: Optional[str] = pydantic.Field(
default=None,
title="Location",
description="The location of the person.",
)
email: Optional[pydantic.EmailStr] = pydantic.Field(
default=None,
title="Email",
description="The email address of the person.",
)
phone: Optional[pydantic_phone_numbers.PhoneNumber] = pydantic.Field(
default=None,
title="Phone",
description="The phone number of the person.",
)
website: Optional[pydantic.HttpUrl] = pydantic.Field(
default=None,
title="Website",
description="The website of the person.",
)
social_networks: Optional[list[SocialNetwork]] = pydantic.Field(
default=None,
title="Social Networks",
description="The social networks of the person.",
)
sections_input: Optional[dict[str, types.SectionInput]] = pydantic.Field(
default=None,
title="Sections",
description="The sections of the CV.",
# This is an alias to allow users to use `sections` in the YAML file:
# `sections` key is preserved for RenderCV's internal use.
alias="sections",
)
@functools.cached_property
def connections(self) -> list[dict[str, str]]:
"""Return all the connections of the person as a list of dictionaries and cache
`connections` as an attribute of the instance.
"""
connections = cf.compute_connections(self)
return connections
@functools.cached_property
def sections(self) -> list[SectionBase]:
"""Return all the sections of the CV with their titles as a list of
`SectionBase` instances and cache `sections` as an attribute of the instance.
"""
sections = cf.compute_sections(self.sections_input)
return sections
class LocaleCatalog(RenderCVBaseModel):
"""This class is the data model of the locale catalog. The values of each field
updates the `locale_catalog` dictionary.
"""
month: Optional[str] = pydantic.Field(
default="month",
title='Translation of "Month"',
description='Translation of the word "month" in the locale.',
validate_default=True, # To initialize the locale catalog with the default values
)
months: Optional[str] = pydantic.Field(
default="months",
title='Translation of "Months"',
description='Translation of the word "months" in the locale.',
validate_default=True, # To initialize the locale catalog with the default values
)
year: Optional[str] = pydantic.Field(
default="year",
title='Translation of "Year"',
description='Translation of the word "year" in the locale.',
validate_default=True, # To initialize the locale catalog with the default values
)
years: Optional[str] = pydantic.Field(
default="years",
title='Translation of "Years"',
description='Translation of the word "years" in the locale.',
validate_default=True, # To initialize the locale catalog with the default values
)
present: Optional[str] = pydantic.Field(
default="present",
title='Translation of "Present"',
description='Translation of the word "present" in the locale.',
validate_default=True, # To initialize the locale catalog with the default values
)
to: Optional[str] = pydantic.Field(
default="–", # en dash
title='Translation of "To"',
description=(
"The word or character used to indicate a range in the locale (e.g.,"
' "2020 - 2021").'
),
validate_default=True, # To initialize the locale catalog with the default values
)
abbreviations_for_months: Optional[
Annotated[list[str], at.Len(min_length=12, max_length=12)]
] = pydantic.Field(
# Month abbreviations are taken from
# https://web.library.yale.edu/cataloging/months:
default=[
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"June",
"July",
"Aug",
"Sept",
"Oct",
"Nov",
"Dec",
],
title="Abbreviations of Months",
description="Abbreviations of the months in the locale.",
validate_default=True, # to initialize the locale catalog with the default values
)
full_names_of_months: Optional[
Annotated[list[str], at.Len(min_length=12, max_length=12)]
] = pydantic.Field(
default=[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
title="Full Names of Months",
description="Full names of the months in the locale.",
validate_default=True, # to initialize the locale catalog with the default values
)
@pydantic.field_validator(
"month",
"months",
"year",
"years",
"present",
"abbreviations_for_months",
"to",
"full_names_of_months",
)
@classmethod
def update_translations(cls, value: str, info: pydantic.ValidationInfo) -> str:
"""Update the `locale_catalog` dictionary with the provided translations."""
if value:
locale_catalog[info.field_name] = value
return value
# The dictionary below will be overwritten by LocaleCatalog class, which will contain
# month names, month abbreviations, and other locale-specific strings.
locale_catalog: dict[str, str | list[str]] = {}
LocaleCatalog() # Initialize the locale catalog with the default values
class RenderCVDataModel(RenderCVBaseModel):
"""This class binds both the CV and the design information together."""
cv: CurriculumVitae = pydantic.Field(
title="Curriculum Vitae",
description="The data of the CV.",
)
design: types.RenderCVDesign = pydantic.Field(
default=ClassicThemeOptions(theme="classic"),
title="Design",
description=(
"The design information of the CV. The default is the classic theme."
),
)
locale_catalog: Optional[LocaleCatalog] = pydantic.Field(
default=None,
title="Locale Catalog",
description=(
"The locale catalog of the CV to allow the support of multiple languages."
),
)
def read_input_file(
file_path_or_contents: pathlib.Path | str,
) -> RenderCVDataModel:
"""Read the input file (YAML or JSON) and return them as an instance of
`RenderCVDataModel`, which is a Pydantic data model of RenderCV's data format.
Args:
file_path_or_contents (str): The path to the input file or the contents of the
input file as a string.
Returns:
RenderCVDataModel: The data models with $\\LaTeX$ and Markdown strings.
"""
if isinstance(file_path_or_contents, pathlib.Path):
# Check if the file exists:
if not file_path_or_contents.exists():
raise FileNotFoundError(
f"The input file [magenta]{file_path_or_contents}[/magenta] doesn't"
" exist!"
)
# Check the file extension:
accepted_extensions = [".yaml", ".yml", ".json", ".json5"]
if file_path_or_contents.suffix not in accepted_extensions:
user_friendly_accepted_extensions = [
f"[green]{ext}[/green]" for ext in accepted_extensions
]
user_friendly_accepted_extensions = ", ".join(
user_friendly_accepted_extensions
)
raise ValueError(
"The input file should have one of the following extensions:"
f" {user_friendly_accepted_extensions}. The input file is"
f" [magenta]{file_path_or_contents}[/magenta]."
)
file_content = file_path_or_contents.read_text(encoding="utf-8")
else:
file_content = file_path_or_contents
input_as_dictionary: dict[str, Any] = ruamel.yaml.YAML().load(file_content) # type: ignore
# Validate the parsed dictionary by creating an instance of RenderCVDataModel:
rendercv_data_model = RenderCVDataModel(**input_as_dictionary)
return rendercv_data_model
+123
View File
@@ -0,0 +1,123 @@
"""
The `rendercv.data_models.types` module contains all the custom types created for
RenderCV, and it contains important information about the package, such as
`available_themes`, `available_social_networks`, etc.
"""
from typing import Annotated, Any, Literal, Optional, get_args
import pydantic
from ..themes.classic import ClassicThemeOptions
from ..themes.engineeringresumes import EngineeringresumesThemeOptions
from ..themes.moderncv import ModerncvThemeOptions
from ..themes.sb2nov import Sb2novThemeOptions
from . import models
from . import validators as val
# See https://docs.pydantic.dev/2.7/concepts/types/#custom-types and
# https://docs.pydantic.dev/2.7/concepts/validators/#annotated-validators
# for more information about custom types.
# Create custom types for dates:
# ExactDate that accepts only strings in YYYY-MM-DD or YYYY-MM format:
ExactDate = Annotated[
str,
pydantic.Field(
pattern=r"\d{4}-\d{2}(-\d{2})?",
),
]
# ArbitraryDate that accepts either an integer or a string, but it is validated with
# `validate_date_field` function:
ArbitraryDate = Annotated[
Optional[int | str],
pydantic.BeforeValidator(val.validate_date_field),
]
# StartDate that accepts either an integer or an ExactDate, but it is validated with
# `validate_start_and_end_date_fields` function:
StartDate = Annotated[
Optional[int | ExactDate],
pydantic.BeforeValidator(val.validate_start_and_end_date_fields),
]
# EndDate that accepts either an integer, the string "present", or an ExactDate, but it
# is validated with `validate_start_and_end_date_fields` function:
EndDate = Annotated[
Optional[Literal["present"] | int | ExactDate],
pydantic.BeforeValidator(val.validate_start_and_end_date_fields),
]
# Create a custom type named RenderCVBuiltinDesign:
# It is a union of all the design options and the correct design option is determined by
# the theme field, thanks to Pydantic's discriminator feature.
# See https://docs.pydantic.dev/2.7/concepts/fields/#discriminator for more information
RenderCVBuiltinDesign = Annotated[
ClassicThemeOptions
| ModerncvThemeOptions
| Sb2novThemeOptions
| EngineeringresumesThemeOptions,
pydantic.Field(discriminator="theme"),
]
# Create a custom type named RenderCVDesign:
# RenderCV supports custom themes as well. Therefore, `Any` type is used to allow custom
# themes. However, the JSON Schema generation is skipped, otherwise, the JSON Schema
# would accept any `design` field in the YAML input file.
RenderCVDesign = Annotated[
pydantic.json_schema.SkipJsonSchema[Any] | RenderCVBuiltinDesign,
pydantic.BeforeValidator(val.validate_a_custom_theme),
]
# Create a custom type named Entry:
Entry = (
models.OneLineEntry
| models.NormalEntry
| models.ExperienceEntry
| models.EducationEntry
| models.PublicationEntry
| models.BulletEntry
| str
)
# Create a custom type named ListOfEntries:
ListOfEntries = list[Entry]
# Create a custom type named SectionInput so that it can be validated with
# `validate_section_input` function.
SectionInput = Annotated[
ListOfEntries,
pydantic.BeforeValidator(val.validate_a_section),
]
# Create a custom type named SocialNetworkName:
SocialNetworkName = Literal[
"LinkedIn",
"GitHub",
"GitLab",
"Instagram",
"ORCID",
"Mastodon",
"StackOverflow",
"ResearchGate",
"YouTube",
"Google Scholar",
]
# ======================================================================================
# Create variables that show the available stuff: ======================================
# ======================================================================================
available_social_networks = get_args(SocialNetworkName)
# Entry.__args__[:-1] is a tuple of all the entry types except str:
available_entry_types = Entry.__args__[:-1]
available_entry_type_names = [
entry_type.__name__ for entry_type in available_entry_types
] + ["TextEntry"]
available_theme_options = get_args(RenderCVBuiltinDesign)[0]
available_themes = ["classic", "moderncv", "sb2nov", "engineeringresumes"]
+225
View File
@@ -0,0 +1,225 @@
"""
The `rendercv.data_models.utilities` module contains utility functions that are required
by data models.
"""
import io
import re
from datetime import date as Date
from typing import Any
import pydantic
import ruamel.yaml
def dictionary_to_yaml(dictionary: dict[str, Any]):
"""Converts a dictionary to a YAML string.
Args:
dictionary (dict[str, Any]): The dictionary to be converted to YAML.
Returns:
str: The YAML string.
"""
yaml_object = ruamel.yaml.YAML()
yaml_object.encoding = "utf-8"
yaml_object.width = 60
yaml_object.indent(mapping=2, sequence=4, offset=2)
with io.StringIO() as string_stream:
yaml_object.dump(dictionary, string_stream)
yaml_string = string_stream.getvalue()
return yaml_string
def get_date_object(date: str | int) -> Date:
"""Parse a date string in YYYY-MM-DD, YYYY-MM, or YYYY format and return a
`datetime.date` object. This function is used throughout the validation process of
the data models.
Args:
date (str | int): The date string to parse.
Returns:
Date: The parsed date.
"""
if isinstance(date, int):
date_object = Date.fromisoformat(f"{date}-01-01")
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
# Then it is in YYYY-MM-DD format
date_object = Date.fromisoformat(date)
elif re.fullmatch(r"\d{4}-\d{2}", date):
# Then it is in YYYY-MM format
date_object = Date.fromisoformat(f"{date}-01")
elif re.fullmatch(r"\d{4}", date):
# Then it is in YYYY format
date_object = Date.fromisoformat(f"{date}-01-01")
elif date == "present":
date_object = Date.today()
else:
raise ValueError(
"This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or"
" YYYY format."
)
return date_object
def format_date(
date: Date, locale_catalog: dict[str, str | list[str]], use_full_name: bool = False
) -> str:
"""Formats a `Date` object to a string in the following format: "Jan 2021". The
month abbreviation is taken from the locale catalog's `abbreviations_for_months` or
`full_names_of_months` field.
Example:
```python
format_date(Date(2024, 5, 1))
```
will return
`#!python "May 2024"`
Args:
date (Date): The date to format.
locale_catalog (dict[str, str | list[str]]): The locale catalog to use for
formatting the date.
use_full_name (bool, optional): If `True`, the full name of the month will be
used. Defaults to `False`.
Returns:
str: The formatted date.
"""
# Month abbreviations,
# taken from: https://web.library.yale.edu/cataloging/months
if use_full_name:
month_names = locale_catalog["full_names_of_months"]
else:
month_names = locale_catalog["abbreviations_for_months"]
month = int(date.strftime("%m"))
month_abbreviation = month_names[month - 1]
year = date.strftime(format="%Y")
date_string = f"{month_abbreviation} {year}"
return date_string
def dictionary_key_to_proper_section_title(key: str) -> str:
"""Convert a dictionary key to a proper section title.
Example:
```python
dictionary_key_to_proper_section_title("section_title")
```
returns
`#!python "Section Title"`
Args:
key (str): The key to convert to a proper section title.
Returns:
str: The proper section title.
"""
title = key.replace("_", " ")
words = title.split(" ")
# loop through the words and if the word doesn't contain any uppercase letters,
# capitalize the first letter of the word. If the word contains uppercase letters,
# don't change the word.
proper_title = " ".join(
word.capitalize() if word.islower() else word for word in words
)
return proper_title
def set_or_update_a_value(
data_model: pydantic.BaseModel | dict | list,
key: str,
value: str,
sub_model: pydantic.BaseModel | dict | list = None,
):
"""Set or update a value in a data model for a specific key. For example, a key can
be `cv.sections.education.3.institution` and the value can be "Bogazici University".
Args:
data_model (pydantic.BaseModel | dict | list): The data model to set or update
the value.
key (str): The key to set or update the value.
value (Any): The value to set or update.
sub_model (pydantic.BaseModel | dict | list, optional): The sub model to set or
update the value. This is used for recursive calls. When the value is set
to a sub model, the original data model is validated. Defaults to None.
"""
# Recursively call this function until the last key is reached:
# Rename `sections` with `sections_input` since the key is `sections` is an alias:
key = key.replace("sections.", "sections_input.")
keys = key.split(".")
if sub_model is not None:
model = sub_model
else:
model = data_model
if len(keys) == 1:
# Set the value:
if value.startswith("{") and value.endswith("}"):
# Allow users to assign dictionaries:
value = eval(value)
elif value.startswith("[") and value.endswith("]"):
# Allow users to assign lists:
value = eval(value)
if isinstance(model, pydantic.BaseModel):
setattr(model, key, value)
elif isinstance(model, dict):
model[key] = value
elif isinstance(model, list):
model[int(key)] = value
else:
raise ValueError(
"The data model should be either a Pydantic data model, dictionary, or"
" list.",
)
data_model = type(data_model).model_validate(
(data_model.model_dump(by_alias=True))
)
return data_model
else:
# get the first key and call the function with remaining keys:
first_key = keys[0]
key = ".".join(keys[1:])
if isinstance(model, pydantic.BaseModel):
sub_model = getattr(model, first_key)
elif isinstance(model, dict):
sub_model = model[first_key]
elif isinstance(model, list):
sub_model = model[int(first_key)]
else:
raise ValueError(
"The data model should be either a Pydantic data model, dictionary, or"
" list.",
)
set_or_update_a_value(data_model, key, value, sub_model)
def make_a_url_clean(url: str) -> str:
"""Make a URL clean by removing the protocol, www, and trailing slashes.
Example:
```python
make_a_url_clean("https://www.example.com/")
```
returns
`#!python "example.com"`
Args:
url (str): The URL to make clean.
Returns:
str: The clean URL.
"""
url = url.replace("https://", "").replace("http://", "").replace("www.", "")
if url.endswith("/"):
url = url[:-1]
return url
+366
View File
@@ -0,0 +1,366 @@
"""
The `rendercv.data_models.validators` module contains all the functions used to validate
the data models of RenderCV, in addition to Pydantic inner validation.
"""
import importlib
import importlib.machinery
import importlib.util
import pathlib
import re
from datetime import date as Date
from typing import Any, Optional
import pydantic
from . import models
# from .types import (
# available_entry_types,
# available_theme_options,
# available_themes,
# available_entry_type_names,
# # RenderCVBuiltinDesign,
# )
from . import utilities as util
# Create a URL validator:
# url_validator = pydantic.TypeAdapter(pydantic.HttpUrl)
# Create a RenderCVDesign validator:
# rendercv_design_validator = pydantic.TypeAdapter(RenderCVBuiltinDesign)
def validate_url(url: str) -> str:
"""Validate a URL.
Args:
url (str): The URL to validate.
Returns:
str: The validated URL.
"""
url_validator.validate_strings(url)
return url
def validate_date_field(date: Optional[int | str]) -> Optional[int | str]:
"""Check if the `date` field is provided correctly.
Args:
date (Optional[int | str]): The date to validate.
Returns:
Optional[int | str]: The validated date.
"""
date_is_provided = date is not None
if date_is_provided:
if isinstance(date, str):
if re.fullmatch(r"\d{4}-\d{2}(-\d{2})?", date):
# Then it is in YYYY-MM-DD or YYYY-MMY format
# Check if it is a valid date:
util.get_date_object(date)
elif re.fullmatch(r"\d{4}", date):
# Then it is in YYYY format, so, convert it to an integer:
# This is not required for start_date and end_date because they
# can't be casted into a general string. For date, this needs to
# be done manually, because it can be a general string.
date = int(date)
elif isinstance(date, Date):
# Pydantic parses YYYY-MM-DD dates as datetime.date objects. We need to
# convert them to strings because that is how RenderCV uses them.
date = date.isoformat()
return date
def validate_start_and_end_date_fields(
date: str | Date,
) -> str:
"""Check if the `start_date` and `end_date` fields are provided correctly.
Args:
date (Optional[Literal["present"] | int | RenderCVDate]): The date to validate.
Returns:
Optional[Literal["present"] | int | RenderCVDate]: The validated date.
"""
date_is_provided = date is not None
if date_is_provided:
if isinstance(date, Date):
# Pydantic parses YYYY-MM-DD dates as datetime.date objects. We need to
# convert them to strings because that is how RenderCV uses them.
date = date.isoformat()
elif date != "present":
# Validate the date:
util.get_date_object(date)
return date
def validate_and_adjust_dates_of_an_entry(entry: models.EntryBase):
"""Check if the dates are provided correctly and make the necessary adjustments.
Args:
entry (EntryBase): The entry to validate its dates.
Returns:
EntryBase: The validated entry.
"""
date_is_provided = entry.date is not None
start_date_is_provided = entry.start_date is not None
end_date_is_provided = entry.end_date is not None
if date_is_provided:
# If only date is provided, ignore start_date and end_date:
entry.start_date = None
entry.end_date = None
elif not start_date_is_provided and end_date_is_provided:
# If only end_date is provided, assume it is a one-day event and act like
# only the date is provided:
entry.date = entry.end_date
entry.start_date = None
entry.end_date = None
elif start_date_is_provided:
start_date = util.get_date_object(entry.start_date)
if not end_date_is_provided:
# If only start_date is provided, assume it is an ongoing event, i.e.,
# the end_date is present:
entry.end_date = "present"
if entry.end_date != "present":
end_date = util.get_date_object(entry.end_date)
if start_date > end_date:
raise ValueError(
'"start_date" can not be after "end_date"!',
"start_date", # This is the location of the error
str(start_date), # This is value of the error
)
return entry
def validate_a_social_network_username(username: str, network: str) -> str:
"""Check if the `username` field in the `SocialNetwork` model is provided correctly.
Args:
username (str): The username to validate.
Returns:
str: The validated username.
"""
if network == "Mastodon":
mastodon_username_pattern = r"@[^@]+@[^@]+"
if not re.fullmatch(mastodon_username_pattern, username):
raise ValueError(
'Mastodon username should be in the format "@username@domain"!'
)
if network == "StackOverflow":
stackoverflow_username_pattern = r"\d+\/[^\/]+"
if not re.fullmatch(stackoverflow_username_pattern, username):
raise ValueError(
'StackOverflow username should be in the format "user_id/username"!'
)
if network == "YouTube":
if username.startswith("@"):
raise ValueError(
'YouTube username should not start with "@"! Remove "@" from the'
" beginning of the username."
)
return username
def validate_a_section(sections_input: list[Any]) -> list[Any]:
"""Validate a list of entries (a section).
Sections input is a list of entries. Since there are multiple entry types, it is not
possible to validate it directly. Firstly, the entry type is determined with the
`get_entry_and_section_type` function. If the entry type cannot be determined, an
error is raised. If the entry type is determined, the rest of the list is validated
based on the determined entry type.
Args:
sections_input (list[Any]): The sections input to validate.
Returns:
list[Any]: The validated sections input.
"""
if isinstance(sections_input, list):
# Find the entry type based on the first identifiable entry:
entry_type = None
section_type = None
for entry in sections_input:
try:
entry_type, section_type = (
validate_an_entry_type_and_get_entry_type_name(entry)
)
break
except ValueError:
pass
if entry_type is None or section_type is None:
raise ValueError(
"RenderCV couldn't match this section with any entry types! Please"
" check the entries and make sure they are provided correctly.",
"", # This is the location of the error
"", # This is value of the error
)
section = {
"title": "Test Section",
"entry_type": entry_type,
"entries": sections_input,
}
try:
section_type.model_validate(
section,
)
except pydantic.ValidationError as e:
new_error = ValueError(
"There are problems with the entries. RenderCV detected the entry type"
f" of this section to be {entry_type}! The problems are shown below.",
"", # This is the location of the error
"", # This is value of the error
)
raise new_error from e
return sections_input
def validate_an_entry_type_and_get_entry_type_name(
entry: dict[str, Any] | str,
) -> str:
"""Determine the entry type based on an entry.
Args:
entry: The entry to determine the type.
Returns:
str: The name of the entry type.
"""
# Get the class attributes of EntryBase class:
common_attributes = set(models.EntryBase.model_fields.keys())
if isinstance(entry, dict):
entry_type_name = None # the entry type is not determined yet
for EntryType in available_entry_types:
characteristic_entry_attributes = (
set(EntryType.model_fields.keys()) - common_attributes
)
# If at least one of the characteristic_entry_attributes is in the entry,
# then it means the entry is of this type:
if characteristic_entry_attributes & set(entry.keys()):
entry_type_name = EntryType.__name__
break
if entry_type_name is None:
raise ValueError("The entry is not provided correctly.")
elif isinstance(entry, str):
# Then it is a TextEntry
entry_type_name = "TextEntry"
return entry_type_name
def validate_a_custom_theme(
design: Any,
) -> Any:
"""Validate a custom theme.
Args:
design (Any | RenderCVBuiltinDesign): The design to validate.
Returns:
RenderCVBuiltinDesign | Any: The validated design.
"""
if (
isinstance(design, available_theme_options)
or design["theme"] in available_themes
):
# Then it means it is a built-in theme. Return it as it is:
return design
theme_name: str = str(design["theme"])
# Check if the theme name is valid:
if not theme_name.isalpha():
raise ValueError(
"The custom theme name should contain only letters.",
"theme", # this is the location of the error
theme_name, # this is value of the error
)
custom_theme_folder = pathlib.Path(theme_name)
# Check if the custom theme folder exists:
if not custom_theme_folder.exists():
raise ValueError(
f"The custom theme folder `{custom_theme_folder}` does not exist."
" It should be in the working directory as the input file.",
"", # this is the location of the error
theme_name, # this is value of the error
)
# check if all the necessary files are provided in the custom theme folder:
required_entry_files = [
entry_type_name + ".j2.tex" for entry_type_name in available_entry_type_names
]
required_files = [
"SectionBeginning.j2.tex", # section beginning template
"SectionEnding.j2.tex", # section ending template
"Preamble.j2.tex", # preamble template
"Header.j2.tex", # header template
] + required_entry_files
for file in required_files:
file_path = custom_theme_folder / file
if not file_path.exists():
raise ValueError(
f"You provided a custom theme, but the file `{file}` is not"
f" found in the folder `{custom_theme_folder}`.",
"", # This is the location of the error
theme_name, # This is value of the error
)
# Import __init__.py file from the custom theme folder if it exists:
path_to_init_file = pathlib.Path(f"{theme_name}/__init__.py")
if path_to_init_file.exists():
spec = importlib.util.spec_from_file_location(
"theme",
path_to_init_file,
)
theme_module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(theme_module) # type: ignore
except SyntaxError:
raise ValueError(
f"The custom theme {theme_name}'s __init__.py file has a syntax"
" error. Please fix it.",
)
except ImportError:
raise ValueError(
f"The custom theme {theme_name}'s __init__.py file has an"
" import error. If you have copy-pasted RenderCV's built-in"
" themes, make sure tto update the import statements (e.g.,"
' "from . import" to "from rendercv.themes import").',
)
ThemeDataModel = getattr(
theme_module, f"{theme_name.capitalize()}ThemeOptions" # type: ignore
)
# Initialize and validate the custom theme data model:
theme_data_model = ThemeDataModel(**design)
else:
# Then it means there is no __init__.py file in the custom theme folder.
# Create a dummy data model and use that instead.
class ThemeOptionsAreNotProvided(models.RenderCVBaseModel):
theme: str = theme_name
theme_data_model = ThemeOptionsAreNotProvided(theme=theme_name)
return theme_data_model
+31
View File
@@ -0,0 +1,31 @@
"""
The `rendercv.renderer` package contains the necessary classes and functions for
for generating $\\LaTeX$, PDF, Markdown, HTML, and PNG files from the
`RenderCVDataModel` object.
The $\\LaTeX$ and Markdown files are generated with
[Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates. Then, the $\\LaTeX$
file is rendered into a PDF with [TinyTeX](https://yihui.org/tinytex/), a $\\LaTeX$
distribution. The Markdown file is rendered into an HTML file with `markdown` package.
The PDF files are rendered into PNG files with `PyMuPDF`/`fitz` package.
"""
from .renderer import (
copy_theme_files_to_output_directory,
render_a_latex_file,
render_a_markdown_file,
render_a_latex_file_and_copy_theme_files,
render_pdf_from_latex,
render_pngs_from_pdf,
render_html_from_markdown,
)
__all__ = [
"copy_theme_files_to_output_directory",
"render_a_latex_file",
"render_a_markdown_file",
"render_a_latex_file_and_copy_theme_files",
"render_pdf_from_latex",
"render_pngs_from_pdf",
"render_html_from_markdown",
]
File diff suppressed because it is too large. Load diff
+823
View File
@@ -0,0 +1,823 @@
"""
The `rendercv.renderer.templating` module contains all the necessary classes and
functions for templating the $\\LaTeX$ and Markdown files for the `RenderCVDataModel`
object.
"""
import copy
import pathlib
import re
from datetime import date as Date
from typing import Any, Optional
import jinja2
from . import data_models as dm
class TemplatedFile:
"""This class is a base class for `LaTeXFile` and `MarkdownFile` classes. It
contains the common methods and attributes for both classes. These classes are used
to generate the $\\LaTeX$ and Markdown files with the data model and Jinja2
templates.
Args:
data_model (dm.RenderCVDataModel): The data model.
environment (jinja2.Environment): The Jinja2 environment.
"""
def __init__(
self,
data_model: dm.RenderCVDataModel,
environment: jinja2.Environment,
):
self.cv = data_model.cv
self.design = data_model.design
self.environment = environment
def template(
self,
theme_name: str,
template_name: str,
extension: str,
entry: Optional[dm.Entry] = None,
**kwargs,
) -> str:
"""Template one of the files in the `themes` directory.
Args:
template_name (str): The name of the template file.
entry (Optional[dm.Entry]): The title of the section.
Returns:
str: The templated file.
"""
template = self.environment.get_template(
f"{theme_name}/{template_name}.j2.{extension}"
)
# Loop through the entry attributes and make them "" if they are None:
# This is necessary because otherwise they will be templated as "None" since
# it's the string representation of None.
# Only don't touch the date fields, because only date_string is called and
# setting dates to "" will cause problems.
fields_to_ignore = ["start_date", "end_date", "date"]
if entry is not None and not isinstance(entry, str):
entry_dictionary = entry.model_dump()
for key, value in entry_dictionary.items():
if value is None and key not in fields_to_ignore:
entry.__setattr__(key, "")
# The arguments of the template can be used in the template file:
result = template.render(
cv=self.cv,
design=self.design,
entry=entry,
today=dm.format_date(Date.today(), use_full_name=True),
**kwargs,
)
return result
def get_full_code(self, main_template_name: str, **kwargs) -> str:
"""Combine all the templates to get the full code of the file."""
main_template = self.environment.get_template(main_template_name)
latex_code = main_template.render(
**kwargs,
)
return latex_code
class LaTeXFile(TemplatedFile):
"""This class represents a $\\LaTeX$ file. It generates the $\\LaTeX$ code with the
data model and Jinja2 templates. It inherits from the `TemplatedFile` class.
"""
def __init__(
self,
data_model: dm.RenderCVDataModel,
environment: jinja2.Environment,
):
latex_file_data_model = copy.deepcopy(data_model)
if latex_file_data_model.cv.sections_input is not None:
transformed_sections = transform_markdown_sections_to_latex_sections(
latex_file_data_model.cv.sections_input
)
latex_file_data_model.cv.sections_input = transformed_sections
super().__init__(latex_file_data_model, environment)
def render_templates(self) -> tuple[str, str, list[tuple[str, list[str], str]]]:
"""Render and return all the templates for the $\\LaTeX$ file.
Returns:
Tuple[str, str, List[Tuple[str, List[str], str]]]: The preamble, header, and
sections of the $\\LaTeX$ file.
"""
# Template the preamble, header, and sections:
preamble = self.template("Preamble")
header = self.template("Header")
sections: list[tuple[str, list[str], str]] = []
for section in self.cv.sections:
section_beginning = self.template(
"SectionBeginning",
section_title=section.title,
entry_type=section.entry_type,
)
entries: list[str] = []
for i, entry in enumerate(section.entries):
is_first_entry = i == 0
entries.append(
self.template(
section.entry_type,
entry=entry,
section_title=section.title,
entry_type=section.entry_type,
is_first_entry=is_first_entry,
)
)
section_ending = self.template(
"SectionEnding",
section_title=section.title,
entry_type=section.entry_type,
)
sections.append((section_beginning, entries, section_ending))
return preamble, header, sections
def template(
self,
template_name: str,
entry: Optional[dm.Entry] = None,
**kwargs,
) -> str:
"""Template one of the files in the `themes` directory.
Args:
template_name (str): The name of the template file.
entry (Optional[dm.Entry]): The data model of the entry.
Returns:
str: The templated file.
"""
result = super().template(
self.design.theme,
template_name,
"tex",
entry,
**kwargs,
)
result = revert_nested_latex_style_commands(result)
return result
def get_latex_code(self) -> str:
"""Get the $\\LaTeX$ code of the file.
Returns:
str: The $\\LaTeX$ code.
"""
preamble, header, sections = self.render_templates()
latex_code: str = self.get_full_code(
"main.j2.tex",
preamble=preamble,
header=header,
sections=sections,
)
return latex_code
def render_a_latex_file(self, file_path: pathlib.Path):
"""Write the $\\LaTeX$ code to a file."""
file_path.write_text(self.get_latex_code(), encoding="utf-8")
class MarkdownFile(TemplatedFile):
"""This class represents a Markdown file. It generates the Markdown code with the
data model and Jinja2 templates. It inherits from the `TemplatedFile` class.
Markdown files are generated to produce an HTML which can be copy-pasted to
[Grammarly](https://app.grammarly.com/) for proofreading.
"""
def render_templates(self) -> tuple[str, list[tuple[str, list[str]]]]:
"""Render and return all the templates for the Markdown file.
Returns:
tuple[str, List[Tuple[str, List[str]]]]: The header and sections of the
Markdown file.
"""
# Template the header and sections:
header = self.template("Header")
sections: list[tuple[str, list[str]]] = []
for section in self.cv.sections:
section_beginning = self.template(
"SectionBeginning",
section_title=section.title,
entry_type=section.entry_type,
)
entries: list[str] = []
for i, entry in enumerate(section.entries):
if i == 0:
is_first_entry = True
else:
is_first_entry = False
entries.append(
self.template(
section.entry_type,
entry=entry,
section_title=section.title,
entry_type=section.entry_type,
is_first_entry=is_first_entry,
)
)
sections.append((section_beginning, entries))
result: tuple[str, list[tuple[str, list[str]]]] = (header, sections)
return result
def template(
self,
template_name: str,
entry: Optional[dm.Entry] = None,
**kwargs,
) -> str:
"""Template one of the files in the `themes` directory.
Args:
template_name (str): The name of the template file.
entry (Optional[dm.Entry]): The data model of the entry.
Returns:
str: The templated file.
"""
result = super().template(
"markdown",
template_name,
"md",
entry,
**kwargs,
)
return result
def get_markdown_code(self) -> str:
"""Get the Markdown code of the file.
Returns:
str: The Markdown code.
"""
header, sections = self.render_templates()
markdown_code: str = self.get_full_code(
"main.j2.md",
header=header,
sections=sections,
)
return markdown_code
def render_a_markdown_file(self, file_path: pathlib.Path):
"""Write the Markdown code to a file."""
file_path.write_text(self.get_markdown_code(), encoding="utf-8")
def revert_nested_latex_style_commands(latex_string: str) -> str:
"""Revert the nested $\\LaTeX$ style commands to allow users to unbold or
unitalicize a bold or italicized text.
Args:
latex_string (str): The string to revert the nested $\\LaTeX$ style
commands.
Returns:
str: The string with the reverted nested $\\LaTeX$ style commands.
"""
# If there is nested \textbf, \textit, or \underline commands, replace the inner
# ones with \textnormal:
nested_commands_to_look_for = [
"textbf",
"textit",
"underline",
]
for command in nested_commands_to_look_for:
nested_commands = True
while nested_commands:
# replace all the inner commands with \textnormal until there are no
# nested commands left:
# find the first nested command:
nested_commands = re.findall(
rf"\\{command}{{[^}}]*?(\\{command}{{.*?}})", latex_string
)
# replace the nested command with \textnormal:
for nested_command in nested_commands:
new_command = nested_command.replace(command, "textnormal")
latex_string = latex_string.replace(nested_command, new_command)
return latex_string
def escape_latex_characters(latex_string: str, strict: bool = True) -> str:
"""Escape $\\LaTeX$ characters in a string.
This function is called during the reading of the input file. Before the validation
process, each input field's special $\\LaTeX$ characters are escaped. It is also can
be used as a Jinja2 filter in templates.
Example:
```python
escape_latex_characters("This is a # string.")
```
returns
`#!python "This is a \\# string."`
Args:
latex_string (str): The string to escape.
strict (bool): Whether to escape all the special $\\LaTeX$ characters or not. If
you want to allow math input, set it to False.
Returns:
str: The escaped string.
"""
# Dictionary of escape characters:
escape_characters = {
"#": "\\#",
"%": "\\%",
"&": "\\&",
"~": "\\textasciitilde{}",
}
strict_escape_characters = {
"$": "\\$",
"_": "\\_",
"^": "\\textasciicircum{}",
}
if strict:
# To allow math input, users can use this function with strict = False
escape_characters.update(strict_escape_characters)
translation_map = str.maketrans(escape_characters)
strict_translation_map = str.maketrans(strict_escape_characters)
# Don't escape urls as hyperref package will do it automatically:
# Also always escape link placeholders strictly (as we don't expect any math in
# them):
# Find all the links in the sentence:
links = re.findall(r"\[(.*?)\]\((.*?)\)", latex_string)
# Replace the links with a dummy string and save links with escaped characters:
new_links = []
for i, link in enumerate(links):
placeholder = link[0]
escaped_placeholder = placeholder.translate(strict_translation_map)
escaped_placeholder = escaped_placeholder.translate(translation_map)
url = link[1]
original_link = f"[{placeholder}]({url})"
latex_string = latex_string.replace(original_link, f"!!-link{i}-!!")
new_link = f"[{escaped_placeholder}]({url})"
new_links.append(new_link)
# Loop through the letters of the sentence and if you find an escape character,
# replace it with its LaTeX equivalent:
latex_string = latex_string.translate(translation_map)
# Replace !!-link{i}-!!" with the original urls:
for i, new_link in enumerate(new_links):
latex_string = latex_string.replace(f"!!-link{i}-!!", new_link)
return latex_string
def markdown_to_latex(markdown_string: str) -> str:
"""Convert a Markdown string to $\\LaTeX$.
This function is called during the reading of the input file. Before the validation
process, each input field is converted from Markdown to $\\LaTeX$.
Example:
```python
markdown_to_latex("This is a **bold** text with an [*italic link*](https://google.com).")
```
returns
`#!python "This is a \\textbf{bold} text with a \\href{https://google.com}{\\textit{link}}."`
Args:
markdown_string (str): The Markdown string to convert.
Returns:
str: The $\\LaTeX$ string.
"""
# convert links
links = re.findall(r"\[([^\]\[]*)\]\((.*?)\)", markdown_string)
if links is not None:
for link in links:
link_text = link[0]
link_url = link[1]
old_link_string = f"[{link_text}]({link_url})"
new_link_string = "\\href{" + link_url + "}{" + link_text + "}"
markdown_string = markdown_string.replace(old_link_string, new_link_string)
# convert bold
bolds = re.findall(r"\*\*(.+?)\*\*", markdown_string)
if bolds is not None:
for bold_text in bolds:
old_bold_text = f"**{bold_text}**"
new_bold_text = "\\textbf{" + bold_text + "}"
markdown_string = markdown_string.replace(old_bold_text, new_bold_text)
# convert italic
italics = re.findall(r"\*(.+?)\*", markdown_string)
if italics is not None:
for italic_text in italics:
old_italic_text = f"*{italic_text}*"
new_italic_text = "\\textit{" + italic_text + "}"
markdown_string = markdown_string.replace(old_italic_text, new_italic_text)
# convert code
# not supported by rendercv currently
# codes = re.findall(r"`([^`]*)`", markdown_string)
# if codes is not None:
# for code_text in codes:
# old_code_text = f"`{code_text}`"
# new_code_text = "\\texttt{" + code_text + "}"
# markdown_string = markdown_string.replace(old_code_text, new_code_text)
latex_string = markdown_string
return latex_string
def transform_markdown_sections_to_latex_sections(
sections: dict[str, dm.SectionInput],
) -> Optional[dict[str, dm.SectionInput]]:
"""
Recursively loop through sections and convert all the Markdown strings (user input
is in Markdown format) to $\\LaTeX$ strings. Also, escape special $\\LaTeX$
characters.
Args:
sections (Optional[dict[str, dm.SectionInput]]): Sections with Markdown strings.
Returns:
Optional[dict[str, dm.SectionInput]]: Sections with $\\LaTeX$ strings.
"""
for key, value in sections.items():
# loop through the list and apply markdown_to_latex and escape_latex_characters
# to each item:
transformed_list = []
for entry in value:
if isinstance(entry, str):
# Then it means it's a TextEntry.
result = markdown_to_latex(escape_latex_characters(entry, strict=False))
transformed_list.append(result)
else:
# Then it means it's one of the other entries.
entry_as_dict = entry.model_dump()
for entry_key, value in entry_as_dict.items():
if isinstance(value, str):
result = markdown_to_latex(
escape_latex_characters(value, strict=False)
)
setattr(entry, entry_key, result)
elif isinstance(value, list):
for j, item in enumerate(value):
if isinstance(item, str):
value[j] = markdown_to_latex(
escape_latex_characters(item, strict=False)
)
setattr(entry, entry_key, value)
transformed_list.append(entry)
sections[key] = transformed_list
return sections
def replace_placeholders_with_actual_values(
text: str, placeholders: dict[str, Optional[str]]
) -> str:
"""Replace the placeholders in a string with actual values.
This function can be used as a Jinja2 filter in templates.
Args:
text (str): The text with placeholders.
placeholders (dict[str, str]): The placeholders and their values.
Returns:
str: The string with actual values.
"""
for placeholder, value in placeholders.items():
text = text.replace(placeholder, str(value))
return text
def make_matched_part_something(
value: str, something: str, match_str: Optional[str] = None
) -> str:
"""Make the matched parts of the string something. If the match_str is None, the
whole string will be made something.
Warning:
This function shouldn't be used directly. Use
[make_matched_part_bold][rendercv.renderer.make_matched_part_bold],
[make_matched_part_underlined][rendercv.renderer.make_matched_part_underlined],
[make_matched_part_italic][rendercv.renderer.make_matched_part_italic],
or
[make_matched_part_non_line_breakable][rendercv.renderer.make_matched_part_non_line_breakable]
instead.
Args:
value (str): The string to make something.
something (str): The $\\LaTeX$ command to use.
match_str (str): The string to match.
Returns:
str: The string with the matched part something.
"""
if match_str is None:
# If the match_str is None, the whole string will be made something:
value = f"\\{something}{{{value}}}"
elif match_str in value and match_str != "":
# If the match_str is in the value, then make the matched part something:
value = value.replace(match_str, f"\\{something}{{{match_str}}}")
return value
def make_matched_part_bold(value: str, match_str: Optional[str] = None) -> str:
"""Make the matched parts of the string bold. If the match_str is None, the whole
string will be made bold.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_bold("Hello World!", "Hello")
```
returns
`#!python "\\textbf{Hello} World!"`
Args:
value (str): The string to make bold.
match_str (str): The string to match.
Returns:
str: The string with the matched part bold.
"""
return make_matched_part_something(value, "textbf", match_str)
def make_matched_part_underlined(value: str, match_str: Optional[str] = None) -> str:
"""Make the matched parts of the string underlined. If the match_str is None, the
whole string will be made underlined.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_underlined("Hello World!", "Hello")
```
returns
`#!python "\\underline{Hello} World!"`
Args:
value (str): The string to make underlined.
match_str (str): The string to match.
Returns:
str: The string with the matched part underlined.
"""
return make_matched_part_something(value, "underline", match_str)
def make_matched_part_italic(value: str, match_str: Optional[str] = None) -> str:
"""Make the matched parts of the string italic. If the match_str is None, the whole
string will be made italic.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_italic("Hello World!", "Hello")
```
returns
`#!python "\\textit{Hello} World!"`
Args:
value (str): The string to make italic.
match_str (str): The string to match.
Returns:
str: The string with the matched part italic.
"""
return make_matched_part_something(value, "textit", match_str)
def make_matched_part_non_line_breakable(
value: str, match_str: Optional[str] = None
) -> str:
"""Make the matched parts of the string non line breakable. If the match_str is
None, the whole string will be made nonbreakable.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_nolinebreak("Hello World!", "Hello")
```
returns
`#!python "\\mbox{Hello} World!"`
Args:
value (str): The string to disable line breaks.
match_str (str): The string to match.
Returns:
str: The string with the matched part non line breakable.
"""
return make_matched_part_something(value, "mbox", match_str)
def abbreviate_name(name: Optional[str]) -> str:
"""Abbreviate a name by keeping the first letters of the first names.
This function can be used as a Jinja2 filter in templates.
Example:
```python
abbreviate_name("John Doe")
```
returns
`#!python "J. Doe"`
Args:
name (str): The name to abbreviate.
Returns:
str: The abbreviated name.
"""
if name is None:
return ""
number_of_words = len(name.split(" "))
if number_of_words == 1:
return name
first_names = name.split(" ")[:-1]
first_names_initials = [first_name[0] + "." for first_name in first_names]
last_name = name.split(" ")[-1]
abbreviated_name = " ".join(first_names_initials) + " " + last_name
return abbreviated_name
def divide_length_by(length: str, divider: float) -> str:
r"""Divide a length by a number. Length is a string with the following regex
pattern: `\d+\.?\d* *(cm|in|pt|mm|ex|em)`
This function can be used as a Jinja2 filter in templates.
Example:
```python
divide_length_by("10.4cm", 2)
```
returns
`#!python "5.2cm"`
Args:
length (str): The length to divide.
divider (float): The number to divide the length by.
Returns:
str: The divided length.
"""
# Get the value as a float and the unit as a string:
value = re.search(r"\d+\.?\d*", length)
if value is None:
raise ValueError(f"Invalid length {length}!")
else:
value = value.group()
if divider <= 0:
raise ValueError(f"The divider must be greater than 0, but got {divider}!")
unit = re.findall(r"[^\d\.\s]+", length)[0]
return str(float(value) / divider) + " " + unit
def get_an_item_with_a_specific_attribute_value(
items: Optional[list[Any]], attribute: str, value: Any
) -> Any:
"""Get an item from a list of items with a specific attribute value.
Example:
```python
get_an_item_with_a_specific_attribute_value(
[item1, item2], # where item1.name = "John" and item2.name = "Jane"
"name",
"Jane"
)
```
returns
`item2`
This function can be used as a Jinja2 filter in templates.
Args:
items (list[Any]): The list of items.
attribute (str): The attribute to check.
value (Any): The value of the attribute.
Returns:
Any: The item with the specific attribute value.
"""
if items is not None:
for item in items:
if not hasattr(item, attribute):
raise AttributeError(
f"The attribute {attribute} doesn't exist in the item {item}!"
)
else:
if getattr(item, attribute) == value:
return item
else:
return None
# Only one Jinja2 environment is needed for all the templates:
jinja2_environment: Optional[jinja2.Environment] = None
def setup_jinja2_environment() -> jinja2.Environment:
"""Setup and return the Jinja2 environment for templating the $\\LaTeX$ files.
Returns:
jinja2.Environment: The theme environment.
"""
global jinja2_environment
themes_directory = pathlib.Path(__file__).parent / "themes"
if jinja2_environment is None:
# create a Jinja2 environment:
# we need to add the current working directory because custom themes might be used.
environment = jinja2.Environment(
loader=jinja2.FileSystemLoader([pathlib.Path.cwd(), themes_directory]),
trim_blocks=True,
lstrip_blocks=True,
)
# set custom delimiters for LaTeX templating:
environment.block_start_string = "((*"
environment.block_end_string = "*))"
environment.variable_start_string = "<<"
environment.variable_end_string = ">>"
environment.comment_start_string = "((#"
environment.comment_end_string = "#))"
# add custom filters to make it easier to template the LaTeX files and add new
# themes:
environment.filters["make_it_bold"] = make_matched_part_bold
environment.filters["make_it_underlined"] = make_matched_part_underlined
environment.filters["make_it_italic"] = make_matched_part_italic
environment.filters["make_it_nolinebreak"] = (
make_matched_part_non_line_breakable
)
environment.filters["make_it_something"] = make_matched_part_something
environment.filters["divide_length_by"] = divide_length_by
environment.filters["abbreviate_name"] = abbreviate_name
environment.filters["replace_placeholders_with_actual_values"] = (
replace_placeholders_with_actual_values
)
environment.filters["get_an_item_with_a_specific_attribute_value"] = (
get_an_item_with_a_specific_attribute_value
)
environment.filters["escape_latex_characters"] = escape_latex_characters
jinja2_environment = environment
else:
# update the loader in case the current working directory has changed:
jinja2_environment.loader = jinja2.FileSystemLoader(
[pathlib.Path.cwd(), themes_directory]
)
return jinja2_environment
+3 -2
View File
@@ -16,6 +16,7 @@ import pypdf
import pytest
import rendercv.renderer as r
import rendercv.renderer.templating as rt
from rendercv import data_models as dm
# RenderCV is being tested by comparing the output to reference files. Therefore,
@@ -153,7 +154,7 @@ def return_a_value_for_a_field_type(
```python
return_a_value_for_a_field_type("institution", str)
```
will return:
returns
`#!python "Boğaziçi University"`
Args:
@@ -317,7 +318,7 @@ def rendercv_filled_curriculum_vitae_data_model(
@pytest.fixture
def jinja2_environment() -> jinja2.Environment:
"""Return a Jinja2 environment."""
return r.setup_jinja2_environment()
return rt.setup_jinja2_environment()
@pytest.fixture
+21 -18
View File
@@ -10,6 +10,9 @@ import ruamel.yaml
import typer.testing
import rendercv.cli as cli
import rendercv.cli.printer as printer
import rendercv.cli.utilities as util
import rendercv.cli.handlers as handlers
import rendercv.data_models as dm
from rendercv import __version__
@@ -28,47 +31,47 @@ def run_render_command(input_file_path, working_path, extra_arguments=[]):
def test_welcome():
cli.welcome()
printer.welcome()
def test_warning():
cli.warning("This is a warning message.")
printer.warning("This is a warning message.")
def test_error():
with pytest.raises(typer.Exit):
cli.error("This is an error message.")
printer.error("This is an error message.")
def test_error_without_text():
with pytest.raises(typer.Exit):
cli.error()
printer.error()
def test_error_without_text_with_exception():
with pytest.raises(typer.Exit):
cli.error(exception=ValueError("This is an error message."))
printer.error(exception=ValueError("This is an error message."))
def test_information():
cli.information("This is an information message.")
printer.information("This is an information message.")
def test_get_error_message_and_location_and_value_from_a_custom_error():
error_string = "('error message', 'location', 'value')"
result = cli.get_error_message_and_location_and_value_from_a_custom_error(
result = util.get_error_message_and_location_and_value_from_a_custom_error(
error_string
)
assert result == ("error message", "location", "value")
error_string = """("er'ror message", 'location', 'value')"""
result = cli.get_error_message_and_location_and_value_from_a_custom_error(
result = util.get_error_message_and_location_and_value_from_a_custom_error(
error_string
)
assert result == ("er'ror message", "location", "value")
error_string = "error message"
result = cli.get_error_message_and_location_and_value_from_a_custom_error(
result = util.get_error_message_and_location_and_value_from_a_custom_error(
error_string
)
assert result == (None, None, None)
@@ -167,12 +170,12 @@ def test_get_error_message_and_location_and_value_from_a_custom_error():
),
],
)
def test_handle_validation_error(data_model_class, invalid_model):
def test_print_validation_errors(data_model_class, invalid_model):
try:
data_model_class(**invalid_model)
except pydantic.ValidationError as e:
with pytest.raises(typer.Exit):
cli.handle_validation_error(e)
printer.print_validation_errors(e)
@pytest.mark.parametrize(
@@ -186,7 +189,7 @@ def test_handle_validation_error(data_model_class, invalid_model):
],
)
def test_handle_exceptions(exception):
@cli.handle_exceptions
@handlers.handle_exceptions
def function_that_raises_exception():
raise exception
@@ -195,7 +198,7 @@ def test_handle_exceptions(exception):
def test_live_progress_reporter_class():
with cli.LiveProgressReporter(number_of_steps=3) as progress:
with printer.LiveProgressReporter(number_of_steps=3) as progress:
progress.start_a_step("Test step 1")
progress.finish_the_current_step()
@@ -211,7 +214,7 @@ def test_live_progress_reporter_class():
["markdown"] + dm.available_themes,
)
def test_copy_templates(tmp_path, folder_name):
copied_path = cli.copy_templates(
copied_path = util.copy_templates(
folder_name=folder_name,
copy_to=tmp_path,
)
@@ -574,7 +577,7 @@ def test_main_file():
def test_get_latest_version_number_from_pypi():
version = cli.get_latest_version_number_from_pypi()
version = util.get_latest_version_number_from_pypi()
assert isinstance(version, str)
@@ -584,7 +587,7 @@ def test_if_welcome_prints_new_version_available(monkeypatch):
import io
with contextlib.redirect_stdout(io.StringIO()) as f:
cli.welcome()
printer.welcome()
output = f.getvalue()
assert "A new version of RenderCV is available!" in output
@@ -609,8 +612,8 @@ def test_rendercv_version_when_there_is_not_a_new_version(monkeypatch):
def test_warn_if_new_version_is_available(monkeypatch):
monkeypatch.setattr(cli, "get_latest_version_number_from_pypi", lambda: __version__)
assert not cli.warn_if_new_version_is_available()
assert not printer.warn_if_new_version_is_available()
monkeypatch.setattr(cli, "get_latest_version_number_from_pypi", lambda: "999")
assert cli.warn_if_new_version_is_available()
assert printer.warn_if_new_version_is_available()
+12
View File
@@ -558,6 +558,15 @@ def test_sections_with_invalid_entries():
dm.CurriculumVitae(**input)
def test_sections_without_list():
input = {"name": "John Doe", "sections": dict()}
input["sections"]["section_title"] = {
"this section": "does not have a list of entries but a single entry."
}
with pytest.raises(pydantic.ValidationError):
dm.CurriculumVitae(**input)
@pytest.mark.parametrize(
"invalid_custom_theme_name",
[
@@ -769,3 +778,6 @@ def test_default_input_file_doesnt_have_local_catalog():
)
def test_dictionary_key_to_proper_section_title(key, expected_section_title):
assert dm.dictionary_key_to_proper_section_title(key) == expected_section_title
# def test_if_available_themes_and_avaialble_theme_options_has_the_same_length():
+40 -38
View File
@@ -20,7 +20,7 @@ folder_name_dictionary = {
def test_latex_file_class(tmp_path, rendercv_data_model, jinja2_environment):
latex_file = r.LaTeXFile(rendercv_data_model, jinja2_environment)
latex_file.get_latex_code()
latex_file.generate_latex_file(tmp_path / "test.tex")
latex_file.render_a_latex_file(tmp_path / "test.tex")
@pytest.mark.parametrize(
@@ -83,7 +83,7 @@ def test_latex_file_revert_nested_latex_style_commands_method(string, expected_s
def test_markdown_file_class(tmp_path, rendercv_data_model, jinja2_environment):
latex_file = r.MarkdownFile(rendercv_data_model, jinja2_environment)
latex_file.get_markdown_code()
latex_file.generate_markdown_file(tmp_path / "test.tex")
latex_file.render_a_markdown_file(tmp_path / "test.tex")
@pytest.mark.parametrize(
@@ -360,7 +360,7 @@ def test_setup_jinja2_environment():
],
)
@time_machine.travel("2024-01-01")
def test_generate_latex_file(
def test_render_a_latex_file(
run_a_function_and_check_if_output_is_the_same_as_reference,
request: pytest.FixtureRequest,
theme_name,
@@ -377,22 +377,22 @@ def test_generate_latex_file(
f"{theme_name}_{folder_name_dictionary[curriculum_vitae_data_model]}.tex"
)
def generate_latex_file(output_directory_path, reference_file_or_directory_path):
r.generate_latex_file(data_model, output_directory_path)
def render_a_latex_file(output_directory_path, reference_file_or_directory_path):
r.render_a_latex_file(data_model, output_directory_path)
assert run_a_function_and_check_if_output_is_the_same_as_reference(
generate_latex_file,
render_a_latex_file,
reference_file_name,
output_file_name,
)
def test_if_generate_latex_file_can_create_a_new_directory(
def test_if_render_a_latex_file_can_create_a_new_directory(
tmp_path, rendercv_data_model
):
new_directory = tmp_path / "new_directory"
latex_file_path = r.generate_latex_file(rendercv_data_model, new_directory)
latex_file_path = r.render_a_latex_file(rendercv_data_model, new_directory)
assert latex_file_path.exists()
@@ -409,7 +409,7 @@ def test_if_generate_latex_file_can_create_a_new_directory(
],
)
@time_machine.travel("2024-01-01")
def test_generate_markdown_file(
def test_render_a_markdown_file(
run_a_function_and_check_if_output_is_the_same_as_reference,
request: pytest.FixtureRequest,
theme_name,
@@ -426,22 +426,22 @@ def test_generate_markdown_file(
f"{theme_name}_{folder_name_dictionary[curriculum_vitae_data_model]}.md"
)
def generate_markdown_file(output_directory_path, reference_file_or_directory_path):
r.generate_markdown_file(data_model, output_directory_path)
def render_a_markdown_file(output_directory_path, reference_file_or_directory_path):
r.render_a_markdown_file(data_model, output_directory_path)
assert run_a_function_and_check_if_output_is_the_same_as_reference(
generate_markdown_file,
render_a_markdown_file,
reference_file_name,
output_file_name,
)
def test_if_generate_markdown_file_can_create_a_new_directory(
def test_if_render_a_markdown_file_can_create_a_new_directory(
tmp_path, rendercv_data_model
):
new_directory = tmp_path / "new_directory"
latex_file_path = r.generate_markdown_file(rendercv_data_model, new_directory)
latex_file_path = r.render_a_markdown_file(rendercv_data_model, new_directory)
assert latex_file_path.exists()
@@ -481,7 +481,7 @@ def test_copy_theme_files_to_output_directory_custom_theme(
dummytheme_path.mkdir(parents=True, exist_ok=True)
# create a txt file called test.txt in the custom theme directory:
for entry_type_name in dm.entry_type_names:
for entry_type_name in dm.available_entry_types:
pathlib.Path(dummytheme_path / f"{entry_type_name}.j2.tex").touch()
pathlib.Path(dummytheme_path / "Header.j2.tex").touch()
@@ -545,7 +545,7 @@ def test_copy_theme_files_to_output_directory_nonexistent_theme():
],
)
@time_machine.travel("2024-01-01")
def test_generate_latex_file_and_copy_theme_files(
def test_render_a_latex_file_and_copy_theme_files(
run_a_function_and_check_if_output_is_the_same_as_reference,
request: pytest.FixtureRequest,
theme_name,
@@ -560,13 +560,13 @@ def test_generate_latex_file_and_copy_theme_files(
design={"theme": theme_name},
)
def generate_latex_file_and_copy_theme_files(
def render_a_latex_file_and_copy_theme_files(
output_directory_path, reference_file_or_directory_path
):
r.generate_latex_file_and_copy_theme_files(data_model, output_directory_path)
r.render_a_latex_file_and_copy_theme_files(data_model, output_directory_path)
assert run_a_function_and_check_if_output_is_the_same_as_reference(
generate_latex_file_and_copy_theme_files,
render_a_latex_file_and_copy_theme_files,
reference_directory_name,
)
@@ -583,7 +583,7 @@ def test_generate_latex_file_and_copy_theme_files(
],
)
@time_machine.travel("2024-01-01")
def test_latex_to_pdf(
def test_render_pdf_from_latex(
request: pytest.FixtureRequest,
run_a_function_and_check_if_output_is_the_same_as_reference,
theme_name,
@@ -601,7 +601,7 @@ def test_latex_to_pdf(
def generate_pdf_file(output_directory_path, reference_file_or_directory_path):
latex_sources_path = (
reference_file_or_directory_path.parent.parent
/ "test_generate_latex_file_and_copy_theme_files"
/ "test_render_a_latex_file_and_copy_theme_files"
/ reference_name
)
@@ -609,7 +609,7 @@ def test_latex_to_pdf(
shutil.copytree(latex_sources_path, output_directory_path, dirs_exist_ok=True)
# convert the latex code to a pdf
r.latex_to_pdf(output_directory_path / f"{name}_CV.tex")
r.render_pdf_from_latex(output_directory_path / f"{name}_CV.tex")
assert run_a_function_and_check_if_output_is_the_same_as_reference(
function=generate_pdf_file,
@@ -618,10 +618,10 @@ def test_latex_to_pdf(
)
def test_latex_to_pdf_nonexistent_latex_file():
def test_render_pdf_from_latex_nonexistent_latex_file():
with pytest.raises(FileNotFoundError):
file_path = pathlib.Path("file_doesnt_exist.tex")
r.latex_to_pdf(file_path)
r.render_pdf_from_latex(file_path)
@pytest.mark.parametrize(
@@ -636,7 +636,7 @@ def test_latex_to_pdf_nonexistent_latex_file():
],
)
@time_machine.travel("2024-01-01")
def test_markdown_to_html(
def test_render_html_from_markdown(
run_a_function_and_check_if_output_is_the_same_as_reference,
theme_name,
curriculum_vitae_data_model,
@@ -647,12 +647,14 @@ def test_markdown_to_html(
output_file_name = f"{reference_name}.html"
reference_file_name = f"{reference_name}.html"
def markdown_to_html(output_directory_path, reference_file_or_directory_path):
def render_html_from_markdown(
output_directory_path, reference_file_or_directory_path
):
markdown_file_name = f"{reference_name}.md"
markdown_source_path = (
reference_file_or_directory_path.parent.parent
/ "test_generate_markdown_file"
/ "test_render_a_markdown_file"
/ markdown_file_name
)
@@ -660,22 +662,22 @@ def test_markdown_to_html(
shutil.copy(markdown_source_path, output_directory_path)
# convert markdown to html
r.markdown_to_html(output_directory_path / markdown_file_name)
r.render_html_from_markdown(output_directory_path / markdown_file_name)
assert run_a_function_and_check_if_output_is_the_same_as_reference(
function=markdown_to_html,
function=render_html_from_markdown,
reference_file_or_directory_name=reference_file_name,
output_file_name=output_file_name,
)
def test_markdown_to_html_nonexistent_markdown_file():
def test_render_html_from_markdown_nonexistent_markdown_file():
with pytest.raises(FileNotFoundError):
file_path = pathlib.Path("file_doesnt_exist.md")
r.markdown_to_html(file_path)
r.render_html_from_markdown(file_path)
def test_pdf_to_pngs_single_page(
def test_render_a_markdown_file_single_page(
run_a_function_and_check_if_output_is_the_same_as_reference,
):
output_file_name = "classic_empty_1.png"
@@ -686,7 +688,7 @@ def test_pdf_to_pngs_single_page(
pdf_path = (
reference_file_or_directory_path.parent.parent
/ "test_latex_to_pdf"
/ "test_render_pdf_from_latex"
/ pdf_file_name
)
@@ -694,7 +696,7 @@ def test_pdf_to_pngs_single_page(
shutil.copy(pdf_path, output_directory_path)
# convert pdf to pngs
r.pdf_to_pngs(output_directory_path / pdf_file_name)
r.render_a_markdown_file(output_directory_path / pdf_file_name)
assert run_a_function_and_check_if_output_is_the_same_as_reference(
generate_pngs,
@@ -703,7 +705,7 @@ def test_pdf_to_pngs_single_page(
)
def test_pdf_to_pngs(
def test_render_a_markdown_file(
run_a_function_and_check_if_output_is_the_same_as_reference,
):
reference_directory_name = "pngs"
@@ -713,7 +715,7 @@ def test_pdf_to_pngs(
pdf_path = (
reference_file_or_directory_path.parent.parent
/ "test_latex_to_pdf"
/ "test_render_pdf_from_latex"
/ pdf_file_name
)
@@ -721,7 +723,7 @@ def test_pdf_to_pngs(
shutil.copy(pdf_path, output_directory_path)
# convert pdf to pngs
r.pdf_to_pngs(output_directory_path / pdf_file_name)
r.render_a_markdown_file(output_directory_path / pdf_file_name)
# remove the pdf file
(output_directory_path / pdf_file_name).unlink()
@@ -735,4 +737,4 @@ def test_pdf_to_pngs(
def test_pdf_to_png_nonexistent_pdf_file():
with pytest.raises(FileNotFoundError):
file_path = pathlib.Path("file_doesnt_exist.pdf")
r.pdf_to_pngs(file_path)
r.render_a_markdown_file(file_path)