Coverage for src / rendercv / renderer / templater / date.py: 100%

73 statements  

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

1from datetime import date as Date 

2 

3from rendercv.exception import RenderCVInternalError 

4from rendercv.schema.models.cv.entries.bases.entry_with_complex_fields import ( 

5 get_date_object, 

6) 

7from rendercv.schema.models.locale.locale import Locale 

8 

9from .string_processor import substitute_placeholders 

10 

11 

12def build_date_placeholders(date: Date, *, locale: Locale) -> dict[str, str]: 

13 """Build all date-related template placeholders from a date and locale. 

14 

15 Why: 

16 Date placeholders are needed in single_date templates, footer, top_note, 

17 and file path resolution. Centralizing construction eliminates duplication 

18 and ensures new placeholders propagate everywhere. 

19 

20 Args: 

21 date: Date to extract components from. 

22 locale: Locale providing month names and abbreviations. 

23 

24 Returns: 

25 Dict mapping placeholder names to their string values. 

26 """ 

27 month = date.month 

28 day = date.day 

29 year = date.year 

30 

31 return { 

32 "MONTH_NAME": locale.month_names[month - 1], 

33 "MONTH_ABBREVIATION": locale.month_abbreviations[month - 1], 

34 "MONTH": str(month), 

35 "MONTH_IN_TWO_DIGITS": f"{month:02d}", 

36 "DAY": str(day), 

37 "DAY_IN_TWO_DIGITS": f"{day:02d}", 

38 "YEAR": str(year), 

39 "YEAR_IN_TWO_DIGITS": f"{year % 100:02d}", 

40 } 

41 

42 

43def date_object_to_string( 

44 date: Date, *, locale: Locale, single_date_template: str 

45) -> str: 

46 """Convert date object to localized string using template placeholders. 

47 

48 Why: 

49 Date display varies by locale and user preference. Template-based 

50 formatting allows MONTH_ABBREVIATION YEAR or MONTH_NAME, YEAR without 

51 hardcoding formats for each language. 

52 

53 Example: 

54 ```py 

55 result = date_object_to_string( 

56 Date(2025, 3, 15), 

57 locale=english_locale, 

58 single_date_template="MONTH_ABBREVIATION YEAR", 

59 ) 

60 # Returns: "Mar 2025" 

61 ``` 

62 

63 Args: 

64 date: Date to format. 

65 locale: Locale providing month names and abbreviations. 

66 single_date_template: Template with date placeholders. 

67 

68 Returns: 

69 Formatted date string with placeholders substituted. 

70 """ 

71 return substitute_placeholders( 

72 single_date_template, build_date_placeholders(date, locale=locale) 

73 ) 

74 

75 

76def format_date_range( 

77 start_date: str | int, 

78 end_date: str | int, 

79 *, 

80 locale: Locale, 

81 single_date_template: str, 

82 date_range_template: str, 

83) -> str: 

84 """Format date range with localized start and end dates. 

85 

86 Why: 

87 CV entries use date ranges for employment and education. Users provide 

88 dates as year-only integers, YYYY-MM strings, YYYY-MM-DD strings, or 

89 "present". Unified formatting handles all input types consistently. 

90 

91 Example: 

92 ```py 

93 result = format_date_range( 

94 "2020-06", 

95 "present", 

96 locale=english_locale, 

97 single_date_template="MONTH_ABBREVIATION YEAR", 

98 date_range_template="START_DATE to END_DATE", 

99 ) 

100 # Returns: "Jun 2020 to present" 

101 ``` 

102 

103 Args: 

104 start_date: Start date as integer year or ISO date string. 

105 end_date: End date as integer year, ISO date string, or "present". 

106 locale: Locale providing month names and present translation. 

107 single_date_template: Template for formatting individual dates. 

108 date_range_template: Template combining start and end dates. 

109 

110 Returns: 

111 Formatted date range string. 

112 """ 

113 if isinstance(start_date, int): 

114 # Then it means only the year is provided 

115 start_date = str(start_date) 

116 else: 

117 # Then it means start_date is either in YYYY-MM-DD or YYYY-MM format 

118 date_object = get_date_object(start_date) 

119 start_date = date_object_to_string( 

120 date_object, locale=locale, single_date_template=single_date_template 

121 ) 

122 

123 if end_date == "present": 

124 end_date = locale.present 

125 elif isinstance(end_date, int): 

126 # Then it means only the year is provided 

127 end_date = str(end_date) 

128 else: 

129 # Then it means end_date is either in YYYY-MM-DD or YYYY-MM format 

130 date_object = get_date_object(end_date) 

131 end_date = date_object_to_string( 

132 date_object, locale=locale, single_date_template=single_date_template 

133 ) 

134 

135 placeholders: dict[str, str] = { 

136 "START_DATE": start_date, 

137 "END_DATE": end_date, 

138 } 

139 

140 return substitute_placeholders(date_range_template, placeholders) 

141 

142 

143def format_single_date( 

144 date: str | int, *, locale: Locale, single_date_template: str 

145) -> str: 

