Skip to content

Commit

Permalink
Merge pull request #167 from sciris/fixes-mar2021
Browse files Browse the repository at this point in the history
Date improvements
  • Loading branch information
cliffckerr committed Mar 10, 2021
2 parents 9f1305e + ef4a1df commit 5291693
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 58 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file.
By import convention, components of the Sciris library are listed beginning with ``sc.``, e.g. ``sc.odict()``.


Version 1.0.2 (2021-03-10)
---------------------------
1. Fixed bug (introduced in version 1.0.1) with ``sc.readdate()`` returning only the first element of a list of a dates.
2. Fixed bug (introduced in version 1.0.1) with ``sc.date()`` treating an integer as a timestamp rather than an integer number of days when a start day is supplied.
3. Updated ``sc.readdate()``, ``sc.date()``, and ``sc.day()`` to always return consistent output types (e.g. if an array is supplied as an input, an array is supplied as an output).


Version 1.0.1 (2021-03-01)
---------------------------
1. Fixed bug with Matplotlib 3.4.0 also defining colormap ``'turbo'``, which caused Sciris to fail to load.
Expand Down
113 changes: 66 additions & 47 deletions sciris/sc_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,42 @@ def getdate(obj=None, astype='str', dateformat=None):
return None # Should not be possible to get to this point


def _sanitize_iterables(obj, *args):
'''
Take input as a list, array, or non-iterable type, along with one or more
arguments, and return a list, along with information on what the input types
were.
**Examples**::
_sanitize_iterables(1, 2, 3) # Returns [1,2,3], False, False
_sanitize_iterables([1, 2], 3) # Returns [1,2,3], True, False
_sanitize_iterables(np.array([1, 2]), 3) # Returns [1,2,3], True, True
_sanitize_iterables(np.array([1, 2, 3])) # Returns [1,2,3], False, True
'''
is_list = isinstance(obj, list) or len(args)>0 # If we're given a list of args, treat it like a list
is_array = isinstance(obj, np.ndarray) # Check if it's an array
if is_array: # If it is, convert it to a list
obj = obj.tolist()
objs = dcp(promotetolist(obj)) # Ensure it's a list, and deepcopy to avoid mutability
objs.extend(args) # Add on any arguments
return objs, is_list, is_array


def _sanitize_output(obj, is_list, is_array, dtype=None):
'''
The companion to _sanitize_iterables, convert the object back to the original
type supplied.
'''
if is_array:
output = np.array(obj, dtype=dtype)
elif not is_list and len(obj) == 1:
output = obj[0]
else:
output = obj
return output


