Reputation: 40871
I have a large collection of functions and methods that normally accept date/time objects. I want to adapt these to also accept string representations of a date/time objects.
Consider the following function, which is a simple case
def days_until(until_date, from_date=None):
if from_date is None:
from_date = datetime.datetime.now()
delta = until_date - from_date
return delta.days
Using dateutil, I would approach this by altering the function as follows.
def days_until(until_date, from_date=None):
if isinstance(until_date, str): # preserve backwards compatibility with datetime objects
until_date = dateutil.parser.parse(until_date)
if isinstance(from_date, str):
from_date = dateutil.parser.parse(from_date)
# ... rest of function as it was before
While this works, the code is very repetitive and it is tedious to do across a large collection of functions, some of which accept as many as five datetimes.
Is there an automatic/generic way to accomplish this conversion to achieve code that is DRY?
Upvotes: 1
Views: 1893
Reputation: 40871
One could create a decorator that does this. Here, we just blindly attempt to convert each argument into a date/time. If it works, great, we use the date/time, otherwise just use the object.
import functools
import dateutil.parser
def dt_convert(obj):
if not isinstance(obj, str):
return obj
try:
return dateutil.parser.parse(obj)
except TypeError:
return obj
def parse_dates(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
new_args = map(dt_convert, args)
new_kwargs = {kw: dt_convert(value) for kw, value in kwargs.items()}
return func(*new_args, **new_kwargs)
return wrapper
Which allows you to simply add the decorator to the existing functions like so
@parse_dates
def days_until(until_date, from_date=None)
# ... the original function logic
>>> christmas = dateutil.parser.parse('12/25')
>>> day_until(christmas, from_date='12/20')
5
This works in this particular case. However, in some cases you may have arguments that should actually be strings, but would be converted into datetimes erroneously, if the string happened to be a valid datetime as well.
Take for example the following
@parse_dates
def tricky_case(dt: datetime.datetime, tricky_string: str):
print(dt, tricky_string)
The result could potentially be unexpected
>>> tricky_case('12/25', '24')
2018-12-25 00:00:00 2018-03-24 00:00:00
As a workaround for this, we can have a decorator whose parameters are the names of the arguments we want to convert in the decorated function. This solution cheats a little by using inspect
to work with the signature of the decorated function. However, it allows us to bind the signature, appropriately handling positional and keyword arguments.
def parse_dates(*argnames):
def decorator(func):
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
ba = sig.bind(*args, **kwargs)
for argname in argnames:
if argname in ba.arguments and isinstance(ba.arguments[argname], str):
ba.arguments[argname] = dateutil.parser.parse(ba.arguments[argname])
return func(*ba.args, **ba.kwargs)
return wrapper
return decorator
Then the problem encountered in the ambiguous case can be avoided by specifically specifying that only dt
should be converted.
@parse_dates('dt')
def tricky_case(dt: datetime.datetime, tricky_string: str)
print(dt, tricky_string)
Then the result is no longer unexpected
>>> tricky_case('12/25', '24')
2018-12-25 00:00:00 24
A downside compared to the naive approach is, in this case, you still need to visit each function and identify the datetime argument names. Also, this hack uses a feature of inspect
that is not available in Python2.x -- in which case you would need to either rework this using inspect.get_arg_spec
, or use a third party module that provides the backport for the legacy version of Python.
Upvotes: 4