146 """Format single date with locale-aware template or pass through custom strings. 

147 

148 Why: 

149 Publications and certifications use single dates rather than ranges. 

150 Custom date strings like "Spring 2024" need preservation while standard 

151 dates require localized formatting. 

152 

153 Example: 

154 ```py 

155 # Standard date formatting 

156 result = format_single_date( 

157 "2024-03", locale=english_locale, single_date_template="MONTH_NAME YEAR" 

158 ) 

159 # Returns: "March 2024" 

160 

161 # Custom string pass-through 

162 result = format_single_date( 

163 "Spring 2024", locale=english_locale, single_date_template="MONTH_NAME YEAR" 

164 ) 

165 # Returns: "Spring 2024" 

166 ``` 

167 

168 Args: 

169 date: Date as integer year, ISO date string, "present", or custom text. 

170 locale: Locale providing present translation. 

171 single_date_template: Template for formatting standard dates. 

172 

173 Returns: 

174 Formatted date string or original custom text. 

175 """ 

176 if isinstance(date, int): 

177 # Only year is provided 

178 date_string = str(date) 

179 elif date == "present": 

180 date_string = locale.present 

181 else: 

182 try: 

183 date_object = get_date_object(date) 

184 date_string = date_object_to_string( 

185 date_object, locale=locale, single_date_template=single_date_template 

186 ) 

187 except RenderCVInternalError: 

188 # Then it is a custom date string (e.g., "My Custom Date") 

189 date_string = str(date) 

190 

191 return date_string 

192 

193 

194def compute_time_span_string( 

195 start_date: str | int, 

196 end_date: str | int, 

197 *, 

198 locale: Locale, 

199 current_date: Date, 

200 time_span_template: str, 

201) -> str: 

202 """Calculate and format duration between dates with localized units. 

203 

204 Why: 

205 CV readers need quick understanding of experience length. Automatic 

206 calculation shows "2 years 3 months" or "1 year" based on date 

207 precision, with proper singular/plural forms per locale. 

208 

209 Example: 

210 ```py 

211 result = compute_time_span_string( 

212 "2020-06", 

213 "2023-09", 

214 locale=english_locale, 

215 current_date=Date(2025, 1, 1), 

216 time_span_template="HOW_MANY_YEARS YEARS HOW_MANY_MONTHS MONTHS", 

217 ) 

218 # Returns: "3 years 4 months" 

219 ``` 

220 

221 Args: 

222 start_date: Start date as integer year or ISO date string. 

223 end_date: End date as integer year, ISO date string, or "present". 

224 locale: Locale providing year/month singular/plural forms. 

225 current_date: Reference date for "present" calculation. 

226 time_span_template: Template for formatting duration output. 

227 

228 Returns: 

229 Formatted time span string with years and months. 

230 """ 

231 if isinstance(start_date, int) or isinstance(end_date, int): 

232 # Then it means one of the dates is year, so time span cannot be more 

233 # specific than years. 

234 start_year = get_date_object(start_date, current_date).year 

235 end_year = get_date_object(end_date, current_date).year 

236 

237 time_span_in_years = end_year - start_year 

238 

239 if time_span_in_years < 2: 

240 how_many_years = "1" 

241 locale_years = locale.year 

242 else: 

243 how_many_years = str(time_span_in_years) 

244 locale_years = locale.years 

245 

246 placeholders: dict[str, str] = { 

247 "HOW_MANY_YEARS": how_many_years, 

248 "YEARS": locale_years, 

249 "HOW_MANY_MONTHS": "", 

250 "MONTHS": "", 

251 } 

252 

253 return substitute_placeholders(time_span_template, placeholders) 

254 

255 # Then it means both start_date and end_date are in YYYY-MM-DD or YYYY-MM 

256 # format. 

257 end_date_object = get_date_object(end_date, current_date) 

258 start_date_object = get_date_object(start_date, current_date) 

259 

260 # Calculate the number of days between start_date and end_date: 

261 timespan_in_days = (end_date_object - start_date_object).days 

262 

263 # Calculate the number of years and months between start_date and end_date: 

264 how_many_years = timespan_in_days // 365 

265 how_many_months = (timespan_in_days % 365) // 30 + 1 

266 # Deal with overflow (prevent rounding to 1 year 12 months, etc.) 

267 how_many_years += how_many_months // 12 

268 how_many_months %= 12 

269 

270 # Format the number of years and months between start_date and end_date: 

271 if how_many_years == 0: 

272 how_many_years = "" 

273 locale_years = "" 

274 elif how_many_years == 1: 

275 how_many_years = "1" 

276 locale_years = locale.year 

277 else: 

278 how_many_years = str(how_many_years) 

279 locale_years = locale.years 

280 

281 # Format the number of months between start_date and end_date: 

282 if how_many_months == 0: 

283 how_many_months = "" 

284 locale_months = "" 

285 elif how_many_months == 1: 

286 how_many_months = "1" 

287 locale_months = locale.month 

288 else: 

289 how_many_months = str(how_many_months) 

290 locale_months = locale.months 

291 

292 placeholders = { 

293 "HOW_MANY_YEARS": how_many_years, 

294 "YEARS": locale_years, 

295 "HOW_MANY_MONTHS": how_many_months, 

296 "MONTHS": locale_months, 

297 } 

298 return substitute_placeholders(time_span_template, placeholders)