Coverage for rendercv/data/models/entry_types.py: 98%

120 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-12-25 23:06 +0000

1""" 

2The `rendercv.models.data.entry_types` module contains the data models of all the available 

3entry types in RenderCV. 

4""" 

5 

6import functools 

7import re 

8from datetime import date as Date 

9from typing import Annotated, Literal, Optional 

10 

11import pydantic 

12 

13from . import computers 

14from .base import RenderCVBaseModelWithExtraKeys 

15 

16# ====================================================================================== 

17# Create validator functions: ========================================================== 

18# ====================================================================================== 

19 

20 

21def validate_date_field(date: Optional[int | str]) -> Optional[int | str]: 

22 """Check if the `date` field is provided correctly. 

23 

24 Args: 

25 date: The date to validate. 

26 

27 Returns: 

28 The validated date. 

29 """ 

30 date_is_provided = date is not None 

31 

32 if date_is_provided: 

33 if isinstance(date, str): 

34 if re.fullmatch(r"\d{4}-\d{2}(-\d{2})?", date): 

35 # Then it is in YYYY-MM-DD or YYYY-MMY format 

36 # Check if it is a valid date: 

37 computers.get_date_object(date) 

38 elif re.fullmatch(r"\d{4}", date): 

39 # Then it is in YYYY format, so, convert it to an integer: 

40 

41 # This is not required for start_date and end_date because they 

42 # can't be casted into a general string. For date, this needs to 

43 # be done manually, because it can be a general string. 

44 date = int(date) 

45 

46 elif isinstance(date, Date): 

47 # Pydantic parses YYYY-MM-DD dates as datetime.date objects. We need to 

48 # convert them to strings because that is how RenderCV uses them. 

49 date = date.isoformat() 

50 

51 return date 

52 

53 

54def validate_start_and_end_date_fields( 

55 date: str | Date, 

56) -> str: 

57 """Check if the `start_date` and `end_date` fields are provided correctly. 

58 

59 Args: 

60 date: The date to validate. 

61 

62 Returns: 

63 The validated date. 

64 """ 

65 date_is_provided = date is not None 

66 

67 if date_is_provided: 

68 if isinstance(date, Date): 

69 # Pydantic parses YYYY-MM-DD dates as datetime.date objects. We need to 

70 # convert them to strings because that is how RenderCV uses them. 

71 date = date.isoformat() 

72 

73 elif date != "present": 

74 # Validate the date: 

75 computers.get_date_object(date) 

76 

77 return date 

78 

79 

80# See https://peps.python.org/pep-0484/#forward-references for more information about 

81# the quotes around the type hints. 

82def validate_and_adjust_dates_for_an_entry( 

83 start_date: "StartDate", 

84 end_date: "EndDate", 

85 date: "ArbitraryDate", 

86) -> tuple["StartDate", "EndDate", "ArbitraryDate"]: 

87 """Check if the dates are provided correctly and make the necessary adjustments. 

88 

89 Args: 

90 start_date: The start date of the event. 

91 end_date: The end date of the event. 

92 date: The date of the event. 

93 

94 Returns: 

95 The validated and adjusted `start_date`, `end_date`, and `date`. 

96 """ 

97 date_is_provided = date is not None 

98 start_date_is_provided = start_date is not None 

99 end_date_is_provided = end_date is not None 

100 

101 if date_is_provided: 

102 # If only date is provided, ignore start_date and end_date: 

103 start_date = None 

104 end_date = None 

105 elif not start_date_is_provided and end_date_is_provided: 

106 # If only end_date is provided, assume it is a one-day event and act like 

107 # only the date is provided: 

108 date = end_date 

109 start_date = None 

110 end_date = None 

111 elif start_date_is_provided: 

112 start_date_object = computers.get_date_object(start_date) 

113 if not end_date_is_provided: 

114 # If only start_date is provided, assume it is an ongoing event, i.e., 

115 # the end_date is present: 

116 end_date = "present" 

117 

118 if end_date != "present": 

119 end_date_object = computers.get_date_object(end_date) 

120 

121 if start_date_object > end_date_object: 

122 message = '"start_date" can not be after "end_date"!' 

123 

124 raise ValueError( 

125 message, 

126 "start_date", # This is the location of the error 

127 str(start_date), # This is value of the error 

128 ) 

