From 1b0ede6ea6e31b16a83fdaeea2fb5211623a796c Mon Sep 17 00:00:00 2001 From: Sina Atalay Date: Mon, 1 Jul 2024 00:05:02 +0300 Subject: [PATCH] refactor completely (#103) --- docs/update_entry_figures.py | 6 +- docs/update_examples.py | 2 +- pyproject.toml | 6 +- rendercv/cli/__init__.py | 16 +- rendercv/cli/commands.py | 16 +- rendercv/cli/handlers.py | 87 ++ rendercv/cli/printer.py | 161 +- rendercv/cli/utilities.py | 240 +-- rendercv/data_models/__init__.py | 66 + rendercv/data_models/computed_fields.py | 339 +++++ rendercv/data_models/data_models.py | 1854 ----------------------- rendercv/data_models/generators.py | 385 +++++ rendercv/data_models/models.py | 615 ++++++++ rendercv/data_models/types.py | 123 ++ rendercv/data_models/utilities.py | 225 +++ rendercv/data_models/validators.py | 366 +++++ rendercv/renderer/__init__.py | 31 + rendercv/renderer/renderer.py | 978 +----------- rendercv/renderer/templating.py | 823 ++++++++++ tests/conftest.py | 5 +- tests/test_cli.py | 39 +- tests/test_data_models.py | 12 + tests/test_renderer.py | 78 +- 23 files changed, 3409 insertions(+), 3064 deletions(-) create mode 100644 rendercv/cli/handlers.py create mode 100644 rendercv/data_models/computed_fields.py delete mode 100644 rendercv/data_models/data_models.py create mode 100644 rendercv/data_models/generators.py create mode 100644 rendercv/data_models/models.py create mode 100644 rendercv/data_models/types.py create mode 100644 rendercv/data_models/utilities.py create mode 100644 rendercv/data_models/validators.py create mode 100644 rendercv/renderer/templating.py diff --git a/docs/update_entry_figures.py b/docs/update_entry_figures.py index 5ceeda2e..3268bf76 100644 --- a/docs/update_entry_figures.py +++ b/docs/update_entry_figures.py @@ -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 diff --git a/docs/update_examples.py b/docs/update_examples.py index d0accdc0..6b269c3e 100644 --- a/docs/update_examples.py +++ b/docs/update_examples.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index fae4d4c0..8257a617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/rendercv/cli/__init__.py b/rendercv/cli/__init__.py index 9672f018..0f66c439 100644 --- a/rendercv/cli/__init__.py +++ b/rendercv/cli/__init__.py @@ -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", +] diff --git a/rendercv/cli/commands.py b/rendercv/cli/commands.py index 124e5bc6..fa84a6d5 100644 --- a/rendercv/cli/commands.py +++ b/rendercv/cli/commands.py @@ -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, diff --git a/rendercv/cli/handlers.py b/rendercv/cli/handlers.py new file mode 100644 index 00000000..2196990a --- /dev/null +++ b/rendercv/cli/handlers.py @@ -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 diff --git a/rendercv/cli/printer.py b/rendercv/cli/printer.py index 391085da..116abf18 100644 --- a/rendercv/cli/printer.py +++ b/rendercv/cli/printer.py @@ -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 diff --git a/rendercv/cli/utilities.py b/rendercv/cli/utilities.py index 92aea483..f95d6b9a 100644 --- a/rendercv/cli/utilities.py +++ b/rendercv/cli/utilities.py @@ -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." ) diff --git a/rendercv/data_models/__init__.py b/rendercv/data_models/__init__.py index e69de29b..2547879d 100644 --- a/rendercv/data_models/__init__.py +++ b/rendercv/data_models/__init__.py @@ -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", +] diff --git a/rendercv/data_models/computed_fields.py b/rendercv/data_models/computed_fields.py new file mode 100644 index 00000000..a9a8c832 --- /dev/null +++ b/rendercv/data_models/computed_fields.py @@ -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 diff --git a/rendercv/data_models/data_models.py b/rendercv/data_models/data_models.py deleted file mode 100644 index be03e5a6..00000000 --- a/rendercv/data_models/data_models.py +++ /dev/null @@ -1,1854 +0,0 @@ -""" -This module contains the necessary classes to store CV data. These classes are called -data models. The YAML input file is transformed into instances of these classes (i.e., -the input file is read) with the -[`read_input_file`][rendercv.data_models.read_input_file] function. RenderCV utilizes -these instances to generate a $\\LaTeX$ file which is then rendered into a PDF file. - -The data models are initialized with data validation to prevent unexpected bugs. During -the initialization, we ensure that everything is in the correct place and that the user -has provided a valid RenderCV input. This is achieved using -[Pydantic](https://pypi.org/project/pydantic/). Each class method decorated with -`pydantic.model_validator` or `pydantic.field_validator` is executed automatically -during the data classes' initialization. -""" - -import functools -import importlib -import importlib.machinery -import importlib.util -import io -import json -import pathlib -import re -import warnings -from datetime import date as Date -from typing import Annotated, Any, Literal, Optional, Type, get_args - -import annotated_types as at -import pydantic -import pydantic_extra_types.phone_numbers as pydantic_phone_numbers -import ruamel.yaml - -from .themes.classic import ClassicThemeOptions -from .themes.engineeringresumes import EngineeringresumesThemeOptions -from .themes.moderncv import ModerncvThemeOptions -from .themes.sb2nov import Sb2novThemeOptions - -# Disable Pydantic warnings: -warnings.filterwarnings("ignore") - - -# The dictionary below will be overwritten by LocaleCatalog class. -locale_catalog = {} - - -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, use_full_name: bool = False) -> str: - """Formats a `Date` object to a string in the following format: "Jan. 2021". - - Example: - ```python - format_date(Date(2024, 5, 1)) - ``` - will return - - `#!python "May 2024"` - - Args: - date (Date): The date to format. - 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 - - -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: ======================================================================== -# ====================================================================================== - -# Create a URL validator to validate social network URLs: -url_validator = pydantic.TypeAdapter(pydantic.HttpUrl) # type: ignore - - -# Create a custom type called RenderCVDate that accepts only strings in YYYY-MM-DD or -# YYYY-MM format: -# This type is used to validate the date fields in the data. -# See https://docs.pydantic.dev/2.5/concepts/types/#custom-types for more information -# about custom types. -date_pattern_for_validation = r"\d{4}-\d{2}(-\d{2})?" -RenderCVDate = Annotated[ - str, - pydantic.Field( - pattern=date_pattern_for_validation, - ), -] - - -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): - date: Optional[int | str] = 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"], - ) - - @pydantic.field_validator("date", mode="before") - @classmethod - def check_date( - cls, date: Optional[int | RenderCVDate | str] - ) -> Optional[int | RenderCVDate | str]: - """Check if `date` is provided correctly.""" - date_is_provided = date is not None - - if date_is_provided: - if isinstance(date, str): - date_pattern = r"\d{4}(-\d{2})?(-\d{2})?" - if re.fullmatch(date_pattern, date): - # Then it is in YYYY-MM-DD, YYYY-MM, or YYYY format - # Check if it is a valid date: - get_date_object(date) - - # Check if it is in YYYY format, and if so, convert it to an - # integer: - if re.fullmatch(r"\d{4}", date): - # 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 - - @functools.cached_property - def date_string(self) -> str: - if self.date: - if isinstance(self.date, int): - # Then it means only the year is provided - date_string = str(self.date) - else: - try: - date_object = get_date_object(self.date) - date_string = format_date(date_object) - except ValueError: - # Then it is a custom date string (e.g., "My Custom Date") - date_string = str(self.date) - else: - date_string = "" - - return date_string - - -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.""" - return f"https://doi.org/{self.doi}" - - @functools.cached_property - def clean_url(self) -> str: - """Return the clean URL of the publication.""" - url_is_provided = self.url is not None - - if url_is_provided: - return str(self.url).replace("https://", "").rstrip("/") - else: - return "" - - -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: Optional[int | RenderCVDate] = 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: Optional[Literal["present"] | int | RenderCVDate] = 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.field_validator("start_date", "end_date", mode="before") - @classmethod - def check_and_parse_dates( - cls, - date: Optional[Literal["present"] | int | RenderCVDate], - ) -> Optional[Literal["present"] | int | RenderCVDate]: - 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: - get_date_object(date) - - return date - - @pydantic.model_validator(mode="after") - def check_and_adjust_dates(self) -> "EntryBase": - """ - Check if the dates are provided correctly and make the necessary adjustments. - """ - date_is_provided = self.date is not None - start_date_is_provided = self.start_date is not None - end_date_is_provided = self.end_date is not None - - if date_is_provided: - # If only date is provided, ignore start_date and end_date: - self.start_date = None - self.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: - self.date = self.end_date - self.start_date = None - self.end_date = None - elif start_date_is_provided: - start_date = get_date_object(self.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: - self.end_date = "present" - - if self.end_date != "present": - end_date = get_date_object(self.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 self - - @functools.cached_property - def date_string(self) -> str: - """ - Return a date string based on the `date`, `start_date`, and `end_date` fields. - - Example: - ```python - entry = dm.EntryBase(start_date="2020-10-11", end_date="2021-04-04").date_string - ``` - will return: - `#!python "Nov. 2020 to Apr. 2021"` - """ - date_is_provided = self.date is not None - start_date_is_provided = self.start_date is not None - end_date_is_provided = self.end_date is not None - - if date_is_provided: - date_string = super().date_string - - elif start_date_is_provided and end_date_is_provided: - if isinstance(self.start_date, int): - # Then it means only the year is provided - start_date = str(self.start_date) - else: - # Then it means start_date is either in YYYY-MM-DD or YYYY-MM format - date_object = get_date_object(self.start_date) - start_date = format_date(date_object) - - if self.end_date == "present": - end_date = locale_catalog["present"] - elif isinstance(self.end_date, int): - # Then it means only the year is provided - end_date = str(self.end_date) - else: - # Then it means end_date is either in YYYY-MM-DD or YYYY-MM format - date_object = get_date_object(self.end_date) - end_date = format_date(date_object) - - date_string = f"{start_date} {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 - - @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. - - Example: - ```python - entry = dm.EntryBase(start_date="2020-10-11", end_date="2021-04-04").date_string - ``` - will return: - `#!python "2020 to 2021"` - """ - date_is_provided = self.date is not None - start_date_is_provided = self.start_date is not None - end_date_is_provided = self.end_date is not None - - if date_is_provided: - try: - date_object = get_date_object(self.date) - date_string = str(date_object.year) - except ValueError: - # Then it is a custom date string (e.g., "My Custom Date") - date_string = str(self.date) - - elif start_date_is_provided and end_date_is_provided: - if isinstance(self.start_date, int): - # Then it means only the year is provided - start_date = str(self.start_date) - else: - # Then it means start_date is either in YYYY-MM-DD or YYYY-MM format - date_object = get_date_object(self.start_date) - start_date = date_object.year - - if self.end_date == "present": - end_date = "present" - elif isinstance(self.end_date, int): - # Then it means only the year is provided - end_date = str(self.end_date) - else: - # Then it means end_date is either in YYYY-MM-DD or YYYY-MM format - date_object = get_date_object(self.end_date) - end_date = date_object.year - - date_string = f"{start_date} {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 - - @functools.cached_property - def time_span_string(self) -> str: - """ - Return a time span string based on the `date`, `start_date`, and `end_date` - fields. - - Example: - ```python - entry = dm.EntryBase(start_date="2020-01-01", end_date="2020-04-20").time_span - ``` - will return: - `#!python "4 months"` - """ - date_is_provided = self.date is not None - start_date_is_provided = self.start_date is not None - end_date_is_provided = self.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(self.start_date, int) or isinstance(self.end_date, int): - # Then it means one of the dates is year, so time span cannot be more - # specific than years. - start_year = get_date_object(self.start_date).year # type: ignore - end_year = get_date_object(self.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 = get_date_object(self.end_date) # type: ignore - start_date = get_date_object(self.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 {locale_catalog['year']}" - else: - how_many_years_string = f"{how_many_years} {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 {locale_catalog['month']}" - else: - how_many_months_string = f"{how_many_months} {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 - - -class NormalEntryBase(RenderCVBaseModel): - name: str = pydantic.Field( - title="Name", - description="The name of the NormalEntry.", - ) - - -# The following class is to ensure NormalEntryBase keys come first, -# then the keys of the EntryBase class. The only way to achieve this in Pydantic is -# to do this. -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.", - ) - - -# The following class is to make sure ExperienceEntryBase keys come first, -# then the keys of the EntryBase class. The only way to achieve this in Pydantic is -# to do this. -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"], - ) - - -# The following class is to make sure EducationEntryBase keys come first, -# then the keys of the EntryBase class. The only way to achieve this in Pydantic is -# to do this. -class EducationEntry(EntryBase, EducationEntryBase): - """This class is the data model of `EducationEntry`.""" - - pass - - -# Create custom types named Entry and ListOfEntries: -Entry = ( - OneLineEntry - | NormalEntry - | ExperienceEntry - | EducationEntry - | PublicationEntry - | BulletEntry - | str -) -ListOfEntries = list[ - OneLineEntry - | NormalEntry - | ExperienceEntry - | EducationEntry - | PublicationEntry - | BulletEntry - | str -] -entry_types = Entry.__args__[:-1] # a tuple of all the entry types except str -entry_type_names = [entry_type.__name__ for entry_type in entry_types] + ["TextEntry"] - -# ====================================================================================== -# Section models: ====================================================================== -# ====================================================================================== -# Each section data model has a field called `entry_type` and a field called `entries`. -# Since the same pydantic.Field object is used in all of the section models, it is -# defined as a separate variable and used in all of the section models: -entry_type_field_of_section_model = pydantic.Field( - title="Entry Type", - description="The type of the entries in the section.", -) -entries_field_of_section_model = pydantic.Field( - title="Entries", - description="The entries of the section. The format depends on the entry type.", -) - - -class SectionBase(RenderCVBaseModel): - """This class is the parent class of all the section types. It is being used - because all of the section types have a common field called `title`. - """ - - title: str - entry_type: str - entries: list[Entry] - - -def create_a_section_model(entry_type: Type[Entry]) -> Type[SectionBase]: - """Create a section model based on the entry type. See [Pydantic's documentation - about dynamic model - creation](https://pydantic-docs.helpmanual.io/usage/models/#dynamic-model-creation) - for more information. - - Args: - entry_type (Type[Entry]): The entry type to create the section model. - Returns: - Type[SectionBase]: The section model. - """ - if entry_type == str: - model_name = "SectionWithTextEntries" - entry_type_name = "TextEntry" - else: - model_name = "SectionWith" + entry_type.__name__.replace("Entry", "Entries") - entry_type_name = entry_type.__name__ - - SectionModel = pydantic.create_model( - model_name, - entry_type=(Literal[entry_type_name], ...), # type: ignore - entries=(list[entry_type], ...), - __base__=SectionBase, - ) - - return SectionModel - - -def get_entry_and_section_type( - entry: dict[str, Any] | Entry, -) -> tuple[ - str, - Type[SectionBase], -]: - """Determine the entry and section type based on the entry. - - Args: - entry: The entry to determine the type. - Returns: - tuple[str, Type[Section]]: The entry type and the section type. - """ - # Get the class attributes of EntryBase class: - common_attributes = set(EntryBase.model_fields.keys()) - - if isinstance(entry, dict): - entry_type = None # the entry type is not determined yet - - for EntryType in 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 = EntryType.__name__ - section_type = create_a_section_model(EntryType) - break - - if entry_type is None: - raise ValueError("The entry is not provided correctly.") - - elif isinstance(entry, str): - # Then it is a TextEntry - entry_type = "TextEntry" - section_type = create_a_section_model(str) - - else: - # Then the entry is already initialized with a data model: - entry_type = entry.__class__.__name__ - section_type = create_a_section_model(entry.__class__) - - return entry_type, section_type - - -def validate_section_input( - sections_input: SectionBase | list[Any], -) -> SectionBase | list[Any]: - """Validate a `SectionInput` object and raise an error if it is not valid. - - Sections input is very complex. It is either a `Section` object or a list of - entries. Since there are multiple entry types, it is not possible to validate it - directly. This function looks at the entry list's first element and determines the - section's entry type based on the first element. Then, it validates the rest of the - list based on the determined entry type. If it is a `Section` object, it then - validates it directly. - - Args: - sections_input (SectionBase | list[Any]): The sections input to validate. - Returns: - SectionBase | 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 = get_entry_and_section_type(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 - ) - - test_section = { - "title": "Test Section", - "entry_type": entry_type, - "entries": sections_input, - } - - try: - section_type.model_validate( - test_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 - - -# Create a custom type named SectionInput so that it can be validated with -# `validate_section_input` function. -SectionInput = Annotated[ - ListOfEntries, - pydantic.BeforeValidator(validate_section_input), -] - - -# ====================================================================================== -# Full RenderCV data models: =========================================================== -# ====================================================================================== - -SocialNetworkName = Literal[ - "LinkedIn", - "GitHub", - "GitLab", - "Instagram", - "ORCID", - "Mastodon", - "StackOverflow", - "ResearchGate", - "YouTube", - "Google Scholar", -] -available_social_networks = get_args(SocialNetworkName) - - -class SocialNetwork(RenderCVBaseModel): - """This class is the data model of a social network.""" - - network: 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"] - - 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 - - @pydantic.model_validator(mode="after") # type: ignore - def check_url(self) -> "SocialNetwork": - """Validate the URLs of the social networks.""" - 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. - url = self.url - url_validator.validate_strings(url) - - return self - - @functools.cached_property - def url(self) -> str: - """Return the URL of the social network.""" - if self.network == "Mastodon": - # Split domain and username - dummy, username, domain = self.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[self.network] + self.username - - return url - - -class CurriculumVitae(RenderCVBaseModel): - """This class is the data model of the CV.""" - - 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, SectionInput]] = pydantic.Field( - default=None, - title="Sections", - description="The sections of the CV.", - alias="sections", - ) - - @functools.cached_property - def connections(self) -> list[dict[str, str]]: - """Return all the connections of the person.""" - connections: list[dict[str, str]] = [] - - if self.location is not None: - connections.append( - { - "latex_icon": "\\faMapMarker*", - "url": None, - "clean_url": None, - "placeholder": self.location, - } - ) - - if self.email is not None: - connections.append( - { - "latex_icon": "\\faEnvelope[regular]", - "url": f"mailto:{self.email}", - "clean_url": self.email, - "placeholder": self.email, - } - ) - - if self.phone is not None: - phone_placeholder = self.phone.replace("tel:", "").replace("-", " ") - connections.append( - { - "latex_icon": "\\faPhone*", - "url": f"{self.phone}", - "clean_url": phone_placeholder, - "placeholder": phone_placeholder, - } - ) - - if self.website is not None: - website_placeholder = str(self.website).replace("https://", "").rstrip("/") - connections.append( - { - "latex_icon": "\\faLink", - "url": self.website, - "clean_url": website_placeholder, - "placeholder": website_placeholder, - } - ) - - if self.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 self.social_networks: - clean_url = social_network.url.replace("https://", "").rstrip("/") - 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 - - @functools.cached_property - def sections(self) -> list[SectionBase]: - """Return all the sections of the CV with their titles.""" - sections: list[SectionBase] = [] - if self.sections_input is not None: - for title, section_or_entries in self.sections_input.items(): - title = dictionary_key_to_proper_section_title(title) - - entry_type, section_type = get_entry_and_section_type( - section_or_entries[0] - ) - - section = section_type( - title=title, - entry_type=entry_type, # type: ignore - entries=section_or_entries, # type: ignore - ) - sections.append(section) - - 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 - - -LocaleCatalog() # Initialize the locale catalog with the default values - -# ====================================================================================== -# ====================================================================================== -# ====================================================================================== - -# Create a custom type named Design: -# 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.5/concepts/fields/#discriminator for more information -RenderCVDesign = Annotated[ - ClassicThemeOptions - | ModerncvThemeOptions - | Sb2novThemeOptions - | EngineeringresumesThemeOptions, - pydantic.Field(discriminator="theme"), -] -rendercv_design_validator = pydantic.TypeAdapter(RenderCVDesign) -available_themes = ["classic", "moderncv", "sb2nov", "engineeringresumes"] - - -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: pydantic.json_schema.SkipJsonSchema[Any] | 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." - ), - validate_default=True, # to initialize the locale catalog with the default values - ) - - @pydantic.field_validator("design", mode="before") - @classmethod - def initialize_if_custom_theme_is_used( - cls, design: RenderCVDesign | Any - ) -> RenderCVDesign | Any: - """Initialize the custom theme if it is used and validate it. Otherwise, return - the built-in theme.""" - # `get_args` for an Annotated object returns the arguments when Annotated is - # used. The first argument is actually the union of the types, so we need to - # access the first argument to use isinstance function. - theme_data_model_types = get_args(RenderCVDesign)[0] - - if isinstance(design, theme_data_model_types): - # Then it means RenderCVDataModel is already initialized with a design, so - # return it as is: - return design - elif design["theme"] in available_themes: # type: ignore - # Then it means it's a built-in theme, but it is not initialized (validated) - # yet. So, validate and return it: - return rendercv_design_validator.validate_python(design) - else: - # Then it means it is a custom theme, so initialize and validate it: - 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 - ) - - # Then it is a custom theme - 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 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(RenderCVBaseModel): - theme: str = theme_name - - theme_data_model = ThemeOptionsAreNotProvided(theme=theme_name) - - return theme_data_model - - @pydantic.field_validator("locale_catalog") - @classmethod - def initialize_locale_catalog(cls, locale_catalog: LocaleCatalog) -> LocaleCatalog: - """Even if the locale catalog is not provided, initialize it with the default - values.""" - if locale_catalog is None: - LocaleCatalog() - - return locale_catalog - - -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") - ``` - will return: - `#!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 read_input_file( - file_path_or_contents: pathlib.Path | str, -) -> RenderCVDataModel: - """Read the input file and return two instances of - [RenderCVDataModel][rendercv.data_models.RenderCVDataModel]. The first instance is - the data model with $\\LaTeX$ strings and the second instance is the data model with - markdown strings. - - 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 - - -def get_a_sample_data_model( - name: str = "John Doe", theme: str = "classic" -) -> 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 available_themes: - available_themes_string = ", ".join(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": [ - BulletEntry( - bullet=( - "Each section title is arbitrary, and each section contains a list" - " of entries." - ), - ), - BulletEntry( - bullet=( - "There are 7 unique entry types: *BulletEntry*, *TextEntry*," - " *EducationEntry*, *ExperienceEntry*, *NormalEntry*," - " *PublicationEntry*, and *OneLineEntry*." - ), - ), - BulletEntry( - bullet=( - "Select a section title, pick an entry type, and start writing your" - " section!" - ) - ), - BulletEntry( - bullet=( - "[Here](https://docs.rendercv.com/user_guide/), you can find a" - " comprehensive user guide for RenderCV." - ) - ), - ], - "education": [ - 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": [ - 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" - ), - ], - ), - 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" - ), - ], - ), - 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" - ), - ], - ), - 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": [ - 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": [ - 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", - ], - ), - 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", - ], - ), - 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": [ - OneLineEntry( - label="Instructor (2003-2005)", - details="Taught 2 full-credit computer science courses", - ), - OneLineEntry( - label="Third Prize, Senior Design Project", - details=( - "Awarded 3rd prize for a synchronized calendar project out of 100" - " entries" - ), - ), - ], - "technologies": [ - OneLineEntry( - label="Languages", - details="C++, C, Java, Objective-C, C#, SQL, JavaScript", - ), - OneLineEntry( - label="Software", - details=".NET, Microsoft SQL Server, XCode, Interface Builder", - ), - ], - } - cv = CurriculumVitae( - name=name, - location="Your Location", - email="youremail@yourdomain.com", - phone="+905419999999", # type: ignore - website="https://yourwebsite.com", # type: ignore - social_networks=[ - SocialNetwork(network="LinkedIn", username="yourusername"), - SocialNetwork(network="GitHub", username="yourusername"), - ], - sections=sections, # type: ignore - ) - - if theme == "classic": - design = ClassicThemeOptions(theme="classic", show_timespan_in=["Experience"]) - else: - design = rendercv_design_validator.validate_python({"theme": theme}) # type: ignore - - return RenderCVDataModel(cv=cv, design=design) - - -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 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 = 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 = 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") diff --git a/rendercv/data_models/generators.py b/rendercv/data_models/generators.py new file mode 100644 index 00000000..f377920f --- /dev/null +++ b/rendercv/data_models/generators.py @@ -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") diff --git a/rendercv/data_models/models.py b/rendercv/data_models/models.py new file mode 100644 index 00000000..832b79c2 --- /dev/null +++ b/rendercv/data_models/models.py @@ -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 diff --git a/rendercv/data_models/types.py b/rendercv/data_models/types.py new file mode 100644 index 00000000..15639e04 --- /dev/null +++ b/rendercv/data_models/types.py @@ -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"] diff --git a/rendercv/data_models/utilities.py b/rendercv/data_models/utilities.py new file mode 100644 index 00000000..b3193d0e --- /dev/null +++ b/rendercv/data_models/utilities.py @@ -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 diff --git a/rendercv/data_models/validators.py b/rendercv/data_models/validators.py new file mode 100644 index 00000000..73d337da --- /dev/null +++ b/rendercv/data_models/validators.py @@ -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 diff --git a/rendercv/renderer/__init__.py b/rendercv/renderer/__init__.py index e69de29b..5da18194 100644 --- a/rendercv/renderer/__init__.py +++ b/rendercv/renderer/__init__.py @@ -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", +] diff --git a/rendercv/renderer/renderer.py b/rendercv/renderer/renderer.py index 75b5b554..f17b5a13 100644 --- a/rendercv/renderer/renderer.py +++ b/rendercv/renderer/renderer.py @@ -1,895 +1,21 @@ """ -This module contains functions and classes for generating CVs as $\\LaTeX$ files, PDF -files, Markdown files, HTML files, and PNG files from the data model. - -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. +The `rendercv.renderer.renderer` module contains the necessary functions for rendering +$\\LaTeX$, PDF, Markdown, HTML, and PNG files from the `RenderCVDataModel` object. """ -import copy import importlib.resources import pathlib import re import shutil import subprocess import sys -from datetime import date as Date -from typing import Any, Optional +from typing import Optional import fitz -import jinja2 import markdown -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 generate_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 generate_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.") - ``` - will return: - `#!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).") - ``` - - will return: - - `#!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") - ``` - - will return: - - `#!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") - ``` - - will return: - - `#!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") - ``` - - will return: - - `#!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") - ``` - - will return: - - `#!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") - ``` - - will return: - - `#!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) - ``` - - will return: - - `#!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" - ) - ``` - will return: - `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 - - -def generate_latex_file( - rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path -) -> pathlib.Path: - """Generate the $\\LaTeX$ file with the given data model and write it to the output - directory. - - Args: - rendercv_data_model (dm.RenderCVDataModel): The data model. - output_directory (pathlib.Path): Path to the output directory. - Returns: - pathlib.Path: The path to the generated $\\LaTeX$ file. - """ - # create output directory if it doesn't exist: - if not output_directory.is_dir(): - output_directory.mkdir(parents=True) - - jinja2_environment = setup_jinja2_environment() - latex_file_object = LaTeXFile( - rendercv_data_model, - jinja2_environment, - ) - - latex_file_name = f"{str(rendercv_data_model.cv.name).replace(' ', '_')}_CV.tex" - latex_file_path = output_directory / latex_file_name - latex_file_object.generate_latex_file(latex_file_path) - - return latex_file_path - - -def generate_markdown_file( - rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path -) -> pathlib.Path: - """Generate the Markdown file with the given data model and write it to the output - directory. - - Args: - rendercv_data_model (dm.RenderCVDataModel): The data model. - output_directory (pathlib.Path): Path to the output directory. - Returns: - pathlib.Path: The path to the generated Markdown file. - """ - # create output directory if it doesn't exist: - if not output_directory.is_dir(): - output_directory.mkdir(parents=True) - - jinja2_environment = setup_jinja2_environment() - markdown_file_object = MarkdownFile( - rendercv_data_model, - jinja2_environment, - ) - - markdown_file_name = f"{str(rendercv_data_model.cv.name).replace(' ', '_')}_CV.md" - markdown_file_path = output_directory / markdown_file_name - markdown_file_object.generate_markdown_file(markdown_file_path) - - return markdown_file_path +from .. import data_models as dm +from . import templating as tp def copy_theme_files_to_output_directory( @@ -942,11 +68,11 @@ def copy_theme_files_to_output_directory( ) -def generate_latex_file_and_copy_theme_files( +def render_a_latex_file( rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path ) -> pathlib.Path: - """Generate the $\\LaTeX$ file with the given data model in the output directory and - copy the auxiliary theme files to the output directory. + """Render the $\\LaTeX$ file with the given data model and write it to the output + directory. Args: rendercv_data_model (dm.RenderCVDataModel): The data model. @@ -954,22 +80,80 @@ def generate_latex_file_and_copy_theme_files( Returns: pathlib.Path: The path to the generated $\\LaTeX$ file. """ - latex_file_path = generate_latex_file(rendercv_data_model, output_directory) + # create output directory if it doesn't exist: + if not output_directory.is_dir(): + output_directory.mkdir(parents=True) + + jinja2_environment = tp.setup_jinja2_environment() + latex_file_object = tp.LaTeXFile( + rendercv_data_model, + jinja2_environment, + ) + + latex_file_name = f"{str(rendercv_data_model.cv.name).replace(' ', '_')}_CV.tex" + latex_file_path = output_directory / latex_file_name + latex_file_object.render_a_latex_file(latex_file_path) + + return latex_file_path + + +def render_a_markdown_file( + rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path +) -> pathlib.Path: + """Render the Markdown file with the given data model and write it to the output + directory. + + Args: + rendercv_data_model (dm.RenderCVDataModel): The data model. + output_directory (pathlib.Path): Path to the output directory. + Returns: + pathlib.Path: The path to the rendered Markdown file. + """ + # create output directory if it doesn't exist: + if not output_directory.is_dir(): + output_directory.mkdir(parents=True) + + jinja2_environment = tp.setup_jinja2_environment() + markdown_file_object = tp.MarkdownFile( + rendercv_data_model, + jinja2_environment, + ) + + markdown_file_name = f"{str(rendercv_data_model.cv.name).replace(' ', '_')}_CV.md" + markdown_file_path = output_directory / markdown_file_name + markdown_file_object.render_a_markdown_file(markdown_file_path) + + return markdown_file_path + + +def render_a_latex_file_and_copy_theme_files( + rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path +) -> pathlib.Path: + """Render the $\\LaTeX$ file with the given data model in the output directory and + copy the auxiliary theme files to the output directory. + + Args: + rendercv_data_model (dm.RenderCVDataModel): The data model. + output_directory (pathlib.Path): Path to the output directory. + Returns: + pathlib.Path: The path to the rendered $\\LaTeX$ file. + """ + latex_file_path = render_a_latex_file(rendercv_data_model, output_directory) copy_theme_files_to_output_directory( rendercv_data_model.design.theme, output_directory ) return latex_file_path -def latex_to_pdf( +def render_pdf_from_latex( latex_file_path: pathlib.Path, local_latex_command: Optional[str] = None ) -> pathlib.Path: - """Run TinyTeX with the given $\\LaTeX$ file to generate the PDF. + """Run TinyTeX with the given $\\LaTeX$ file to render the PDF. Args: - latex_file_path (str): The path to the $\\LaTeX$ file to compile. + latex_file_path (str): The path to the $\\LaTeX$ file. Returns: - pathlib.Path: The path to the generated PDF file. + pathlib.Path: The path to the rendered PDF file. """ # check if the file exists: if not latex_file_path.is_file(): @@ -1070,13 +254,13 @@ def latex_to_pdf( return pdf_file_path -def pdf_to_pngs(pdf_file_path: pathlib.Path) -> list[pathlib.Path]: - """Convert the given PDF file to PNG files. +def render_pngs_from_pdf(pdf_file_path: pathlib.Path) -> list[pathlib.Path]: + """Render a PNG file for each page of the given PDF file. Args: - pdf_file_path (pathlib.Path): The path to the PDF file to convert. + pdf_file_path (pathlib.Path): The path to the PDF file. Returns: - list[pathlib.Path]: The paths to the generated PNG files. + list[pathlib.Path]: The paths to the rendered PNG files. """ # check if the file exists: if not pdf_file_path.is_file(): @@ -1096,14 +280,14 @@ def pdf_to_pngs(pdf_file_path: pathlib.Path) -> list[pathlib.Path]: return png_files -def markdown_to_html(markdown_file_path: pathlib.Path) -> pathlib.Path: - """Convert a markdown file to HTML with the same name and in the same directory. - It uses `rendercv/themes/main.j2.html` as the Jinja2 template. +def render_html_from_markdown(markdown_file_path: pathlib.Path) -> pathlib.Path: + """Render an HTML file from a Markdown file with the same name and in the same + directory. It uses `rendercv/themes/main.j2.html` as the Jinja2 template. Args: - markdown_file_path (pathlib.Path): The path to the markdown file to convert. + markdown_file_path (pathlib.Path): The path to the Markdown file. Returns: - pathlib.Path: The path to the generated HTML file. + pathlib.Path: The path to the rendered HTML file. """ # check if the file exists: if not markdown_file_path.is_file(): @@ -1120,7 +304,7 @@ def markdown_to_html(markdown_file_path: pathlib.Path) -> pathlib.Path: else: title = title.group(1) - jinja2_environment = setup_jinja2_environment() + jinja2_environment = tp.setup_jinja2_environment() html_template = jinja2_environment.get_template("main.j2.html") html = html_template.render(html_body=html_body, title=title) diff --git a/rendercv/renderer/templating.py b/rendercv/renderer/templating.py new file mode 100644 index 00000000..25e0d81d --- /dev/null +++ b/rendercv/renderer/templating.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index efe6672f..b4dc9ec1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 55f5bb7c..9e4236c9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_data_models.py b/tests/test_data_models.py index ab45cf11..ecbc6d94 100644 --- a/tests/test_data_models.py +++ b/tests/test_data_models.py @@ -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(): diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 9ff4aa09..9f8be513 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -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)