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
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
1from datetime import date as Date
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
9from .string_processor import substitute_placeholders
12def build_date_placeholders(date: Date, *, locale: Locale) -> dict[str, str]:
13 """Build all date-related template placeholders from a date and locale.
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.
20 Args:
21 date: Date to extract components from.
22 locale: Locale providing month names and abbreviations.
24 Returns:
25 Dict mapping placeholder names to their string values.
26 """
27 month = date.month
28 day = date.day
29 year = date.year
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 }
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.
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.
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 ```
63 Args:
64 date: Date to format.
65 locale: Locale providing month names and abbreviations.
66 single_date_template: Template with date placeholders.
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 )
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.
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.
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 ```
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.
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 )
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 )
135 placeholders: dict[str, str] = {
136 "START_DATE": start_date,
137 "END_DATE": end_date,
138 }
140 return substitute_placeholders(date_range_template, placeholders)
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.
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.
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"
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 ```
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.
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)
191 return date_string
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.
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.
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 ```
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.
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
237 time_span_in_years = end_year - start_year
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
246 placeholders: dict[str, str] = {
247 "HOW_MANY_YEARS": how_many_years,
248 "YEARS": locale_years,
249 "HOW_MANY_MONTHS": "",
250 "MONTHS": "",
251 }
253 return substitute_placeholders(time_span_template, placeholders)
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)
260 # Calculate the number of days between start_date and end_date:
261 timespan_in_days = (end_date_object - start_date_object).days
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
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
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
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)