Coverage for apis_core/utils/DateParser.py: 61%
151 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-22 07:51 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-22 07:51 +0000
1import math
2import re
3from datetime import datetime, timedelta
6def parse_date(date_string: str) -> (datetime, datetime, datetime):
7 """
8 function to parse a string date field of an entity
10 :param date_string : str :
11 the field value passed by a user
12 :return date_single : datetime :
13 single date which represents either the precise date given by user or median in between a range.
14 :return date_ab : datetime :
15 starting date of a range if user passed a range value either implicit or explicit.
16 :return date_bis : datetime :
17 ending date of a range if user passed a range value either implicit or explicit.
18 """
20 def parse_date_range_individual(date, ab=False, bis=False):
21 """
22 As a sub function to parse_date, this function parse_date_individual handles a very single date since
23 in a text field a user can pass multiple dates.
25 :param date : str :
26 recognized sub string which potentially is a date (in julian calendar format)
27 :param ab : boolean : optional
28 indicates if a single date shall be intepreted as a starting date of a range
29 :param bis : boolean : optional
30 indicates if a single date shall be intepreted as an ending date of a range
31 :return tuple (datetime, datetime) :
32 two datetime objects representing the dates.
33 Two indicate that an implicit single date range was given (e.g. a year without months or days).
34 Has to be further processed then since it can be either a starting or ending date range.
35 or
36 :return datetime :
37 One datetime object representing the date.
38 if a single date was given.
39 """
41 def get_last_day_of_month(month, year):
42 """
43 Helper function to return the last day of a given month and year (respecting leap years)
45 :param month : int
46 :param year : int
47 :return day : int
48 """
50 if month in [1, 3, 5, 7, 8, 10, 12]:
51 # 31 day months
52 return 31
53 elif month in [4, 6, 9, 11]:
54 # 30 day months
55 return 30
56 elif month == 2:
57 # special case february, differentiate leap years with respect to gregorian leap rules
58 if year % 4 == 0:
59 if year % 100 == 0:
60 if year % 400 == 0:
61 # divisible by 4, by 100, by 400
62 # thus is leap year
63 return 29
64 else:
65 # divisible by 4, by 100, not by 400
66 # thus is not leap yar
67 return 28
68 else:
69 # divisible by 4, not by 100, if by 400 doesn't matter
70 # thus is leap year
71 return 29
72 else:
73 # not divisible by 4, if by 100 or by 400 doesn't matter
74 return 28
75 else:
76 # no valid month
77 raise ValueError("Month " + str(month) + " does not exist.")
79 # replace all kinds of delimiters
80 date = (
81 date.replace(" ", "").replace("-", ".").replace("/", ".").replace("\\", ".")
82 )
83 # parse into variables for use later
84 year = None
85 month = None
86 day = None
87 # check for all kind of Y-M-D combinations
88 if re.match(r"\d{3,4}$", date):
89 # year
90 year = int(date)
91 elif re.match(r"\d{1,2}\.\d{3,4}$", date):
92 # month - year
93 tmp = re.split(r"\.", date)
94 month = int(tmp[0])
95 year = int(tmp[1])
96 elif re.match(r"\d{1,2}\.\d{1,2}\.\d{3,4}$", date):
97 # day - month - year
98 tmp = re.split(r"\.", date)
99 day = int(tmp[0])
100 month = int(tmp[1])
101 year = int(tmp[2])
102 elif re.match(r"\d{3,4}\.\d{1,2}\.?$", date):
103 # year - month
104 tmp = re.split(r"\.", date)
105 year = int(tmp[0])
106 month = int(tmp[1])
107 elif re.match(r"\d{3,4}\.\d{1,2}\.\d{1,2}\.?$", date):
108 # year - month - day
109 tmp = re.split(r"\.", date)
110 year = int(tmp[0])
111 month = int(tmp[1])
112 day = int(tmp[2])
113 else:
114 # No sensical interpretation found
115 raise ValueError("Could not interpret date.")
116 if (ab and bis) or year is None:
117 # both ab and bis in one single date are not valid, neither is the absence of a year.
118 raise ValueError("Could not interpret date.")
119 elif not ab and not bis and (month is None or day is None):
120 # if both ab and bis are False and either month or day is empty, then it was given
121 # an implicit date range (range of all months if given a year or all days if given a month)
122 # construct implicit month range
123 if month is None:
124 month_ab = 1
125 month_bis = 12
126 else:
127 month_ab = month
128 month_bis = month
129 # construct implicit day range
130 if day is None:
131 day_ab = 1
132 day_bis = get_last_day_of_month(month_bis, year)
133 else:
134 day_ab = day
135 day_bis = day
137 # return a tuple from a single date (which the calling function has to further process)
138 return (
139 datetime(year=year, month=month_ab, day=day_ab),
140 datetime(year=year, month=month_bis, day=day_bis),
141 )
142 else:
143 # Either ab or bis is True. Then use the respective beginning or end of range and construct a precise date
144 # Or both ab and bis are False. Then construct a precise date from parsed values
145 # construct implicit month range if month is None
146 if month is None:
147 if ab and not bis:
148 # is a starting date, thus take first month of year
149 month = 1
150 elif not ab and bis:
151 # is an ending date, thus take last month of year
152 month = 12
153 # construct implicit day range if day is None
154 if day is None:
155 if ab and not bis:
156 # is a starting date, thus take first day of month
157 day = 1
158 elif not ab and bis:
159 # is an ending date, thus take last month of year
160 day = get_last_day_of_month(month=month, year=year)
162 return datetime(year=year, month=month, day=day)
164 try:
165 # return variables
166 date_single = None
167 date_ab = None
168 date_bis = None
169 # split for angle brackets, check if explicit iso date is contained within them
170 date_split_angle = re.split(r"(<.*?>)", date_string)
171 if len(date_split_angle) > 1:
172 # date string contains angle brackets. Parse them, ignore the rest
173 def parse_iso_date(date_string):
174 date_string_split = date_string.split("-")
175 try:
176 return datetime(
177 year=int(date_string_split[0]),
178 month=int(date_string_split[1]),
179 day=int(date_string_split[2]),
180 )
181 except Exception:
182 raise ValueError("Invalid iso date: ", date_string)
184 if len(date_split_angle) > 3:
185 # invalid case
186 raise ValueError("Too many angle brackets.")
187 elif len(date_split_angle) == 3:
188 # the right amount of substrings, indicating exactly one pair of angle brackets.
189 # Parse the iso date in between
190 # remove angle brackets and split by commas
191 dates_iso = date_split_angle[1][1:-1]
192 # check for commas, which would indicate that either one iso date or three are being input
193 dates_iso = dates_iso.split(",")
194 if len(dates_iso) != 1 and len(dates_iso) != 3:
195 # only either one iso date or three are allowed
196 raise ValueError(
197 "Incorrect number of dates given. Within angle brackets only one or three (separated by commas) are allowed."
198 )
199 elif len(dates_iso) == 3:
200 # three iso dates indicate further start and end dates
201 # parse start date
202 date_ab_string = dates_iso[1].strip()
203 if date_ab_string != "":
204 date_ab = parse_iso_date(date_ab_string)
205 # parse end date
206 date_bis_string = dates_iso[2].strip()
207 if date_bis_string != "":
208 date_bis = parse_iso_date(date_bis_string)
209 # parse single date
210 date_single_string = dates_iso[0].strip()
211 if date_single_string != "":
212 date_single = parse_iso_date(date_single_string)
213 else:
214 # date string contains no angle brackets. Interpret the possible date formats
215 date_string = date_string.lower()
216 date_string = date_string.replace(" ", "")
217 # helper variables for the following loop
218 found_ab = False
219 found_bis = False
220 found_single = False
221 # split by allowed keywords 'ab' and 'bis' and iterate over them
222 date_split_ab_bis = re.split(r"(ab|bis)", date_string)
223 for i, v in enumerate(date_split_ab_bis):
224 if v == "ab":
225 # indicates that the next value must be a start date
226 if found_ab or found_single:
227 # if already found a ab_date or single date before then there is non-conformative redundancy
228 raise ValueError("Redundant dates found.")
229 found_ab = True
230 # parse the next value which must be a parsable date string
231 date_ab = parse_date_range_individual(
232 date_split_ab_bis[i + 1], ab=True
233 )
234 elif v == "bis":
235 # indicates that the next value must be an end date
236 if found_bis or found_single:
237 # if already found a bis_date or single date before then there is non-conformative redundancy
238 raise ValueError("Redundant dates found.")
239 found_bis = True
241 # parse the next value which must be a parsable date string
242 date_bis = parse_date_range_individual(
243 date_split_ab_bis[i + 1], bis=True
244 )
245 elif v != "" and not found_ab and not found_bis and not found_single:
246 # indicates that this value must be a date
247 found_single = True
248 # parse the this value which must be a parsable date string
249 date_single = parse_date_range_individual(v)
250 if type(date_single) is tuple:
251 # if result of parse_date_range_individual is a tuple then the date was an implict range.
252 # Then split it into start and end dates
253 date_ab = date_single[0]
254 date_bis = date_single[1]
255 if date_ab and date_bis:
256 # date is a range
257 if date_ab > date_bis:
258 raise ValueError("'ab-date' must be before 'bis-date' in time")
259 # calculate difference between start and end date of range,
260 # and use it to calculate a single date for usage as median.
261 days_delta_half = math.floor(
262 (date_bis - date_ab).days / 2,
263 )
264 date_single = date_ab + timedelta(days=days_delta_half)
265 elif date_ab is not None and date_bis is None:
266 # date is only the start of a range, save it also as the single date
267 date_single = date_ab
268 elif date_ab is None and date_bis is not None:
269 # date is only the end of a range, save it also as the single date
270 date_single = date_bis
272 except Exception as e:
273 print("Could not parse date: '", date_string, "' due to error: ", e)
275 return date_single, date_ab, date_bis
278def get_date_help_text_from_dates(
279 single_date, single_start_date, single_end_date, single_date_written
280):
281 """
282 function for creating string help text from parsed dates, to provide feedback to the user
283 about the parsing status of a given date field.
285 :param single_date: datetime :
286 the individual date point
287 :param single_start_date: datetime :
288 the start range of a date
289 :param single_end_date: datetime :
290 the endrange of a date
291 :param single_date_written: str :
292 the textual user entry of a date field (needed to check if empty or not)
293 :return help_text: str :
294 The text to be displayed underneath a date field, informing the user about the parsing result
295 """
297 # check which of the dates could be parsed to construct the relevant feedback text
298 help_text = ""
299 if single_date:
300 # single date could be parsed
301 help_text = "Date interpreted as "
302 if single_start_date or single_end_date:
303 # date has also start or end ranges, then ignore single date
304 if single_start_date:
305 # date has start range
306 help_text += (
307 str(single_start_date.year)
308 + "-"
309 + str(single_start_date.month)
310 + "-"
311 + str(single_start_date.day)
312 + " until "
313 )
314 else:
315 # date has no start range, then write "undefined"
316 help_text += "undefined start until "
317 if single_end_date:
318 # date has end range
319 help_text += (
320 str(single_end_date.year)
321 + "-"
322 + str(single_end_date.month)
323 + "-"
324 + str(single_end_date.day)
325 )
326 else:
327 # date has no start range, then write "undefined"
328 help_text += "undefined end"
329 else:
330 # date has no start nor end range. Use single date then.
331 help_text += (
332 str(single_date.year)
333 + "-"
334 + str(single_date.month)
335 + "-"
336 + str(single_date.day)
337 )
338 elif single_date_written is not None:
339 # date input field is not empty but it could not be parsed either. Show parsing info and help text
340 help_text = (
341 "<b>Date could not be interpreted</b><br>" + get_date_help_text_default()
342 )
343 else:
344 # date field is completely empty. Show help text only
345 help_text = get_date_help_text_default()
347 return help_text
350def get_date_help_text_default():
351 return "Dates are interpreted by defined rules. If this fails, an iso-date can be explicitly set with '<YYYY-MM-DD>'."