Address code review feedback on Hypothesis and classic_theme changes

- Update hypothesis to latest version (>=6.151.9)
- Remove pythonpath pytest config (was only needed for tests.strategies)
- Consolidate classic_theme.py into single file with all design models
- Move Hypothesis strategies from strategies.py into their test files
- Add noqa: ARG001 to unused yaml_field_override CLI parameter
- Fix lint and type errors across the codebase
This commit is contained in:
Sina Atalay
2026-03-25 16:26:10 +03:00
parent 2df9d2262b
commit 6956f39835
25 changed files with 3010 additions and 3010 deletions

View File

@@ -25,12 +25,13 @@ from rendercv.schema.models.cv.section import (
from rendercv.schema.models.cv.social_network import available_social_networks
from rendercv.schema.models.design.built_in_design import available_themes
from rendercv.schema.models.design.classic_theme import (
Alignment,
BodyAlignment,
Bullet,
PageSize,
PhoneNumberFormatType,
SectionTitleType,
)
from rendercv.schema.models.design.header import PhoneNumberFormatType
from rendercv.schema.models.design.typography import Alignment, BodyAlignment
from rendercv.schema.models.design.font_family import available_font_families
from rendercv.schema.models.locale.locale import available_locales
from rendercv.schema.yaml_reader import read_yaml

View File

@@ -87,7 +87,7 @@ rendercv = "rendercv.cli.entry_point:entry_point"
[dependency-groups]
dev = [
"black>=26.3.1", # Format the code
"hypothesis>=6.100.0", # Property-based testing
"hypothesis>=6.151.9", # Property-based testing
"prek>=0.3.6", # Run checks before committing (pre-commit alternative)
"pytest>=9.0.2", # Run tests
"pytest-cov>=7.0.0", # Coverage plugin for pytest with xdist support
@@ -223,7 +223,6 @@ addopts = [
"--numprocesses=auto", # Number of processes in parallel
]
testpaths = ["tests"]
pythonpath = ["."]
[tool.codespell]
skip = "*.md"

View File

File diff suppressed because it is too large Load Diff

View File

@@ -186,7 +186,7 @@ def cli_command_render(
),
] = False,
# Dummy argument that only exists to show the override syntax in --help:
yaml_field_override: Annotated[
yaml_field_override: Annotated[ # noqa: ARG001
str | None,
typer.Option(
"--YAMLLOCATION",

View File

@@ -5,7 +5,7 @@ from datetime import date as Date
from rendercv.exception import RenderCVInternalError
from rendercv.schema.models.cv.entries.publication import PublicationEntry
from rendercv.schema.models.cv.section import Entry
from rendercv.schema.models.design.templates import Templates
from rendercv.schema.models.design.classic_theme import Templates
from rendercv.schema.models.locale.locale import Locale
from .date import compute_time_span_string, format_date_range, format_single_date
@@ -450,10 +450,10 @@ def remove_not_provided_placeholders(
"""
# Remove the not provided placeholders from the templates, including characters
# around them:
used_placeholders_in_templates = set(
used_placeholders_in_templates: set[str] = set(
uppercase_word_pattern.findall(" ".join(entry_templates.values()))
)
not_provided_placeholders = used_placeholders_in_templates - set(
not_provided_placeholders: set[str] = used_placeholders_in_templates - set(
entry_fields.keys()
)
if not_provided_placeholders:
@@ -474,7 +474,7 @@ def remove_not_provided_placeholders(
# Sort longest-first so e.g. "AAA" matches before "AA":
sorted_placeholders = sorted(not_provided_placeholders, key=len, reverse=True)
not_provided_placeholders_pattern = re.compile(
r"\S*\b(?:" + "|".join(sorted_placeholders) + r")\b\S*"
r"\S*\b(?:" + "|".join(sorted_placeholders) + r")\b\S*" # ty: ignore[no-matching-overload]
)
entry_templates = {
key: clean_trailing_parts(

View File

@@ -158,7 +158,4 @@ def clean_url(url: str | pydantic.HttpUrl) -> str:
Returns:
Clean URL string.
"""
url = str(url).replace("https://", "").replace("http://", "")
url = url.rstrip("/")
return url
return str(url).replace("https://", "").replace("http://", "").rstrip("/")

View File

@@ -4,35 +4,16 @@ import pydantic
from rendercv.schema.models.base import BaseModelWithoutExtraKeys
from rendercv.schema.models.design.color import Color
from rendercv.schema.models.design.header import (
Header,
Links,
from rendercv.schema.models.design.font_family import FontFamily as FontFamilyType
from rendercv.schema.models.design.typst_dimension import (
TypstDimension,
length_common_description,
)
from rendercv.schema.models.design.templates import (
Templates,
)
from rendercv.schema.models.design.typography import (
Typography,
)
from rendercv.schema.models.design.typst_dimension import TypstDimension
type Bullet = Literal["", "", "", "-", "", "", "", "", ""]
type SectionTitleType = Literal[
"with_partial_line",
"with_full_line",
"without_line",
"moderncv",
"centered_without_line",
"centered_with_partial_line",
"centered_with_centered_partial_line",
"centered_with_full_line",
]
# Page
type PageSize = Literal["a4", "a5", "us-letter", "us-executive"]
length_common_description = (
"It can be specified with units (cm, in, pt, mm, em). For example, `0.1cm`."
)
class Page(BaseModelWithoutExtraKeys):
size: PageSize = pydantic.Field(
@@ -73,6 +54,8 @@ class Page(BaseModelWithoutExtraKeys):
)
# Colors
color_common_description = (
"The color can be specified either with their name"
" (https://www.w3.org/TR/SVG11/types.html#ColorKeywords), hexadecimal value, RGB"
@@ -133,6 +116,328 @@ class Colors(BaseModelWithoutExtraKeys):
)
# Typography
type BodyAlignment = Literal["left", "justified", "justified-with-no-hyphenation"]
type Alignment = Literal["left", "center", "right"]
class FontFamily(BaseModelWithoutExtraKeys):
body: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for body text. The default value is `Source Sans 3`."
),
)
name: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for the name. The default value is `Source Sans 3`."
),
)
headline: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for the headline. The default value is `Source Sans 3`."
),
)
connections: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for connections. The default value is `Source Sans 3`."
),
)
section_titles: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for section titles. The default value is `Source Sans 3`."
),
)
class FontSize(BaseModelWithoutExtraKeys):
body: TypstDimension = pydantic.Field(
default="10pt",
description="The font size for body text. The default value is `10pt`.",
)
name: TypstDimension = pydantic.Field(
default="30pt",
description="The font size for the name. The default value is `30pt`.",
)
headline: TypstDimension = pydantic.Field(
default="10pt",
description="The font size for the headline. The default value is `10pt`.",
)
connections: TypstDimension = pydantic.Field(
default="10pt",
description="The font size for connections. The default value is `10pt`.",
)
section_titles: TypstDimension = pydantic.Field(
default="1.4em",
description="The font size for section titles. The default value is `1.4em`.",
)
class SmallCaps(BaseModelWithoutExtraKeys):
name: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for the name. The default value is `false`."
),
)
headline: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for the headline. The default value is `false`."
),
)
connections: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for connections. The default value is `false`."
),
)
section_titles: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for section titles. The default value is"
" `false`."
),
)
class Bold(BaseModelWithoutExtraKeys):
name: bool = pydantic.Field(
default=True,
description="Whether to make the name bold. The default value is `true`.",
)
headline: bool = pydantic.Field(
default=False,
description="Whether to make the headline bold. The default value is `false`.",
)
connections: bool = pydantic.Field(
default=False,
description="Whether to make connections bold. The default value is `false`.",
)
section_titles: bool = pydantic.Field(
default=True,
description="Whether to make section titles bold. The default value is `true`.",
)
class Typography(BaseModelWithoutExtraKeys):
line_spacing: TypstDimension = pydantic.Field(
default="0.6em",
description=(
"Space between lines of text. Larger values create more vertical space. The"
" default value is `0.6em`."
),
)
alignment: Literal["left", "justified", "justified-with-no-hyphenation"] = (
pydantic.Field(
default="justified",
description=(
"Text alignment. Options: 'left', 'justified' (spreads text across full"
" width), 'justified-with-no-hyphenation' (justified without word"
" breaks). The default value is `justified`."
),
)
)
date_and_location_column_alignment: Alignment = pydantic.Field(
default="right",
description=(
"Alignment for dates and locations in entries. Options: 'left', 'center',"
" 'right'. The default value is `right`."
),
)
font_family: FontFamily | FontFamilyType = pydantic.Field(
default_factory=FontFamily,
description=(
"The font family. You can provide a single font name as a string (applies"
" to all elements), or a dictionary with keys 'body', 'name', 'headline',"
" 'connections', and 'section_titles' to customize each element. Any system"
" font can be used."
),
)
font_size: FontSize = pydantic.Field(
default_factory=FontSize,
description="Font sizes for different elements.",
)
small_caps: SmallCaps = pydantic.Field(
default_factory=SmallCaps,
description="Small caps styling for different elements.",
)
bold: Bold = pydantic.Field(
default_factory=Bold,
description="Bold styling for different elements.",
)
@pydantic.field_validator(
"font_family", mode="plain", json_schema_input_type=FontFamily | FontFamilyType
)
@classmethod
def validate_font_family(
cls, font_family: FontFamily | FontFamilyType
) -> FontFamily:
"""Convert string font to FontFamily object with uniform styling.
Why:
Users can provide simple string "Latin Modern Roman" for all text,
or specify per-element fonts via FontFamily dict. Validator accepts
both, expanding strings to full FontFamily objects.
Args:
font_family: String font name or FontFamily object.
Returns:
FontFamily object with all fields populated.
"""
if isinstance(font_family, str):
return FontFamily(
body=font_family,
name=font_family,
headline=font_family,
connections=font_family,
section_titles=font_family,
)
return FontFamily.model_validate(font_family)
# Links
class Links(BaseModelWithoutExtraKeys):
underline: bool = pydantic.Field(
default=False,
description="Underline hyperlinks. The default value is `false`.",
)
show_external_link_icon: bool = pydantic.Field(
default=False,
description=(
"Show an external link icon next to URLs. The default value is `false`."
),
)
# Header
type PhoneNumberFormatType = Literal["national", "international", "E164"]
class Connections(BaseModelWithoutExtraKeys):
phone_number_format: PhoneNumberFormatType = pydantic.Field(
default="national",
description="Phone number format. The default value is `national`.",
)
hyperlink: bool = pydantic.Field(
default=True,
description=(
"Make contact information clickable in the PDF. The default value is"
" `true`."
),
)
show_icons: bool = pydantic.Field(
default=True,
description=(
"Show icons next to contact information. The default value is `true`."
),
)
display_urls_instead_of_usernames: bool = pydantic.Field(
default=False,
description=(
"Display full URLs instead of labels. The default value is `false`."
),
)
separator: str = pydantic.Field(
default="",
description=(
"Character(s) to separate contact items (e.g., '|' or ''). Leave empty for"
" no separator. The default value is `''`."
),
)
space_between_connections: TypstDimension = pydantic.Field(
default="0.5cm",
description=(
"Horizontal space between contact items. "
+ length_common_description
+ " The default value is `0.5cm`."
),
)
class Header(BaseModelWithoutExtraKeys):
alignment: Alignment = pydantic.Field(
default="center",
description=(
"Header alignment. Options: 'left', 'center', 'right'. The default value is"
" `center`."
),
)
photo_width: TypstDimension = pydantic.Field(
default="3.5cm",
description="Photo width. "
+ length_common_description
+ " The default value is `3.5cm`.",
)
photo_position: Literal["left", "right"] = pydantic.Field(
default="left",
description="Photo position (left or right). The default value is `left`.",
)
photo_space_left: TypstDimension = pydantic.Field(
default="0.4cm",
description=(
"Space to the left of the photo. "
+ length_common_description
+ " The default value is `0.4cm`."
),
)
photo_space_right: TypstDimension = pydantic.Field(
default="0.4cm",
description=(
"Space to the right of the photo. "
+ length_common_description
+ " The default value is `0.4cm`."
),
)
space_below_name: TypstDimension = pydantic.Field(
default="0.7cm",
description="Space below your name. "
+ length_common_description
+ " The default value is `0.7cm`.",
)
space_below_headline: TypstDimension = pydantic.Field(
default="0.7cm",
description="Space below the headline. "
+ length_common_description
+ " The default value is `0.7cm`.",
)
space_below_connections: TypstDimension = pydantic.Field(
default="0.7cm",
description="Space below contact information. "
+ length_common_description
+ " The default value is `0.7cm`.",
)
connections: Connections = pydantic.Field(
default_factory=Connections,
description="Contact information settings.",
)
# Section Titles
type SectionTitleType = Literal[
"with_partial_line",
"with_full_line",
"without_line",
"moderncv",
"centered_without_line",
"centered_with_partial_line",
"centered_with_centered_partial_line",
"centered_with_full_line",
]
class SectionTitles(BaseModelWithoutExtraKeys):
type: SectionTitleType = pydantic.Field(
default="with_partial_line",
@@ -161,6 +466,9 @@ class SectionTitles(BaseModelWithoutExtraKeys):
)
# Sections
class Sections(BaseModelWithoutExtraKeys):
allow_page_break: bool = pydantic.Field(
default=True,
@@ -200,6 +508,11 @@ class Sections(BaseModelWithoutExtraKeys):
return [section_title.lower().replace(" ", "_") for section_title in value]
# Entries
type Bullet = Literal["", "", "", "-", "", "", "", "", ""]
class Summary(BaseModelWithoutExtraKeys):
space_above: TypstDimension = pydantic.Field(
default="0cm",
@@ -319,6 +632,237 @@ class Entries(BaseModelWithoutExtraKeys):
)
# Templates
class OneLineEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**LABEL:** DETAILS",
description=(
"Template for one-line entries. Available placeholders:\n- `LABEL`: The"
' label text (e.g., "Languages", "Citizenship")\n- `DETAILS`: The details'
' text (e.g., "English (native), Spanish (fluent)")\n\nYou can also add'
" arbitrary keys to entries and use them as UPPERCASE placeholders.\n\nThe"
" default value is `**LABEL:** DETAILS`."
),
)
class EducationEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**INSTITUTION**, AREA\nSUMMARY\nHIGHLIGHTS",
description=(
"Template for education entry main column. Available placeholders:\n-"
" `INSTITUTION`: Institution name\n- `AREA`: Field of study/major\n-"
" `DEGREE`: Degree type (e.g., BS, PhD)\n- `DEGREE_WITH_AREA`: Locale-aware"
" phrase combining degree and area (e.g., 'BS in Computer Science')\n-"
" `SUMMARY`: Summary text\n-"
" `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location text\n- `DATE`:"
" Formatted date or date range\n\nYou can also add arbitrary keys to"
" entries and use them as UPPERCASE placeholders.\n\nThe default value is"
" `**INSTITUTION**, AREA\\nSUMMARY\\nHIGHLIGHTS`."
),
)
degree_column: str | None = pydantic.Field(
default="**DEGREE**",
description=(
"Optional degree column template. If provided, displays degree in separate"
" column. If `null`, no degree column is shown. Available placeholders:\n-"
" `INSTITUTION`: Institution name\n- `AREA`: Field of study/major\n-"
" `DEGREE`: Degree type (e.g., BS, PhD)\n- `SUMMARY`: Summary text\n-"
" `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location text\n- `DATE`:"
" Formatted date or date range\n\nYou can also add arbitrary keys to"
" entries and use them as UPPERCASE placeholders.\n\nThe default value is"
" `**DEGREE**`."
),
)
date_and_location_column: str = pydantic.Field(
default="LOCATION\nDATE",
description=(
"Template for education entry date/location column. Available"
" placeholders:\n- `INSTITUTION`: Institution name\n- `AREA`: Field of"
" study/major\n- `DEGREE`: Degree type (e.g., BS, PhD)\n- `SUMMARY`:"
" Summary text\n- `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location"
" text\n- `DATE`: Formatted date or date range\n\nYou can also add"
" arbitrary keys to entries and use them as UPPERCASE placeholders.\n\nThe"
" default value is `LOCATION\\nDATE`."
),
)
class NormalEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**NAME**\nSUMMARY\nHIGHLIGHTS",
description=(
"Template for normal entry main column. Available placeholders:\n- `NAME`:"
" Entry name/title\n- `SUMMARY`: Summary text\n- `HIGHLIGHTS`: Bullet"
" points list\n- `LOCATION`: Location text\n- `DATE`: Formatted date or"
" date range\n\nYou can also add arbitrary keys to entries and use them as"
" UPPERCASE placeholders.\n\nThe default value is"
" `**NAME**\\nSUMMARY\\nHIGHLIGHTS`."
),
)
date_and_location_column: str = pydantic.Field(
default="LOCATION\nDATE",
description=(
"Template for normal entry date/location column. Available placeholders:\n-"
" `NAME`: Entry name/title\n- `SUMMARY`: Summary text\n- `HIGHLIGHTS`:"
" Bullet points list\n- `LOCATION`: Location text\n- `DATE`: Formatted date"
" or date range\n\nYou can also add arbitrary keys to entries and use them"
" as UPPERCASE placeholders.\n\nThe default value is `LOCATION\\nDATE`."
),
)
class ExperienceEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**COMPANY**, POSITION\nSUMMARY\nHIGHLIGHTS",
description=(
"Template for experience entry main column. Available placeholders:\n-"
" `COMPANY`: Company name\n- `POSITION`: Job title/position\n- `SUMMARY`:"
" Summary text\n- `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location"
" text\n- `DATE`: Formatted date or date range\n\nYou can also add"
" arbitrary keys to entries and use them as UPPERCASE placeholders.\n\nThe"
" default value is `**COMPANY**, POSITION\\nSUMMARY\\nHIGHLIGHTS`."
),
)
date_and_location_column: str = pydantic.Field(
default="LOCATION\nDATE",
description=(
"Template for experience entry date/location column. Available"
" placeholders:\n- `COMPANY`: Company name\n- `POSITION`: Job"
" title/position\n- `SUMMARY`: Summary text\n- `HIGHLIGHTS`: Bullet points"
" list\n- `LOCATION`: Location text\n- `DATE`: Formatted date or date"
" range\n\nYou can also add arbitrary keys to entries and use them as"
" UPPERCASE placeholders.\n\nThe default value is `LOCATION\\nDATE`."
),
)
class PublicationEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**TITLE**\nSUMMARY\nAUTHORS\nURL (JOURNAL)",
description=(
"Template for publication entry main column. Available placeholders:\n-"
" `TITLE`: Publication title\n- `AUTHORS`: List of authors (formatted as"
" comma-separated string)\n- `SUMMARY`: Summary/abstract text\n- `DOI`:"
" Digital Object Identifier\n- `URL`: Publication URL (if DOI not"
" provided)\n- `JOURNAL`: Journal/conference/venue name\n- `DATE`:"
" Formatted date\n\nYou can also add arbitrary keys to entries and use them"
" as UPPERCASE placeholders.\n\nThe default value is"
" `**TITLE**\\nSUMMARY\\nAUTHORS\\nURL (JOURNAL)`."
),
)
date_and_location_column: str = pydantic.Field(
default="DATE",
description=(
"Template for publication entry date column. Available placeholders:\n-"
" `TITLE`: Publication title\n- `AUTHORS`: List of authors (formatted as"
" comma-separated string)\n- `SUMMARY`: Summary/abstract text\n- `DOI`:"
" Digital Object Identifier\n- `URL`: Publication URL (if DOI not"
" provided)\n- `JOURNAL`: Journal/conference/venue name\n- `DATE`:"
" Formatted date\n\nYou can also add arbitrary keys to entries and use them"
" as UPPERCASE placeholders.\n\nThe default value is `DATE`."
),
)
class Templates(BaseModelWithoutExtraKeys):
footer: str = pydantic.Field(
default="*NAME -- PAGE_NUMBER/TOTAL_PAGES*",
description=(
"Template for the footer. Available placeholders:\n"
"- `NAME`: The CV owner's name from `cv.name`\n"
"- `PAGE_NUMBER`: Current page number\n"
"- `TOTAL_PAGES`: Total number of pages\n"
"- `CURRENT_DATE`: Formatted date based on `design.templates.single_date`\n"
"- `MONTH_NAME`: Full month name (e.g., January)\n"
"- `MONTH_ABBREVIATION`: Abbreviated month name (e.g., Jan)\n"
"- `MONTH`: Month number (e.g., 1)\n"
"- `MONTH_IN_TWO_DIGITS`: Zero-padded month (e.g., 01)\n"
"- `DAY`: Day of the month (e.g., 5)\n"
"- `DAY_IN_TWO_DIGITS`: Zero-padded day (e.g., 05)\n"
"- `YEAR`: Full year (e.g., 2025)\n"
"- `YEAR_IN_TWO_DIGITS`: Two-digit year (e.g., 25)\n\n"
"The default value is `*NAME -- PAGE_NUMBER/TOTAL_PAGES*`."
),
)
top_note: str = pydantic.Field(
default="*LAST_UPDATED CURRENT_DATE*",
description=(
"Template for the top note. Available placeholders:\n- `LAST_UPDATED`:"
' Localized "last updated" text from `locale.last_updated`\n-'
" `CURRENT_DATE`: Formatted date based on `design.templates.single_date`\n-"
" `NAME`: The CV owner's name from `cv.name`\n- `MONTH_NAME`: Full month"
" name (e.g., January)\n- `MONTH_ABBREVIATION`: Abbreviated month name"
" (e.g., Jan)\n- `MONTH`: Month number (e.g., 1)\n- `MONTH_IN_TWO_DIGITS`:"
" Zero-padded month (e.g., 01)\n- `DAY`: Day of the month (e.g., 5)\n-"
" `DAY_IN_TWO_DIGITS`: Zero-padded day (e.g., 05)\n- `YEAR`: Full year"
" (e.g., 2025)\n- `YEAR_IN_TWO_DIGITS`: Two-digit year (e.g., 25)\n\n"
"The default value is `*LAST_UPDATED CURRENT_DATE*`."
),
)
single_date: str = pydantic.Field(
default="MONTH_ABBREVIATION YEAR",
description=(
"Template for single dates. Available placeholders:\n"
"- `MONTH_NAME`: Full month name (e.g., January)\n"
"- `MONTH_ABBREVIATION`: Abbreviated month name (e.g., Jan)\n"
"- `MONTH`: Month number (e.g., 1)\n"
"- `MONTH_IN_TWO_DIGITS`: Zero-padded month (e.g., 01)\n"
"- `DAY`: Day of the month (e.g., 5)\n"
"- `DAY_IN_TWO_DIGITS`: Zero-padded day (e.g., 05)\n"
"- `YEAR`: Full year (e.g., 2025)\n"
"- `YEAR_IN_TWO_DIGITS`: Two-digit year (e.g., 25)\n\n"
"The default value is `MONTH_ABBREVIATION YEAR`."
),
)
date_range: str = pydantic.Field(
default="START_DATE END_DATE",
description=(
"Template for date ranges. Available placeholders:\n- `START_DATE`:"
" Formatted start date based on `design.templates.single_date`\n-"
" `END_DATE`: Formatted end date based on `design.templates.single_date`"
' (or "present"/"ongoing" for current positions)\n\nThe default value is'
" `START_DATE END_DATE`."
),
)
time_span: str = pydantic.Field(
default="HOW_MANY_YEARS YEARS HOW_MANY_MONTHS MONTHS",
description=(
"Template for time spans (duration calculations). Available"
" placeholders:\n- `HOW_MANY_YEARS`: Number of years (e.g., 2)\n- `YEARS`:"
' Localized word for "years" from `locale.years` (or singular "year")\n-'
" `HOW_MANY_MONTHS`: Number of months (e.g., 3)\n- `MONTHS`: Localized word"
' for "months" from `locale.months` (or singular "month")\n\nThe default'
" value is `HOW_MANY_YEARS YEARS HOW_MANY_MONTHS MONTHS`."
),
)
one_line_entry: OneLineEntryTemplate = pydantic.Field(
default_factory=OneLineEntryTemplate,
description="Template for one-line entries.",
)
education_entry: EducationEntryTemplate = pydantic.Field(
default_factory=EducationEntryTemplate,
description="Template for education entries.",
)
normal_entry: NormalEntryTemplate = pydantic.Field(
default_factory=NormalEntryTemplate,
description="Template for normal entries.",
)
experience_entry: ExperienceEntryTemplate = pydantic.Field(
default_factory=ExperienceEntryTemplate,
description="Template for experience entries.",
)
publication_entry: PublicationEntryTemplate = pydantic.Field(
default_factory=PublicationEntryTemplate,
description="Template for publication entries.",
)
# ClassicTheme
class ClassicTheme(BaseModelWithoutExtraKeys):
theme: Literal["classic"] = "classic"
page: Page = pydantic.Field(default_factory=Page)

