1

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?

sytech
  • 29,298
  • 3
  • 45
  • 86

1 Answers1

4

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.

sytech
  • 29,298
  • 3
  • 45
  • 86
  • Hmmm... I don't think decorator was necessary for this case, but it could be a opinion based comment. – Moinuddin Quadri Mar 29 '18 at 20:37
  • 2
    In Python 2 you'd use [`inspect.get_arg_spec()`](https://docs.python.org/2/library/inspect.html#inspect.getargspec) to get argument names; it's doable. I'm actually surprised you don't use the annotations you have in your sample `tricky_case()` function. – Martijn Pieters Mar 29 '18 at 20:37
  • 1
    @MoinuddinQuadri: a decorator is an excellent way to abstract the auto-conversion away from multiple functions. Personally, I'd prefer converting strings to datetime objects *as early as possible* and not leave them hanging around to have to pollute your codebase with decorators like this. – Martijn Pieters Mar 29 '18 at 20:38
  • @MartijnPieters The annotation was an attempt to reduce confusion for readers, illustrating the argument was originally intended to be a string. In practice, the functions we were converting do not already have annotations, though I suppose we could (should) have added annotations. – sytech Mar 29 '18 at 20:40
  • 1
    @sytech: when you add proper [type hinting annotations](https://docs.python.org/3/library/typing.html) you can validate your code too, *and* have your decorator pull out the annotations to make such conversion decisions. OTOH a decorator like yours then completely blinds the validator again as the decorator can't declare it's input argument types anymore.. – Martijn Pieters Mar 29 '18 at 20:47
  • @sytech Honestly, adding annotations while fixing up old functions like this is one of the best use cases I've found for static type checking. (And it's not too far different from porting a bunch of Python 2.7 code to 3.x, the use case that convinced Guido to add optional static typing to the language.) – abarnert Mar 29 '18 at 20:47