Coverage for src / rendercv / schema / models / cv / cv.py: 100%
60 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
1import functools
2from typing import Any, Self
4import pydantic
5import pydantic_extra_types.phone_numbers as pydantic_phone_numbers
7from rendercv.exception import RenderCVInternalError
9from ..base import BaseModelWithoutExtraKeys
10from ..path import ExistingPathRelativeToInput
11from .custom_connection import CustomConnection
12from .section import BaseRenderCVSection, Section, get_rendercv_sections
13from .social_network import SocialNetwork
15email_validator = pydantic.TypeAdapter[pydantic.EmailStr](pydantic.EmailStr)
16emails_validator = pydantic.TypeAdapter[list[pydantic.EmailStr]](
17 list[pydantic.EmailStr]
18)
19website_validator = pydantic.TypeAdapter[pydantic.HttpUrl](pydantic.HttpUrl)
20websites_validator = pydantic.TypeAdapter[list[pydantic.HttpUrl]](
21 list[pydantic.HttpUrl]
22)
23phone_validator = pydantic.TypeAdapter[pydantic_phone_numbers.PhoneNumber](
24 pydantic_phone_numbers.PhoneNumber
25)
26phones_validator = pydantic.TypeAdapter[list[pydantic_phone_numbers.PhoneNumber]](
27 list[pydantic_phone_numbers.PhoneNumber]
28)
31class Cv(BaseModelWithoutExtraKeys):
32 name: str | None = pydantic.Field(
33 default=None,
34 examples=["John Doe", "Jane Smith"],
35 )
36 headline: str | None = pydantic.Field(
37 default=None,
38 examples=["Software Engineer", "Data Scientist", "Product Manager"],
39 )
40 location: str | None = pydantic.Field(
41 default=None,
42 examples=["New York, NY", "London, UK", "Istanbul, Türkiye"],
43 )
44 email: pydantic.EmailStr | list[pydantic.EmailStr] | None = pydantic.Field(
45 default=None,
46 description="You can provide multiple emails as a list.",
47 examples=[
48 "john.doe@example.com",
49 ["john.doe.1@example.com", "john.doe.2@example.com"],
50 ],
51 )
52 photo: ExistingPathRelativeToInput | pydantic.HttpUrl | None = pydantic.Field(
53 default=None,
54 union_mode="left_to_right",
55 description="Photo file path (relative to the YAML file) or a URL.",
56 examples=["photo.jpg", "images/profile.png", "https://example.com/photo.jpg"],
57 )
58 phone: (
59 pydantic_phone_numbers.PhoneNumber
60 | list[pydantic_phone_numbers.PhoneNumber]
61 | None
62 ) = pydantic.Field(
63 default=None,
64 description=(
65 "Your phone number with country code in international format (e.g., +1 for"
66 " USA, +44 for UK). The display format in the output is controlled by"
67 " `design.header.connections.phone_number_format`. You can provide multiple"
68 " numbers as a list."
69 ),
70 examples=[
71 "+1-234-567-8900",
72 ["+1-234-567-8900", "+44 20 1234 5678"],
73 ],
74 )
75 website: pydantic.HttpUrl | list[pydantic.HttpUrl] | None = pydantic.Field(
76 default=None,
77 description="You can provide multiple URLs as a list.",
78 examples=[
79 "https://johndoe.com",
80 ["https://johndoe.com", "https://www.janesmith.dev"],
81 ],
82 )
83 social_networks: list[SocialNetwork] | None = pydantic.Field(
84 default=None,
85 )
86 custom_connections: list[CustomConnection] | None = pydantic.Field(
87 default=None,
88 description=(
89 "Additional header connections you define yourself. Each item has a"
90 " `placeholder` (the displayed text), an optional `url`, and the Font"
91 " Awesome icon name to render (from https://fontawesome.com/search)."
92 ),
93 examples=[
94 [
95 {
96 "placeholder": "Book a call",
97 "url": "https://cal.com/johndoe",
98 "fontawesome_icon": "calendar-days",
99 }
100 ],
101 ],
102 )
103 sections: dict[str, Section] | None = pydantic.Field(
104 default=None,
105 description=(
106 "The sections of your CV. Keys are section titles (e.g., Experience,"
107 " Education), and values are lists of entries. Entry types are"
108 " automatically detected based on their fields."
109 ),
110 examples=[
111 {
112 "Experience": "...",
113 "Education": "...",
114 "Projects": "...",
115 "Skills": "...",
116 }
117 ],
118 )
120 _plain_name: str | None = pydantic.PrivateAttr()
121 _connections: list[str] = pydantic.PrivateAttr()
122 _top_note: str = pydantic.PrivateAttr()
123 _footer: str = pydantic.PrivateAttr()
124 # Store the order of the keys so that the header can be rendered in the same order
125 # that the user defines.
126 _key_order: list[str] = pydantic.PrivateAttr(default_factory=list)
128 @functools.cached_property
129 def rendercv_sections(self) -> list[BaseRenderCVSection]:
130 """Transform user's section dict to list of typed section objects.
132 Why:
133 Templates need sections as list with title/entry_type metadata.
134 Cached property computes once after validation, enabling repeated
135 template access without recomputation.
137 Returns:
138 List of section objects for template rendering.
139 """
140 return get_rendercv_sections(self.sections)
142 @pydantic.model_validator(mode="wrap") # ty: ignore[invalid-argument-type]
143 @classmethod
144 def capture_input_order(
145 cls, data: Any, handler: pydantic.ModelWrapValidatorHandler[Self]
146 ) -> "Cv":
147 """Preserve YAML field order for header rendering.
149 Why:
150 Header fields (name, label, location, etc.) must render in user-defined
151 order, not alphabetical. Wrap validator captures dict key order before
152 Pydantic reorders fields.
154 Args:
155 data: Raw input data before validation.
156 handler: Pydantic's validation handler.
158 Returns:
159 Validated CV instance with _key_order preserved.
160 """
161 # If data is already a Cv instance, preserve its _key_order
162 if isinstance(data, cls):
163 return data
165 # Capture the input order before validation
166 key_order = list(data.keys()) if isinstance(data, dict) else []
168 # Let Pydantic do its validation
169 instance = handler(data)
171 # Set the private attribute on the instance:
172 # If the values of those keys are None, remove the key from the key_order
173 instance._key_order = [key for key in key_order if data.get(key) is not None] # ty: ignore[invalid-assignment]
175 return instance
177 @pydantic.field_validator("website", "email", "phone", mode="plain")
178 @classmethod
179 def validate_list_or_scalar_fields(
180 cls, value: Any, info: pydantic.ValidationInfo
181 ) -> (
182 pydantic.EmailStr
183 | pydantic.HttpUrl
184 | pydantic_phone_numbers.PhoneNumber
185 | list[pydantic.EmailStr]
186 | list[pydantic.HttpUrl]
187 | list[pydantic_phone_numbers.PhoneNumber]
188 | None
189 ):
190 """Validate fields that accept single value or list with type-specific errors.
192 Why:
193 Users provide either `email: "x@y.com"` or `email: ["x@y.com", "a@b.com"]`.
194 Plain mode validator detects list vs scalar first, enabling specific error
195 messages like "invalid email in list" instead of generic validation errors.
197 Args:
198 value: Single value or list to validate.
199 info: Validation context containing field name.
201 Returns:
202 Validated single value or list.
203 """
204 # Allow None values since these fields are optional
205 if value is None:
206 return None
208 if info.field_name is None:
209 raise RenderCVInternalError("field_name is None in validator")
211 validators: tuple[
212 pydantic.TypeAdapter[pydantic.EmailStr]
213 | pydantic.TypeAdapter[pydantic.HttpUrl]
214 | pydantic.TypeAdapter[pydantic_phone_numbers.PhoneNumber],
215 (
216 pydantic.TypeAdapter[list[pydantic.EmailStr]]
217 | pydantic.TypeAdapter[list[pydantic.HttpUrl]]
218 | pydantic.TypeAdapter[list[pydantic_phone_numbers.PhoneNumber]]
219 ),
220 ] = {
221 "website": (website_validator, websites_validator),
222 "email": (email_validator, emails_validator),
223 "phone": (phone_validator, phones_validator),
224 }[info.field_name]
226 if isinstance(value, list):
227 return validators[1].validate_python(value)
229 return validators[0].validate_python(value)
231 @pydantic.field_serializer("phone")
232 def serialize_phone(
233 self, phone: pydantic_phone_numbers.PhoneNumber | None
234 ) -> str | None:
235 """Remove tel: prefix from phone number for clean serialization.
237 Why:
238 phone number library adds "tel:" URI scheme for validation.
239 Serialization strips prefix so templates render plain numbers.
241 Args:
242 phone: Validated phone number with tel: prefix.
244 Returns:
245 Phone string without tel: prefix, or None.
246 """
247 if phone is not None:
248 return phone.replace("tel:", "")
250 return phone