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

123 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-07 17:51 +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 (Optional[int | str]): The date to validate. 

26 

27 Returns: 

28 Optional[int | str]: 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 (Optional[Literal["present"] | int | RenderCVDate]): The date to validate. 

61 

62 Returns: 

63 Optional[Literal["present"] | int | RenderCVDate]: 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 (StartDate): The start date of the event. 

91 end_date (EndDate): The end date of the event. 

92 date (ArbitraryDate): The date of the event. 

93 

94 Returns: 

95 EntryBase: The validated 

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 raise ValueError( 

123 '"start_date" can not be after "end_date"!', 

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

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

126 ) 

127 

128 return start_date, end_date, date 

129 

130 

131# ====================================================================================== 

132# Create custom types: ================================================================= 

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

134 

135 

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

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

138# for more information about custom types. 

139 

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

141ExactDate = Annotated[ 

142 str, 

143 pydantic.Field( 

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

145 ), 

146] 

147 

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

149# `validate_date_field` function: 

150ArbitraryDate = Annotated[ 

151 Optional[int | str], 

152 pydantic.BeforeValidator(validate_date_field), 

153] 

154 

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

156# `validate_start_and_end_date_fields` function: 

157StartDate = Annotated[ 

158 Optional[int | ExactDate], 

159 pydantic.BeforeValidator(validate_start_and_end_date_fields), 

160] 

161 

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

163# is validated with `validate_start_and_end_date_fields` function: 

164EndDate = Annotated[ 

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

166 pydantic.BeforeValidator(validate_start_and_end_date_fields), 

167] 

168 

169# ====================================================================================== 

170# Create the entry models: ============================================================= 

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

172 

173 

174class OneLineEntry(RenderCVBaseModelWithExtraKeys): 

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

176 

177 label: str = pydantic.Field( 

178 title="Name", 

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

180 ) 

181 details: str = pydantic.Field( 

182 title="Details", 

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

184 ) 

185 

186 

187class BulletEntry(RenderCVBaseModelWithExtraKeys): 

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

189 

190 bullet: str = pydantic.Field( 

191 title="Bullet", 

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

193 ) 

194 

195 

196class EntryWithDate(RenderCVBaseModelWithExtraKeys): 

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

198 fields. 

199 """ 

200 

201 date: ArbitraryDate = pydantic.Field( 

202 default=None, 

203 title="Date", 

204 description=( 

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

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

207 ), 

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

209 ) 

210 

211 @functools.cached_property 

212 def date_string(self) -> str: 

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

214 an attribute of the instance. 

215 """ 

216 return computers.compute_date_string( 

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

218 ) 

219 

220 

221class PublicationEntryBase(RenderCVBaseModelWithExtraKeys): 

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

223 

224 title: str = pydantic.Field( 

225 title="Publication Title", 

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

227 ) 

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

229 title="Authors", 

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

231 ) 

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

233 default=None, 

234 title="DOI", 

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

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

237 ) 

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

239 default=None, 

240 title="URL", 

241 description=( 

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

243 ), 

244 ) 

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

246 default=None, 

247 title="Journal", 

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

249 ) 

250 

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

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

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

254 doi_is_provided = self.doi is not None 

255 

256 if doi_is_provided: 

257 self.url = None 

258 

259 return self 

260 

261 @functools.cached_property 

262 def doi_url(self) -> str: 

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

264 instance. 

265 """ 

266 doi_is_provided = self.doi is not None 

267 

268 if doi_is_provided: 

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

270 else: 

271 return "" 

272 

273 @functools.cached_property 

274 def clean_url(self) -> str: 

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

276 of the instance. 

277 """ 

278 url_is_provided = self.url is not None 

279 

280 if url_is_provided: 

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

282 else: 

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 pass 

296 

297 

298class EntryBase(EntryWithDate): 

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

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

301 etc. 

302 """ 

303 

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

305 default=None, 

306 title="Location", 

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

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

309 ) 

310 start_date: StartDate = pydantic.Field( 

311 default=None, 

312 title="Start Date", 

313 description=( 

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

315 ), 

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

317 ) 

318 end_date: EndDate = pydantic.Field( 

319 default=None, 

320 title="End Date", 

321 description=( 

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

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

324 " start_date." 

325 ), 

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

327 ) 

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

329 default=None, 

330 title="Highlights", 

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

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

333 ) 

334 

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

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

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

338 dates. 

339 """ 

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

341 validate_and_adjust_dates_for_an_entry( 

342 start_date=self.start_date, end_date=self.end_date, date=self.date 

343 ) 

344 ) 

345 return self 

346 

347 @functools.cached_property 

348 def date_string(self) -> str: 

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

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

351 

352 Example: 

353 ```python 

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

355 ``` 

356 returns 

357 `#!python "Nov 2020 to Apr 2021"` 

358 """ 

359 return computers.compute_date_string( 

360 start_date=self.start_date, end_date=self.end_date, date=self.date 

361 ) 

362 

363 @functools.cached_property 

364 def date_string_only_years(self) -> str: 

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

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

367 attribute of the instance. 

368 

369 Example: 

370 ```python 

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

372 ``` 

373 returns 

374 `#!python "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 pass 

409 

410 

411class ExperienceEntryBase(RenderCVBaseModelWithExtraKeys): 

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

413 

414 company: str = pydantic.Field( 

415 title="Company", 

416 description="The company name.", 

417 ) 

418 position: str = pydantic.Field( 

419 title="Position", 

420 description="The position.", 

421 ) 

422 

423 

424class ExperienceEntry(EntryBase, ExperienceEntryBase): 

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

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

427 fields in the correct order. 

428 """ 

429 

430 pass 

431 

432 

433class EducationEntryBase(RenderCVBaseModelWithExtraKeys): 

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

435 

436 institution: str = pydantic.Field( 

437 title="Institution", 

438 description="The institution name.", 

439 ) 

440 area: str = pydantic.Field( 

441 title="Area", 

442 description="The area of study.", 

443 ) 

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

445 default=None, 

446 title="Degree", 

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

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

449 ) 

450 

451 

452class EducationEntry(EntryBase, EducationEntryBase): 

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

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

455 fields in the correct order. 

456 """ 

457 

458 pass 

459 

460 

461# ====================================================================================== 

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

463# ====================================================================================== 

464# Create a custom type named Entry: 

465Entry = ( 

466 OneLineEntry 

467 | NormalEntry 

468 | ExperienceEntry 

469 | EducationEntry 

470 | PublicationEntry 

471 | BulletEntry 

472 | str 

473) 

474 

475# Create a custom type named ListOfEntries: 

476ListOfEntries = ( 

477 list[OneLineEntry] 

478 | list[NormalEntry] 

479 | list[ExperienceEntry] 

480 | list[EducationEntry] 

481 | list[PublicationEntry] 

482 | list[BulletEntry] 

483 | list[str] 

484) 

485 

486# ====================================================================================== 

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

488# ====================================================================================== 

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

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

491# not a Pydantic model, but a string. 

492available_entry_models = list(Entry.__args__[:-1]) 

493 

494available_entry_type_names = [ 

495 entry_type.__name__ for entry_type in available_entry_models 

496] + ["TextEntry"]