"""Date based tools"""
import datetime
from dateutil import parser as date_parser
import calendar
MONTHS = {
"jan": 1,
"jan.": 1,
"january": 1,
"feb": 2,
"feb.": 2,
"february": 2,
"mar": 3,
"mar.": 3,
"march": 3,
"apr": 4,
"apr.": 4,
"april": 4,
"may": 5,
"may.": 5,
"jun": 6,
"jun.": 6,
"june": 6,
"jul": 7,
"jul.": 7,
"july": 7,
"aug": 8,
"aug.": 8,
"august": 8,
"sep": 9,
"sep.": 9,
"sept": 9,
"sept.": 9,
"september": 9,
"oct": 10,
"oct.": 10,
"october": 10,
"nov": 11,
"nov.": 11,
"november": 11,
"dec": 12,
"dec.": 12,
"december": 12,
"": 1,
"tbd": 12
}
[docs]def month_to_int(m):
"""Converts a month to an integer."""
try:
m = int(m)
except ValueError:
m = MONTHS[m.lower()]
return m
[docs]def month_to_str_int(m):
"""Converts a month to an int form, str type, with a leading zero"""
mi = month_to_int(m)
if mi < 10:
ms = "0{}".format(mi)
else:
ms = str(mi)
return ms
[docs]def day_to_str_int(d):
"""Converts a day to an int form, str type, with a leading zero"""
if d < 10:
ds = "0{}".format(d)
else:
ds = str(d)
return ds
[docs]def date_to_float(y, m, d=0):
"""Converts years / months / days to a float, eg 2015.0818 is August
18th 2015. """
y = int(y)
m = month_to_int(m)
d = int(d)
return y + (m / 100.0) + (d / 10000.0)
[docs]def find_gaps_overlaps(dateslist, overlaps_ok=False):
'''
Find whether there is a gap or an overlap in a list of date-ranges
Parameters
----------
dateslist: list of tuples of datetime.date objects
The list of date-ranges.
overlaps_ok: bool
Returns false if there are gaps but true if there are overlaps but no gaps
Returns
-------
True if there are no gaps or overlaps else False
'''
status = True
dateslist.sort(key=lambda x: x[0])
for i in range(len(dateslist) - 1):
if dateslist[i + 1][0] <= dateslist[i][1] and not overlaps_ok:
status = False
elif (dateslist[i + 1][0] - dateslist[i][1]).days > 1:
status = False
return status
[docs]def last_day(year, month):
"""
Returns the last day of the month for the month given
Parameters
----------
year: integer
the year that the month is in
month: integer or string
the month. if a string should be resolvable using regolith month_to_int
Returns
-------
The last day of that month
"""
return calendar.monthrange(year, month_to_int(month))[1]
[docs]def convert_doc_iso_to_date(doc):
def convert_date(obj):
"""
Recursively goes through the dictionary obj and converts date from iso to datetime.date
"""
if isinstance(obj, str):
try:
date = datetime.datetime.strptime(obj, '%Y-%m-%d').date()
except ValueError:
return obj
else:
return date
if isinstance(obj, (int, float)):
return obj
if isinstance(obj, dict):
new = obj.__class__()
for k, v in obj.items():
new[k] = convert_date(v)
elif isinstance(obj, (list, set, tuple)):
new = obj.__class__(convert_date(v) for v in obj)
else:
return obj
return new
return convert_date(doc)
[docs]def get_dates(thing, date_field_prefix=None):
'''
given a dict like thing, return the date items
Parameters
----------
thing: dict
the dict that contains the dates
date_field_prefix: string (optional)
the prefix to look for before the date parameter. For example given "submission"
the function will search for submission_day, submission_year, etc.
Returns
-------
dict containing datetime.date objects for valid begin_date end_date and date, and
prefix_date if a prefix string was passed. Missing and empty dates and
date items that contain the string 'tbd' are not returned. If no valid
date items are found, an empty dict is returned
Description
-----------
If "begin_date", "end_date" or "date" values are found, if these are are in
an ISO format string they will be converted to datetime.date objects and
returned in the dictionary under keys of the same name. A specified date
will override any date built from year/month/day data.
If they are not found the function will look for begin_year, end_year and
year.
If "year", "month" and "day" are found the function will return these in the
"date" field and begin_date and end_date will match the "date" field. If only
a "year" is found, then the date attribute will be none but the begin and end
dates will be the first and last day of that respective year.
If year is found but no month or day are found the function will return
begin_date and end_date with the beginning and the end of the given year/month.
The returned date will be None.
If end_year is found, the end month and end day are missing they are set to
12 and 31, respectively
If begin_year is found, the begin month and begin day are missing they are set to
1 and 1, respectively
If a date field prefix is passed in this function will search for prefix_year as
well as prefix_month, prefix_day, and prefix_date. For example, if the prefix string
passed in is "submitted" then this function will look for submitted_date instead of
just date.
Examples
--------
>>> get_dates({'submission_day': 10, 'submission_year': 2020, 'submission_month': 'Feb'}, "submission")
This would return a dictionary consisting of the begin date, end, date, and date for the given input.
Instead of searching for "day" in the thing, it would search for "submission_day" since a prefix was
given. The following dictionary is returned (note that a submission_date and a date key are in the
dictionary):
{'begin_date': datetime.date(2020, 2, 10),
'end_date': datetime.date(2020, 2, 10),
'submission_date': datetime.date(2020, 2, 10),
'date': datetime.date(2020, 2, 10)
}
>>> get_dates({'begin_year': 2019, 'end_year': 2020, 'end_month': 'Feb'})
This will return a dictionary consisting of the begin date, end date, and date for the given input.
Because no prefix string was passed in, the function will search for "date" in the input instead of
prefix_input. The following dictionary is returned:
{'begin_date': datetime.date(2019, 1, 1),
'end_date': datetime.date(2020, 2, 29),
}
'''
datenames = ["day", "month", "year", "date"]
if date_field_prefix:
datenames = [f"{date_field_prefix}_{datename}" for datename in datenames]
minimal_set = ["end_year", "begin_year", "year", "begin_date", "end_date",
"date"]
minimal_things = list(set([thing.get(i) for i in datenames])) if date_field_prefix \
else list(set([thing.get(i) for i in minimal_set]))
if len(minimal_things) == 1 and not minimal_things[0]:
print("WARNING: cannot find any dates")
dates = {}
return dates
for key, value in thing.items():
if key in minimal_set or key in ['month', 'day', 'begin_day', 'begin_month']:
if isinstance(value, str):
if value.strip().lower() == 'tbd':
thing[key] = None
else:
try:
thing[key] = int(value)
except ValueError:
pass
if thing.get("end_year") and not thing.get("begin_year"):
print('WARNING: end_year specified without begin_year')
begin_date, end_date, date = None, None, None
if thing.get("begin_year"):
if not thing.get("begin_month"):
thing["begin_month"] = 1
if not thing.get("begin_day"):
thing["begin_day"] = 1
begin_date = datetime.date(thing["begin_year"],month_to_int(thing["begin_month"]),
thing["begin_day"])
if thing.get("end_year"):
if not thing.get("end_month"):
thing["end_month"] = 12
if not thing.get("end_day"):
thing["end_day"] = last_day(thing["end_year"], thing["end_month"])
end_date = datetime.date(thing["end_year"],month_to_int(thing["end_month"]),
thing["end_day"])
if thing.get(datenames[2]):
if not thing.get(datenames[1]):
if thing.get("begin_year"):
print("WARNING: both year and begin_year specified. Year info will be used")
begin_date = datetime.date(thing[datenames[2]],1,1)
end_date = datetime.date(thing[datenames[2]],12,31)
elif not thing.get(datenames[0]):
if thing.get("begin_year"):
print("WARNING: both year and begin_year specified. Year info will be used")
begin_date = datetime.date(thing[datenames[2]],month_to_int(thing[datenames[1]]),
1)
end_date = datetime.date(thing[datenames[2]],
month_to_int(thing[datenames[1]]),
last_day(thing[datenames[2]], thing[datenames[1]]))
else:
date = datetime.date(thing[datenames[2]],
month_to_int(thing[datenames[1]]),
int(thing[datenames[0]]))
begin_date = datetime.date(int(thing[datenames[2]]),
month_to_int(thing[datenames[1]]),
int(thing[datenames[0]]))
end_date = datetime.date(int(thing[datenames[2]]),
month_to_int(thing[datenames[1]]),
int(thing[datenames[0]]))
if thing.get('begin_date'):
if isinstance(thing.get('begin_date'), str):
begin_date = date_parser.parse(thing.get('begin_date')).date()
else:
begin_date = thing.get('begin_date')
if thing.get('end_date'):
if isinstance(thing.get('end_date'), str):
end_date = date_parser.parse(thing.get('end_date')).date()
else:
end_date = thing.get('end_date')
if thing.get(datenames[3]):
if isinstance(thing.get(datenames[3]), str):
date = date_parser.parse(thing.get(datenames[3])).date()
else:
date = thing.get(datenames[3])
if date_field_prefix:
dates = {'begin_date': begin_date, 'end_date': end_date, datenames[3]: date, 'date': date}
else:
dates = {'begin_date': begin_date, 'end_date': end_date, 'date': date}
dates_no_nones = {k: v for k, v in dates.items() if v is not None}
return dates_no_nones
[docs]def get_due_date(thing):
"""
Parameters
----------
thing: dict
gets the field named 'due_date' from doc and ensurese it is a
datetime.date object
Returns
-------
The due date as a datetime.date object
"""
due_date = thing.get('due_date')
if isinstance(due_date, str):
due_date = date_parser.parse(due_date).date()
elif isinstance(due_date, datetime.date):
pass
else:
raise RuntimeError(f'due date not a known type')
return due_date
[docs]def is_current(thing, now=None):
"""
given a thing with dates, returns true if the thing is current
looks for begin_ and end_ daty things (date, year, month, day), or just
the daty things themselves. e.g., begin_date, end_month, month, and so on.
Parameters
----------
thing: dict
the thing that we want to know whether or not it is current
now: datetime.date object
a date for now. If it is None it uses the current date. Default is None
Returns
-------
True if the thing is current and false otherwise
"""
if not now:
now = datetime.date.today()
dates = get_dates(thing)
current = False
if not dates.get("end_date"):
dates["end_date"] = datetime.date(5000, 12, 31)
try:
if dates.get("begin_date") <= now <= dates.get("end_date"):
current = True
except:
raise RuntimeError(f"Cannot find begin_date in document:\n {thing['_id']}")
return current
[docs]def has_started(thing, now=None):
"""
given a thing with dates, returns true if the thing has started
Parameters
----------
thing: dict
the thing that we want to know whether or not it is has started
now: datetime.date object
a date for now. If it is None it uses the current date. Default is None
Returns
-------
True if the thing has started and false otherwise
"""
if not now:
now = datetime.date.today()
dates = get_dates(thing)
started = False
try:
if dates.get("begin_date") <= now:
started = True
except:
raise RuntimeError(f"Cannot find begin_date in document:\n {thing}")
return started
[docs]def has_finished(thing, now=None):
"""
given a thing with dates, returns true if the thing has finished
Parameters
----------
thing: dict
the thing that we want to know whether or not it has finished
now: datetime.date object
a date for now. If it is None it uses the current date. Default is None
Returns
-------
True if the thing has finished and false otherwise
"""
if not now:
now = datetime.date.today()
dates = get_dates(thing)
finished = False
if not dates.get("end_date"):
dates["end_date"] = datetime.date(5000, 12, 31)
if dates.get("end_date") < now:
finished = True
return finished
[docs]def is_before(thing, now=None):
"""
given a thing with a date, returns true if the thing is before the input date
Parameters
----------
thing: dict
the thing that we want to know whether or not is before a date
now: datetime.date object
a date for now. If it is None it uses the current date. Default is None
Returns
-------
True if the thing is before the date
"""
if not now:
now = datetime.date.today()
dates = get_dates(thing)
before = False
try:
if dates.get("date") < now:
before = True
except:
raise RuntimeError(f"Cannot find date in document:\n {thing}")
return before
[docs]def is_after(thing, now=None):
"""
given a thing with a date, returns true if the thing is after the input date
Parameters
----------
thing: dict
the thing that we want to know whether or not is after a date
now: datetime.date object
a date for now. If it is None it uses the current date. Default is None
Returns
-------
True if the thing is after the date
"""
if not now:
now = datetime.date.today()
dates = get_dates(thing)
after = False
try:
if now < dates.get('date'):
after = True
except:
raise RuntimeError(f"Cannot find date in document:\n {thing}")
return after
[docs]def is_between(thing, start=None, end=None):
"""
given a thing with a date, returns true if the thing is between the start and end date
Parameters
----------
thing: dict
the thing that we want to know whether or not is after a date
start: datetime.date object
a date for the start. If it is None it uses the current date. Default is None
end: datetime.date object
a date for the end. If it is None it uses the current date. Default is None
Returns
-------
True if the thing is between the start and end
"""
if not start:
start = datetime.date.today()
if not end:
end = datetime.date.today()
between = False
if is_after(thing, start) and is_before(thing, end):
between = True
return between