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

138 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 00:25 +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 

176def make_keywords_bold_in_a_string(string: str, keywords: list[str]) -> str: 

177 """Make the given keywords bold in the given string.""" 

178 replacement_map = {keyword: f"**{keyword}**" for keyword in keywords} 

179 for keyword, replacement in replacement_map.items(): 

180 string = string.replace(keyword, replacement) 

181 

182 return string 

183 

184 

185class OneLineEntry(RenderCVBaseModelWithExtraKeys): 

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

187 

188 label: str = pydantic.Field( 

189 title="Name", 

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

191 ) 

192 details: str = pydantic.Field( 

193 title="Details", 

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

195 ) 

196 

197 def make_keywords_bold(self, keywords: list[str]) -> "OneLineEntry": 

198 """Make the given keywords bold in the `details` field. 

199 

200 Args: 

201 keywords: The keywords to make bold. 

202 

203 Returns: 

204 A OneLineEntry with the keywords made bold in the `details` field. 

205 """ 

206 self.details = make_keywords_bold_in_a_string(self.details, keywords) 

207 return self 

208 

209 

210class BulletEntry(RenderCVBaseModelWithExtraKeys): 

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

212 

213 bullet: str = pydantic.Field( 

214 title="Bullet", 

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

216 ) 

217 

218 def make_keywords_bold(self, keywords: list[str]) -> "BulletEntry": 

219 """Make the given keywords bold in the `bullet` field. 

220 

221 Args: 

222 keywords: The keywords to make bold. 

223 

224 Returns: 

225 A BulletEntry with the keywords made bold in the `bullet` field. 

226 """ 

227 self.bullet = make_keywords_bold_in_a_string(self.bullet, keywords) 

228 return self 

229 

230 

231class EntryWithDate(RenderCVBaseModelWithExtraKeys): 

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

233 fields. 

234 """ 

235 

236 date: ArbitraryDate = pydantic.Field( 

237 default=None, 

238 title="Date", 

239 description=( 

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

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

242 ), 

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

244 ) 

245 

246 @functools.cached_property 

247 def date_string(self) -> str: 

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

249 an attribute of the instance. 

250 """ 

251 return computers.compute_date_string( 

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

253 ) 

254 

255 

256class PublicationEntryBase(RenderCVBaseModelWithExtraKeys): 

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

258 

259 title: str = pydantic.Field( 

260 title="Publication Title", 

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

262 ) 

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

264 title="Authors", 

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

266 ) 

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

268 default=None, 

269 title="DOI", 

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

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

272 ) 

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

274 default=None, 

275 title="URL", 

276 description=( 

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

278 ), 

279 ) 

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

281 default=None, 

282 title="Journal", 

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

284 ) 

285 

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

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

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

289 doi_is_provided = self.doi is not None 

290 

291 if doi_is_provided: 

292 self.url = None 

293 

294 return self 

295 

296 @functools.cached_property 

297 def doi_url(self) -> str: 

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

299 instance. 

300 """ 

301 doi_is_provided = self.doi is not None 

302 

303 if doi_is_provided: 

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

305 return "" 

306 

307 @functools.cached_property 

308 def clean_url(self) -> str: 

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

310 of the instance. 

311 """ 

312 url_is_provided = self.url is not None 

313 

314 if url_is_provided: 

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

316 return "" 

317 

318 

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

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

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

322class PublicationEntry(EntryWithDate, PublicationEntryBase): 

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

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

325 the fields in the correct order. 

326 """ 

327 

328 

329class EntryBase(EntryWithDate): 

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

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

332 etc. 

333 """ 

334 

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

336 default=None, 

337 title="Location", 

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

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

340 ) 

341 start_date: StartDate = pydantic.Field( 

342 default=None, 

343 title="Start Date", 

344 description=( 

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

346 ), 

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

348 ) 

349 end_date: EndDate = pydantic.Field( 

350 default=None, 

351 title="End Date", 

352 description=( 

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

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

355 " start_date." 

356 ), 

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

358 ) 

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

360 default=None, 

361 title="Highlights", 

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

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

364 ) 

365 summary: Optional[str] = pydantic.Field( 

366 default=None, 

367 title="Summary", 

368 description="The summary of the event.", 

369 examples=["Did this and that."], 

370 ) 

