mirror of
https://github.com/rendercv/rendercv.git
synced 2026-03-14 20:56:37 -04:00
refactor to resolve circular dependencies
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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("--", "")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
64
rendercv/data_models/field_types.py
Normal file
64
rendercv/data_models/field_types.py
Normal file
@@ -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)
|
||||
115
rendercv/data_models/field_validators.py
Normal file
115
rendercv/data_models/field_validators.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
93
rendercv/data_models/model_types.py
Normal file
93
rendercv/data_models/model_types.py
Normal file
@@ -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,
|
||||
)
|
||||
),
|
||||
]
|
||||
365
rendercv/data_models/model_validators.py
Normal file
365
rendercv/data_models/model_validators.py
Normal file
@@ -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
|
||||
@@ -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=(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user