Rebellious Magic Methods and Extending Python Syntax

Let me show you something really interesting. Imagine a library for working with large data sets - I'll called it fakepandas, because on the surface it looks a little like the excellent Pandas data analysis library. Fakepandas has a class called Dataset:

  1. >>> from fakepandas import Dataset
  2. >>> ds = Dataset({
  3. ... 'A': [-137, 22, -3, 4, 5],
  4. ... 'B': [10, 11, 121, 13, 14],
  5. ... 'C': [3, 6, 91, 12, 15],
  6. ... })
  7. >>> ds.pprint()
  8. -------------------
  9. | A | B | C |
  10. -------------------
  11. | -137 | 10 | 3 |
  12. | 22 | 11 | 6 |
  13. | -3 | 121 | 91 |
  14. | 4 | 13 | 12 |
  15. | 5 | 14 | 15 |
  16. -------------------

Notice in the constructor, you're passing in a dictionary which maps column labels to the data for that column. So in your mind, you rotate each horizontal list into a vertical column - got that? Now here's the really interesting part:

3 Keys To Level Up Your Python

Free 30-page Guide
* indicates required
100% privacy. No games, no B.S., no spam. When you sign up, we'll keep you posted with about one email per week.
  1. # Create a new Dataset called "positive_a".
  2. >>> positive_a = ds[ds.A > 0]
  3. >>> positive_a.pprint()
  4. ----------------
  5. | A | B | C |
  6. ----------------
  7. | 22 | 11 | 6 |
  8. | 4 | 13 | 12 |
  9. | 5 | 14 | 15 |
  10. ----------------
  11. >>> big_b = ds[ds.B >= 14]
  12. >>> big_b.pprint()
  13. -----------------
  14. | A | B | C |
  15. -----------------
  16. | -3 | 121 | 91 |
  17. | 5 | 14 | 15 |
  18. -----------------
  19. >>> coupled = ds[ds.A + ds.B < 20]
  20. >>> coupled.pprint()
  21. ------------------
  22. | A | B | C |
  23. ------------------
  24. | -137 | 10 | 3 |
  25. | 4 | 13 | 12 |
  26. | 5 | 14 | 15 |
  27. ------------------

In an expression like ds[ds.A > 0], the part in the brackets becomes a filter; you get a new Dataset with only the matching rows.

But when you think about it, that's a little weird. Boolean expressions are supposed to evaluate to either True or False. That means everything I typed above should become either ds[True] or ds[False] at runtime... encoding no information about the rows you actually want.

So what's going on? How in the world does this work?

Magic Methods

The secret relies on Python's "magic methods". You won't find that phrase in the official Python docs; it's a term the wider community invented to describe methods your objects can define, which hook into Python's built-in operators and behavior. For example, imagine a class that represents angles in degrees:

  1. class Angle:
  2. def __init__(self, degrees):
  3. # Wrap it around so that the value is always
  4. # in the interval of 0 and 359.999...
  5. self.degrees = degrees % 360
  6. # Less-than
  7. def __lt__(self, other):
  8. return self.degrees < other.degrees
  9. # Less-than-or-equals
  10. def __le__(self, other):
  11. return self.degrees <= other.degrees
  12. # Greater-than
  13. def __gt__(self, other):
  14. return self.degrees > other.degrees
  15. # Greater-than-or-equals
  16. def __ge__(self, other):
  17. return self.degrees >= other.degrees
  18. # Equals
  19. def __eq__(self, other):
  20. return self.degrees == other.degrees

__lt__, __ge__, etc. are magic methods. When a class defines them, objects of that class can be used with the normal comparison operators, like "<" and ">=":

  1. >>> a = Angle(20)
  2. >>> b = Angle(380)
  3. >>> c = Angle(45)
  4. >>>
  5. >>> b == a
  6. True
  7. >>> c >= a
  8. True
  9. >>> b < c
  10. True
  11. >>> a < b
  12. False

There are many more than these three. All such magic methods are surrounded by a pair of underscores; when speaking, people often say "dunder foo" to mean __foo__, because (with __gt__, for example) "dunder gee tee" takes much less time to say than "underscore underscore gee tee underscore underscore".

Normally these comparison magic methods - __lt__, __ge__, __eq__, and so on - are programmed to return either True or False. But as far as Python's runtime is concerned, they can return anything. In fact, you can have magic methods return an instance of a class you define. Let's see how we can exploit this for the Dataset class.

The Dataset Class

The constructor is straightforward enough. Its single argument is a dictionary, mapping column labels (strings) to the data in that column (as a list):

  1. class Dataset:
  2. def __init__(self, data: dict):
  3. self.data = data
  4. self.length = num_rows(data)
  5. self.labels = sorted(data.keys())

