Skip to main content

A namedtuple-style library for defining immutable sum types in Python.

Project description

sumtype

A namedtuple-style library for defining immutable sum types in Python.

You may know sum types under a different name – they're also referred to as tagged unions, enums in Rust/Swift, and variants in C++. If you haven't heard about them yet, here's a nice introduction.

The current version is 0.9.5, quickly approaching 1.0. The library supports Python 3.x (on versions <= 3.4, please install typing). The core code has lived in various utils folders for about a year, before I got tired of copying it around and decided to release it as an independent package. (see also: Should I use it?)

Suggestions, feedback and contributions are very welcome!

A quick tour

    >>> from sumtype import sumtype
    >>>
    >>> class Thing(sumtype):
    ...     def Foo(x: int, y: int): ...
    ...     def Bar(y: str, hmm: str): ...
    ...     def Zap(): ...
    ...
    >>>

This means that a Thing value can be one of three variants:

  • a Foo with two int fields, x and y
  • a Bar with two string fields, y and hmm
  • a Zap with no fields

You can also add your own docstring and methods in the class definition. If you prefer namedtuple-style definitions, sumtype supports those too - see Thing2 in sumtype.sumtype.demo() for an example.

Creating values and attribute access

    >>> foo = Thing.Foo(x=3, y=5)          # named arguments
    >>> bar = Thing.Bar('hello', 'world')  # positional arguments
    >>> zap = Thing.Zap()

Note that they're still just different values of the same type, not subclasses:

    >>> type(foo) is Thing  and  type(bar) is Thing  and  type(zap) is Thing
    True

Every specified field gets an accessor:

    >>> foo.x, foo.y;
    (3, 5)
    >>> bar.y,  bar.hmm
    ('hello', 'world')

...with checks if the access is valid and descriptive error messages:

    >>> Thing.Zap().hmm  # only `Bar`s have a `hmm` field
    Traceback (most recent call last):
      ...
    AttributeError: Incorrect 'Thing' variant: Field 'hmm' not declared in variant 'Zap'...
    >>>
    >>> Thing.Foo(x=1, y=2).blah_blah_blah  # no variant has a `blah_blah_blah` field 
    Traceback (most recent call last):
      ...
    AttributeError: Unknown attribute: Field 'blah_blah_blah' not declared in any variant of 'Thing'...

The values also have a nice __repr__():

    >>> foo; bar; zap
    Thing.Foo(x=3, y=5)
    Thing.Bar(y='hello', hmm='world')
    Thing.Zap()

The library is designed with efficiency in mind¹ – it uses __slots__ for attribute storage and generates specialized versions of all the methods for each class. To see the generated code, do class Thing(sumtype, verbose=True):.

¹ At least I like to think so ;) I try to do my best with profiling things though!

Features

Equality and hashing

    >>> Thing.Foo(1,2) == Thing.Foo(1,2)
    True
    >>> Thing.Foo(1,2) == Thing.Bar('a', 'b');
    False
    >>> {foo, foo, bar, zap} == {foo, bar, zap}
    True

__eq__ and __hash__ pay attention to variant - even if we had a variant Moo(x: int, y: int), Foo(1,2) != Moo(1,2) and hash(Foo(1,2)) != hash(Moo(1,2)).

Note: Just like tuples, sumtypes __eq__/__hash__ work by __eq__ing/__hash__ing the values inside, so the values must all implement the relevant method for it to work.

Modifying values

    >>> foo;  foo.replace(x=99)
    Thing.Foo(x=3, y=5)
    Thing.Foo(x=99, y=5)
    >>>
    >>> bar;  bar.replace(y='abc', hmm='xyz')
    Thing.Bar(y='hello', hmm='world')
    Thing.Bar(y='abc', hmm='xyz')

foo.replace(x=99) returns a new value, just like in namedtuple.

Note: replace and all the other methods have underscored aliases (_replace). So even if you have a field called replace, you can still use my_value._replace(x=15).

Pattern matching

Statement form:
    >>> def do_something(val: Thing):
    ...     if val.is_Foo():
    ...         print(val.x * val.y)
    ...     elif val.is_Bar():
    ...         print('The result is', val.y, val.hmm)
    ...     elif val.is_Zap():
    ...         print('Whoosh!')
    ...     else: val.impossible() # throws an error - nice if you like having all cases covered
    ...
    >>> for val in (foo, bar, zap):
    ...     do_something(val)
    ...
    15
    The result is hello world
    Whoosh!
Expression form:
    >>> [ val.match(
    ...      Foo = lambda x, y: x*y, 
    ...      Zap = lambda: 999,
    ...      _   = lambda: -1 # default case
    ...   )
    ...  for val in (foo, bar, zap)]
    [15, -1, 999]

Conversions between sumtypes and standard types

To...

    >>> foo.values();  foo.values_dict();
    (3, 5)
    OrderedDict([('x', 3), ('y', 5)])
    >>> foo.as_tuple();  foo.as_dict()
    ('Foo', 3, 5)
    OrderedDict([('variant', 'Foo'), ('x', 3), ('y', 5)])

...and from

    >>> Thing.from_tuple(('Foo', 10, 15));  Thing.from_dict({'variant':'Foo', 'x': 10, 'y': 15})
    Thing.Foo(x=10, y=15)
    Thing.Foo(x=10, y=15)

Also, x == Thing.from_tuple(x.as_tuple()) and x == Thing.from_dict(x.as_dict()).

Pickle support

    >>> import pickle
    >>> vals  = [Thing.Foo(1, 2), Thing.Bar('one', 'two'), Thing.Zap()]
    >>> vals2 = pickle.loads(pickle.dumps(vals))
    >>> vals;  vals == vals2
    [Thing.Foo(x=1, y=2), Thing.Bar(y='one', hmm='two'), Thing.Zap()]
    True

There's also tests in sumtype.tests to ensure that it all works correctly. And that's everything... for now!

Features coming in 1.0

  • Default values (easy, just haven't gotten around to it yet)

  • Argument typechecking – always, or in __debug__ mode only (less easy because of typing annotations like Tuple[int, str]typing does not expose a way to check if a value matches a type like that)

Should I use it?

Yeah! I didn't just build this library because I thought it'd be nice – I'm using it heavily in an app I'm developing and a few smaller projects. Saying that it's battle-tested is a bit much, but it's getting there.

Possible future features

  • mypy support. Unfortunately, last time I checked, mypy didn't handle metaclass-created classes too well, but that might have changed. Alternatively, we could provide a way to generate mypy stub files. Also, right now there's no way to tell mypy that the return type of accessors depend on the variant – Union[a, b] is close, but mypy will complain that not all cases are handled even if they are.

  • Statically generating a class definition to a file

  • Dynamic alternatives to custom-generated methods – might be useful if startup time is more important than efficiency

  • An alternative implementation backed by tuples if true immutability is desired – there's currently no way to make a __slots__-based implementation watertight in that aspect, I'm doing my best.

  • Maybe opt-in mutability – currently, you can use Thing._unsafe_set_Foo_x(foo, 10) if you want that, but that's not a nice interface

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sumtype-0.9.5.post1.tar.gz (33.5 kB view hashes)

Uploaded Source

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page