:param left: left iterable to be joined
    :param right: right iterable to be joined
    :param str | function key: either an attr name, dict key, or function that produces hashable value
    :param how: 'inner', 'left', 'right', or 'outer'
    :param join_fn: function called on joined left and right iterable items to complete join
    :rtype: list

def join(left, right, how='inner', key=None, left_key=None, right_key=None,
         join_fn=tuple_join):
    """
    :param left: left iterable to be joined
    :param right: right iterable to be joined
    :param str | function key: either an attr name, dict key, or function that produces hashable value
    :param how: 'inner', 'left', 'right', or 'outer'
    :param join_fn: function called on joined left and right iterable items to complete join
    :rtype: list
    """
    if key is None and (left_key is None or right_key is None):
        raise ValueError("Must provide either key param or both left_key and right_key")

    if key is not None:
        lkey = rkey = key if callable(key) else make_key_fn(key)
    else:
        lkey = left_key if callable(left_key) else make_key_fn(left_key)
        rkey = right_key if callable(right_key) else make_key_fn(right_key)

    try:
        join_impl = {
            "left": _left_join,
            "right": _right_join,
            "inner": _inner_join,
            "outer": _outer_join,
        }[how]
    except KeyError:
        raise ValueError("Invalid value for how: {}, must be left, right, "
                         "inner, or outer.".format(str(how)))
    else:
        return join_impl(left, right, lkey, rkey, join_fn)