Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to define PyCharm-friendly value object in Python?

Tags:

python

pycharm

I'm curious about good way to define value object in Python. Per Wikipedia: "value object is a small object that represents a simple entity whose equality isn't based on identity: i.e. two value objects are equal when they have the same value, not necessarily being the same object". In Python that essentially means redefined __eq__ and __hash__ methods, as well as immutability.

Standard namedtuple seems like almost perfect solution with exception that they don't play well with modern Python IDE like PyCharm. I mean that IDE will not really provide any helpful insights about class defined as namedtuple. While it's possible to attach docstring to such class using trick like this:

class Point2D(namedtuple("Point2D", "x y")):
    """Class for immutable value objects"""
    pass

there's simply no place where to put description of constructor arguments and specify their types. PyCharm is smart enough to guess arguments for Point2D "constructor", but type-wise it's blind.

This code have some type information pushed in, but it's not very useful:

class Point2D(namedtuple("Point2D", "x y")):
    """Class for immutable value objects"""
    def __new__(cls, x, y):
        """
        :param x: X coordinate
        :type x: float

        :param y: Y coordinate
        :type y: float

        :rtype: Point2D
        """
        return super(Point2D, cls).__new__(cls, x, y)

point = Point2D(1.0, 2.0)

PyCharm will see types when constructing new objects, but will not grasp that point.x and point.y are floats, so would not not help to detect their misuse. And I also dislike the idea of redefining "magic" methods on routine basis.

So I'm looking for something that will be:

  • just as easy to define as normal Python class or namedtuple
  • provide value semantics (equality, hashes, immutability)
  • easy to document in a way that will play nicely with IDE

Ideal solution could look like this:

class Point2D(ValueObject):
    """Class for immutable value objects"""
    def __init__(self, x, y):
        """
        :param x: X coordinate
        :type x: float

        :param y: Y coordinate
        :type y: float
        """
        super(Point2D, self).__init__(cls, x, y)

Or that:

class Point2D(object):
    """Class for immutable value objects"""

    __metaclass__ = ValueObject

    def __init__(self, x, y):
        """
        :param x: X coordinate
        :type x: float

        :param y: Y coordinate
        :type y: float
        """
        pass

I tried to find something like this but without success. I thought that it will be wise to ask for help before implementing it by myself.

UPDATE: With help of user4815162342 I managed to come up with something that works. Here's the code:

class ValueObject(object):
    __slots__ = ()

    def __repr__(self):
        attrs = ' '.join('%s=%r' % (slot, getattr(self, slot)) for slot in self.__slots__)
        return '<%s %s>' % (type(self).__name__, attrs)

    def _vals(self):
        return tuple(getattr(self, slot) for slot in self.__slots__)

    def __eq__(self, other):
        if not isinstance(other, ValueObject):
            return NotImplemented
        return self.__slots__ == other.__slots__ and self._vals() == other._vals()

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(self._vals())

    def __getstate__(self):
        """
        Required to pickle classes with __slots__
        Must be consistent with __setstate__
        """
        return self._vals()

    def __setstate__(self, state):
        """
        Required to unpickle classes with __slots__
        Must be consistent with __getstate__
        """
        for slot, value in zip(self.__slots__, state):
            setattr(self, slot, value)

It's very far from an ideal solution. Class declaration looks like this:

class X(ValueObject):
    __slots__ = "a", "b", "c"

    def __init__(self, a, b, c):
        """
        :param a:
        :type a: int
        :param b:
        :type b: str
        :param c:
        :type c: unicode
        """
        self.a = a
        self.b = b
        self.c = c

It's total FOUR times to list all attributes: in __slots__, in ctor arguments, in docstring and in ctor body. So far I have no idea how to make it less awkward.

like image 981
Michael Korbakov Avatar asked Oct 22 '13 03:10

Michael Korbakov


People also ask