num_rows is a helper function returning the number of rows in the columns. (It also validates all columns have the same number of rows, raising a ValueError if they're inconsistent. I'll show you the source later.) Notice we auto-sort the column labels, which will make certain things easier.

Now, to make an expression like ds[ds.A > 0] work, we must make ds.A meaningful. We do that with the __getattr__ magic method:

  1. class Dataset:
  2. # ...
  3. def __getattr__(self, label):
  4. if label not in self.data:
  5. raise AttributeError("'{}' object has no attribute '{}'".format(self.__class__.__name__, label))
  6. return LabelReference(label)

When we say df.A, Python essentially translates that to df.__getattr__("A"). Generally speaking, when you use __getattr__, you will want to only accept certain values, and raise AttributeError otherwise. In this case, since "A" is a valid column label, df.A returns an instance of a class called LabelReference:

  1. >>> ds.A
  2. <fakepandas.LabelReference object at 0x1014180f0>

LabelReference represents a column label, and the operations that work on it. The code for LabelReference looks like this:

  1. class LabelReference:
  2. def __init__(self, label: str):
  3. self.label = label
  4. def __gt__(self, value):
  5. return Comparison(self.label, value, operator.gt)

Notice how LabelReference's rebellious dunder-gee-tee method does not return a boolean. It instead returns a decidedly non-boolean object, of a type called Comparison. This creative mis-use is exactly what lets this whole thing work!

Comparisons

In essence, Comparison represents lazily comparing a specific row's value to some threshold:

  1. class Comparison:
  2. def __init__(self, label, value, operate):
  3. self.label = label
  4. self.value = value
  5. self.operate = operate
  6. def apply(self, data, row_number):
  7. other_value = data[self.label][row_number]
  8. return self.operate(other_value, self.value)

Breaking this down, for ds.A > 0:

To review: ds.A > 0 is translated by Python into ds.__getitem__("A").__gt__(0). The value returned by ds.__getitem__("A") is of type LabelReference; when we invoke __gt__(0) on that object, what we get back is of type Comparision.

Now all that's left is to make the square brackets work on the Dataset object. We do that with a magic method called __getitem__. If you're not familiar with it, it works like this:

  1. >>> class Petstore:
  2. ... def __init__(self, inventory: dict):
  3. ... # A dict mapping pet species (str)
  4. ... # to number in the store (int).
  5. ... self.inventory = inventory
  6. ... def __getitem__(self, pet: str):
  7. ... # Return how many of that pet we have.
  8. ... return self.inventory[pet]
  9. ...
  10. >>> pet_store = Petstore({
  11. ... "turtle": 3,
  12. ... "dog": 7,
  13. ... "cat": 2,
  14. ... "elephant": 1,
  15. ... })
  16. >>>
  17. >>> num_turtles = pet_store["turtle"]
  18. >>> print("We have {} turtles in stock.".format(num_turtles))
  19. We have 3 turtles in stock.

In other words, Python automatically translates pet_store["turtle"] into pet_store.__getitem__("turtle"). Neat, huh? This lets us put the last Dataset piece into place:

  1. # In class Dataset:
  2. def __getitem__(self, comparison):
  3. filtered_data = dict((label, [])
  4. for label in self.labels)
  5. # Internal helper function.
  6. def append_row(row_number):
  7. for label in self.labels:
  8. value = self.data[label][row_number]
  9. filtered_data[label].append(value)
  10. # Now add in rows.
  11. for row_number in range(self.length):
  12. if comparison.apply(self.data, row_number):
  13. append_row(row_number)
  14. return Dataset(filtered_data)

This is where we use the apply method, towards the end of this code block. Importantly, its form is very general - which means we can support more complex expressions simply by creating more sophisticated comparison classes, with their own apply methods.

Richer Syntax

With this foundation in place for greater-than comparisons, we can easily add the others: less-than, greater-than-equals, etc. All we have to do is add more methods to LabelReference:

  1. # The full version, supporting
  2. # ==, <, >, <= and >=.
  3. class LabelReference:
  4. def __init__(self, label: str):
  5. self.label = label
  6. def __gt__(self, value):
  7. return Comparison(self.label, value, operator.gt)
  8. def __lt__(self, value):
  9. return Comparison(self.label, value, operator.lt)
  10. def __le__(self, value):
  11. return Comparison(self.label, value, operator.le)
  12. def __ge__(self, value):
  13. return Comparison(self.label, value, operator.ge)
  14. def __eq__(self, value):
  15. return Comparison(self.label, value, operator.eq)

But this is just the start. We can go much further in the interface we provide. Using the same principles we've covered so far, we can express things like:

These are all demonstrated in the full source of fakepandas. Having read this far, you now can understand the rest on your own. Take a look at the source, and let me know what you think.