/
date_calculator.py
392 lines (288 loc) · 14.4 KB
/
date_calculator.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
from collections import Counter
from datetime import timedelta
import arrow
from arrow.arrow import datetime
from date_format_mappings import DEFAULT_WORKFLOW_SETTINGS, \
TIME_CALCULATION, VALID_FORMAT_OPTIONS, MAX_LOOKAHEAD_ATTEMPTS
from date_formatters import DATE_FORMATTERS_MAP
from date_functions import EXCLUSION_MAP
from date_parser import DateParser
from dateutil.relativedelta import relativedelta
from dateutil.rrule import rruleset, rrule, DAILY
from humanfriendly import *
from utils import convert_date_time
from versioning import update_settings
from workflow import Workflow, ICON_ERROR
class FormatError(Exception):
"""
Throw this when there are
repeated characters in the
format field.
"""
pass
class IncompatibleFunctionError(Exception):
"""
Throw this bad boy when someone attempts
to use the exclusions with the formatting.
"""
pass
class UnknownExclusionTypeError(Exception):
"""
This exception is thrown when we encounter an exclusion type that
somehow makes it through the checking list. Shouldn't occur really,
but if it does then we want to know about it.
"""
pass
class ExclusionTooFarAheadError(Exception):
"""
We'll throw this bad boy if the exclusion calculations goes
further than a preset value. We don't want the process running years into the future
"""
pass
class ExclusionNoDaysFoundError(Exception):
"""
We're going to check to make sure that the user doesn't
enter an exclusion clause that blocks out all the days in the
week. Should be easy to find. If the exclusion set has seven
items in it, then that's all the days in the week!
"""
pass
def do_formats(command, settings):
date_time, _ = convert_date_time(command.dateTime, settings)
if command.dateFormat.lower() in DATE_FORMATTERS_MAP:
# noinspection PyCallingNonCallable
return DATE_FORMATTERS_MAP[command.dateFormat.lower()](date_time)
else:
return "Invalid function . . . "
def delta_arithmetic(date_time, operand):
delta_date_time = date_time
for timespan in operand.timeSpans:
delta_operand = relativedelta(seconds=int(timespan.amount) if timespan.span == "s" else 0,
minutes=int(timespan.amount) if timespan.span == "M" else 0,
hours=int(timespan.amount) if timespan.span == "h" else 0,
days=int(timespan.amount) if timespan.span == "d" else 0,
weeks=int(timespan.amount) if timespan.span == "w" else 0,
months=int(timespan.amount) if timespan.span == "m" else 0,
years=int(timespan.amount) if timespan.span == "y" else 0)
if operand.operator == "+":
delta_date_time += delta_operand
else:
delta_date_time -= delta_operand
return delta_date_time
def do_timespans(command, settings):
date_time, output_format = convert_date_time(command.dateTime, settings)
original_date_time = date_time
for operand in command.operandList:
date_time = delta_arithmetic(date_time, operand)
date_time = exclusion_check(original_date_time, date_time, command, settings)
return date_time.strftime(output_format)
def do_subtraction(command, settings):
date_time_1, output_format_1 = convert_date_time(command.dateTime1, settings)
date_time_2, output_format_2 = convert_date_time(command.dateTime2, settings)
# In a moment of madness, we've decided to allow operands in a date from date
# subtraction. It's much easier to process these first.
if hasattr(command, "operandList1"):
for operand in command.operandList1:
date_time_1 = delta_arithmetic(date_time_1, operand)
if hasattr(command, "operandList2"):
for operand in command.operandList2:
date_time_2 = delta_arithmetic(date_time_2, operand)
return normalised_days(command, date_time_1, date_time_2)
def exclusion_check(original_date_time, date_time, command, settings):
if not hasattr(command, "exclusionCommands"):
return date_time
exclusion_day_set = build_exclusion_day_set(command.exclusionCommands)
# if there are seven elements in the exclusion day set then there is no way
# we can calculate the exclusions, so throw an error
if len(exclusion_day_set) >= 7:
raise ExclusionNoDaysFoundError
starting_date_time = original_date_time
lookahead_date = date_time
lookahead_count = 0
extra_days = calculate_rrule_exclusions(starting_date_time, lookahead_date, command.exclusionCommands, settings)
while extra_days > 0:
starting_date_time = lookahead_date
lookahead_date = lookahead_date + timedelta(days=extra_days)
lookahead_count = lookahead_count + 1
if lookahead_count >= MAX_LOOKAHEAD_ATTEMPTS:
raise ExclusionTooFarAheadError
extra_days = calculate_rrule_exclusions(starting_date_time, lookahead_date, command.exclusionCommands, settings)
return lookahead_date
def build_exclusion_day_set(exclusion_commands):
excluded_days = set()
exclusion_types = exclusion_commands.exclusionList
for exclusionType in exclusion_types:
if hasattr(exclusionType, "exclusionMacro"):
excluded_days.update(EXCLUSION_MAP[exclusionType.exclusionMacro]['days'])
return excluded_days
def calculate_rrule_exclusions(start_date, end_date, exclusion_commands, settings):
exclusion_ruleset = rruleset()
exclusion_types = exclusion_commands.exclusionList
for exclusion_type in exclusion_types:
if hasattr(exclusion_type, "exclusionRange"):
from_date, _ = convert_date_time(exclusion_type.exclusionRange.fromDateTime, settings)
to_date, _ = convert_date_time(exclusion_type.exclusionRange.toDateTime, settings)
exclusion_ruleset.rrule(rrule(freq=DAILY, dtstart=from_date, until=to_date))
elif hasattr(exclusion_type, "exclusionDateTime"):
real_date, _ = convert_date_time(exclusion_type.exclusionDateTime, settings)
exclusion_ruleset.rrule(rrule(freq=DAILY, dtstart=real_date, until=real_date))
elif hasattr(exclusion_type, "exclusionMacro"):
macro_value = exclusion_type.exclusionMacro
exclusion_rule = EXCLUSION_MAP[macro_value]['rule'](start=start_date, end=end_date)
exclusion_ruleset.rrule(exclusion_rule)
else:
# in that case, I have no idea what this is (the parser should have caught it) so just
# raise an error or something
raise UnknownExclusionTypeError
matched_dates = list(exclusion_ruleset.between(after=start_date, before=end_date, inc=True))
return len(matched_dates)
def valid_command_format(command_format):
"""
This returns true if the format option entered
by the user is valid. The parser will take care
of most of the validation except for repeated
characters, which is what we're testing for here.
:param command_format:
:return: true if valid
"""
repeats_search = Counter(command_format)
repeated_items = filter(lambda x: x > 1, repeats_search.values())
if len(repeated_items) == 0:
return True
else:
return False
def tack_on_time(date_time):
return datetime.combine(date_time, datetime.max.time())
def calculate_time_interval(interval, start_datetime, end_datetime):
"""
So how does this work. Well, as it turns out, trying use division by seconds to get
the intervals is the wrong way to do it. The problem is the uneven months and leapyears
leads to inaccuracies. So this is the new way of doing it.
Use the rrule to get a list of all the dates that fall inside the given range. If you count
the dates (whether the frequency is YEARLY, MONTHLY, WEEKLY etc.) then the count will tell
you how many intervals fall inside the range. Here's the clever bit: the last date inside
the range is kind of your remainder. Set that to the start date for the next calculation and
you pick up at the starting poing where the last count ended!
Note. We knock one of the count because rrule includes the date you're counting from in the list,
which you don't really want.
:param interval: The interval we're calculating over: year, month, week, day, hour, minute or second.
:param start_datetime: When you're counting from
:param end_datetime: When you're counting to.
:return:
"""
datetime_list = arrow.Arrow.range(interval, start_datetime, end_datetime)
if datetime_list:
return len(datetime_list) - 1, datetime_list[-1]
else:
return 0, start_datetime
def later_date_last(date_time_1, date_time_2):
"""
The rrule is fussy about the order of the dates: the lower one
has to go first. But we don't force our users to always put the
higher date first, so this method will flip them if expression
is entered incorrectly.
:param date_time_1:
:param date_time_2:
:return:
"""
if date_time_1 < date_time_2:
start_date_time = date_time_1
end_date_time = date_time_2
else:
start_date_time = date_time_2
end_date_time = date_time_1
return start_date_time, end_date_time
def normalised_days(command, date_time_1, date_time_2):
# If the user selected long then he wants the full
# date, so fill in the format before carrying on.
# First off, do we have any exclusions to worry about?
if not valid_command_format(command.format):
raise FormatError
if command.format == "long":
difference = relativedelta(date_time_1, date_time_2)
return "{years}, {months}, {days}, {hours}, {minutes}, {seconds}".format(
years=pluralize(abs(difference.years), TIME_CALCULATION['y']['singular'], TIME_CALCULATION['y']['plural']),
months=pluralize(abs(difference.months), TIME_CALCULATION['m']['singular'],
TIME_CALCULATION['m']['plural']),
days=pluralize(abs(difference.days), TIME_CALCULATION['d']['singular'], TIME_CALCULATION['d']['plural']),
hours=pluralize(abs(difference.hours), TIME_CALCULATION['h']['singular'], TIME_CALCULATION['h']['plural']),
minutes=pluralize(abs(difference.minutes), TIME_CALCULATION['M']['singular'],
TIME_CALCULATION['M']['plural']),
seconds=pluralize(abs(difference.seconds), TIME_CALCULATION['s']['singular'],
TIME_CALCULATION['s']['plural']))
# Python gotcha. The keys in the map are not guaranteed
# to come out in the same order you put then; so we have
# to scan them specifically in the order we want them to
# appear in the calculation. If they're out of sequence
# then the calculation will return the wrong result.
# And it does matter which way round the dates go.
date_1, date_2 = later_date_last(date_time_1, date_time_2)
start_date_time = arrow.get(date_1)
end_date_time = arrow.get(date_2)
if command.format:
ordered_format_options = [option for option in VALID_FORMAT_OPTIONS if option in command.format]
show_zero_items = True
else:
ordered_format_options = VALID_FORMAT_OPTIONS
show_zero_items = False
normalised_elements = []
for x in ordered_format_options:
count, start_date_time = calculate_time_interval(TIME_CALCULATION[x]['interval'],
start_date_time, end_date_time)
# If this is the last format option in the list, then we need some
# fractional magic! Remember, the next line only works because the
# items in the list are unique and sorted into order! If this changes
# then you should use an enumerate call
if x == ordered_format_options[-1]:
fractional = abs((end_date_time - start_date_time).total_seconds()) / TIME_CALCULATION[x]['seconds']
count += fractional
# If no format is set then go for a compact display that suppresses all items that are
# zero. And don't bother showing the seconds calculation under any circumstances. It is pretty
# useless and prone to error.
if (show_zero_items or count > 0) and TIME_CALCULATION[x]['interval'] != 'second':
normalised_elements.append(pluralize(round_number(count), TIME_CALCULATION[x]['singular'],
TIME_CALCULATION[x]['plural']))
# We put each part of the calculation in a list
# so that Python can handle comma-separating them later on
return ', '.join(normalised_elements)
def main(wf):
# Get the date format from the configuration
update_settings(wf)
args = wf.args
command_parser = DateParser(wf.settings)
try:
command = command_parser.parse_command(args[0])
if hasattr(command, "dateTime"):
output = do_timespans(command, wf.settings)
if hasattr(command, "dateFormat"):
setattr(command, "dateTime", output)
# and run it through the functions function
output = do_formats(command, wf.settings)
elif hasattr(command, "dateTime1") and hasattr(command, "dateTime2"):
output = do_subtraction(command, wf.settings)
else:
output = "Invalid Expression"
except SyntaxError:
output = "Invalid Command"
except ValueError:
output = "Invalid Date/time"
except FormatError:
output = "Invalid format"
except IncompatibleFunctionError:
output = "Invalid command - Don't use exclusions and formats together."
except UnknownExclusionTypeError:
output = "Invalid exclusion - Try again."
except ExclusionNoDaysFoundError:
output = "All days excluded"
except ExclusionTooFarAheadError:
output = "That's too far into the future"
if output.startswith("Invalid"):
wf.add_item(title=". . .", subtitle=output, valid=False, arg=args[0], icon=ICON_ERROR)
else:
wf.add_item(title=output, subtitle="Copy to clipboard", valid=True, arg=output)
wf.send_feedback()
# ## Python calling routine. Will only run this app if it is the main program
# ## Otherwise it won't run because it is an included module -- clever!
if __name__ == '__main__':
workflow = Workflow(default_settings=DEFAULT_WORKFLOW_SETTINGS)
sys.exit(workflow.run(main))