What is def __ str __( self?

def str(self): is a python method which is called when we use print/str to convert object into a string.

What is the __ str __ method in Python?

Python __str__() This method returns the string representation of the object. This method is called when print() or str() function is invoked on an object. This method must return the String object.

What is the value of an object in Python?

A Value Object is one of the fundamental building blocks of Domain-Driven Design. It is a small object (in terms of memory), which consists of one or more attributes, and which represents a conceptual whole. Value Object is usually a part of Entity.

How do you print the value of a object in Python?

Use Python's vars() to Print an Object's Attributes The dir() function, as shown above, prints all of the attributes of a Python object. Let's say you only wanted to print the object's instance attributes as well as their values, we can use the vars() function.


2 Answers

New in version 3.5: The typing module and NamedTuple

In version 3.5, the typing module has been added, in it, you will find a class that perfectly fits your needs.

The new NamedTuple

It works just as you'd expect:

  • Simple type definition:

    from typing import NamedTuple
    class DownloadableFile(NamedTuple):
        file_path: str
        download_url: str
    
  • Recognized in PyCharm:

    PyCharm completion PyCharm type detection


Note:
As of today, the API is still in a provisional stage. It means it isn't guaranteed to be backwards compatible when new version is released. Changes to the interface though are not expected. My personal take on it is: given the simplicity of the design, if change there is, I am sure it will be an easy refactor ;)

like image 102
ThisIsFlorianK Avatar answered Oct 02 '22 06:10

ThisIsFlorianK


Your requirements, although carefully expressed, are not quite clear to me, partly because I don't use the PyCharm GUI. But here is an attempt:

class ValueObject(object):
    __slots__ = ()

    def __init__(self, *vals):
        if len(vals) != len(self.__slots__):
            raise TypeError, "%s.__init__ accepts %d arguments, got %d" \
                % (type(self).__name__, len(self.__slots__), len(vals))
        for slot, val in zip(self.__slots__, vals):
            super(ValueObject, self).__setattr__(slot, val)

    def __repr__(self):
        return ('<%s[0x%x] %s>'
                % (type(self).__name__, id(self),
                   ' '.join('%s=%r' % (slot, getattr(self, slot))
                            for slot in self.__slots__)))

    def _vals(self):
        return tuple(getattr(self, slot) for slot in self.__slots__)

    def __eq__(self, other):
        if not isinstance(other, ValueObject):
            return NotImplemented
        return self.__slots__ == other.__slots__ and self._vals() == other._vals()

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(self._vals())

    def __setattr__(self, attr, val):
        if attr in self.__slots__:
            raise AttributeError, "%s slot '%s' is read-only" % (type(self).__name__, attr)
        super(ValueObject, self).__setattr__(attr, val)

Usage is like this:

class X(ValueObject):
  __slots__ = 'a', 'b'

This gets you a concrete value class with two read-only slots and an autogenerated constructor, __eq__, and __hash__. For example:

>>> x = X(1.0, 2.0, 3.0)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 5, in __init__
TypeError: X.__init__ accepts 2 arguments, got 3
>>> x = X(1.0, 2.0)
>>> x
<X[0x4440a50] a=1.0 b=2.0>
>>> x.a
1.0
>>> x.b
2.0
>>> x.a = 10
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 32, in __setattr__
AttributeError: X slot 'a' is read-only
>>> x.c = 10
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 33, in __setattr__
AttributeError: 'X' object has no attribute 'c'
>>> dir(x)
['__class__', '__delattr__', '__dict__', '__doc__', '__eq__', '__format__', '__getattribute__', '__hash__', '__init__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', '__weakref__', '_vals', 'a', 'b']
>>> x == X(1.0, 2.0)
True
>>> x == X(1.0, 3.0)
False
>>> hash(x)
3713081631934410656
>>> hash(X(1.0, 2.0))
3713081631934410656
>>> hash(X(1.0, 3.0))
3713081631933328131

If you want, you can define your own __init__ with the docstring that (presumably) provides your IDE with type annotation hints.

like image 43
user4815162342 Avatar answered Oct 02 '22 06:10

user4815162342