Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] add an expand_grid function
Recently watched a video by David Robinson on tidytuesday where he used the crossing function (a wrapper of the base r function expand_grid) from tidyr R library to do a Monte Carlo simulation.  I think this would a great addition to pyjanitor.  Don’t have the bandwidth at the moment to add it, but could in the near future.  I did come across a stackoverflow solution to this 

https://stackoverflow.com/questions/12130883/r-expand-grid-function-in-python

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>


Discussion:
With much trial and error I came up with this:

```python
import pandas as pd
import itertools

@pf.register_dataframe_method
def expand_grid(df: pd.DataFrame(), data_dict: dict, key: str):
    """Create a dataframe from every combination of given values."""  

    if key == 'None':
        rows = itertools.product(*data_dict.values())
        new_df = pd.DataFrame.from_records(rows, columns=data_dict.keys())
        return new_df
    
    else:
        data_dict.update({key:df[key]})
        rows = itertools.product(*data_dict.values())
        new_df = pd.DataFrame.from_records(rows, columns=data_dict.keys())
        final_df = df.merge(new_df, how = 'outer', on = key, sort= False)
        return final_df

```
I will provide two examples later this afternoon. This requires intertools import. 

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>
First example:  creating a grid from a blank dataframe

![image](https://user-images.githubusercontent.com/48399986/72206819-448fc900-3460-11ea-9fca-f243cef8b19c.png)


Second example: creating with dataframe columns

![image](https://user-images.githubusercontent.com/48399986/72206856-84ef4700-3460-11ea-905b-5505221d2b11.png)

Third example: with df column as index and additional custom columns

![image](https://user-images.githubusercontent.com/48399986/72206880-e9aaa180-3460-11ea-9d2e-851af4772806.png)

I would like later to add a jupyter notebook example of tidytuesday analysis using monte carlo simulation with the expand_grid function. 
checking here as well. are u still working on this @jcvall-olympus ?
If you want to open this one up or take it on go ahead.  I would love to see it in pyjanitor.  This one I do want to contribute an example using a tidytuesday example on simulation.  If it doesn’t get attention in the next month or so I will try to take it on.  I have some weakness around testing in python. I need to get more up to speed.  A weakness of mine I need to work on.

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>
@jcvall you've been doing great with your contributions to the library, and @samukweku I'm super happy that you've just jumped in and done stuff with us! 

@samukweku please do go ahead and take it on if you'd like to!

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,7 +11,6 @@
 # -- Path setup --------------------------------------------------------------
 
 import datetime
-
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -30,8 +30,10 @@
 
 from .errors import JanitorError
 from .utils import (
+    _check_instance,
     _clean_accounting_column,
     _currency_column_to_numeric,
+    _grid_computation,
     _replace_empty_string_with_none,
     _replace_original_empty_string_with_none,
     _strip_underscores,
@@ -1297,6 +1299,7 @@ def filter_date(
     parsed, you would pass `{'format': your_format}` to `column_date_options`.
 
     """
+
     # TODO: need to convert this to notebook.
     #     :Setup:
     # .. code-block:: python
@@ -3869,3 +3872,116 @@ def sort_naturally(
     """
     new_order = index_natsorted(df[column_name], **natsorted_kwargs)
     return df.iloc[new_order, :]
+
+
+@pf.register_dataframe_method
+def expand_grid(
+    df: pd.DataFrame = None, df_key: str = None, others: Dict = None
+) -> pd.DataFrame:
+    """
+    Creates a dataframe from a combination of all inputs.
+
+    This works with a dictionary of name value pairs,
+    and will work with structures that are not dataframes.
+    If method-chaining to a dataframe,
+    a key to represent the column name in the output must be provided.
+
+    The output will always be a dataframe.
+
+    Example:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame({"x":range(1,3), "y":[2,1]})
+        others = {"z" : range(1,4)}
+
+        df.expand_grid(df_key="df",others=others)
+
+        # df_x |   df_y |   z
+        #    1 |      2 |   1
+        #    1 |      2 |   2
+        #    1 |      2 |   3
+        #    2 |      1 |   1
+        #    2 |      1 |   2
+        #    2 |      1 |   3
+
+        #create a dataframe from all combinations in a dictionary
+        data = {"x":range(1,4), "y":[1,2]}
+
+        jn.expand_grid(others=data)
+
+        #  x |   y
+        #  1 |   1
+        #  1 |   2
+        #  2 |   1
+        #  2 |   2
+        #  3 |   1
+        #  3 |   2
+
+
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+        df = jn.expand_grid(df=df, df_key="...", others={...})
+
+    Method-chaining usage syntax:
+
+    .. code-block:: python
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...).expand_grid(df_key="bla",others={...})
+
+    Usage independent of a dataframe
+
+    .. code-block:: python
+
+        import pandas as pd
+        from janitor import expand_grid
+
+        df = expand_grid({"x":range(1,4), "y":[1,2]})
+
+    :param df: A pandas dataframe.
+    :param df_key: name of key for the dataframe.
+                   It becomes the column name of the dataframe.
+    :param others: A dictionary that contains the data
+                   to be combined with the dataframe.
+                   If no dataframe exists, all inputs
+                   in others will be combined to create a dataframe.
+    :returns : A pandas dataframe of all combinations of name value pairs.
+    :raises: TypeError if others is not a dictionary
+    :raises: KeyError if there is a dataframe and no key is provided.
+
+
+    """
+    # check if others is a dictionary
+    if not isinstance(others, dict):
+        # strictly name value pairs
+        # same idea as in R and tidyverse implementation
+        raise TypeError("others must be a dictionary")
+    # if there is a dataframe, for the method chaining,
+    # it must have a key, to create a name value pair
+    if df is not None:
+        if isinstance(df.index, pd.MultiIndex) or isinstance(
+            df.columns, pd.MultiIndex
+        ):
+            raise TypeError("`expand_grid` does not work with pd.MultiIndex")
+        if not df_key:
+            raise KeyError(
+                """
+                Using `expand_grid` as part of a DataFrame method chain
+                 requires that a string `df_key` be passed in.
+                 """
+            )
+        others.update({df_key: df})
+    dfs, dicts = _check_instance(others)
+
+    return _grid_computation(dfs, dicts)
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -8,6 +8,7 @@
 
 import numpy as np
 import pandas as pd
+from numpy.lib import recfunctions as rfn
 
 from .errors import JanitorError
 
@@ -405,3 +406,238 @@ def _wrapped(x, *args, **kwargs):
             return return_val
 
     return _wrapped
+
+
+def _check_instance(entry: Dict):
+    """
+    Function to check instances in the expand_grid function.
+
+    This checks if entry is a dictionary,
+    checks the instance of value in key:value pairs in entry,
+    and makes changes to other types as deemed necessary.
+
+    Additionally, type-specific errors are raised
+    if unsupported data types are passed in as values
+    in the entry dictionary.
+
+    How each type is handled, and their associated exceptions,
+    are pretty clear from the code.
+    """
+    # dictionary should not be empty
+    if not entry:
+        raise ValueError("passed dictionary cannot be empty")
+    # If it is a NoneType, number, Boolean, or string,
+    # then wrap in a list
+    entry = {
+        key: [value]
+        if isinstance(value, (type(None), int, float, bool, str))
+        else value
+        for key, value in entry.items()
+    }
+
+    # Convert to list if value is a set|tuple|range
+    entry = {
+        key: list(value) if isinstance(value, (set, tuple, range)) else value
+        for key, value in entry.items()
+    }
+
+    # collect dataframes here
+    dfs = []
+
+    # collect non dataframes here, proper dicts
+    dicts = {}
+
+    for key, value in entry.items():
+
+        # exclude dicts:
+        if isinstance(value, dict):
+            raise TypeError("Nested dictionaries are not allowed")
+
+        # process arrays
+        if isinstance(value, np.ndarray):
+            if value.size == 0:
+                raise ValueError("array cannot be empty")
+            if value.ndim == 1:
+                dfs.append(pd.DataFrame(value, columns=[key]))
+            elif value.ndim == 2:
+                dfs.append(pd.DataFrame(value).add_prefix(f"{key}_"))
+            else:
+                raise TypeError(
+                    "`expand_grid` works with only vector and matrix arrays"
+                )
+        # process series
+        if isinstance(value, pd.Series):
+            if value.empty:
+                raise ValueError("passed Series cannot be empty")
+            if not isinstance(value.index, pd.MultiIndex):
+                # this section checks if the Series has a name or not
+                # and uses that information to create a new column name
+                # for the resulting Dataframe
+                if value.name:
+                    value = value.to_frame(name=f"{key}_{value.name}")
+                    dfs.append(value)
+                else:
+                    value = value.to_frame(name=f"{key}")
+                    dfs.append(value)
+            else:
+                raise TypeError(
+                    "`expand_grid` does not work with pd.MultiIndex"
+                )
+        # process dataframe
+        if isinstance(value, pd.DataFrame):
+            if value.empty:
+                raise ValueError("passed DataFrame cannot be empty")
+            if not (
+                isinstance(value.index, pd.MultiIndex)
+                or isinstance(value.columns, pd.MultiIndex)
+            ):
+                # add key to dataframe columns
+                value = value.add_prefix(f"{key}_")
+                dfs.append(value)
+            else:
+                raise TypeError(
+                    "`expand_grid` does not work with pd.MultiIndex"
+                )
+        # process lists
+        if isinstance(value, list):
+            if not value:
+                raise ValueError("passed Sequence cannot be empty")
+            if np.array(value).ndim == 1:
+                checklist = (type(None), str, int, float, bool)
+                instance_check_type = (
+                    isinstance(internal, checklist) for internal in value
+                )
+                if all(instance_check_type):
+                    dicts.update({key: value})
+                else:
+                    raise ValueError("values in iterable must be scalar")
+            elif np.array(value).ndim == 2:
+                value = pd.DataFrame(value).add_prefix(f"{key}_")
+                dfs.append(value)
+            else:
+                raise ValueError("Sequence's dimension should be 1d or 2d")
+
+    return dfs, dicts
+
+
+def _grid_computation_dict(dicts: Dict) -> pd.DataFrame:
+    """
+    Function used within the expand_grid function,
+    to compute dataframe from values that are not dataframes/arrays/series.
+    These values are collected into a dictionary,
+    and processed with numpy meshgrid.
+
+    Numpy's meshgrid is faster than itertools' product,
+    and when converting to a dataframe, is fast as well.
+    Structured arrays are used here, to ensure the datatypes are preserved.
+    """
+    # if there is only name value pair in the dictionary
+    if len(dicts) == 1:
+        final = pd.DataFrame(dicts)
+    # if there are more than one name value pair
+    else:
+        # extract value from each key:value pair
+        # in the dicts dictionary
+        extracted_data = [value for key, value in dicts.items()]
+        # create the cartesian product of the extracted data
+        res = np.meshgrid(*extracted_data)
+        # create structured array
+        # keeps data type of each value in the dict
+        outcome = np.core.records.fromarrays(res, names=",".join(dicts))
+        # reshape into a 1 column array
+        # using the size of any of the arrays obtained
+        # from the meshgrid computation
+        outcome = np.reshape(outcome, (np.size(res[0]), 1))
+        # flatten structured array into 1d array
+        outcome = np.concatenate(outcome)
+        # sort array
+        outcome.sort(axis=0, order=list(dicts))
+        # create dataframe
+        # structed array already has names,
+        # this gets transferred as column names
+        final = pd.DataFrame.from_records(outcome)
+    return final
+
+
+def _compute_two_dfs(df1: pd.DataFrame, df2: pd.DataFrame) -> pd.DataFrame:
+    """
+    Compute the cartesian product of two Dataframes.
+
+    Used by the expand_grid function.
+
+    Numpy is employed here, to get faster computations,
+    compared to running a many-to-many join with pandas merge.
+
+    Structured arrays are employed, to preserve data type.
+    """
+    # get lengths of dataframes(number of rows) and swap
+    # essentially we'll pair one dataframe with the other's length:
+    lengths = reversed([ent.index.size for ent in (df1, df2)])
+
+    # grab the maximum string length
+    string_cols = [
+        frame.select_dtypes(include="object").columns for frame in (df1, df2)
+    ]
+
+    # pair max string length with col
+    # will be passed into frame.to_records,
+    # to get dtype in numpy recarray
+    string_cols = [
+        {col: f"<U{frame[col].str.len().max()}" for col in ent}
+        for ent, frame in zip(string_cols, (df1, df2))
+    ]
+
+    # pair length, column data type and dataframe
+    (len_first, col_dtypes, first), (len_last, col_dtypes, last) = list(
+        zip(lengths, string_cols, (df1, df2))
+    )
+
+    # export to numpy as recarray,
+    # ensuring that the column data types are captured
+    # this is particularly relevant to object data type
+    first = first.to_records(column_dtypes=col_dtypes, index=False)
+
+    # tile first with len_first
+    # remember, len_first is the length of the other dataframe
+    first = np.tile(first, (len_first, 1))
+
+    # get a 1d array
+    first = np.concatenate(first)
+
+    # sorting here ensures we get each row of the first
+    # with the entire rows of the other dataframe
+    np.recarray.sort(first, order=first.dtype.names[0])
+
+    # same process as first, except there'll be no sorting
+    last = last.to_records(column_dtypes=col_dtypes, index=False)
+    last = np.tile(last, (len_last, 1))
+    last = np.concatenate(last)
+
+    # merge first and last
+    # and return a dataframe
+    result = rfn.merge_arrays((first, last), flatten=True, asrecarray=True)
+    return pd.DataFrame.from_records(result)
+
+
+def _grid_computation_list(dfs: List):
+    """
+    Computes cartesian product of Dataframes in the expand_grid function.
+
+    This builds on _compute_two_dfs function,
+    by applying it to more two or more Dataframes.
+    """
+    return functools.reduce(_compute_two_dfs, dfs)
+
+
+def _grid_computation(dfs: List, dicts: Dict) -> pd.DataFrame:
+    """
+    Return the final output of the expand_grid function.
+    """
+    if not dicts:
+        result = _grid_computation_list(dfs)
+    elif not dfs:
+        result = _grid_computation_dict(dicts)
+    else:
+        dfs.append(_grid_computation_dict(dicts))
+        result = _grid_computation_list(dfs)
+    return result


Test patch:
diff --git a/tests/functions/test_expand_grid.py b/tests/functions/test_expand_grid.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_expand_grid.py
@@ -0,0 +1,329 @@
+from string import ascii_lowercase, ascii_uppercase
+
+import numpy as np
+import pandas as pd
+import pytest
+from pandas._testing import assert_frame_equal
+
+from janitor.functions import expand_grid
+from janitor.utils import _check_instance
+
+
+def test_not_a_dict():
+    """Test that entry(list) is not a dictionary"""
+    data = [60, 70]
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_not_a_dict_1():
+    """Test that entry (dataframe) is not a dictionary"""
+    data = pd.DataFrame([60, 70])
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_empty_dict():
+    """Test that entry should not be empty"""
+    data = {}
+    with pytest.raises(ValueError):
+        expand_grid(others=data)
+
+
+def test_scalar_to_list():
+    """
+    Test that dictionary values are all converted to lists.
+    """
+    data = {
+        "x": 1,
+        "y": "string",
+        "z": {2, 3, 4},
+        "a": (26, 50),
+        "b": None,
+        "c": 1.2,
+        "d": True,
+        "e": False,
+    }
+    expected = (
+        [],
+        {
+            "x": [1],
+            "y": ["string"],
+            "z": [2, 3, 4],
+            "a": [26, 50],
+            "b": [None],
+            "c": [1.2],
+            "d": [True],
+            "e": [False],
+        },
+    )
+    # assert that it is an empty list
+    assert not _check_instance(data)[0]
+    # assert that the dictionaries match
+    assert _check_instance(data)[-1] == expected[-1]
+
+
+def test_nested_dict():
+    """Raise error if dictionary is nested in a dictionary's values"""
+    data = {"x": {"y": 2}}
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_numpy():
+    """Raise error if numpy array in dictionary's values is empty"""
+    data = {"x": np.array([])}
+    with pytest.raises(ValueError):
+        expand_grid(others=data)
+
+
+def test_numpy_1d():
+    """Test output from a 1d numpy array """
+    data = {"x": np.array([2, 3])}
+    expected = pd.DataFrame(np.array([2, 3]), columns=["x"])
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_numpy_2d():
+    """Test output from a 2d numpy array"""
+    data = {"x": np.array([[2, 3]])}
+    expected = pd.DataFrame(np.array([[2, 3]])).add_prefix("x_")
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_numpy_gt_2d():
+    """Raise error if numpy array dimension is greater than 2"""
+    data = {"x": np.array([[[2, 3]]])}
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_series_empty():
+    """Test that values in key value pair should not be empty ... for Series"""
+    data = {"x": pd.Series([], dtype="int")}
+    with pytest.raises(ValueError):
+        expand_grid(others=data)
+
+
+def test_series_not_multi_index_no_name():
+    """Test for single index series"""
+    data = {"x": pd.Series([2, 3])}
+    expected = pd.DataFrame([2, 3], columns=["x"])
+    assert_frame_equal(_check_instance(data)[0][0], expected)
+
+
+def test_series_not_multi_index_with_name():
+    """Test for single index series with name"""
+    data = {"x": pd.Series([2, 3], name="y")}
+    expected = pd.DataFrame([2, 3], columns=["x_y"])
+    assert_frame_equal(_check_instance(data)[0][0], expected)
+
+
+def test_series_multi_index():
+    """Test that multiIndexed series trigger error"""
+    data = {
+        "x": pd.Series(
+            [2, 3], index=pd.MultiIndex.from_arrays([[1, 2], [3, 4]])
+        )
+    }
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_dataframe_empty():
+    """Trigger error for empty dataframes"""
+    data = {"x": pd.DataFrame([])}
+    with pytest.raises(ValueError):
+        expand_grid(others=data)
+
+
+def test_dataframe_single_index():
+    """Test for single indexed dataframes"""
+    data = {"x": pd.DataFrame([[2, 3], [6, 7]])}
+    expected = pd.DataFrame([[2, 3], [6, 7]]).add_prefix("x_")
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_dataframe_multi_index_index():
+    """Trigger error if dataframe has a MultiIndex index"""
+    data = {
+        "x": pd.DataFrame(
+            [[2, 3], [6, 7]],
+            index=pd.MultiIndex.from_arrays([["a", "b"], ["y", "z"]]),
+        )
+    }
+
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_dataframe_multi_index_column():
+    """Trigger error if dataframe has a MultiIndex column"""
+    data = {
+        "x": pd.DataFrame(
+            [[2, 3], [6, 7]],
+            columns=pd.MultiIndex.from_arrays([["m", "n"], ["p", "q"]]),
+        )
+    }
+
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_dataframe_multi_index_index_and_column():
+    """Trigger error if dataframe has a MultiIndex column or index"""
+    data = {
+        "x": pd.DataFrame(
+            [[2, 3], [6, 7]],
+            index=pd.MultiIndex.from_arrays([["a", "b"], ["y", "z"]]),
+            columns=pd.MultiIndex.from_arrays([["m", "n"], ["p", "q"]]),
+        )
+    }
+    with pytest.raises(TypeError):
+        expand_grid(others=data)
+
+
+def test_list_empty():
+    """Raise error if any list in dictionary's values is empty"""
+    data = {"x": [], "y": [2, 3]}
+    with pytest.raises(ValueError):
+        expand_grid(others=data)
+
+
+def test_lists():
+    """
+    Test expected output
+    from one level nested lists
+    in a dictionary's values
+    """
+    data = {"x": [[2, 3], [4, 3]]}
+    expected = pd.DataFrame([[2, 3], [4, 3]]).add_prefix("x_")
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_lists_all_scalar():
+    """
+    Test that all values in a list
+    in dictionary's values are scalar
+    """
+    data = {"x": [2, 3, 4, 5, "ragnar"]}
+    expected = ([], {"x": [2, 3, 4, 5, "ragnar"]})
+    # assert that it is an empty list
+    assert not _check_instance(data)[0]
+    # assert that the dictionaries match
+    assert _check_instance(data)[-1] == expected[-1]
+
+
+def test_lists_not_all_scalar():
+    """
+    Trigger error if values in a list
+    in the dictionary's values are not scalar
+    """
+    data = {"x": [[2, 3], 4, 5, "ragnar"]}
+    with pytest.raises(ValueError):
+        expand_grid(others=data)
+
+
+def test_computation_output_1():
+    """Test output if entry contains no dataframes/series"""
+    data = {"x": range(1, 4), "y": [1, 2]}
+    expected = pd.DataFrame({"x": [1, 1, 2, 2, 3, 3], "y": [1, 2, 1, 2, 1, 2]})
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_computation_output_2():
+    """Test output if entry contains only dataframes/series"""
+    data = {
+        "df": pd.DataFrame({"x": range(1, 6), "y": [5, 4, 3, 2, 1]}),
+        "df1": pd.DataFrame({"x": range(4, 7), "y": [6, 5, 4]}),
+    }
+
+    expected = pd.DataFrame(
+        {
+            "df_x": [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5],
+            "df_y": [5, 5, 5, 4, 4, 4, 3, 3, 3, 2, 2, 2, 1, 1, 1],
+            "df1_x": [4, 5, 6, 4, 5, 6, 4, 5, 6, 4, 5, 6, 4, 5, 6],
+            "df1_y": [6, 5, 4, 6, 5, 4, 6, 5, 4, 6, 5, 4, 6, 5, 4],
+        }
+    )
+
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_computation_output_3():
+    """Test mix of dataframes and lists"""
+    data = {
+        "df": pd.DataFrame({"x": range(1, 3), "y": [2, 1]}),
+        "z": range(1, 4),
+    }
+    expected = pd.DataFrame(
+        {
+            "df_x": [1, 1, 1, 2, 2, 2],
+            "df_y": [2, 2, 2, 1, 1, 1],
+            "z": [1, 2, 3, 1, 2, 3],
+        }
+    )
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_computation_output_4():
+    """ Test output from list of strings"""
+    data = {"l1": list(ascii_lowercase[:3]), "l2": list(ascii_uppercase[:3])}
+    expected = pd.DataFrame(
+        {
+            "l1": ["a", "a", "a", "b", "b", "b", "c", "c", "c"],
+            "l2": ["A", "B", "C", "A", "B", "C", "A", "B", "C"],
+        }
+    )
+    assert_frame_equal(expand_grid(others=data), expected)
+
+
+def test_df_key():
+    """ Raise error if dataframe key is not supplied"""
+    df = pd.DataFrame({"x": [2, 3]})
+    others = {"df": pd.DataFrame({"x": range(1, 6), "y": [5, 4, 3, 2, 1]})}
+
+    with pytest.raises(KeyError):
+        expand_grid(df, others=others)
+
+
+def test_df_others():
+    """ Raise error if others is not a dict"""
+    df = pd.DataFrame({"x": [2, 3]})
+    others = [5, 4, 3, 2, 1]
+    with pytest.raises(TypeError):
+        expand_grid(df, others=others)
+
+
+def test_df_output():
+    """
+    Test output from chaining method to a dataframe.
+    Example is from tidyverse's expand_grid page -
+    https://tidyr.tidyverse.org/reference/expand_grid.html#compared-to-expand-grid
+    """
+    df = pd.DataFrame({"x": range(1, 3), "y": [2, 1]})
+    others = {"z": range(1, 4)}
+    expected = pd.DataFrame(
+        {
+            "df_x": [1, 1, 1, 2, 2, 2],
+            "df_y": [2, 2, 2, 1, 1, 1],
+            "z": [1, 2, 3, 1, 2, 3],
+        }
+    )
+    result = expand_grid(df, df_key="df", others=others)
+    assert_frame_equal(result, expected)
+
+
+def test_df_multi_index():
+    """Test that datafarme is not a multiIndex"""
+    df = {
+        "x": pd.DataFrame(
+            [[2, 3], [6, 7]],
+            columns=pd.MultiIndex.from_arrays([["m", "n"], ["p", "q"]]),
+        )
+    }
+
+    others = [5, 4, 3, 2, 1]
+
+    with pytest.raises(TypeError):
+        expand_grid(df, others=others)