Monads in Python, 2

Today I worked with monads again. In the process I wrote an implementation of the Maybe monad in Python, using the same ABC I wrote in my former post. I also came across another interesting topic, but I'll get to that in a bit; below is my implementation of Maybe.
#! /usr/bin/env python3.6
# -*- coding: utf-8 -*-

from typing import Any
from types import FunctionType as Function
from monads1 import Monad


class Maybe(Monad):
    def __init__(self, value: Any =None, just: bool =True) -> None:
        self.value = value
        self.just = just

    def __str__(self) -> str:
        if self.just:
            return f'Just({self.value})'
        else:
            return 'Nothing'

    def __repr__(self) -> str:
        return self.__str__()

    def bind(self, f: Function) -> 'Maybe':
        """ Action upon value in Maybe context that returns a new Maybe context
        """
        if self.just:
            return f(self.value)
        else:
            return self

    def __eq__(self, other: 'Maybe') -> bool:
        if type(other) is type(self):
            return self.__dict__ == other.__dict__
        return False

    @classmethod
    def unit(cls, value: Any) -> 'Maybe':
        return cls(value)


class Just(Maybe):
    """ A successful Maybe monad """
    def __init__(self, value: Any) -> None:
        super(Just, self).__init__(value)


class Nothing(Maybe):
    """ A failed Maybe monad """
    def __init__(self) -> None:
        super(Nothing, self).__init__(just=False)


def isJust(arg: Any) -> bool:
    return type(arg) is Just


def isNothing(arg: Any) -> bool:
    return type(arg) is Nothing
I included definitions for Just and Nothing, as well as the library functions isJust and isNothing mentioned on the Haskell Wiki page for Maybe (c.f. scabl).

A simple example of using this type includes handling ZeroDivisionErrors, as in the following function.
def division(num: int, den: int) -> Maybe:
    """ breaks if j == 0 (ZeroDivisionError) """
    if den == 0:
        return Nothing()
    else:
        return Just(num // den)
Now we can act upon a value in the Maybe context.
from functools import partial


print(f'** {division(0, 1) >> partial(division, den=1)}')
print(f'** {division(1, 0) >> partial(division, den=1)}')
print(f'** {division(1, 1) >> partial(division, den=0) >> partial(division, den=1)}')

# ** Just(0)
# ** Nothing
# ** Nothing
We see that attempting \(\frac{1}{0}\) results in Nothing, whereas \(\frac{0}{1}\) is Just(0), as we'd expect. Moreover, further attempts to divide a value in the Nothing context results in Nothing, even though it may be a valid denominator for division. This is a result of the bind method's definition, i.e.
...
        if self.just:
            return f(self.value)
        else:
            return self
...
In words, if self.just is False, then we get the same instance of Nothing back. As opposed to exceptions and try-except blocks, we use if-then statements.

We can also check the monad axioms I defined in my last post, to make sure we're dealing with a monad.
check_monad_axioms(Maybe, 15, lambda d: Maybe(d * 2), partial(division, den=0.0))

# ** All tests passed for Maybe

I also tried to come up with an interesting use case of this monad. In the following block of code I've written a decorator that terminates a function with signal if it takes too long to return a value.
import signal
from functools import wraps
from typing import Optional, Callable


def signaling_limiter(time: int) -> Function:
    def _outer_f(f: Function) -> Function:
        @wraps(f)
        def _inner_f(*args, **kwargs) -> Maybe:

            res: Optional[Maybe] = None


            class SignalFunctionEndError(Exception):
                pass


            def handler(*args) -> None:
                # sets res to Nothing if time's up
                nonlocal res
                res = Nothing()
                raise SignalFunctionEndError

            try:
                # If this finishes, `handler` is never called
                signal.signal(signal.SIGALRM, handler)
                signal.alarm(time)

                res = f(*args, **kwargs)

            except SignalFunctionEndError:
                pass

            finally:
                signal.alarm(0)

            return res

        return _inner_f
    return _outer_f


def infinity() -> int:
    while True:
        pass

    # This return statement is never, ever reached
    return 1


@signaling_limiter(time=1)
def timeout(comp: Callable) -> Maybe:
    if not hasattr(comp, '__call__'):
        return Nothing()
    else:
        return Just(comp())
In the event that it does (or we don't pass it an argument with the right attributes), we get Nothing.
print(f'** {Maybe(infinity) >> timeout}')

# ** Nothing
On the other hand, changing pass to break in the infinite while-loop, we get
# ** Just(1)
as we'd expect, since the return statement is allowed to complete.

Now for that `interesting topic' I mentioned at the outset. I'm not exactly happy with the decorator above because it uses, again, the try-except block, which means we used exception handling. However, attempting to run f (or, in this case, infinity) in a thread led to a rabbit hole of issues I wouldn't want to occur in real software. If it's one thing that's been a terrible nuisance as long as I've been using Python, it's the GIL and threading.