129 

130 return start_date, end_date, date 

131 

132 

133# ====================================================================================== 

134# Create custom types: ================================================================= 

135# ====================================================================================== 

136 

137 

138# See https://docs.pydantic.dev/2.7/concepts/types/#custom-types and 

139# https://docs.pydantic.dev/2.7/concepts/validators/#annotated-validators 

140# for more information about custom types. 

141 

142# ExactDate that accepts only strings in YYYY-MM-DD or YYYY-MM format: 

143ExactDate = Annotated[ 

144 str, 

145 pydantic.Field( 

146 pattern=r"\d{4}-\d{2}(-\d{2})?", 

147 ), 

148] 

149 

150# ArbitraryDate that accepts either an integer or a string, but it is validated with 

151# `validate_date_field` function: 

152ArbitraryDate = Annotated[ 

153 Optional[int | str], 

154 pydantic.BeforeValidator(validate_date_field), 

155] 

156 

157# StartDate that accepts either an integer or an ExactDate, but it is validated with 

158# `validate_start_and_end_date_fields` function: 

159StartDate = Annotated[ 

160 Optional[int | ExactDate], 

161 pydantic.BeforeValidator(validate_start_and_end_date_fields), 

162] 

163 

164# EndDate that accepts either an integer, the string "present", or an ExactDate, but it 

165# is validated with `validate_start_and_end_date_fields` function: 

166EndDate = Annotated[ 

167 Optional[Literal["present"] | int | ExactDate], 

168 pydantic.BeforeValidator(validate_start_and_end_date_fields), 

169] 

170 

171# ====================================================================================== 

172# Create the entry models: ============================================================= 

173# ====================================================================================== 

174 

175 

176class OneLineEntry(RenderCVBaseModelWithExtraKeys): 

177 """This class is the data model of `OneLineEntry`.""" 

178 

179 label: str = pydantic.Field( 

180 title="Name", 

181 description="The label of the OneLineEntry.", 

182 ) 

183 details: str = pydantic.Field( 

184 title="Details", 

185 description="The details of the OneLineEntry.", 

186 ) 

187 

188 

189class BulletEntry(RenderCVBaseModelWithExtraKeys): 

190 """This class is the data model of `BulletEntry`.""" 

191 

192 bullet: str = pydantic.Field( 

193 title="Bullet", 

194 description="The bullet of the BulletEntry.", 

195 ) 

196 

197 

198class EntryWithDate(RenderCVBaseModelWithExtraKeys): 

199 """This class is the parent class of some of the entry types that have date 

200 fields. 

201 """ 

202 

203 date: ArbitraryDate = pydantic.Field( 

204 default=None, 

205 title="Date", 

206 description=( 

207 "The date field can be filled in YYYY-MM-DD, YYYY-MM, or YYYY formats or as" 

208 ' an arbitrary string like "Fall 2023".' 

209 ), 

210 examples=["2020-09-24", "Fall 2023"], 

211 ) 

212 

213 @functools.cached_property 

214 def date_string(self) -> str: 

215 """Return a date string based on the `date` field and cache `date_string` as 

216 an attribute of the instance. 

217 """ 

218 return computers.compute_date_string( 

219 start_date=None, end_date=None, date=self.date 

220 ) 

221 

222 

223class PublicationEntryBase(RenderCVBaseModelWithExtraKeys): 

224 """This class is the parent class of the `PublicationEntry` class.""" 

225 

226 title: str = pydantic.Field( 

227 title="Publication Title", 

228 description="The title of the publication.", 

229 ) 

230 authors: list[str] = pydantic.Field( 

231 title="Authors", 

232 description="The authors of the publication in order as a list of strings.", 

233 ) 

234 doi: Optional[Annotated[str, pydantic.Field(pattern=r"\b10\..*")]] = pydantic.Field( 

235 default=None, 

236 title="DOI", 

237 description="The DOI of the publication.", 

238 examples=["10.48550/arXiv.2310.03138"], 

239 ) 

240 url: Optional[pydantic.HttpUrl] = pydantic.Field( 

241 default=None, 

242 title="URL", 

243 description=( 

244 "The URL of the publication. If DOI is provided, it will be ignored." 

245 ), 

246 ) 

