Coverage for src / rendercv / schema / models / cv / entries / bases / entry_with_complex_fields.py: 100%

59 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-25 13:49 +0000

1import re 

2from datetime import date as Date 

3from typing import Annotated, Literal, Self 

4 

5import pydantic 

6import pydantic_core 

7 

8from rendercv.exception import RenderCVInternalError 

9 

10from .....pydantic_error_handling import CustomPydanticErrorTypes 

11from ....validation_context import get_current_date 

12from .entry_with_date import BaseEntryWithDate 

13 

14 

15def validate_exact_date(date: str | int) -> str | int: 

16 """Validate date conforms to strict format requirements. 

17 

18 Why: 

19 start_date/end_date need strict formats for date arithmetic (calculating 

20 duration). Unlike arbitrary dates, these must parse to actual Date objects 

21 for comparison and duration rendering. 

22 

23 Args: 

24 date: Date value to validate. 

25 

26 Returns: 

27 Original date if valid. 

28 """ 

29 try: 

30 get_date_object(date) 

31 except RenderCVInternalError as e: 

32 raise pydantic_core.PydanticCustomError( 

33 CustomPydanticErrorTypes.other.value, 

34 "This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY" 

35 " format.", 

36 ) from e 

37 return date 

38 

39 

40type ExactDate = Annotated[str | int, pydantic.AfterValidator(validate_exact_date)] 

41 

42 

43def get_date_object(date: str | int, current_date: Date | None = None) -> Date: 

44 """Convert date string/int to Python Date object. 

45 

46 Why: 

47 Date arithmetic (start/end comparison, duration calculation) requires 

48 Python Date objects. This parser handles multiple formats including 

49 "present" keyword for ongoing positions. 

50 

51 Example: 

52 ```py 

53 date_obj = get_date_object("2023-05", Date(2025, 1, 1)) 

54 # Returns Date(2023, 5, 1) 

55 

56 present_obj = get_date_object("present", Date(2025, 1, 1)) 

57 # Returns Date(2025, 1, 1) 

58 ``` 

59 

60 Args: 

61 date: Date in YYYY-MM-DD, YYYY-MM, YYYY format, or "present". 

62 current_date: Reference date for "present" keyword. 

63 

64 Returns: 

65 Python Date object. 

66 """ 

67 if isinstance(date, int): 

68 date_object = Date(date, 1, 1) 

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

70 # Then it is in YYYY-MM-DD format 

71 date_object = Date.fromisoformat(date) 

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

73 # Then it is in YYYY-MM format 

74 date_object = Date.fromisoformat(f"{date}-01") 

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

76 # Then it is in YYYY format 

77 date_object = Date.fromisoformat(f"{date}-01-01") 

78 elif date == "present": 

79 if current_date is None: 

80 raise RenderCVInternalError( 

81 "current_date is None when processing 'present' date" 

82 ) 

83 date_object = current_date 

84 else: 

85 raise RenderCVInternalError("This is not a valid date!") 

86 

87 return date_object 

88 

89 

90class BaseEntryWithComplexFields(BaseEntryWithDate): 

91 model_config = pydantic.ConfigDict(json_schema_extra={"description": None}) 

92 

93 start_date: ExactDate | None = pydantic.Field( 

94 default=None, 

95 description="The start date in YYYY-MM-DD, YYYY-MM, or YYYY format.", 

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

97 ) 

98 end_date: ExactDate | Literal["present"] | None = pydantic.Field( 

99 default=None, 

100 description=( 

101 'The end date in YYYY-MM-DD, YYYY-MM, or YYYY format. Use "present" for' 

102 " ongoing events, or omit it to indicate the event is ongoing." 

103 ), 

104 examples=["2024-05-20", "2024-05", "2024", "present"], 

105 ) 

106 location: str | None = pydantic.Field( 

107 default=None, 

108 examples=["Istanbul, Türkiye", "New York, NY", "Remote"], 

109 ) 

110 summary: str | None = pydantic.Field( 

111 default=None, 

112 examples=[ 

113 "Led a team of 5 engineers to develop innovative solutions.", 

114 ( 

115 "Completed advanced coursework in machine learning and artificial" 

116 " intelligence." 

117 ), 

118 ], 

119 ) 

120 highlights: list[str] | None = pydantic.Field( 

121 default=None, 

122 description=( 

123 "Bullet points for key achievements, responsibilities, or contributions." 

124 ), 

125 examples=[ 

126 [ 

127 "Increased system performance by 40% through optimization.", 

128 "Mentored 3 junior developers and conducted code reviews.", 

129 "Implemented CI/CD pipeline reducing deployment time by 60%.", 

130 ] 

131 ], 

132 ) 

133 

134 @pydantic.model_validator(mode="after") 

135 def check_and_adjust_dates(self, info: pydantic.ValidationInfo) -> Self: 

136 date_is_provided = self.date is not None 

137 start_date_is_provided = self.start_date is not None 

138 end_date_is_provided = self.end_date is not None 

139 

140 if date_is_provided: 

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

142 self.start_date = None 

143 self.end_date = None 

144 elif not start_date_is_provided and end_date_is_provided: 

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

146 # only the date is provided: 

147 self.date = self.end_date 

148 self.start_date = None 

149 self.end_date = None 

150 elif start_date_is_provided and not end_date_is_provided: 

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

152 # end_date is present: 

153 self.end_date = "present" 

154 

155 if self.start_date and self.end_date: 

156 # Check if the start_date is before the end_date: 

157 current_date = get_current_date(info) 

158 start_date_object = get_date_object(self.start_date, current_date) 

159 end_date_object = get_date_object(self.end_date, current_date) 

160 if start_date_object > end_date_object: 

161 raise pydantic_core.PydanticCustomError( 

162 CustomPydanticErrorTypes.other.value, 

163 "`start_date` cannot be after `end_date`. The `start_date` is" 

164 " {start_date} and the `end_date` is {end_date}.", 

165 { 

166 "start_date": self.start_date, 

167 "end_date": self.end_date, 

168 }, 

169 ) 

170 

171 return self