Notebook Development Utilities

Supporting interactive notebook-driven development
The autoreload extension is already loaded. To reload it, use:
  %reload_ext autoreload

Class Definitions

One challenge with a highly iterative notebook-driven development workflow where we’re taking small steps is defining classes. Generally, you’d define the entire class in one giant cell, which makes it hard to add prose around it or break it down into small executable pieces.

To address this challenge, we’re going to add a couple helpers that are inspired from the wonderful fastcore library: patch and patch_to. These will allow easily monkeypatching a class so we can define bits of it at a time.


patch_to


def patch_to(
    o:object
)->Callable:

Decorator that takes in a object and attaches the decorated function onto it.

Exported source
def patch_to(o: object) -> Callable:
    "Decorator that takes in a object and attaches the decorated function onto it."
    def inner(fn: Callable) -> Callable:
        print(fn)
        nm = callable_name(fn)
        setattr(o, nm, fn)
        return fn
    return inner

Okay, let’s try it out. We’ll create a dummy class named _T.

class _T:
    pass

Now, we’ll attach on a function to _T in a separate code cell. This allows us to, say, define the __init__ with the class definition of _T together and document those and then document each individual function one by one separately with lots of examples.

@patch_to(_T)
def say_hello(self: _T, name: str) -> str:
    return f"Hello, {name}"
<function say_hello>

So, now _T should have a say_hello function on it. Let’s confirm.

_t = _T()
assert _t.say_hello('eugene') == 'Hello, eugene'

Okay, so that works. But why are we getting __name__ or __class__.__name__? It turns out that classes in Python can be callable if they have a __call__ function defined, but instances of those classes don’t have a __name__.

class _TC:
    def __call__(self, x):
        return x + x
_tc = _TC()
with test.raises(AttributeError):
    _tc.__name__

Now, that’s cool and all but notice that self: _T and @patch_to(_T) are both two different ways to provide the same sort of information (attach this method onto _T). So, now let’s take a look into adding a patch decorator which looks at the type hint to determine which object to patch.

We already have patch_to, so all we need to do inside patch is find the object to patch and call patch_to. Now, how do we do that? Let’s take a look at inspect.

inspect.get_annotations(patch_to)
{'o': object, 'return': typing.Callable}

Okay, so if we assume the object we’re patching will always be called self i.e. patching a class, then this should be easy-peasy.


patch


def patch(
    fn:Callable
)->Callable:

Decorator to patch the object of the type-hinted ‘self’ argument of fn with fn.

Let’s see if we can try patch out now with our dummy _T class.

@patch
def say_goodbye(self: _T, name: str) -> str:
    return f'Goodbye, {name}'
<function say_goodbye>

Cool, can our _t object now say goodbye?

test.equal(_t.say_goodbye('eugene'), 'Goodbye, eugene')
say_goodbye.__qualname__
'say_goodbye'
class MyCallable:
       def __call__(self, x):
           return x
   
obj = MyCallable()
type(say_goodbye)
function