Simple Import, Strange Error

Klaas van Schelven
Klaas van Schelven; March 18 - 3 min read
Magician switching two birds
Look at my hands! Are you watching?

Some Python behaviors are unexpected if you’ve never seen them. Others still seem unexpected once you’ve spent an hour debugging them. Here’s one that might catch you off guard:

# weirdness/__init__.py
import requests
import BeautifulSoup

from django.http import HttpResponse

from weirdness.requests import NOT_FOUND


def get(url):
    response = requests.get(url)
    if response.status_code == NOT_FOUND:
        return None
    return response.content


# ...more code...

All seems fine – until you run it:

>>> from weirdness import get
>>> get("https://www.bugsink.com/")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/tmp/weirdness/weirdness/__init__.py", line 11, in get
    response = requests.get(url)
AttributeError: module 'weirdness.requests' has no attribute 'get'

Wait… what? requests.get() is a standard call. How can requests suddenly lack get?

Finding the Problem

The error message tells us that requests isn’t what we thought it was: it complains about weirdness.requests lacking an attribute, rather than the global requests module. This is the first clue. Somewhere along the way, requests got swapped out. But how?

The first clue is the fact that there is at least a line mentioning weirdness.requests (the thing we end up with) among the imports in our example:

from weirdness.requests import NOT_FOUND

But that’s not a real explanation, is it? We’re not importing requests in that line, we’re importing from it. You might expect that such an import would only bring NOT_FOUND into scope, not requests. So what’s going on?

The final clue lies in the first line of the “puzzle” code: the code is actually part of a package’s __init__.py file…

The Trap

Normally, from x import y only brings y into scope. However, when that line lives in __init__.py, it has an extra, effect: it also imports the module x into the namespace of the __init__.py file.

This means that from weirdness.requests import NOT_FOUND also imports weirdness.requests into the namespace of that __init__.py file, replacing the global requests module that we just imported a few lines earlier.

Then, when we try to call requests.get(url), Python looks for get in the weirdness.requests module, not the requests module we expected.

Since the weirdness.requests module has nothing to do with the requests library (and has no get function), the AttributeError is thrown.

How to Avoid This

The best way to avoid this trap might just be to crawl out of it once you’re in it: you’ve now read this article, so you know what to look for. If you run into this issue, you can quickly identify the problem and fix it.

I say that only partly in jest; the “real advice” is to keep your __init__.py files as simple as possible. They’re meant to be a place to collect all the things you want to expose from your package, not a place to put logic. But… in practice, it’s often useful to put some logic in there (conditionally doing one thing or another, or behaving differently based on settings). In those cases the trap is easy to fall into.

Linters and IDEs being aware of this behavior would be nice, but I’m not aware of any that are (I haven’t checked extensively, and I’d be happy to be proven wrong).