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

1import functools 

2from typing import Any, Self 

3 

4import pydantic 

5import pydantic_extra_types.phone_numbers as pydantic_phone_numbers 

6 

7from rendercv.exception import RenderCVInternalError 

8 

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 

14 

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) 

29 

30 

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 ) 

119 

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) 

127 

128 @functools.cached_property 

129 def rendercv_sections(self) -> list[BaseRenderCVSection]: 

130 """Transform user's section dict to list of typed section objects. 

131 

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. 

136 

137 Returns: 

138 List of section objects for template rendering. 

139 """ 

140 return get_rendercv_sections(self.sections) 

141 

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. 

148 

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. 

153 

154 Args: 

155 data: Raw input data before validation. 

156 handler: Pydantic's validation handler. 

157 

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 

164 

165 # Capture the input order before validation 

166 key_order = list(data.keys()) if isinstance(data, dict) else [] 

167 

168 # Let Pydantic do its validation 

169 instance = handler(data) 

170 

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] 

174 

175 return instance 

176 

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. 

191 

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. 

196 

197 Args: 

198 value: Single value or list to validate. 

199 info: Validation context containing field name. 

200 

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 

207 

208 if info.field_name is None: 

209 raise RenderCVInternalError("field_name is None in validator") 

210 

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] 

225 

226 if isinstance(value, list): 

227 return validators[1].validate_python(value) 

228 

229 return validators[0].validate_python(value) 

230 

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. 

236 

237 Why: 

238 phone number library adds "tel:" URI scheme for validation. 

239 Serialization strips prefix so templates render plain numbers. 

240 

241 Args: 

242 phone: Validated phone number with tel: prefix. 

243 

244 Returns: 

245 Phone string without tel: prefix, or None. 

246 """ 

247 if phone is not None: 

248 return phone.replace("tel:", "") 

249 

250 return phone