mirror of
https://github.com/rendercv/rendercv.git
synced 2026-10-24 05:08:46 -04:00
refactor completely (#103)
This commit is contained in:
1 parent
c0f501df21
commit
1b0ede6ea6
23 files changed
+3409
-3064
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
+81
-897
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user