View File

@@ -1,125 +0,0 @@
from typing import Literal
import pydantic
from rendercv.schema.models.base import BaseModelWithoutExtraKeys
from rendercv.schema.models.design.typography import Alignment
from rendercv.schema.models.design.typst_dimension import TypstDimension
type PhoneNumberFormatType = Literal["national", "international", "E164"]
length_common_description = (
"It can be specified with units (cm, in, pt, mm, em). For example, `0.1cm`."
)
class Links(BaseModelWithoutExtraKeys):
underline: bool = pydantic.Field(
default=False,
description="Underline hyperlinks. The default value is `false`.",
)
show_external_link_icon: bool = pydantic.Field(
default=False,
description=(
"Show an external link icon next to URLs. The default value is `false`."
),
)
class Connections(BaseModelWithoutExtraKeys):
phone_number_format: PhoneNumberFormatType = pydantic.Field(
default="national",
description="Phone number format. The default value is `national`.",
)
hyperlink: bool = pydantic.Field(
default=True,
description=(
"Make contact information clickable in the PDF. The default value is"
" `true`."
),
)
show_icons: bool = pydantic.Field(
default=True,
description=(
"Show icons next to contact information. The default value is `true`."
),
)
display_urls_instead_of_usernames: bool = pydantic.Field(
default=False,
description=(
"Display full URLs instead of labels. The default value is `false`."
),
)
separator: str = pydantic.Field(
default="",
description=(
"Character(s) to separate contact items (e.g., '|' or ''). Leave empty for"
" no separator. The default value is `''`."
),
)
space_between_connections: TypstDimension = pydantic.Field(
default="0.5cm",
description=(
"Horizontal space between contact items. "
+ length_common_description
+ " The default value is `0.5cm`."
),
)
class Header(BaseModelWithoutExtraKeys):
alignment: Alignment = pydantic.Field(
default="center",
description=(
"Header alignment. Options: 'left', 'center', 'right'. The default value is"
" `center`."
),
)
photo_width: TypstDimension = pydantic.Field(
default="3.5cm",
description="Photo width. "
+ length_common_description
+ " The default value is `3.5cm`.",
)
photo_position: Literal["left", "right"] = pydantic.Field(
default="left",
description="Photo position (left or right). The default value is `left`.",
)
photo_space_left: TypstDimension = pydantic.Field(
default="0.4cm",
description=(
"Space to the left of the photo. "
+ length_common_description
+ " The default value is `0.4cm`."
),
)
photo_space_right: TypstDimension = pydantic.Field(
default="0.4cm",
description=(
"Space to the right of the photo. "
+ length_common_description
+ " The default value is `0.4cm`."
),
)
space_below_name: TypstDimension = pydantic.Field(
default="0.7cm",
description="Space below your name. "
+ length_common_description
+ " The default value is `0.7cm`.",
)
space_below_headline: TypstDimension = pydantic.Field(
default="0.7cm",
description="Space below the headline. "
+ length_common_description
+ " The default value is `0.7cm`.",
)
space_below_connections: TypstDimension = pydantic.Field(
default="0.7cm",
description="Space below contact information. "
+ length_common_description
+ " The default value is `0.7cm`.",
)
connections: Connections = pydantic.Field(
default_factory=Connections,
description="Contact information settings.",
)

