From b5b159342365decd234e59bbd4308b7a5c8b6ebc Mon Sep 17 00:00:00 2001 From: Sina Atalay Date: Mon, 1 Jul 2024 18:31:08 +0300 Subject: [PATCH] refactor to resolve circular dependencies --- rendercv/cli/commands.py | 14 +- rendercv/cli/handlers.py | 87 ------ rendercv/cli/printer.py | 82 +++++ rendercv/cli/utilities.py | 19 +- rendercv/data_models/__init__.py | 14 +- rendercv/data_models/computed_fields.py | 117 +++++--- rendercv/data_models/field_types.py | 64 ++++ rendercv/data_models/field_validators.py | 115 +++++++ rendercv/data_models/generators.py | 17 +- rendercv/data_models/model_types.py | 93 ++++++ rendercv/data_models/model_validators.py | 365 ++++++++++++++++++++++ rendercv/data_models/models.py | 45 ++- rendercv/data_models/types.py | 123 -------- rendercv/data_models/utilities.py | 40 --- rendercv/data_models/validators.py | 366 ----------------------- rendercv/themes/classic/__init__.py | 5 +- tests/test_cli.py | 1 - 17 files changed, 856 insertions(+), 711 deletions(-) delete mode 100644 rendercv/cli/handlers.py create mode 100644 rendercv/data_models/field_types.py create mode 100644 rendercv/data_models/field_validators.py create mode 100644 rendercv/data_models/model_types.py create mode 100644 rendercv/data_models/model_validators.py delete mode 100644 rendercv/data_models/types.py delete mode 100644 rendercv/data_models/validators.py diff --git a/rendercv/cli/commands.py b/rendercv/cli/commands.py index fa84a6d5..a0c595bd 100644 --- a/rendercv/cli/commands.py +++ b/rendercv/cli/commands.py @@ -22,10 +22,10 @@ from .printer import ( warn_if_new_version_is_available, warning, welcome, + handle_and_print_raised_exceptions, ) from .utilities import ( copy_templates, - handle_exceptions, parse_render_command_override_arguments, ) @@ -50,7 +50,7 @@ app = typer.Typer( # allow extra arguments for updating the data model: context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, ) -@handle_exceptions +@handle_and_print_raised_exceptions def cli_command_render( input_file_name: Annotated[ str, typer.Argument(help="Name of the YAML input file.") @@ -316,12 +316,22 @@ def cli_command_new( theme_folder = copy_templates(theme, pathlib.Path.cwd()) if theme_folder is not None: created_files_and_folders.append(theme_folder.name) + else: + warning( + f'The theme folder "{theme}" already exists! The theme files are not' + " created." + ) if not dont_create_markdown_source_files: # copy the package's markdown files to the current directory markdown_folder = copy_templates("markdown", pathlib.Path.cwd()) if markdown_folder is not None: created_files_and_folders.append(markdown_folder.name) + else: + warning( + 'The "markdown" folder already exists! The Markdown files are not' + " created." + ) if len(created_files_and_folders) > 0: created_files_and_folders_string = ",\n".join(created_files_and_folders) diff --git a/rendercv/cli/handlers.py b/rendercv/cli/handlers.py deleted file mode 100644 index 2196990a..00000000 --- a/rendercv/cli/handlers.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -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 116abf18..9c1cf915 100644 --- a/rendercv/cli/printer.py +++ b/rendercv/cli/printer.py @@ -9,6 +9,7 @@ import pydantic import rich import typer from rich import print +import rich.live from .. import __version__ from .utilities import ( @@ -16,6 +17,15 @@ from .utilities import ( get_latest_version_number_from_pypi, ) +import functools +from typing import Callable + +import jinja2 +import pydantic +import ruamel.yaml +import ruamel.yaml.parser +import typer + class LiveProgressReporter(rich.live.Live): """This class is a wrapper around `rich.live.Live` that provides the live progress @@ -337,3 +347,75 @@ def print_validation_errors(exception: pydantic.ValidationError): print(table) error() # exit the program + + +def handle_and_print_raised_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/utilities.py b/rendercv/cli/utilities.py index f95d6b9a..e6594aad 100644 --- a/rendercv/cli/utilities.py +++ b/rendercv/cli/utilities.py @@ -11,8 +11,6 @@ from typing import Optional import typer -from .printer import error, warning - def get_latest_version_number_from_pypi() -> Optional[str]: """Get the latest version number of RenderCV from PyPI. @@ -74,7 +72,6 @@ def copy_templates( folder_name: str, copy_to: pathlib.Path, new_folder_name: Optional[str] = None, - suppress_warning: bool = False, ) -> Optional[pathlib.Path]: """Copy one of the folders found in `rendercv.templates` to `copy_to`. @@ -92,18 +89,6 @@ def copy_templates( destination = copy_to / folder_name if destination.exists(): - if not suppress_warning: - if folder_name != "markdown": - warning( - f'The theme folder "{folder_name}" already exists! New theme files' - " are not created." - ) - else: - warning( - 'The folder "markdown" already exists! New Markdown files are not' - " created." - ) - return None else: # copy the folder but don't include __init__.py: @@ -138,7 +123,7 @@ def parse_render_command_override_arguments( # below parses `ctx.args` accordingly. if len(extra_arguments.args) % 2 != 0: - error( + raise ValueError( "There is a problem with the extra arguments! Each key should have" " a corresponding value." ) @@ -147,7 +132,7 @@ def parse_render_command_override_arguments( key = extra_arguments.args[i] value = extra_arguments.args[i + 1] if not key.startswith("--"): - error(f"The key ({key}) should start with double dashes!") + raise ValueError(f"The key ({key}) should start with double dashes!") key = key.replace("--", "") diff --git a/rendercv/data_models/__init__.py b/rendercv/data_models/__init__.py index da74a003..4a3a57ec 100644 --- a/rendercv/data_models/__init__.py +++ b/rendercv/data_models/__init__.py @@ -33,10 +33,20 @@ from .generators import ( get_a_sample_data_model, ) -from .types import ( +from .field_types import ( + available_social_networks, +) + +from .model_types import ( available_entry_type_names, available_themes, - available_social_networks, + Entry, + SectionInput, +) + + +from .computed_fields import ( + format_date, ) from .utilities import set_or_update_a_value, dictionary_to_yaml diff --git a/rendercv/data_models/computed_fields.py b/rendercv/data_models/computed_fields.py index a9a8c832..838523c2 100644 --- a/rendercv/data_models/computed_fields.py +++ b/rendercv/data_models/computed_fields.py @@ -5,11 +5,48 @@ calculate the time span between two dates, the date string, the URL of a social etc. """ +from datetime import date as Date from typing import Optional -from . import models +# from .models import locale_catalog, CurriculumVitae + from . import utilities as util -from . import validators as val + +# from . import validators as val + + +def format_date(date: Date, use_full_name: bool = False) -> str: + """Formats a `Date` object to a string in the following format: "Jan 2021". The + month names are taken from the `locale_catalog` dictionary from the + `rendercv.data_models.models` module. + + 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. + """ + 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 compute_time_span_string( @@ -80,18 +117,16 @@ def compute_time_span_string( 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']}" + how_many_years_string = f"1 {locale_catalog['year']}" else: - how_many_years_string = f"{how_many_years} {models.locale_catalog['years']}" + 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 {models.locale_catalog['month']}" + how_many_months_string = f"1 {locale_catalog['month']}" else: - how_many_months_string = ( - f"{how_many_months} {models.locale_catalog['months']}" - ) + how_many_months_string = f"{how_many_months} {locale_catalog['months']}" # Combine howManyYearsString and howManyMonthsString: if how_many_years_string is None: @@ -145,7 +180,7 @@ def compute_date_string( if show_only_years: date_string = str(date_object.year) else: - date_string = util.format_date(date_object) + date_string = format_date(date_object) except ValueError: # Then it is a custom date string (e.g., "My Custom Date") date_string = str(date) @@ -159,10 +194,10 @@ def compute_date_string( if show_only_years: start_date = date_object.year else: - start_date = util.format_date(date_object) + start_date = format_date(date_object) if end_date == "present": - end_date = models.locale_catalog["present"] + end_date = locale_catalog["present"] elif isinstance(end_date, int): # Then it means only the year is provided end_date = str(end_date) @@ -172,9 +207,9 @@ def compute_date_string( if show_only_years: end_date = date_object.year else: - end_date = util.format_date(date_object) + end_date = format_date(date_object) - date_string = f"{start_date} {models.locale_catalog['to']} {end_date}" + date_string = f"{start_date} {locale_catalog['to']} {end_date}" else: # Neither date, start_date, nor end_date are provided, so return an empty @@ -214,7 +249,7 @@ def compute_social_network_url(network: str, username: str): return url -def compute_connections(cv: models.CurriculumVitae) -> list[dict[str, str]]: +def compute_connections(cv) -> 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." @@ -304,36 +339,36 @@ def compute_connections(cv: models.CurriculumVitae) -> list[dict[str, str]]: 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. +# 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. +# 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] = [] +# 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) +# 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] - ) +# 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) +# section = models.SectionBase( +# title=title, +# entry_type=entry_type_name, +# entries=section_or_entries, +# ) +# sections.append(section) - return sections +# return sections diff --git a/rendercv/data_models/field_types.py b/rendercv/data_models/field_types.py new file mode 100644 index 00000000..24b52ec7 --- /dev/null +++ b/rendercv/data_models/field_types.py @@ -0,0 +1,64 @@ +""" +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, Literal, Optional, get_args + +import pydantic + +from . import field_validators as field_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(field_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(field_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(field_val.validate_start_and_end_date_fields), +] + +# 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) diff --git a/rendercv/data_models/field_validators.py b/rendercv/data_models/field_validators.py new file mode 100644 index 00000000..3434764f --- /dev/null +++ b/rendercv/data_models/field_validators.py @@ -0,0 +1,115 @@ +""" +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 re +from datetime import date as Date +from typing import Optional + +import pydantic + +from . import utilities as util + + +# Create a URL validator: +url_validator = pydantic.TypeAdapter(pydantic.HttpUrl) + + +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_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 diff --git a/rendercv/data_models/generators.py b/rendercv/data_models/generators.py index f377920f..9f27adf1 100644 --- a/rendercv/data_models/generators.py +++ b/rendercv/data_models/generators.py @@ -11,9 +11,12 @@ from typing import Any, Optional import pydantic from ..themes.classic import ClassicThemeOptions +from ..themes.sb2nov import Sb2novThemeOptions +from ..themes.moderncv import ModerncvThemeOptions +from ..themes.engineeringresumes import EngineeringresumesThemeOptions from . import models from . import utilities as utils -from . import validators as val +from . import field_validators as field_val def get_a_sample_data_model( @@ -269,10 +272,14 @@ def get_a_sample_data_model( 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}) + themes = { + "classic": ClassicThemeOptions, + "moderncv": ModerncvThemeOptions, + "sb2nov": Sb2novThemeOptions, + "engineeringresumes": EngineeringresumesThemeOptions, + } + + design = themes[theme](theme=theme) return models.RenderCVDataModel(cv=cv, design=design) diff --git a/rendercv/data_models/model_types.py b/rendercv/data_models/model_types.py new file mode 100644 index 00000000..b5e22161 --- /dev/null +++ b/rendercv/data_models/model_types.py @@ -0,0 +1,93 @@ +from typing import Annotated, Any + +import pydantic + +from ..themes.classic import ClassicThemeOptions +from ..themes.engineeringresumes import EngineeringresumesThemeOptions +from ..themes.moderncv import ModerncvThemeOptions +from ..themes.sb2nov import Sb2novThemeOptions +from .models import ( + OneLineEntry, + NormalEntry, + ExperienceEntry, + EducationEntry, + PublicationEntry, + BulletEntry, +) +from . import model_validators + +# 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 a custom type named Entry: +Entry = ( + OneLineEntry + | NormalEntry + | ExperienceEntry + | EducationEntry + | PublicationEntry + | BulletEntry + | str +) + +# 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"] + +# Create a custom type named ListOfEntries: +ListOfEntries = list[Entry] + + +# Create a custom type named SectionInput so that it can be validated with +# `validate_a_section` function. +SectionInput = Annotated[ + ListOfEntries, + pydantic.PlainValidator( + lambda entries: model_validators.validate_a_section( + entries, entry_types=available_entry_types + ) + ), +] + + +# 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"), +] + +available_theme_options = { + "classic": ClassicThemeOptions, + "moderncv": ModerncvThemeOptions, + "sb2nov": Sb2novThemeOptions, + "engineeringresumes": EngineeringresumesThemeOptions, +} + +available_themes = list(available_theme_options.keys()) + + +# 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[ + RenderCVBuiltinDesign | pydantic.json_schema.SkipJsonSchema[Any], + pydantic.PlainValidator( + lambda design: model_validators.validate_design_options( + design, + available_theme_options=available_theme_options, + available_entry_type_names=available_entry_type_names, + ) + ), +] diff --git a/rendercv/data_models/model_validators.py b/rendercv/data_models/model_validators.py new file mode 100644 index 00000000..1ab6a35c --- /dev/null +++ b/rendercv/data_models/model_validators.py @@ -0,0 +1,365 @@ +from typing import Optional, Any, Type, Literal + +import pathlib +import pydantic +import importlib +import importlib.util + + +# from .types import ( +# available_entry_types, +# available_theme_options, +# available_themes, +# available_entry_type_names, +# # RenderCVBuiltinDesign, +# ) + +from . import utilities as util +from . import field_types +from .models import RenderCVBaseModel + + +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: list[Any] + + +def create_a_section_validator(entry_type: Type) -> 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. + + The section model is used to validate a section. + + Args: + entry_type (Type[Entry]): The entry type to create the section model. It's not + an instance of the entry type, but the entry type itself. + 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 validate_and_adjust_dates( + start_date: field_types.StartDate, + end_date: field_types.EndDate, + date: Optional[field_types.ArbitraryDate], +) -> tuple[field_types.StartDate, field_types.EndDate, field_types.ArbitraryDate]: + """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 + """ + 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 date is provided, ignore start_date and end_date: + start_date = None + 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: + date = end_date + start_date = None + end_date = None + elif start_date_is_provided: + start_date = util.get_date_object(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: + end_date = "present" + + if end_date != "present": + end_date = util.get_date_object(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 start_date, end_date, date + + +def get_characteristic_entry_attributes( + entry_types: list[Type], +) -> dict[Type, set[str]]: + """Get the characteristic attributes of the entry types. + + Args: + entry_types (list[Type]): The entry types to get their characteristic + attributes. + Returns: + dict[Type, list[str]]: The characteristic attributes of the entry types. + """ + # Look at all the entry types, collect their attributes with + # EntryType.model_fields.keys() and find the common ones. + all_attributes = [] + for EntryType in entry_types: + all_attributes.extend(EntryType.model_fields.keys()) + + common_attributes = set( + attribute for attribute in all_attributes if all_attributes.count(attribute) > 1 + ) + + # Store each entry type's characteristic attributes in a dictionary: + characteristic_entry_attributes = {} + for EntryType in entry_types: + characteristic_entry_attributes[EntryType] = ( + set(EntryType.model_fields.keys()) - common_attributes + ) + + return characteristic_entry_attributes + + +def get_entry_type_name_and_section_validator( + entry: dict[str, Any] | str, entry_types: list[Type] +) -> tuple[str, Type[SectionBase]]: + """Get the entry type name and the section validator based on the entry. + + It takes an entry (as a dictionary or a string) and a list of entry types. Then + it determines the entry type and creates a section validator based on the entry + type. + + Args: + entry (dict[str, Any] | str): The entry to determine its type. + entry_types (list[Type]): The entry types to determine the entry type. These + are not instances of the entry types, but the entry types themselves. `str` + type should not be included in this list. + Returns: + tuple[str, Type[SectionBase]]: The entry type name and the section validator. + """ + characteristic_entry_attributes = get_characteristic_entry_attributes(entry_types) + + if isinstance(entry, dict): + entry_type_name = None # the entry type is not determined yet + + for ( + EntryType, + characteristic_attributes, + ) in characteristic_entry_attributes.items(): + # If at least one of the characteristic_entry_attributes is in the entry, + # then it means the entry is of this type: + if characteristic_attributes & set(entry.keys()): + entry_type_name = EntryType.__name__ + section_type = create_a_section_validator(EntryType) + 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" + section_type = create_a_section_validator(str) + + else: + # Then the entry is already initialized with a data model: + entry_type_name = entry.__class__.__name__ + section_type = create_a_section_validator(entry.__class__) + + return entry_type_name, section_type + + +def validate_a_section(sections_input: list[Any], entry_types: list[Type]) -> list[Any]: + """Validate a list of entries (a section) based on the entry types. + + 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_type_name_and_section_validator` 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 with the section validator. + + Args: + sections_input (list[Any]): The sections input to validate. + entry_types (list[Type]): The entry types to determine the entry type. These + are not instances of the entry types, but the entry types themselves. `str` + type should not be included in this list. + Returns: + list[Any]: The validated sections input. + """ + if isinstance(sections_input, list): + # Find the entry type based on the first identifiable entry: + entry_type_name = None + section_type = None + for entry in sections_input: + try: + entry_type_name, section_type = ( + get_entry_type_name_and_section_validator(entry, entry_types) + ) + break + except ValueError: + # If the entry type cannot be determined, try the next entry: + pass + + if entry_type_name 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_name, + "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_name}! 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_design_options( + design: Any, + available_theme_options: dict[str, Type], + available_entry_type_names: list[str], +) -> Any: + """Chech if the design options are for a built-in theme or a custom theme. If it is + a built-in theme, validate it with the corresponding data model. If it is a custom + theme, check if the necessary files are provided and validate it with the custom + theme data model, found in the `__init__.py` file of the custom theme folder. + + Args: + design (Any | RenderCVBuiltinDesign): The design options to validate. + available_theme_options (dict[str, Type]): The available theme options. The keys + are the theme names and the values are the corresponding data models. + available_entry_type_names (list[str]): The available entry type names. These + are used to validate if all the templates are provided in the custom theme + folder. + Returns: + Any: The validated design as a Pydantic data model. + """ + if isinstance(design, available_theme_options): + # Then it means it is an already validated built-in theme. Return it as it is: + return design + elif design["theme"] in available_theme_options: + # Then it is a built-in theme, but it is not validated yet. Validate it and + # return it: + ThemeDataModel = available_theme_options[design["theme"]] + return ThemeDataModel(**design) + else: + # It is a custom theme. 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 + ) + + 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 to 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 diff --git a/rendercv/data_models/models.py b/rendercv/data_models/models.py index 832b79c2..b9c9f025 100644 --- a/rendercv/data_models/models.py +++ b/rendercv/data_models/models.py @@ -14,9 +14,9 @@ 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 field_types from . import utilities as util -from . import validators as val +from . import field_validators # Disable Pydantic warnings: # warnings.filterwarnings("ignore") @@ -63,7 +63,7 @@ class EntryWithDate(RenderCVBaseModel): fields. """ - date: types.ArbitraryDate = pydantic.Field( + date: field_types.ArbitraryDate = pydantic.Field( default=None, title="Date", description=( @@ -165,7 +165,7 @@ class EntryBase(EntryWithDate): description="The location of the event.", examples=["Istanbul, Türkiye"], ) - start_date: types.StartDate = pydantic.Field( + start_date: field_types.StartDate = pydantic.Field( default=None, title="Start Date", description=( @@ -173,7 +173,7 @@ class EntryBase(EntryWithDate): ), examples=["2020-09-24"], ) - end_date: types.EndDate = pydantic.Field( + end_date: field_types.EndDate = pydantic.Field( default=None, title="End Date", description=( @@ -195,7 +195,12 @@ class EntryBase(EntryWithDate): """Call the `validate_adjust_dates_of_an_entry` function to validate the dates. """ - return val.validate_and_adjust_dates_of_an_entry(self) + self.start_date, self.end_date, self.date = ( + model_validators.validate_and_adjust_dates( + start_date=self.start_date, end_date=self.end_date, date=self.date + ) + ) + return self @functools.cached_property def date_string(self) -> str: @@ -301,27 +306,17 @@ class EducationEntry(EntryBase, EducationEntryBase): # ====================================================================================== -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: =========================================================== # ====================================================================================== +from . import model_types +from . import model_validators class SocialNetwork(RenderCVBaseModel): """This class is the data model of a social network.""" - network: types.SocialNetworkName = pydantic.Field( + network: field_types.SocialNetworkName = pydantic.Field( title="Social Network", description="Name of the social network.", ) @@ -342,7 +337,9 @@ class SocialNetwork(RenderCVBaseModel): network = info.data["network"] - username = val.validate_a_social_network_username(username, network) + username = field_validators.validate_a_social_network_username( + username, network + ) return username @@ -352,7 +349,7 @@ class SocialNetwork(RenderCVBaseModel): 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) + field_validators.validate_url(self.url) return self @@ -402,7 +399,7 @@ class CurriculumVitae(RenderCVBaseModel): title="Social Networks", description="The social networks of the person.", ) - sections_input: Optional[dict[str, types.SectionInput]] = pydantic.Field( + sections_input: Optional[dict[str, model_types.SectionInput]] = pydantic.Field( default=None, title="Sections", description="The sections of the CV.", @@ -421,7 +418,7 @@ class CurriculumVitae(RenderCVBaseModel): return connections @functools.cached_property - def sections(self) -> list[SectionBase]: + def sections(self) -> list[model_validators.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. """ @@ -551,7 +548,7 @@ class RenderCVDataModel(RenderCVBaseModel): title="Curriculum Vitae", description="The data of the CV.", ) - design: types.RenderCVDesign = pydantic.Field( + design: model_types.RenderCVDesign = pydantic.Field( default=ClassicThemeOptions(theme="classic"), title="Design", description=( diff --git a/rendercv/data_models/types.py b/rendercv/data_models/types.py deleted file mode 100644 index 15639e04..00000000 --- a/rendercv/data_models/types.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -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 index b3193d0e..9732b195 100644 --- a/rendercv/data_models/utilities.py +++ b/rendercv/data_models/utilities.py @@ -62,46 +62,6 @@ def get_date_object(date: str | int) -> Date: 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. diff --git a/rendercv/data_models/validators.py b/rendercv/data_models/validators.py deleted file mode 100644 index 73d337da..00000000 --- a/rendercv/data_models/validators.py +++ /dev/null @@ -1,366 +0,0 @@ -""" -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/themes/classic/__init__.py b/rendercv/themes/classic/__init__.py index 8171613b..2a766c8a 100644 --- a/rendercv/themes/classic/__init__.py +++ b/rendercv/themes/classic/__init__.py @@ -44,13 +44,12 @@ class ClassicThemeOptions(ThemeOptions): description="The font family of the CV. The default value is Source Sans 3.", ) show_timespan_in: list[str] = pydantic.Field( - default=[], + default=["Experience"], title="Show Time Span in These Sections", description=( "The time span will be shown in the date and location column in these" " sections. The input should be a list of section titles as strings" - " (case-sensitive). The default value is an empty list, which means the" - " time span will not be shown in any section." + " (case-sensitive). The default value is ['Experience']." ), ) margins: MarginsForClassic = pydantic.Field( diff --git a/tests/test_cli.py b/tests/test_cli.py index 728dfe0d..59e5c2ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,6 @@ import ruamel.yaml import typer.testing import rendercv.cli as cli -import rendercv.cli.handlers as handlers import rendercv.cli.printer as printer import rendercv.cli.utilities as util import rendercv.data_models as dm