Modern Functions in Python 3

Home / Developer Tools / Modern Functions in Python 3
Modern Functions in Python 3

Python has thrived over the past few decades as the language which lets you work quickly and effectively. Like many modern companies, we use Python quite extensively for a majority of our stack, but in many cases, continue to live on Python 2.7.

The harsh reality is that Python 2.7 is going away, and honestly — it’s about time! We’ve been writing all new code in Python 3.x, and are continuously pushing to upgrade existing projects.

With Python 3 growing at G Adventures, we’ve begun to unravel ourselves from our 2.7 roots, and begin to take advantage of the perks within Python 3. This is a cultural shift, and the approach we’ll eventually settle with will likely be a balance of what is discussed here. In this article, we’ll discuss the various ways we can approach a function signature, what it means, and when best to use it.

def cheeseshop(kind, weight, region=None):

Above, we see a typical function definition within Python. This is valid for any sane version of Python today. Now, one of the reasons Python has done so well is that it’s simple and clean. There’s no extra cruft here! However, we’ve been writing plenty of Go lately, and a few perks of a strictly typed language are rubbing off on us. In Python 3, we can take advantage of totally optionalPEPs, and annotate our functions with types. Before we get into more detail, let’s just see how it looks:

from decimal import Decimal
​
def cheeseshop(
    kind: str,
    weight: int,
    region=None: str,
) -> Decimal:

Well, this just got interesting! Annotations can be quite a lengthy discussion, and I’ll recommend reading the PEP as the best place to get full coverage, but what I will focus on here is the benefits of blending of dynamic typing and static typing, and maintain a high level on the details of annotations.

You can see here I’ve used a : colon separator to help define what each variable is. I use built-in types to tell the developer reading my code we expect a str (String) for the first argument (kind), and so on.

Additionally, we have this unique -> Decimal at the end of our function definition. This defines what the function returns.

Python 3 offers a wide range of built-in types through typing module, In addition to these, the types can be any object you define yourself. We use Django plenty at G Adventures and have found it beneficial to annotate variables with the underlying model we expect for a function.

Here’s an expanded example, using a Django model as a type

from decimal import Decimal
from django.db import models
​
class Cheese(models.Model):
    # model snipped for brevity
    name = models.CharField(...)
​
def cheeseshop(
    kind: Cheese,
    weight: int,
    region=None: str,
) -> Decimal:

Now we can see that kind is actually an instance of Cheese! We originally expected a str, but have since updated our function to take a complex object. We’d expect our function to fetch certain details from this model instance to help define the returning Decimal. But wait, what kind of decimal is this function returning? Hm, well, we could implement our own type there too!

from decimal import Decimal
from typing import NewType
​
Price = NewType('Price', Decimal)
​
def cheeseshop(
    kind: Cheese,
    weight: int,
    region: str=None,
) -> Price:
​

Look back at our initial implementation of this function. One can argue readability (and with annotations, this is quite the debate), but what we have here is context. Developers spend (or well, should spend) more time reading code than they write, and the information we present here is objectively more detailed.

Before we continue down our annotation exercise, I want to offer this fun aside. Here, I’ve made a change to our function definition:

def cheeseshop(
    kind: Cheese,
    weight: int,
    *,
    region: str=None,
) -> Price:

Can you see the difference? We’ve added an asterisk * to the middle of the function. What this signifies is the separation between args and kwargs, and it actually enforces this. What this means is that region— if given, because it’s still optional — must be provided as a _kwarg_. For example:

cheeseshop(le_bocke, 200, 'Quebec')

Will throw a TypeError like so:

File "cheese.py", line 24, in <module>
    cheeseshop(le_bocke, 200, 'Quebec')
TypeError: cheeseshop() takes 2 positional arguments but 3 were given

This is a simple, but powerful enforcement. In many situations, the kwarg is an argument which you prefer to have identified with the variable it’s going to be scoped to, as it provides context during the function call. This enforcement is one of the simplest changes you can make to your code today to ensure consistent args and kwargs.

Ok! That was a fun aside, but let’s continue, let’s look at how we’d use built-in typing objects, and how they work. Back to our example:

from decimal import Decimal
from typing import NewType
​
Price = NewType('Price', Decimal)
​
def cheeseshop(
    kind: Cheese,
    weight: int,
    region: str=None,
) -> Price:
​

This can go on and on, but let’s jump to applying this work. We can use a tool named mypy to actually run static analysis on our code, and let us know if we’re using incorrect types. This is where type hinting and annotations can really shine. By preventing the issue early on, you’re getting the benefits of a statically typed language while living in your dynamic Python world. Rad! (Yes, I am a child of the late 80s)

Let’s give mypy a go on our example. What I’ve done to further our example is made our Cheese class a simple, non-Django class, have created a single cheese and pushed it through our cheese shop. Let’s see what that looks like in full:

from decimal import Decimal
from typing import NewType
​
class Cheese:
    def __init__(self, name, price):
        self.name = name
        self.price = price
​
Price = NewType('Price', Decimal)
​
def cheeseshop(
        kind: Cheese,
        weight: int,
        region: str=None,
) -> Price:
    print("From %s!", region)
    return weight * kind.price
​
baluchon = Cheese('Baluchon', 0.99)
​
print(cheeseshop(baluchon, 2.5)) # results in 2.475
​

Looking at this code, and the result. Things look pretty clear. In fact, this code passes just fine. But, running mypy against it, you get the following:

cheese.py:23: error: Argument 2 to "cheeseshop" has incompatible type "float"; expected "int"

Ah! In our call to the cheeseshop, we passed a float (2.5), when our function expected an int.

Now, in this example extremely simplified example, this doesn’t look too harmful, but what if you’re calculating commissions, fees, or anything to do with finances? Yes, a good set of eyes is always useful, but sanity checks during your editing experience, before anything has been run can be the difference between hours of headache, or just moving towards the next goal.

The dive into mypy here was very minimal, as this tool goes beyond what we can discuss here today. A good first step is to plug mypy into the editor of your choice. Vim, Visual Studio Code, PyCharm, etc., all have integrations with it. Additionally, it’s just as simple to run it on the commit line, perhaps as a pre-commit hook? How you approach using it is exactly why we love being part of this Python ecosystem!

The benefit of mypy is definitely, more clearly shown when you’re dealing with larger applications. Anything beyond this pet demo will be a mixture of modules, external libraries, multiple layers of abstraction, and non-trivial paths of execution. Yes, we all strive to write clean code that is isolated from the rest of the system, but the reality is that imperfect systems will always exist, and even our utopian visions of what we want to accomplish can be challenged.

Python has done incredibly well as the language to get things done. Its next evolutionary steps look to embrace the benefits of static analysis while offering the speed and flexibility of the language we all know and love. By giving us these options, the language creators have opened up the doors to new tools, style guides, and a new realm of systems design that Python likely has not experienced before.

I hope you found this high level dive into Python 3 functions exciting (and educational!). For further reading, I do recommend the PEPs for type hintsand function annotations as they truly are the most robust examples (and what I reference on a regular) on these subjects.

Source: tech.gadventure