View File

@@ -1,228 +0,0 @@
import pydantic
from rendercv.schema.models.base import BaseModelWithoutExtraKeys
class OneLineEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**LABEL:** DETAILS",
description=(
"Template for one-line entries. Available placeholders:\n- `LABEL`: The"
' label text (e.g., "Languages", "Citizenship")\n- `DETAILS`: The details'
' text (e.g., "English (native), Spanish (fluent)")\n\nYou can also add'
" arbitrary keys to entries and use them as UPPERCASE placeholders.\n\nThe"
" default value is `**LABEL:** DETAILS`."
),
)
class EducationEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**INSTITUTION**, AREA\nSUMMARY\nHIGHLIGHTS",
description=(
"Template for education entry main column. Available placeholders:\n-"
" `INSTITUTION`: Institution name\n- `AREA`: Field of study/major\n-"
" `DEGREE`: Degree type (e.g., BS, PhD)\n- `DEGREE_WITH_AREA`: Locale-aware"
" phrase combining degree and area (e.g., 'BS in Computer Science')\n-"
" `SUMMARY`: Summary text\n-"
" `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location text\n- `DATE`:"
" Formatted date or date range\n\nYou can also add arbitrary keys to"
" entries and use them as UPPERCASE placeholders.\n\nThe default value is"
" `**INSTITUTION**, AREA\\nSUMMARY\\nHIGHLIGHTS`."
),
)
degree_column: str | None = pydantic.Field(
default="**DEGREE**",
description=(
"Optional degree column template. If provided, displays degree in separate"
" column. If `null`, no degree column is shown. Available placeholders:\n-"
" `INSTITUTION`: Institution name\n- `AREA`: Field of study/major\n-"
" `DEGREE`: Degree type (e.g., BS, PhD)\n- `SUMMARY`: Summary text\n-"
" `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location text\n- `DATE`:"
" Formatted date or date range\n\nYou can also add arbitrary keys to"
" entries and use them as UPPERCASE placeholders.\n\nThe default value is"
" `**DEGREE**`."
),
)
date_and_location_column: str = pydantic.Field(
default="LOCATION\nDATE",
description=(
"Template for education entry date/location column. Available"
" placeholders:\n- `INSTITUTION`: Institution name\n- `AREA`: Field of"
" study/major\n- `DEGREE`: Degree type (e.g., BS, PhD)\n- `SUMMARY`:"
" Summary text\n- `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location"
" text\n- `DATE`: Formatted date or date range\n\nYou can also add"
" arbitrary keys to entries and use them as UPPERCASE placeholders.\n\nThe"
" default value is `LOCATION\\nDATE`."
),
)
class NormalEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**NAME**\nSUMMARY\nHIGHLIGHTS",
description=(
"Template for normal entry main column. Available placeholders:\n- `NAME`:"
" Entry name/title\n- `SUMMARY`: Summary text\n- `HIGHLIGHTS`: Bullet"
" points list\n- `LOCATION`: Location text\n- `DATE`: Formatted date or"
" date range\n\nYou can also add arbitrary keys to entries and use them as"
" UPPERCASE placeholders.\n\nThe default value is"
" `**NAME**\\nSUMMARY\\nHIGHLIGHTS`."
),
)
date_and_location_column: str = pydantic.Field(
default="LOCATION\nDATE",
description=(
"Template for normal entry date/location column. Available placeholders:\n-"
" `NAME`: Entry name/title\n- `SUMMARY`: Summary text\n- `HIGHLIGHTS`:"
" Bullet points list\n- `LOCATION`: Location text\n- `DATE`: Formatted date"
" or date range\n\nYou can also add arbitrary keys to entries and use them"
" as UPPERCASE placeholders.\n\nThe default value is `LOCATION\\nDATE`."
),
)
class ExperienceEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**COMPANY**, POSITION\nSUMMARY\nHIGHLIGHTS",
description=(
"Template for experience entry main column. Available placeholders:\n-"
" `COMPANY`: Company name\n- `POSITION`: Job title/position\n- `SUMMARY`:"
" Summary text\n- `HIGHLIGHTS`: Bullet points list\n- `LOCATION`: Location"
" text\n- `DATE`: Formatted date or date range\n\nYou can also add"
" arbitrary keys to entries and use them as UPPERCASE placeholders.\n\nThe"
" default value is `**COMPANY**, POSITION\\nSUMMARY\\nHIGHLIGHTS`."
),
)
date_and_location_column: str = pydantic.Field(
default="LOCATION\nDATE",
description=(
"Template for experience entry date/location column. Available"
" placeholders:\n- `COMPANY`: Company name\n- `POSITION`: Job"
" title/position\n- `SUMMARY`: Summary text\n- `HIGHLIGHTS`: Bullet points"
" list\n- `LOCATION`: Location text\n- `DATE`: Formatted date or date"
" range\n\nYou can also add arbitrary keys to entries and use them as"
" UPPERCASE placeholders.\n\nThe default value is `LOCATION\\nDATE`."
),
)
class PublicationEntryTemplate(BaseModelWithoutExtraKeys):
main_column: str = pydantic.Field(
default="**TITLE**\nSUMMARY\nAUTHORS\nURL (JOURNAL)",
description=(
"Template for publication entry main column. Available placeholders:\n-"
" `TITLE`: Publication title\n- `AUTHORS`: List of authors (formatted as"
" comma-separated string)\n- `SUMMARY`: Summary/abstract text\n- `DOI`:"
" Digital Object Identifier\n- `URL`: Publication URL (if DOI not"
" provided)\n- `JOURNAL`: Journal/conference/venue name\n- `DATE`:"
" Formatted date\n\nYou can also add arbitrary keys to entries and use them"
" as UPPERCASE placeholders.\n\nThe default value is"
" `**TITLE**\\nSUMMARY\\nAUTHORS\\nURL (JOURNAL)`."
),
)
date_and_location_column: str = pydantic.Field(
default="DATE",
description=(
"Template for publication entry date column. Available placeholders:\n-"
" `TITLE`: Publication title\n- `AUTHORS`: List of authors (formatted as"
" comma-separated string)\n- `SUMMARY`: Summary/abstract text\n- `DOI`:"
" Digital Object Identifier\n- `URL`: Publication URL (if DOI not"
" provided)\n- `JOURNAL`: Journal/conference/venue name\n- `DATE`:"
" Formatted date\n\nYou can also add arbitrary keys to entries and use them"
" as UPPERCASE placeholders.\n\nThe default value is `DATE`."
),
)
class Templates(BaseModelWithoutExtraKeys):
footer: str = pydantic.Field(
default="*NAME -- PAGE_NUMBER/TOTAL_PAGES*",
description=(
"Template for the footer. Available placeholders:\n"
"- `NAME`: The CV owner's name from `cv.name`\n"
"- `PAGE_NUMBER`: Current page number\n"
"- `TOTAL_PAGES`: Total number of pages\n"
"- `CURRENT_DATE`: Formatted date based on `design.templates.single_date`\n"
"- `MONTH_NAME`: Full month name (e.g., January)\n"
"- `MONTH_ABBREVIATION`: Abbreviated month name (e.g., Jan)\n"
"- `MONTH`: Month number (e.g., 1)\n"
"- `MONTH_IN_TWO_DIGITS`: Zero-padded month (e.g., 01)\n"
"- `DAY`: Day of the month (e.g., 5)\n"
"- `DAY_IN_TWO_DIGITS`: Zero-padded day (e.g., 05)\n"
"- `YEAR`: Full year (e.g., 2025)\n"
"- `YEAR_IN_TWO_DIGITS`: Two-digit year (e.g., 25)\n\n"
"The default value is `*NAME -- PAGE_NUMBER/TOTAL_PAGES*`."
),
)
top_note: str = pydantic.Field(
default="*LAST_UPDATED CURRENT_DATE*",
description=(
"Template for the top note. Available placeholders:\n- `LAST_UPDATED`:"
' Localized "last updated" text from `locale.last_updated`\n-'
" `CURRENT_DATE`: Formatted date based on `design.templates.single_date`\n-"
" `NAME`: The CV owner's name from `cv.name`\n- `MONTH_NAME`: Full month"
" name (e.g., January)\n- `MONTH_ABBREVIATION`: Abbreviated month name"
" (e.g., Jan)\n- `MONTH`: Month number (e.g., 1)\n- `MONTH_IN_TWO_DIGITS`:"
" Zero-padded month (e.g., 01)\n- `DAY`: Day of the month (e.g., 5)\n-"
" `DAY_IN_TWO_DIGITS`: Zero-padded day (e.g., 05)\n- `YEAR`: Full year"
" (e.g., 2025)\n- `YEAR_IN_TWO_DIGITS`: Two-digit year (e.g., 25)\n\n"
"The default value is `*LAST_UPDATED CURRENT_DATE*`."
),
)
single_date: str = pydantic.Field(
default="MONTH_ABBREVIATION YEAR",
description=(
"Template for single dates. Available placeholders:\n"
"- `MONTH_NAME`: Full month name (e.g., January)\n"
"- `MONTH_ABBREVIATION`: Abbreviated month name (e.g., Jan)\n"
"- `MONTH`: Month number (e.g., 1)\n"
"- `MONTH_IN_TWO_DIGITS`: Zero-padded month (e.g., 01)\n"
"- `DAY`: Day of the month (e.g., 5)\n"
"- `DAY_IN_TWO_DIGITS`: Zero-padded day (e.g., 05)\n"
"- `YEAR`: Full year (e.g., 2025)\n"
"- `YEAR_IN_TWO_DIGITS`: Two-digit year (e.g., 25)\n\n"
"The default value is `MONTH_ABBREVIATION YEAR`."
),
)
date_range: str = pydantic.Field(
default="START_DATE END_DATE",
description=(
"Template for date ranges. Available placeholders:\n- `START_DATE`:"
" Formatted start date based on `design.templates.single_date`\n-"
" `END_DATE`: Formatted end date based on `design.templates.single_date`"
' (or "present"/"ongoing" for current positions)\n\nThe default value is'
" `START_DATE END_DATE`."
),
)
time_span: str = pydantic.Field(
default="HOW_MANY_YEARS YEARS HOW_MANY_MONTHS MONTHS",
description=(
"Template for time spans (duration calculations). Available"
" placeholders:\n- `HOW_MANY_YEARS`: Number of years (e.g., 2)\n- `YEARS`:"
' Localized word for "years" from `locale.years` (or singular "year")\n-'
" `HOW_MANY_MONTHS`: Number of months (e.g., 3)\n- `MONTHS`: Localized word"
' for "months" from `locale.months` (or singular "month")\n\nThe default'
" value is `HOW_MANY_YEARS YEARS HOW_MANY_MONTHS MONTHS`."
),
)
one_line_entry: OneLineEntryTemplate = pydantic.Field(
default_factory=OneLineEntryTemplate,
description="Template for one-line entries.",
)
education_entry: EducationEntryTemplate = pydantic.Field(
default_factory=EducationEntryTemplate,
description="Template for education entries.",
)
normal_entry: NormalEntryTemplate = pydantic.Field(
default_factory=NormalEntryTemplate,
description="Template for normal entries.",
)
experience_entry: ExperienceEntryTemplate = pydantic.Field(
default_factory=ExperienceEntryTemplate,
description="Template for experience entries.",
)
publication_entry: PublicationEntryTemplate = pydantic.Field(
default_factory=PublicationEntryTemplate,
description="Template for publication entries.",
)

