11
\$\begingroup\$

The scenario is we're working with a REST endpoint that gives us nice JSON objects. We're using requests, and everything works wonderfully. But one day you notice that data you though was always being pulled back isn't there for some models! You're getting index, key and attribute errors left, right and centre. What used to be obj.phones[0] is now:

(DefaultAttr(obj, None).phones or [None])[0]

Or worse, if obj.phones[0] could actually be None!

And so I decided to build a simple Maybe like group of classes. The interface in Haskell seemed really clean and simple. However there's no case statement in Python, and not one that's based on type. so I decided to use Maybe.get(default: T) -> T and Just._value to get the value instead. Since Just._value isn't implemented on either Nothing or Maybe, I decided to make the equality check if the LHS is an instance of the RHS, and so Just(1) == Just would be True.

This however isn't good enough for the JSON object, and so I subclassed Maybe to create a MaybeNav, that creates a MaybeNav when you get items or attributes. And so if you don't care if obj.phones[0] is there or None, you can use MaybeNav(obj).phones[0].get(). Which is much cleaner, and simpler to read.

I've added type hints to the code, so that it's a little simpler to reason with. And I've added some docstrings, I'd appreciate any way to improve these, as I don't normally use them. And so my skills with them are likely to be very poor.

Due to using f-strings, the code only works in Python 3.6. Finally any and all help is welcome.

mayby.py

from typing import TypeVar, Generic

T = TypeVar('T')
class Maybe(Generic[T]):
    """Simple Maybe abstract base class"""
    def __init__(self, *args, **kwargs):
        """
        Error as this should be overwridden

        The code for this interface is implemented in Just and Nothing.
        This is to prevent implementation errors. Such as overwritting
        __getattr__ in a child class causing an infinate loop.
        """
        raise TypeError("'Maybe' needs to be instansiated by child constructor")

    def get(self, default: T = None) -> T:
        """Get the value in Just, or return default if Nothing"""
        raise NotImplementedError("'get' should be changed in a sublcass")


class Constant(type):
    def __call__(self, *args, **kwargs):
        """Simple metaclass to create a constant class"""
        # Constants can't be called.
        raise TypeError(f"'{self.__name__}' object is not callable")

    def __repr__(self) -> str:
        # Display the constant, rather than the class location in memory.
        return f'{self.__name__}'


class JustBase:
    def __init__(self, value: T):
        """Younger sibling class of Maybe for Just classes"""
        self.__value = value

    def __repr__(self) -> str:
        return f'{type(self).__name__}({self._value!r})'

    def __eq__(self, other: object) -> bool:
        """
        Check if this is an instance of other

        This makes checking if the class is a Just simpler.
        As it's a common operation.
        """
        return isinstance(other, type) and isinstance(self, other)

    def get(self, default: T = None) -> T:
        """Get the value in Just, or return default if Nothing"""
        return self._value

    @property
    def _value(self):
        return self.__value


def build_maybes(Maybe, just_name, nothing_name):
    """Build a Just and Nothing inheriting from Maybe"""
    Just = type(just_name, (JustBase, Maybe), {})
    class MaybeConstant(Constant, Maybe):
        def get(self, default: T = None) -> T:
            """Get the value in Just, or return default if Nothing"""
            return default
    Nothing = MaybeConstant(nothing_name, (object,), {})
    return Just, Nothing


class MaybeNav(Maybe[T]):
    """Maybe for navigating objects"""
    # __getitem__ and __getattr__ actually return MaybeNav[T].
    def __getitem__(self, item: object) -> Maybe[T]:
        if self == NothingNav:
            return NothingNav
        val = self._value
        try:
            val = val[item]
        except Exception:
            return NothingNav
        else:
            return JustNav(val)

    def __getattr__(self, item: str) -> Maybe[T]:
        obj = object()
        val = getattr(self.get(obj), item, obj)
        if val is obj:
            return NothingNav
        return JustNav(val)


Just, Nothing = build_maybes(Maybe, 'Just', 'Nothing')
JustNav, NothingNav = build_maybes(MaybeNav, 'JustNav', 'NothingNav')

# Don't delete T, so subclasses can use the same generic type
del TypeVar, Generic, Constant, JustBase

An example of using this can be:

import math
from collections import namedtuple
from typing import Iterable

from maybe import Maybe, Just, Nothing, MaybeNav, JustNav

def safe_log(number: float) -> Maybe[float]:
    if number > 0:
        return Just(math.log(number))
    else:
        return Nothing