371 

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

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

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

375 dates. 

376 """ 

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

378 validate_and_adjust_dates_for_an_entry( 

379 start_date=self.start_date, end_date=self.end_date, date=self.date 

380 ) 

381 ) 

382 return self 

383 

384 @functools.cached_property 

385 def date_string(self) -> str: 

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

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

388 

389 Example: 

390 ```python 

391 entry = dm.EntryBase( 

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

393 ).date_string 

394 ``` 

395 returns 

396 `"Nov 2020 to Apr 2021"` 

397 """ 

398 return computers.compute_date_string( 

399 start_date=self.start_date, end_date=self.end_date, date=self.date 

400 ) 

401 

402 @functools.cached_property 

403 def date_string_only_years(self) -> str: 

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

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

406 attribute of the instance. 

407 

408 Example: 

409 ```python 

410 entry = dm.EntryBase( 

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

412 ).date_string_only_years 

413 ``` 

414 returns 

415 `"2020 to 2021"` 

416 """ 

417 return computers.compute_date_string( 

418 start_date=self.start_date, 

419 end_date=self.end_date, 

420 date=self.date, 

421 show_only_years=True, 

422 ) 

423 

424 @functools.cached_property 

425 def time_span_string(self) -> str: 

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

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

428 """ 

429 return computers.compute_time_span_string( 

430 start_date=self.start_date, end_date=self.end_date, date=self.date 

431 ) 

432 

433 def make_keywords_bold(self, keywords: list[str]) -> "EntryBase": 

434 """Make the given keywords bold in the `summary` and `highlights` fields. 

435 

436 Args: 

437 keywords: The keywords to make bold. 

438 

439 Returns: 

440 An EntryBase with the keywords made bold in the `summary` and `highlights` 

441 fields. 

442 """ 

443 if self.summary: 

444 self.summary = make_keywords_bold_in_a_string(self.summary, keywords) 

445 

446 if self.highlights: 

447 self.highlights = [ 

448 make_keywords_bold_in_a_string(highlight, keywords) 

449 for highlight in self.highlights 

450 ] 

451 

452 return self 

453 

454 

455class NormalEntryBase(RenderCVBaseModelWithExtraKeys): 

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

457 

458 name: str = pydantic.Field( 

459 title="Name", 

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

461 ) 

462 

463 

464class NormalEntry(EntryBase, NormalEntryBase): 

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

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

467 correct order. 

468 """ 

469 

470 

471class ExperienceEntryBase(RenderCVBaseModelWithExtraKeys): 

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

473 

474 company: str = pydantic.Field( 

475 title="Company", 

476 description="The company name.", 

477 ) 

478 position: str = pydantic.Field( 

479 title="Position", 

480 description="The position.", 

481 ) 

482 

483 

484class ExperienceEntry(EntryBase, ExperienceEntryBase): 

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

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

487 fields in the correct order. 

488 """ 

489 

490 

491class EducationEntryBase(RenderCVBaseModelWithExtraKeys): 

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

493 

494 institution: str = pydantic.Field( 

495 title="Institution", 

496 description="The institution name.", 

497 ) 

498 area: str = pydantic.Field( 

499 title="Area", 

500 description="The area of study.", 

501 ) 

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

503 default=None, 

504 title="Degree", 

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

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

507 ) 

508 

509 

510class EducationEntry(EntryBase, EducationEntryBase): 

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

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

513 fields in the correct order. 

514 """ 

515 

516 

517# ====================================================================================== 

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

519# ====================================================================================== 

520# Create a custom type named Entry: 

521Entry = ( 

522 OneLineEntry 

523 | NormalEntry 

524 | ExperienceEntry 

525 | EducationEntry 

526 | PublicationEntry 

527 | BulletEntry 

528 | str 

529) 

530 

531# Create a custom type named ListOfEntries: 

532ListOfEntries = ( 

533 list[OneLineEntry] 

534 | list[NormalEntry] 

535 | list[ExperienceEntry] 

536 | list[EducationEntry] 

537 | list[PublicationEntry] 

538 | list[BulletEntry] 

539 | list[str] 

540) 

541 

542# ====================================================================================== 

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

544# ====================================================================================== 

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

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

547# not a Pydantic model, but a string. 

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

549 

550available_entry_type_names = tuple( 

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

552)