def readdate(datestr=None, *args, dateformat=None, return_defaults=False):
'''
Convenience function for loading a date from a string. If dateformat is None,
Expand Down Expand Up @@ -1561,9 +1597,10 @@ def readdate(datestr=None, *args, dateformat=None, return_defaults=False):
for f,fmt in enumerate(format_list):
formats_to_try[f'User supplied {f}'] = fmt

# Ensure everything is in a consistent format
datestrs, is_list, is_array = _sanitize_iterables(datestr, *args)

# Actually process the dates
datestrs = promotetolist(datestr)
datestrs.extend(args)
dateobjs = []
for datestr in datestrs: # Iterate over them
dateobj = None
Expand All @@ -1585,26 +1622,27 @@ def readdate(datestr=None, *args, dateformat=None, return_defaults=False):
raise ValueError(errormsg)
dateobjs.append(dateobj)

# If only a single date was supplied, return just that; else return the list
if not isinstance(datestr, list) and not len(args):
return dateobjs[0]
else:
return dateobjs
# If only a single date was supplied, return just that; else return the list/array
output = _sanitize_output(dateobjs, is_list, is_array, dtype=object)
return output


def date(obj, *args, start_date=None, dateformat=None, as_date=True):
'''
Convert a string or a datetime object to a date object. To convert to an integer
from the start day, it is recommended you supply a start date, or use sc.date()
instead; otherwise, it will calculate the date counting days from 2020-01-01.
This means that the output of sc.date() will not necessarily match the output
of sc.date() for an integer input.
Convert any reasonable object -- a string, integer, or datetime object, or
list/array of any of those -- to a date object. To convert an integer to a
date, you must supply a start date.
Caution: while this function and readdate() are similar, and indeed this function
calls readdate() if the input is a string, in this function an integer is treated
as a number of days from start_day, while for readdate() it is treated as a
timestamp in seconds.
Args:
obj (str, int, float, date, datetime, list, array): the object to convert
args (str, int, float, date, datetime): additional objects to convert
obj (str, int, date, datetime, list, array): the object to convert
args (str, int, date, datetime): additional objects to convert
start_date (str, date, datetime): the starting date, if an integer is supplied
dateformat (str): the format to return the date in
dateformat (str): the format to return the date in, if returning a string
as_date (bool): whether to return as a datetime date instead of a string
Returns:
Expand All @@ -1613,24 +1651,16 @@ def date(obj, *args, start_date=None, dateformat=None, as_date=True):
**Examples**::
sc.date('2020-04-05') # Returns datetime.date(2020, 4, 5)
sc.date('2020-04-14', start_date='2020-04-04', as_date=False) # Returns 10
sc.date([35,36,37], as_date=False) # Returns ['2020-02-05', '2020-02-06', '2020-02-07']
New in version 1.0.0.
'''

# Convert to list and handle other inputs
if obj is None:
return None

# Convert to list and handle other inputs
if isinstance(obj, np.ndarray):
obj = obj.tolist() # If it's an array, convert to a list
obj = promotetolist(obj) # Ensure it's iterable
obj.extend(args)
if dateformat is None:
dateformat = '%Y-%m-%d'
if start_date is None:
start_date = '2020-01-01'
obj, is_list, is_array = _sanitize_iterables(obj, *args)

dates = []
for d in obj:
Expand All @@ -1640,10 +1670,10 @@ def date(obj, *args, start_date=None, dateformat=None, as_date=True):
try:
if type(d) == dt.date: # Do not use isinstance, since must be the exact type
pass
elif isstring(d) or isnumber(d):
d = readdate(d).date()
elif isinstance(d, dt.datetime):
d = d.date()
elif isstring(d):
d = readdate(d).date()
elif isnumber(d):
if start_date is None:
errormsg = f'To convert the number {d} to a date, you must supply start_date'
Expand All @@ -1661,10 +1691,8 @@ def date(obj, *args, start_date=None, dateformat=None, as_date=True):
raise ValueError(errormsg)

# Return an integer rather than a list if only one provided
if len(dates)==1:
dates = dates[0]

return dates
output = _sanitize_output(dates, is_list, is_array, dtype=object)
return output


def day(obj, *args, start_day=None):
Expand All @@ -1687,16 +1715,10 @@ def day(obj, *args, start_day=None):
New in version 1.0.0.
'''

# Do not process a day if it's not supplied
# Do not process a day if it's not supplied, and ensure it's a list
if obj is None:
return None

# Convert to list
if isstring(obj) or isnumber(obj) or isinstance(obj, (dt.date, dt.datetime)):
obj = promotetolist(obj) # Ensure it's iterable
elif isinstance(obj, np.ndarray):
obj = obj.tolist() # Convert to list if it's an array
obj.extend(args)
obj, is_list, is_array = _sanitize_iterables(obj, *args)

days = []
for d in obj:
Expand All @@ -1721,10 +1743,8 @@ def day(obj, *args, start_day=None):
raise ValueError(errormsg)

# Return an integer rather than a list if only one provided
if len(days)==1:
days = days[0]

return days
output = _sanitize_output(days, is_list, is_array, dtype=object)
return output