247 journal: Optional[str] = pydantic.Field( 

248 default=None, 

249 title="Journal", 

250 description="The journal or conference name.", 

251 ) 

252 

253 @pydantic.model_validator(mode="after") # type: ignore 

254 def ignore_url_if_doi_is_given(self) -> "PublicationEntryBase": 

255 """Check if DOI is provided and ignore the URL if it is provided.""" 

256 doi_is_provided = self.doi is not None 

257 

258 if doi_is_provided: 

259 self.url = None 

260 

261 return self 

262 

263 @functools.cached_property 

264 def doi_url(self) -> str: 

265 """Return the URL of the DOI and cache `doi_url` as an attribute of the 

266 instance. 

267 """ 

268 doi_is_provided = self.doi is not None 

269 

270 if doi_is_provided: 

271 return f"https://doi.org/{self.doi}" 

272 return "" 

273 

274 @functools.cached_property 

275 def clean_url(self) -> str: 

276 """Return the clean URL of the publication and cache `clean_url` as an attribute 

277 of the instance. 

278 """ 

279 url_is_provided = self.url is not None 

280 

281 if url_is_provided: 

282 return computers.make_a_url_clean(str(self.url)) # type: ignore 

283 return "" 

284 

285 

286# The following class is to ensure PublicationEntryBase keys come first, 

287# then the keys of the EntryWithDate class. The only way to achieve this in Pydantic is 

288# to do this. The same thing is done for the other classes as well. 

289class PublicationEntry(EntryWithDate, PublicationEntryBase): 

290 """This class is the data model of `PublicationEntry`. `PublicationEntry` class is 

291 created by combining the `EntryWithDate` and `PublicationEntryBase` classes to have 

292 the fields in the correct order. 

293 """ 

294 

295 

296class EntryBase(EntryWithDate): 

297 """This class is the parent class of some of the entry types. It is being used 

298 because some of the entry types have common fields like dates, highlights, location, 

299 etc. 

300 """ 

301 

302 location: Optional[str] = pydantic.Field( 

303 default=None, 

304 title="Location", 

305 description="The location of the event.", 

306 examples=["Istanbul, Türkiye"], 

307 ) 

308 start_date: StartDate = pydantic.Field( 

309 default=None, 

310 title="Start Date", 

311 description=( 

312 "The start date of the event in YYYY-MM-DD, YYYY-MM, or YYYY format." 

313 ), 

314 examples=["2020-09-24"], 

315 ) 

316 end_date: EndDate = pydantic.Field( 

317 default=None, 

318 title="End Date", 

319 description=( 

320 "The end date of the event in YYYY-MM-DD, YYYY-MM, or YYYY format. If the" 

321 ' event is still ongoing, then type "present" or provide only the' 

322 " start_date." 

323 ), 

324 examples=["2020-09-24", "present"], 

325 ) 

326 highlights: Optional[list[str]] = pydantic.Field( 

327 default=None, 

328 title="Highlights", 

329 description="The highlights of the event as a list of strings.", 

330 examples=["Did this.", "Did that."], 

331 ) 

332 

333 @pydantic.model_validator(mode="after") # type: ignore 

334 def check_and_adjust_dates(self) -> "EntryBase": 

335 """Call the `validate_adjust_dates_of_an_entry` function to validate the 

336 dates. 

337 """ 

338 self.start_date, self.end_date, self.date = ( 

339 validate_and_adjust_dates_for_an_entry( 

340 start_date=self.start_date, end_date=self.end_date, date=self.date 

341 ) 

342 ) 

343 return self 

344 

345 @functools.cached_property 

346 def date_string(self) -> str: 

347 """Return a date string based on the `date`, `start_date`, and `end_date` fields 

348 and cache `date_string` as an attribute of the instance. 

349 

350 Example: 

351 ```python 

352 entry = dm.EntryBase(start_date="2020-10-11", end_date="2021-04-04").date_string 

353 ``` 

354 returns 

355 `"Nov 2020 to Apr 2021"` 

356 """ 

357 return computers.compute_date_string( 

358 start_date=self.start_date, end_date=self.end_date, date=self.date 

359 ) 

360 

361 @functools.cached_property 

362 def date_string_only_years(self) -> str: 

