Wednesday, October 13, 2021

Inspired Python: Separating type-specific code with single­dispatch

Separating type-specific code with single­dispatch

Have you ever found yourself writing a litany of if-elif-else statements peppered with isinstance() calls? Notwithstanding error handling, they are often found where your code intersects with APIs; third-party libraries; and services. As it turns out, coalescing complex types – such as pathlib.Path to string, or decimal.Decimal to float or string – is a common occurrence.

But writing a wall of if-statements makes code reuse harder, and it can complicate testing:

# -*- coding: utf-8 -*-
from pathlib import Path
from decimal import Decimal, ROUND_HALF_UP

def convert(o, *, bankers_rounding: bool = True):
    if isinstance(o, (str, int, float)):
        return o
    elif isinstance(o, Path):
        return str(o)
    elif isinstance(o, Decimal):
        if bankers_rounding:
            return float(o.quantize(Decimal(".01"), rounding=ROUND_HALF_UP))
        return float(o)
    else:
        raise TypeError(f'Cannot convert {o}')

assert convert(Path('/tmp/hello.txt')) == '/tmp/hello.txt'
assert convert(Decimal('49.995'), bankers_rounding=True) == 50.0
assert convert(Decimal('49.995'), bankers_rounding=False) == 49.995

In this example I have a convert function that converts complex objects into their primitive types, and if it cannot resolve the given object type, it raises a TypeError. There’s also a keyword argument, bankers_rounding intended for the decimal converter.

Let’s quickly test the converter to make sure it works:

>>> json.dumps({"amount": Decimal('49.995')}, default=convert)
'{"amount": 50.0}'

Yep. It does. Remove the default= argument and the dumps function throws an exception because it does not understand how to serialize Decimal.

But now I’ve trapped a number of independent pieces of logic in one function: I can convert data, yes, but how can I easily test that each conversion function actually does what it’s supposed to? Ideally, there would be a clear separation of concerns. And the keyword argument bankers_rounding only applies to the Decimal routine and yet it’s passed to our shared convert function. In a real-world application there might be dozens of converters and keyword arguments.

But I think we can do better. One easy win is to separate the converter logic into distinct functions, one for each type. That has the advantage of letting me test – and use independently – each converter in isolation. That way I specify the keyword arguments I need for the converter functions that need them. The bankers_rounding keyword is not tangled up with converters where it does not apply.

The code for that will look something like this:

def convert_decimal(o, bankers_rounding: bool = False):
    if not bankers_rounding:
        return str(o)
    else:
        # ...

# ... etc ...

 def convert(o, **kwargs):
     if isinstance(o, Path):
         return convert_path(o, **kwargs)
     else:
         # ...

At this point I have built a dispatcher that delegates the act of converting the data to distinct functions. Now I can test the dispatcher and the converters separately. At this point I could call it quits, but I can get rid of the convert dispatcher almost entirely, by offloading the logic of checking the types to a little-known function hidden away in the functools module called singledispatch.



Read More ->

from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...