variants
A convention for cleaner API design


Paul Ganssle



variants on Github
Github repo for this talk

Motivation

How many times have you seen an API like this?

In [5]:
def read_csv(filepath_or_buffer: Union[str, IO[str]], *args, **kwargs) -> DataFrame:
    """
    If filepath_or_buffer is a str it is interpreted as a file path,
    if it is a stream it is read directly as text
    """
    pass

Or like this?

In [6]:
def somefunc(return_extra: bool=False) -> Union[str, Tuple[str]]:
    rv = 'Foo'
    if return_extra:
        return rv, 'extra'
    
    return rv

print(somefunc())
print(somefunc(return_extra=True))
Foo
('Foo', 'extra')

Alternate Constructors

In [7]:
from itertools import chain

to_chain = [(1, 2), (3, 4), (5, 6, 7)]
In [8]:
c = chain(*to_chain)
print(list(c))
[1, 2, 3, 4, 5, 6, 7]
In [9]:
list(chain.from_iterable(to_chain))
Out[9]:
[1, 2, 3, 4, 5, 6, 7]

API

  • chain(*args: Iterable) -> chain - Create a chain from many iterables.
  • chain.from_iterable(iterable: Iterable[Iterable]) -> chain - Create a chain from an iterable of iterables.

variants

Desired API

  • print_text(txt: str) - Print text passed to this function
  • print_text.from_stream(sobj: IO[str]) - Print text from a stream
  • print_text.from_filepath(*path_components: str) - Open a file on disk and print its contents
In [10]:
import variants

@variants.primary
def print_text(txt):
    """Prints any text passed to this function"""
    print(txt)
In [11]:
@print_text.variant('from_stream')
def print_text(sobj):
    """Read text from a stream and print it"""
    print_text(sobj.read())
In [12]:
@print_text.variant('from_filepath')
def print_text(*path_components):
    """Open the file specified by `path_components` and print the contents"""
    fpath = pathlib.Path(*path_components)
    with open(fpath, 'r') as f:
        print_text.from_stream(f)

Example use

In [13]:
print_text('Hello, world')
Hello, world
In [14]:
print_text.from_stream(StringIO('Hello, world! This is from a stream!'))
Hello, world! This is from a stream!
In [15]:
print_text.from_filepath('extras/hello_world.txt')
Hello, world! This is from a file!

Why use variants?

  • Syntactically marks relatedness, as compared to "pseudo-namespacing" using _:
In [17]:
def print_text_from_filepath(fpath):
    print_text.from_filepath(fpath)
    
print_text_from_filepath('extras/hello_world.txt')
Hello, world! This is from a file!

  • Variants are namespaced under the primary variant:
    • Tab completion helps discovery of variant functions
    • "Top level" API is kept as clean as possible

Autodocument with sphinx

Using the sphinx_autodoc_variants sphinx extension:

.. automodule:: text_print
    :members:

text_print module documentation

Reasons to use variants

Explicit dispatch

In [19]:
@print_text.variant('from_url')
def print_text(url):
    r = requests.get(url)
    print_text(r.text)
In [21]:
print_text('Hello, world!')
print_text.from_filepath('extras/hello_world.txt')
print_text.from_url('https://ganssle.io/files/hello_world.txt')
Hello, world!
Hello, world! This is from a file!

Reasons to use variants

Variation in return type

In [30]:
from datetime import date, timedelta

@variants.primary
def date_range(dt_start: date, dt_end: date) -> Iterator[date]:
    dt = dt_start
    step = timedelta(days=1)
    while dt < dt_end:
        yield dt
        dt += step
    
In [31]:
date_range(date(2018, 1, 14), date(2018, 1, 18))
Out[31]:
<generator object date_range at 0x7f9e9b385eb8>
In [32]:
@date_range.variant('eager')
def date_range(dt_start: date, dt_end: date) -> List[date]:
    return list(date_range(dt_start, dt_end))
In [33]:
date_range.eager(date(2018, 1, 14), date(2018, 1, 18))
Out[33]:
[datetime.date(2018, 1, 14),
 datetime.date(2018, 1, 15),
 datetime.date(2018, 1, 16),
 datetime.date(2018, 1, 17)]

Reasons to use variants

Variation in async behavior

In [34]:
import asyncio
import time

@variants.primary
async def myfunc(n):
    for i in range(n):
        yield i
        await asyncio.sleep(0.5)
        
@myfunc.variant('sync')
def myfunc(n):
    for i in range(n):
        yield i
        time.sleep(0.5)
In [35]:
myfunc(4)
Out[35]:
<async_generator object myfunc at 0x7f9e9b37d730>
In [37]:
myfunc.sync(4)
Out[37]:
<generator object myfunc at 0x7f9e9b2d7780>

Variation in caching behavior

In [38]:
from functools import lru_cache

@variants.primary
@lru_cache()
def get_config():
    return get_config.nocache()

@get_config.variant('nocache')
def get_config():
    print("Retrieving configuration!")
    return {
        'a1': 'value',
        'b': 12348
    }
In [39]:
a = get_config()
Retrieving configuration!
In [40]:
b = get_config()
In [41]:
c = get_config.nocache()
Retrieving configuration!

Project Links