363 """Return a date string that only contains years based on the `date`, 

364 `start_date`, and `end_date` fields and cache `date_string_only_years` as an 

365 attribute of the instance. 

366 

367 Example: 

368 ```python 

369 entry = dm.EntryBase( 

370 start_date="2020-10-11", end_date="2021-04-04" 

371 ).date_string_only_years 

372 ``` 

373 returns 

374 `"2020 to 2021"` 

375 """ 

376 return computers.compute_date_string( 

377 start_date=self.start_date, 

378 end_date=self.end_date, 

379 date=self.date, 

380 show_only_years=True, 

381 ) 

382 

383 @functools.cached_property 

384 def time_span_string(self) -> str: 

385 """Return a time span string based on the `date`, `start_date`, and `end_date` 

386 fields and cache `time_span_string` as an attribute of the instance. 

387 """ 

388 return computers.compute_time_span_string( 

389 start_date=self.start_date, end_date=self.end_date, date=self.date 

390 ) 

391 

392 

393class NormalEntryBase(RenderCVBaseModelWithExtraKeys): 

394 """This class is the parent class of the `NormalEntry` class.""" 

395 

396 name: str = pydantic.Field( 

397 title="Name", 

398 description="The name of the NormalEntry.", 

399 ) 

400 

401 

402class NormalEntry(EntryBase, NormalEntryBase): 

403 """This class is the data model of `NormalEntry`. `NormalEntry` class is created by 

404 combining the `EntryBase` and `NormalEntryBase` classes to have the fields in the 

405 correct order. 

406 """ 

407 

408 

409class ExperienceEntryBase(RenderCVBaseModelWithExtraKeys): 

410 """This class is the parent class of the `ExperienceEntry` class.""" 

411 

412 company: str = pydantic.Field( 

413 title="Company", 

414 description="The company name.", 

415 ) 

416 position: str = pydantic.Field( 

417 title="Position", 

418 description="The position.", 

419 ) 

420 

421 

422class ExperienceEntry(EntryBase, ExperienceEntryBase): 

423 """This class is the data model of `ExperienceEntry`. `ExperienceEntry` class is 

424 created by combining the `EntryBase` and `ExperienceEntryBase` classes to have the 

425 fields in the correct order. 

426 """ 

427 

428 

429class EducationEntryBase(RenderCVBaseModelWithExtraKeys): 

430 """This class is the parent class of the `EducationEntry` class.""" 

431 

432 institution: str = pydantic.Field( 

433 title="Institution", 

434 description="The institution name.", 

435 ) 

436 area: str = pydantic.Field( 

437 title="Area", 

438 description="The area of study.", 

439 ) 

440 degree: Optional[str] = pydantic.Field( 

441 default=None, 

442 title="Degree", 

443 description="The type of the degree.", 

444 examples=["BS", "BA", "PhD", "MS"], 

445 ) 

446 

447 

448class EducationEntry(EntryBase, EducationEntryBase): 

449 """This class is the data model of `EducationEntry`. `EducationEntry` class is 

450 created by combining the `EntryBase` and `EducationEntryBase` classes to have the 

451 fields in the correct order. 

452 """ 

453 

454 

455# ====================================================================================== 

456# Create custom types based on the entry models: ======================================= 

457# ====================================================================================== 

458# Create a custom type named Entry: 

459Entry = ( 

460 OneLineEntry 

461 | NormalEntry 

462 | ExperienceEntry 

463 | EducationEntry 

464 | PublicationEntry 

465 | BulletEntry 

466 | str 

467) 

468 

469# Create a custom type named ListOfEntries: 

470ListOfEntries = ( 

471 list[OneLineEntry] 

472 | list[NormalEntry] 

473 | list[ExperienceEntry] 

474 | list[EducationEntry] 

475 | list[PublicationEntry] 

476 | list[BulletEntry] 

477 | list[str] 

478) 

479 

480# ====================================================================================== 

481# Store the available entry types: ===================================================== 

482# ====================================================================================== 

483# Entry.__args__[:-1] is a tuple of all the entry types except `str``: 

484# `str` (TextEntry) is not included because it's validation handled differently. It is 

485# not a Pydantic model, but a string. 

486available_entry_models: tuple[type[Entry]] = tuple(Entry.__args__[:-1]) 

487 

488available_entry_type_names = tuple( 

489 [entry_type.__name__ for entry_type in available_entry_models] + ["TextEntry"] 

490)