Parameterized Decorators in Python

Parameterized decorators in Python
Python
Published

February 1, 2024

A decorator is a function that takes another function and extends the behavior of the latter function without explicitly modifying it. In what follows, I demonstrate a practical use case for parameterized decorators focusing on units conversion.

To illustrate, we refer to the get_speed function used to estimate the spped of the International Space Station w.r.t. the surface of the Earth. get_speed returns a scalar representing the average speed of the ISS over a given time differential. The result is returned in kilometers per hour, with no parameter available to apply a change of units. To incorporate this functionality, we can either (1) add a units parameter to change the units of the calculated speed prior to get_speed returning, or (2) implement a decorator specifying the units of the calculated value fully outside of get_speed. The declarations for haverdist, getiss and get_speed are provided below:



import datetime
import math
import requests


def haverdist(coords1, coords2):
    """
    Compute distance between geographic coordinate pairs.

    Parameters
    ----------
    coords1: tuple or list;
        (lat1, lon1) of first geolocation.
        
    coords2: tuple or list
        (lat2, lon2) of second geolocation.

    Returns
    -------
    float
        Distance in kilometers between coords1 and coords2.
    """
    # Convert degrees to radians then compute differences.
    R = 6367 
    rlat1, rlon1 = [ii * math.pi / 180 for ii in coords1]
    rlat2, rlon2 = [ii * math.pi / 180 for ii in coords2]
    drlat, drlon = (rlat2 - rlat1), (rlon2 - rlon1)
    inner = (math.sin(drlat / 2.))**2 + (math.cos(rlat1)) * \
            (math.cos(rlat2)) * (math.sin(drlon /2.))**2
    return 2.0 * R * math.asin(min(1., math.sqrt(inner)))


def getiss():
    """
    Get timestamped geo-coordinates of International Space Station.

    Returns
    -------
    dict
        Dictionary with keys "latitude", "longitude" and 
        "timestamp" indicating time and position of ISS. 
    """
    dpos = dict()
    resp = requests.get("http://api.open-notify.org/iss-now.json").json()
    if resp["message"] != "success":
        raise RuntimeError("Unable to access Open Notify API.")
    dpos["timestamp"] = resp["timestamp"]
    dpos["latitude"]  = float(resp["iss_position"]["latitude"])
    dpos["longitude"] = float(resp["iss_position"]["longitude"])
    return dpos



def get_speed(dloc1, dloc2):
    """
    Compute speed of ISS relative to Earth's surface using a pair of coordinates 
    retrieved via `getiss`. 

    Parameters
    ----------
    dloc1: dict
        Dictionary with keys "latitude", "longitude" "timestamp"
        associated with the first positional snapshot.
    dloc2: dict
        Dictionary with keys "latitude", "longitude" "timestamp"
        associated with the second positional snapshot.

    Returns
    -------
    float
        Scalar value representing the average speed in km/s of the
        International Space Station relative to the Earth in translation 
        from `dloc1` to `dloc2`. 
    """
    # Convert unix epochs to timestamp datetime objects.
    ts1  = datetime.datetime.fromtimestamp(dloc1['timestamp'])
    ts2  = datetime.datetime.fromtimestamp(dloc2['timestamp'])
    secs = abs((ts2 - ts1).total_seconds())
    loc1 = (dloc1["latitude"], dloc1["longitude"])
    loc2 = (dloc2["latitude"], dloc2["longitude"])
    dist = haverdist(loc1, loc2)
    return (dist / secs) * 3600

In this case, the first option seems like a good choice. But instead of a simple function like get_speed, imagine a different function (call it legacyfunc) that we didn’t author and which has been in production for a very long time, which has lots of unfamiliar optional parameters and many more lines of code than get_speed, is responsible for returning the value and this value is the one requiring a change of units. In this case, leaving legacyfunc unmodified and wrapping its result with logic to handle the change of units would be preferable.

We’ll implement a function to handle the change of units conversion. This will result in a parameterized decorator, the parameter indicating which units the final result should be converted to. For this example, the only options will be kilometers per hour or miles per hour, but the decorator can be extended to facilitate any number of additional distance or time conversions.


import functools


def units(spec):
    """
    Specify the units to represent orbital speed. 

    Parameters
    ----------
    spec: str {"kph", "mph"}
        Determines the final units for representing orbital speed,
        "kph" for kilometers-per-hour, "mph" for miles-per-hour. 
    """
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result_init = func(*args, **kwargs)
            # result_init is result returned by func.
            if spec == "mph":
                result = result_init * 0.62137119 
            else: 
                result = result_init
            return(result)
        return(wrapper)
    return(decorator)

Next, get_speed is modified by referencing the units decorator as follows:



@units("mph")
def get_speed(dloc1: dict, dloc2: dict) ->float:
    """
    Compute speed of ISS relative to Earth's surface using
    a pair of coordinates retrieved from `getiss` in 
    kilometers-per-hour.

    Parameters
    ----------
    dloc1: dict
        Dictionary with keys "latitude", "longitude" "timestamp"
        associated with the first positional snapshot.
    dloc2: dict
        Dictionary with keys "latitude", "longitude" "timestamp"
        associated with the second positional snapshot.

    Returns
    -------
    float
        Scalar value representing the average speed of the International
        Space Station relative to the Earth going from dloc1 to 
        dloc2 in kilometers-per-hour.
    """
    ts1 = datetime.datetime.fromtimestamp(dloc1['timestamp'])
    ts2 = datetime.datetime.fromtimestamp(dloc2['timestamp'])
    secs = abs((ts2-ts1).total_seconds())
    loc1 = (dloc1["latitude"], dloc1["longitude"])
    loc2 = (dloc2["latitude"], dloc2["longitude"])
    dist = haverdist(loc1, loc2)
    return (dist / secs) * 3600

With this change, the scalar representing kilometers per hour returned by getspeed will be converted to miles per hour. This can be confirmed by calling get_speed:


import time

dpos1 = getiss()
time.sleep(10)
dpos2 = getiss()
mph_speed = get_speed(dpos1, dpos2)

print(f"Speed in mph: {mph_speed:,.0f}.")
Speed in mph: 15,466.

A speed of 15,420mph is ~25,000km/h. Wikipedia puts the ISS average orbital speed at ~27,000km/h.