View File

@@ -1,192 +0,0 @@
from typing import Literal
import pydantic
from rendercv.schema.models.base import BaseModelWithoutExtraKeys
from rendercv.schema.models.design.font_family import FontFamily as FontFamilyType
from rendercv.schema.models.design.typst_dimension import TypstDimension
type BodyAlignment = Literal["left", "justified", "justified-with-no-hyphenation"]
type Alignment = Literal["left", "center", "right"]
class FontFamily(BaseModelWithoutExtraKeys):
body: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for body text. The default value is `Source Sans 3`."
),
)
name: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for the name. The default value is `Source Sans 3`."
),
)
headline: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for the headline. The default value is `Source Sans 3`."
),
)
connections: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for connections. The default value is `Source Sans 3`."
),
)
section_titles: FontFamilyType = pydantic.Field(
default="Source Sans 3",
description=(
"The font family for section titles. The default value is `Source Sans 3`."
),
)
class FontSize(BaseModelWithoutExtraKeys):
body: TypstDimension = pydantic.Field(
default="10pt",
description="The font size for body text. The default value is `10pt`.",
)
name: TypstDimension = pydantic.Field(
default="30pt",
description="The font size for the name. The default value is `30pt`.",
)
headline: TypstDimension = pydantic.Field(
default="10pt",
description="The font size for the headline. The default value is `10pt`.",
)
connections: TypstDimension = pydantic.Field(
default="10pt",
description="The font size for connections. The default value is `10pt`.",
)
section_titles: TypstDimension = pydantic.Field(
default="1.4em",
description="The font size for section titles. The default value is `1.4em`.",
)
class SmallCaps(BaseModelWithoutExtraKeys):
name: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for the name. The default value is `false`."
),
)
headline: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for the headline. The default value is `false`."
),
)
connections: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for connections. The default value is `false`."
),
)
section_titles: bool = pydantic.Field(
default=False,
description=(
"Whether to use small caps for section titles. The default value is"
" `false`."
),
)
class Bold(BaseModelWithoutExtraKeys):
name: bool = pydantic.Field(
default=True,
description="Whether to make the name bold. The default value is `true`.",
)
headline: bool = pydantic.Field(
default=False,
description="Whether to make the headline bold. The default value is `false`.",
)
connections: bool = pydantic.Field(
default=False,
description="Whether to make connections bold. The default value is `false`.",
)
section_titles: bool = pydantic.Field(
default=True,
description="Whether to make section titles bold. The default value is `true`.",
)
class Typography(BaseModelWithoutExtraKeys):
line_spacing: TypstDimension = pydantic.Field(
default="0.6em",
description=(
"Space between lines of text. Larger values create more vertical space. The"
" default value is `0.6em`."
),
)
alignment: Literal["left", "justified", "justified-with-no-hyphenation"] = (
pydantic.Field(
default="justified",
description=(
"Text alignment. Options: 'left', 'justified' (spreads text across full"
" width), 'justified-with-no-hyphenation' (justified without word"
" breaks). The default value is `justified`."
),
)
)
date_and_location_column_alignment: Alignment = pydantic.Field(
default="right",
description=(
"Alignment for dates and locations in entries. Options: 'left', 'center',"
" 'right'. The default value is `right`."
),
)
font_family: FontFamily | FontFamilyType = pydantic.Field(
default_factory=FontFamily,
description=(
"The font family. You can provide a single font name as a string (applies"
" to all elements), or a dictionary with keys 'body', 'name', 'headline',"
" 'connections', and 'section_titles' to customize each element. Any system"
" font can be used."
),
)
font_size: FontSize = pydantic.Field(
default_factory=FontSize,
description="Font sizes for different elements.",
)
small_caps: SmallCaps = pydantic.Field(
default_factory=SmallCaps,
description="Small caps styling for different elements.",
)
bold: Bold = pydantic.Field(
default_factory=Bold,
description="Bold styling for different elements.",
)
@pydantic.field_validator(
"font_family", mode="plain", json_schema_input_type=FontFamily | FontFamilyType
)
@classmethod
def validate_font_family(
cls, font_family: FontFamily | FontFamilyType
) -> FontFamily:
"""Convert string font to FontFamily object with uniform styling.
Why:
Users can provide simple string "Latin Modern Roman" for all text,
or specify per-element fonts via FontFamily dict. Validator accepts
both, expanding strings to full FontFamily objects.
Args:
font_family: String font name or FontFamily object.
Returns:
FontFamily object with all fields populated.
"""
if isinstance(font_family, str):
return FontFamily(
body=font_family,
name=font_family,
headline=font_family,
connections=font_family,
section_titles=font_family,
)
return FontFamily.model_validate(font_family)