def user_values(obj: object) -> Iterable[MaybeNav[object]]:
    obj = JustNav(obj)
    return [
        obj.name,
        obj.first_name,
        obj.last_name,
        obj.phones[0]
    ]


v = safe_log(1000)
print(v == Just, v)
v = safe_log(-1000)
print(v == Just, v)

User = namedtuple('User', 'name first_name last_name phones')
vals = user_values(User('Peilonrayz', 'Peilonrayz', None, []))
print(vals)
print([val.get(None) for val in vals])

I find maybe.py a little hard to read, mostly as I got the inheritance to happen in build_maybes. And so the following is a class diagram of the user defined objects in maybe.py. One thing to note is the dotted arrows from Nothing and NothingNav to MaybeConstant are to show they are using a metaclass, rather than box standard inheritance. The box with some classes in it is to emphasise they are created in build_maybes.

Class diagram

\$\endgroup\$
5
+150
\$\begingroup\$

This may very well be not what you want to hear, but this seems like the wrong kind of solution for a duck-typed language like Python, especially for something like a REST endpoint. You are going to be fighting the language and implementing non-idiomatic solutions to work around the fact that the language is based around a looser object model than you seem to want to impose.

My first reaction to (DefaultAttr(obj, None).phones or [None])[0] would not be fit for this forum, but the next thought would be why obj.phones[0] if len(obj.phones) else None wasn't good enough. It sounds like your data handling might be too generic - you are passing "arbitrary" objects to code which expects an object with a phones attribute. Instead you could pull out the phones-specific code and put that where you are sure of receiving compatible objects. Another option would be to build a generic object handler which finds all non-default attributes and either handles them all or passes them on to attribute-specific handlers.

Generics are much more of a known and expected factor in for example Haskell (as you know) and Java. So implementing this in a different language might better fit your programming style.

Passing type names as strings is a code smell in every language - you instantly lose the ability for automatic code inspection to understand what is happening.

Overriding __repr__ to print the class name is really weird. From the documentation:

If at all possible, this should look like a valid Python expression that could be used to recreate an object with the same value (given an appropriate environment). If this is not possible, a string of the form <...some useful description...> should be returned. […] This is typically used for debugging, so it is important that the representation is information-rich and unambiguous.

A couple of examples should illustrate this point:

>>> 'foo'.__repr__()
"'foo'"
>>> Exception().__repr__()
'Exception()'

With regard to docstrings, you should use them to explain the why, not the what. For example the one for Maybe.__init__ explains something which should be known to developers familiar with the Python object model. One exception in my opinion is when you cannot express a "what" in the language itself. For example, mentioning that something is an abstract base class is good because it's only implicit by the fact that the constructor raises TypeError (that is, the constructor could conceivably throw TypeError for some other reason).

\$\endgroup\$
  • \$\begingroup\$ Thank you for your answer, I've read over it a couple of times, but don't really understand why you think it's "the wrong kind of solution for a duck-typed language". But most of all, I don't really know what you would want me to replace it with, since obj.phones.get(0) raises an attribute error. As for __repr__ what do you expect a constant to look like? \$\endgroup\$ – Peilonrayz Jun 10 '17 at 13:20
  • \$\begingroup\$ Thank you for your edits, :) I must have explained obj.phones.get(0) incorrectly, sorry. Lists don't have a get, and due to that is why I thought using a Maybe would be the best. As then we can use the same interface for any object that doesn't have a get. Again the repr is on a constant. repr(None) makes 'None', repr(None()) raises a type error, as it's not callable. Replace None with Nothing and you get the same. \$\endgroup\$ – Peilonrayz Jun 10 '17 at 14:17
  • \$\begingroup\$ Can you update this, there's no such thing as list.get. And excluding docstrings, you don't say how I can improve the code/approach. \$\endgroup\$ – Peilonrayz Jun 15 '17 at 14:14
  • \$\begingroup\$ The answer is pretty generic, but I can't explain idiomatic Python in sufficient detail in this post. That would take ages and would be well beyond a code review. \$\endgroup\$ – l0b0 Jun 28 '17 at 16:41
  • \$\begingroup\$ I have no problem with this being a generic review. My problem is for the most part you say X, Y and Z are bad, without saying why they're bad or how I could improve them. I know my code is bad, that's why I posted it on Code Review. But I want to improve my programming ability, which I can't do without a why or how to improve. If I didn't want to learn how to improve, then I'd more than happily just post my code on Code Crap. \$\endgroup\$ – Peilonrayz Jun 28 '17 at 17:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.