def daydiff(*args):
Expand Down Expand Up @@ -1772,12 +1792,11 @@ def daterange(start_date, end_date, inclusive=True, as_date=False, dateformat=No
New in version 1.0.0.
'''
start_day = day(start_date)
end_day = day(end_date)
end_day = day(end_date, start_day=start_date)
if inclusive:
end_day += 1
days = np.arange(start_day, end_day)
dates = date(days, as_date=as_date, dateformat=dateformat)
days = list(range(end_day))
dates = date(days, start_date=start_date, as_date=as_date, dateformat=dateformat)
return dates


Expand Down
4 changes: 2 additions & 2 deletions sciris/sc_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

__all__ = ['__version__', '__versiondate__', '__license__']

__version__ = '1.0.1'
__versiondate__ = '2021-03-01'
__version__ = '1.0.2'
__versiondate__ = '2021-03-10'
__license__ = 'Sciris %s (%s) -- (c) Sciris.org' % (__version__, __versiondate__)
54 changes: 45 additions & 9 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,24 +269,50 @@ def test_types():
def test_readdate():
sc.heading('Test string-to-date conversion')

string1 = '2020-Mar-21'
string2 = '2020-03-21'
string3 = 'Sat Mar 21 23:13:56 2020'
# Basic tests
string0 = '2020-Mar-21'
string1 = '2020-03-21'
string2 = 'Sat Mar 21 23:13:56 2020'
string3 = '2020-04-04'
dateobj0 = sc.readdate(string0)
dateobj1 = sc.readdate(string1)
dateobj2 = sc.readdate(string2)
sc.readdate(string3)
assert dateobj1 == dateobj2
assert dateobj0 == dateobj1
with pytest.raises(ValueError):
sc.readdate('Not a date')

# Automated tests
# Test different formats
strlist = [string0, string0, string2]
strarr = np.array(strlist)
fromlist = sc.readdate(strlist, string3)
fromarr = sc.readdate(strarr, string3)
assert fromlist[2] == dateobj2
assert sc.readdate(*strlist)[2] == dateobj2
assert fromarr[2] == dateobj2
assert len(fromlist) == len(fromarr) == len(strlist) + 1 == len(strarr) + 1
assert isinstance(fromlist, list)
assert isinstance(fromarr, np.ndarray)

# Test timestamps
now_datetime = sc.now()
now_timestamp = sc.readdate(sc.tic())
assert now_timestamp.day == now_datetime.day

# Format tests
formats_to_try = sc.readdate(return_defaults=True)
for key,fmt in formats_to_try.items():
datestr = sc.getdate(dateformat=fmt)
dateobj = sc.readdate(datestr, dateformat=fmt)
print(f'{key:15s} {fmt:22s}: {dateobj}')

return dateobj1
# Basic tests
assert sc.sc_utils._sanitize_iterables(1) == ([1], False, False)
assert sc.sc_utils._sanitize_iterables(1, 2, 3) == ([1,2,3], True, False)
assert sc.sc_utils._sanitize_iterables([1, 2], 3) == ([1,2,3], True, False)
assert sc.sc_utils._sanitize_iterables(np.array([1, 2]), 3) == ([1,2,3], True, True)
assert sc.sc_utils._sanitize_iterables(np.array([1, 2, 3])) == ([1,2,3], False, True)

return dateobj0


def test_dates():
Expand All @@ -295,19 +321,29 @@ def test_dates():

print('\nTesting date')
o.date1 = sc.date('2020-04-05') # Returns datetime.date(2020, 4, 5)
o.date2 = sc.date('2020-04-14', start_date='2020-04-04', as_date=False) # Returns 10
o.date3 = sc.date([35,36,37], as_date=False) # Returns ['2020-02-05', '2020-02-06', '2020-02-07']
o.date2 = sc.date(sc.readdate('2020-04-14'), as_date=False, dateformat='%Y%m') # Returns '202004'
o.date3 = sc.date([35,36,37], start_date='2020-01-01', as_date=False) # Returns ['2020-02-05', '2020-02-06', '2020-02-07']
with pytest.raises(ValueError):
sc.date([10,20]) # Can't convert an integer without a start date
assert o.date1.month == 4
assert o.date2 == '202004'
assert len(o.date3) == 3
assert o.date3[0] =='2020-02-05'

print('\nTesting day')
o.day = sc.day('2020-04-04') # Returns 94
assert o.day == 94
assert sc.day('2020-03-01') > sc.day('2021-03-01') # Because of the leap day
assert sc.day('2020-03-01', start_day='2020-01-01') < sc.day('2021-03-01', start_day='2020-01-01') # Because years

print('\nTesting daydiff')
o.diff = sc.daydiff('2020-03-20', '2020-04-05') # Returns 16
o.diffs = sc.daydiff('2020-03-20', '2020-04-05', '2020-05-01') # Returns [16, 26]
assert len(o.diffs) == 2

print('\nTesting daterange')
o.dates = sc.daterange('2020-03-01', '2020-04-04')
assert len(o.dates) == 35

print('\nTesting elapsedtimestr')
now = sc.now()
Expand Down

0 comments on commit 5291693

Please sign in to comment.