View File

@@ -31,3 +31,7 @@ def validate_typst_dimension(dimension: str) -> str:
type TypstDimension = Annotated[str, pydantic.AfterValidator(validate_typst_dimension)]
length_common_description = (
"It can be specified with units (cm, in, pt, mm, em). For example, `0.1cm`."
)

View File

@@ -81,7 +81,7 @@ class ScannerNoAlias(RoundTripScanner):
yaml = ruamel.yaml.YAML()
yaml.Scanner = ScannerNoAlias # ty: ignore[invalid-assignment]
yaml.Scanner = ScannerNoAlias
# Disable ISO date parsing, keep it as a string:
yaml.constructor.yaml_constructors["tag:yaml.org,2002:timestamp"] = (

View File

@@ -28,7 +28,7 @@ class TestCliCommandRender:
"dont_generate_png": False,
"watch": False,
"quiet": False,
"_": None,
"yaml_field_override": None,
"extra_data_model_override_arguments": context,
}

View File

@@ -14,8 +14,11 @@ from rendercv.renderer.templater.connections import (
from rendercv.schema.models.cv.custom_connection import CustomConnection
from rendercv.schema.models.cv.cv import Cv
from rendercv.schema.models.cv.social_network import SocialNetwork, SocialNetworkName
from rendercv.schema.models.design.classic_theme import ClassicTheme
from rendercv.schema.models.design.header import Connections, Header
from rendercv.schema.models.design.classic_theme import (
ClassicTheme,
Connections,
Header,
)
from rendercv.schema.models.locale.locale import EnglishLocale
from rendercv.schema.models.rendercv_model import RenderCVModel

View File

@@ -1,3 +1,4 @@
import calendar
import re
from datetime import date as Date
@@ -16,7 +17,21 @@ from rendercv.schema.models.cv.entries.bases.entry_with_complex_fields import (
get_date_object,
)
from rendercv.schema.models.locale.english_locale import EnglishLocale
from tests.strategies import valid_date_strings
@st.composite
def valid_date_strings(draw: st.DrawFn) -> str:
"""Generate date strings in YYYY-MM-DD, YYYY-MM, or YYYY format."""
year = draw(st.integers(min_value=1, max_value=9999))
fmt = draw(st.sampled_from(["year", "year_month", "year_month_day"]))
if fmt == "year":
return f"{year:04d}"
month = draw(st.integers(min_value=1, max_value=12))
if fmt == "year_month":
return f"{year:04d}-{month:02d}"
max_day = calendar.monthrange(year, month)[1]
day = draw(st.integers(min_value=1, max_value=max_day))
return f"{year:04d}-{month:02d}-{day:02d}"
class TestBuildDatePlaceholders:
@@ -669,7 +684,7 @@ class TestComputeTimeSpanString:
@settings(deadline=None)
@given(
start=valid_date_strings(),
start=valid_date_strings(), # ty: ignore[missing-argument]
delta_days=st.integers(min_value=0, max_value=36500),
)
def test_non_negative_duration(self, start: str, delta_days: int) -> None:
@@ -718,7 +733,7 @@ class TestComputeTimeSpanString:
class TestGetDateObject:
@settings(deadline=None)
@given(date_str=valid_date_strings())
@given(date_str=valid_date_strings()) # ty: ignore[missing-argument]
def test_valid_strings_produce_date_objects(self, date_str: str) -> None:
result = get_date_object(date_str)
assert isinstance(result, Date)

View File

@@ -23,7 +23,7 @@ from rendercv.schema.models.cv.entries.education import EducationEntry
from rendercv.schema.models.cv.entries.experience import ExperienceEntry
from rendercv.schema.models.cv.entries.normal import NormalEntry
from rendercv.schema.models.cv.entries.publication import PublicationEntry
from rendercv.schema.models.design.templates import (
from rendercv.schema.models.design.classic_theme import (
EducationEntryTemplate,
NormalEntryTemplate,
PublicationEntryTemplate,

View File

@@ -9,7 +9,53 @@ from rendercv.renderer.templater.string_processor import (
make_keywords_bold,
substitute_placeholders,
)
from tests.strategies import keyword_lists, placeholder_dicts, urls
keyword_lists = st.lists(
st.text(
alphabet=st.characters(categories=("L", "N", "Zs")),
min_size=1,
max_size=30,
).filter(lambda s: s.strip()),
min_size=0,
max_size=10,
)
@st.composite
def placeholder_dicts(draw: st.DrawFn) -> dict[str, str]:
"""Generate placeholder dicts with UPPERCASE keys."""
keys = draw(
st.lists(
st.from_regex(r"[A-Z]{1,15}", fullmatch=True),
min_size=0,
max_size=5,
unique=True,
)
)
values = draw(
st.lists(
st.text(
alphabet=st.characters(categories=("L", "N", "Zs")),
min_size=0,
max_size=20,
),
min_size=len(keys),
max_size=len(keys),
)
)
return dict(zip(keys, values, strict=True))
@st.composite
def urls(draw: st.DrawFn) -> str:
"""Generate realistic URL strings with http/https protocol."""
protocol = draw(st.sampled_from(["https://", "http://"]))
domain = draw(st.from_regex(r"[a-z]{2,10}\.[a-z]{2,4}", fullmatch=True))
path = draw(st.from_regex(r"[a-z0-9_-]{0,20}", fullmatch=True))
trailing_slash = draw(st.sampled_from(["", "/"]))
if path:
return f"{protocol}{domain}/{path}{trailing_slash}"
return f"{protocol}{domain}{trailing_slash}"
class TestMakeKeywordsBold:
@@ -98,7 +144,7 @@ class TestSubstitutePlaceholders:
assert substitute_placeholders(text, {}) == text
@settings(deadline=None)
@given(placeholders=placeholder_dicts())
@given(placeholders=placeholder_dicts()) # ty: ignore[missing-argument]
def test_all_keys_absent_from_output(self, placeholders: dict[str, str]) -> None:
assume(placeholders)
keys = set(placeholders.keys())
@@ -125,19 +171,19 @@ class TestCleanUrl:
assert clean_url(url) == expected_clean_url
@settings(deadline=None)
@given(url=urls())
@given(url=urls()) # ty: ignore[missing-argument]
def test_is_idempotent(self, url: str) -> None:
assert clean_url(clean_url(url)) == clean_url(url)
@settings(deadline=None)
@given(url=urls())
@given(url=urls()) # ty: ignore[missing-argument]
def test_removes_protocol(self, url: str) -> None:
result = clean_url(url)
assert "https://" not in result
assert "http://" not in result
@settings(deadline=None)
@given(url=urls())
@given(url=urls()) # ty: ignore[missing-argument]
def test_removes_trailing_slashes(self, url: str) -> None:
result = clean_url(url)
if result:

View File

@@ -1,3 +1,4 @@
import calendar
from datetime import date as Date
import pydantic
@@ -13,7 +14,21 @@ from rendercv.schema.models.cv.entries.bases.entry_with_complex_fields import (
from rendercv.schema.models.cv.entries.bases.entry_with_date import (
validate_arbitrary_date,
)
from tests.strategies import valid_date_strings
@st.composite
def valid_date_strings(draw: st.DrawFn) -> str:
"""Generate date strings in YYYY-MM-DD, YYYY-MM, or YYYY format."""
year = draw(st.integers(min_value=1, max_value=9999))
fmt = draw(st.sampled_from(["year", "year_month", "year_month_day"]))
if fmt == "year":
return f"{year:04d}"
month = draw(st.integers(min_value=1, max_value=12))
if fmt == "year_month":
return f"{year:04d}-{month:02d}"
max_day = calendar.monthrange(year, month)[1]
day = draw(st.integers(min_value=1, max_value=max_day))
return f"{year:04d}-{month:02d}-{day:02d}"
class TestGetDateObject:
@@ -69,7 +84,7 @@ class TestBaseEntryWithComplexFields:
)
@settings(deadline=None)
@given(date=valid_date_strings())
@given(date=valid_date_strings()) # ty: ignore[missing-argument]
def test_date_only_clears_start_and_end(self, date: str) -> None:
entry = BaseEntryWithComplexFields(
date=date, start_date="2020-01", end_date="2021-01"
@@ -88,7 +103,7 @@ class TestBaseEntryWithComplexFields:
assert entry.end_date == "present"
@settings(deadline=None)
@given(end_date=valid_date_strings())
@given(end_date=valid_date_strings()) # ty: ignore[missing-argument]
def test_end_only_becomes_date(self, end_date: str) -> None:
entry = BaseEntryWithComplexFields(end_date=end_date)
assert entry.date == end_date
@@ -98,7 +113,7 @@ class TestBaseEntryWithComplexFields:
class TestValidateArbitraryDate:
@settings(deadline=None)
@given(date_str=valid_date_strings())
@given(date_str=valid_date_strings()) # ty: ignore[missing-argument]
def test_valid_date_strings_pass_through(self, date_str: str) -> None:
result = validate_arbitrary_date(date_str)
assert result == date_str
@@ -120,9 +135,9 @@ class TestValidateArbitraryDate:
assert result == text
def test_invalid_month_raises(self) -> None:
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="month must be in"):
validate_arbitrary_date("2020-13-01")
def test_invalid_day_raises(self) -> None:
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="day is out of range"):
validate_arbitrary_date("2020-02-30")

View File

@@ -1,6 +1,6 @@
import pydantic
import pytest
from hypothesis import assume, given, settings
from hypothesis import given, settings
from hypothesis import strategies as st
# They are called dynamically in the test with `eval(f"{entry_type}(**entry)")`.

View File

@@ -147,7 +147,7 @@ class TestSocialNetwork:
username=st.from_regex(r"[a-zA-Z0-9_-]{1,20}", fullmatch=True),
)
def test_valid_network_url_is_valid_http_url(
self, network: str, username: str
self, network: SocialNetworkName, username: str
) -> None:
sn = SocialNetwork(network=network, username=username)
pydantic.TypeAdapter(pydantic.HttpUrl).validate_strings(sn.url)

View File

@@ -1,4 +1,4 @@
from rendercv.schema.models.design.typography import FontFamily, Typography
from rendercv.schema.models.design.classic_theme import FontFamily, Typography
class TestTypography:

View File

@@ -8,7 +8,19 @@ from rendercv.schema.models.design.typst_dimension import (
TypstDimension,
validate_typst_dimension,
)
from tests.strategies import typst_dimensions
@st.composite
def typst_dimensions(draw: st.DrawFn) -> str:
"""Generate valid Typst dimension strings."""
sign = draw(st.sampled_from(["", "-"]))
integer_part = draw(st.integers(min_value=0, max_value=999))
has_decimal = draw(st.booleans())
decimal_part = ""
if has_decimal:
decimal_part = "." + str(draw(st.integers(min_value=0, max_value=99)))
unit = draw(st.sampled_from(["cm", "in", "pt", "mm", "em"]))
return f"{sign}{integer_part}{decimal_part}{unit}"
class TestTypstDimension:
@@ -65,7 +77,7 @@ class TestTypstDimension:
assert result == dimension
@settings(deadline=None)
@given(dim=typst_dimensions())
@given(dim=typst_dimensions()) # ty: ignore[missing-argument]
def test_accepts_random_valid_dimensions(self, dim: str) -> None:
assert validate_typst_dimension(dim) == dim

View File

@@ -2,7 +2,8 @@ import datetime
import pydantic
import pytest
from hypothesis import given, settings as hypothesis_settings
from hypothesis import given
from hypothesis import settings as hypothesis_settings
from hypothesis import strategies as st
from rendercv.schema.models.settings.settings import Settings

View File

@@ -1,92 +0,0 @@
"""Reusable Hypothesis strategies for RenderCV property-based tests."""
import calendar
from hypothesis import strategies as st
@st.composite
def valid_date_strings(draw: st.DrawFn) -> str:
"""Generate date strings in YYYY-MM-DD, YYYY-MM, or YYYY format."""
year = draw(st.integers(min_value=1, max_value=9999))
fmt = draw(st.sampled_from(["year", "year_month", "year_month_day"]))
if fmt == "year":
return f"{year:04d}"
month = draw(st.integers(min_value=1, max_value=12))
if fmt == "year_month":
return f"{year:04d}-{month:02d}"
max_day = calendar.monthrange(year, month)[1]
day = draw(st.integers(min_value=1, max_value=max_day))
return f"{year:04d}-{month:02d}-{day:02d}"
@st.composite
def date_inputs(draw: st.DrawFn) -> str | int:
"""Generate inputs accepted by get_date_object (excluding 'present')."""
return draw(
st.one_of(
valid_date_strings(),
st.integers(min_value=1, max_value=9999),
)
)
keyword_lists = st.lists(
st.text(
alphabet=st.characters(categories=("L", "N", "Zs")),
min_size=1,
max_size=30,
).filter(lambda s: s.strip()),
min_size=0,
max_size=10,
)
@st.composite
def placeholder_dicts(draw: st.DrawFn) -> dict[str, str]:
"""Generate placeholder dicts with UPPERCASE keys."""
keys = draw(
st.lists(
st.from_regex(r"[A-Z]{1,15}", fullmatch=True),
min_size=0,
max_size=5,
unique=True,
)
)
values = draw(
st.lists(
st.text(
alphabet=st.characters(categories=("L", "N", "Zs")),
min_size=0,
max_size=20,
),
min_size=len(keys),
max_size=len(keys),
)
)
return dict(zip(keys, values, strict=True))
@st.composite
def urls(draw: st.DrawFn) -> str:
"""Generate realistic URL strings with http/https protocol."""
protocol = draw(st.sampled_from(["https://", "http://"]))
domain = draw(st.from_regex(r"[a-z]{2,10}\.[a-z]{2,4}", fullmatch=True))
path = draw(st.from_regex(r"[a-z0-9_-]{0,20}", fullmatch=True))
trailing_slash = draw(st.sampled_from(["", "/"]))
if path:
return f"{protocol}{domain}/{path}{trailing_slash}"
return f"{protocol}{domain}{trailing_slash}"
@st.composite
def typst_dimensions(draw: st.DrawFn) -> str:
"""Generate valid Typst dimension strings."""
sign = draw(st.sampled_from(["", "-"]))
integer_part = draw(st.integers(min_value=0, max_value=999))
has_decimal = draw(st.booleans())
decimal_part = ""
if has_decimal:
decimal_part = "." + str(draw(st.integers(min_value=0, max_value=99)))
unit = draw(st.sampled_from(["cm", "in", "pt", "mm", "em"]))
return f"{sign}{integer_part}{decimal_part}{unit}"

2
uv.lock generated
View File

@@ -1270,7 +1270,7 @@ provides-extras = ["full"]
create-executable = [{ name = "pyinstaller", specifier = ">=6.17.0" }]
dev = [
{ name = "black", specifier = ">=26.3.1" },
{ name = "hypothesis", specifier = ">=6.100.0" },
{ name = "hypothesis", specifier = ">=6.151.9" },
{ name = "prek", specifier = ">=0.3.6" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },