Repository: pyjanitor-devs/pyjanitor
Pull request #1028 (2022-02-23 14:22:56)

[DOC] MWE for `label_encode`, `factorize_columns`, `encode_categorical`
# PR Description

Please describe the changes proposed in the pull request:
- Add MWE for the 3 functions mentioned in the title;
- Add note of (deprecation) warning to the docs of `label_encode`.
- Make `factorize_columns` non-mutating.

**This PR is related to #972 .**


(Let's review this after #1029 has been merged. I'll deal with any merge conflicts then.)

 (`encode_categorical` MWE has been incorporated in #1029)

ericmjl: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY -->
🚀 Deployed on https://deploy-preview-1028--pyjanitor.netlify.app

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f12d42e..29350c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
 -   [BUG] Removed/updated error-inducing default arguments in `row_to_names` (#1004) and `round_to_fraction` (#1005). @thatlittleboy
 -   [ENH] `patterns` deprecated in favour of importing `re.compile`. #1007 @samukweku
 -   [ENH] Changes to kwargs in `encode_categorical`, where the values can either be a string or a 1D array. #1021 @samukweku
+-   [ENH] Add `fill_value` and `explicit` parameters to the `complete` function. #1019 @samukweku
 
 ## [v0.22.0] - 2021-11-21
 
diff --git a/janitor/functions/complete.py b/janitor/functions/complete.py
index 71c4cb3..cad930a 100644
--- a/janitor/functions/complete.py
+++ b/janitor/functions/complete.py
@@ -1,9 +1,9 @@
-from typing import Optional, Union, List, Tuple, Dict
+from typing import Optional, Union, List, Tuple, Dict, Any
 from pandas.core.common import apply_if_callable
 import pandas_flavor as pf
 import pandas as pd
 import functools
-from pandas.api.types import is_list_like
+from pandas.api.types import is_list_like, is_scalar
 
 from janitor.utils import check, check_column
 
@@ -16,22 +16,25 @@ def complete(
     *columns,
     sort: bool = False,
     by: Optional[Union[list, str]] = None,
+    fill_value: Optional[Union[Dict, Any]] = None,
+    explicit: bool = True,
 ) -> pd.DataFrame:
     """
     It is modeled after tidyr's `complete` function, and is a wrapper around
-    [`expand_grid`][janitor.functions.expand_grid.expand_grid] and `pd.merge`.
+    [`expand_grid`][janitor.functions.expand_grid.expand_grid], `pd.merge`
+    and `pd.fillna`. In a way, it is the inverse of `pd.dropna`, as it exposes
+    implicitly missing rows.
 
     Combinations of column names or a list/tuple of column names, or even a
     dictionary of column names and new values are possible.
 
-    It can also handle duplicated data.
-
     MultiIndex columns are not supported.
 
     Example:
 
         >>> import pandas as pd
         >>> import janitor
+        >>> import numpy as np
         >>> df = pd.DataFrame(
         ...     {
         ...         "Year": [1999, 2000, 2004, 1999, 2004],
@@ -55,7 +58,7 @@ def complete(
 
     Expose missing pairings of `Year` and `Taxon`:
 
-        >>> df.complete("Year", "Taxon", sort = True)
+        >>> df.complete("Year", "Taxon", sort=True)
            Year       Taxon  Abundance
         0  1999      Agarum        1.0
         1  1999  Saccharina        4.0
@@ -69,7 +72,7 @@ def complete(
         >>> df.complete(
         ...     {"Year": range(df.Year.min(), df.Year.max() + 1)},
         ...     "Taxon",
-        ...     sort=True,
+        ...     sort=True
         ... )
             Year       Taxon  Abundance
         0   1999      Agarum        1.0
@@ -85,6 +88,60 @@ def complete(
         10  2004      Agarum        8.0
         11  2004  Saccharina        2.0
 
+    Fill missing values:
+
+        >>> df = pd.DataFrame(
+        ...     dict(
+        ...         group=(1, 2, 1, 2),
+        ...         item_id=(1, 2, 2, 3),
+        ...         item_name=("a", "a", "b", "b"),
+        ...         value1=(1, np.nan, 3, 4),
+        ...         value2=range(4, 8),
+        ...     )
+        ... )
+        >>> df
+           group  item_id item_name  value1  value2
+        0      1        1         a     1.0       4
+        1      2        2         a     NaN       5
+        2      1        2         b     3.0       6
+        3      2        3         b     4.0       7
+        >>> df.complete(
+        ...     "group",
+        ...     ("item_id", "item_name"),
+        ...     fill_value={"value1": 0, "value2": 99},
+        ...     sort=True
+        ... )
+           group  item_id item_name  value1  value2
+        0      1        1         a       1       4
+        1      1        2         a       0      99
+        2      1        2         b       3       6
+        3      1        3         b       0      99
+        4      2        1         a       0      99
+        5      2        2         a       0       5
+        6      2        2         b       0      99
+        7      2        3         b       4       7
+
+    Limit the fill to only implicit missing values
+    by setting explicit to `False`:
+
+        >>> df.complete(
+        ...     "group",
+        ...     ("item_id", "item_name"),
+        ...     fill_value={"value1": 0, "value2": 99},
+        ...     explicit=False,
+        ...     sort=True
+        ... )
+           group  item_id item_name  value1  value2
+        0      1        1         a     1.0       4
+        1      1        2         a     0.0      99
+        2      1        2         b     3.0       6
+        3      1        3         b     0.0      99
+        4      2        1         a     0.0      99
+        5      2        2         a     NaN       5
+        6      2        2         b     0.0      99
+        7      2        3         b     4.0       7
+
+
     :param df: A pandas DataFrame.
     :param *columns: This refers to the columns to be
         completed. It could be column labels (string type),
@@ -93,6 +150,13 @@ def complete(
     :param sort: Sort DataFrame based on *columns. Default is `False`.
     :param by: label or list of labels to group by.
         The explicit missing rows are returned per group.
+    :param fill_value: Scalar value to use instead of NaN
+        for missing combinations. A dictionary, mapping columns names
+        to a scalar value is also accepted.
+    :param explicit: Determines if only implicitly missing values
+        should be filled (`False`), or all nulls existing in the dataframe
+        (`True`). Default is `True`. `explicit` is applicable only
+        if `fill_value` is not `None`.
     :returns: A pandas DataFrame with explicit missing rows, if any.
     """
 
@@ -101,14 +165,16 @@ def complete(
 
     df = df.copy()
 
-    return _computations_complete(df, columns, sort, by)
+    return _computations_complete(df, columns, sort, by, fill_value, explicit)
 
 
 def _computations_complete(
     df: pd.DataFrame,
     columns: List[Union[List, Tuple, Dict, str]],
-    sort: bool = False,
-    by: Optional[Union[list, str]] = None,
+    sort: bool,
+    by: Optional[Union[list, str]],
+    fill_value: Optional[Union[Dict, Any]],
+    explicit: bool,
 ) -> pd.DataFrame:
     """
     This function computes the final output for the `complete` function.
@@ -117,10 +183,14 @@ def _computations_complete(
 
     A DataFrame, with rows of missing values, if any, is returned.
     """
-
-    columns, column_checker, sort, by = _data_checks_complete(
-        df, columns, sort, by
-    )
+    (
+        columns,
+        column_checker,
+        sort,
+        by,
+        fill_value,
+        explicit,
+    ) = _data_checks_complete(df, columns, sort, by, fill_value, explicit)
 
     all_strings = True
     for column in columns:
@@ -143,17 +213,65 @@ def _computations_complete(
     # of course there could be a better way ...
     if by is None:
         uniques = _generic_complete(df, columns, all_strings)
-        return df.merge(uniques, how="outer", on=column_checker, sort=sort)
-
-    uniques = df.groupby(by)
-    uniques = uniques.apply(_generic_complete, columns, all_strings)
-    uniques = uniques.droplevel(-1)
-    return df.merge(uniques, how="outer", on=by + column_checker, sort=sort)
+    else:
+        uniques = df.groupby(by)
+        uniques = uniques.apply(_generic_complete, columns, all_strings)
+        uniques = uniques.droplevel(-1)
+        column_checker = by + column_checker
+    if fill_value is None:
+        return df.merge(
+            uniques, on=column_checker, how="outer", sort=sort, copy=False
+        )
+    # if fill_value is present
+    if is_scalar(fill_value):
+        # faster when fillna operates on a Series basis
+        fill_value = {col: fill_value for col in df}
+
+    if explicit:
+        return df.merge(
+            uniques, on=column_checker, how="outer", sort=sort, copy=False
+        ).fillna(fill_value, downcast="infer")
+    # keep only columns that are not part of column_checker
+    # IOW, we are excluding columns that were not used
+    # to generate the combinations
+    fill_value = {
+        col: value
+        for col, value in fill_value.items()
+        if col not in column_checker
+    }
+    if not fill_value:  # there is nothing to fill
+        return df.merge(
+            uniques, on=column_checker, how="outer", sort=sort, copy=False
+        )
 
+    # when explicit is False
+    # filter out rows from `unique` that already exist in the parent dataframe
+    # fill the null values in the trimmed `unique`
+    # and merge back to the main dataframe
 
-def _generic_complete(
-    df: pd.DataFrame, columns: list, all_strings: bool = True
-):
+    # to get a name that does not exist in the columns
+    indicator = "".join(df.columns)
+    trimmed = df.loc(axis=1)[column_checker]
+    uniques = uniques.merge(
+        trimmed, how="left", sort=False, copy=False, indicator=indicator
+    )
+    trimmed = uniques.iloc(axis=1)[-1] == "left_only"
+    uniques = uniques.loc[trimmed, column_checker]
+    trimmed = None
+    indicator = None
+    # iteration used here, instead of assign (which is also a for loop),
+    # to cater for scenarios where the column_name is not a string
+    # assign only works with keys that are strings
+    for column_name, value in fill_value.items():
+        uniques[column_name] = value
+    df = pd.concat([df, uniques], sort=False, copy=False, axis="index")
+    if sort:
+        df = df.sort_values(column_checker)
+    df.index = range(len(df))
+    return df
+
+
+def _generic_complete(df: pd.DataFrame, columns: list, all_strings: bool):
     """
     Generate cartesian product for `_computations_complete`.
 
@@ -182,7 +300,7 @@ def _generic_complete(
 
 
 @functools.singledispatch
-def _complete_column(column, df):
+def _complete_column(column: str, df):
     """
     Args:
         column : str/list/dict
@@ -191,22 +309,6 @@ def _complete_column(column, df):
     A Pandas Series/DataFrame with no duplicates,
     or a list of unique Pandas Series is returned.
     """
-    raise TypeError(
-        """This type is not supported in the `complete` function."""
-    )
-
-
-@_complete_column.register(str)  # noqa: F811
-def _sub_complete_column(column, df):  # noqa: F811
-    """
-    Args:
-        column : str
-        df: Pandas DataFrame
-
-    Returns:
-        Pandas Series
-    """
-
     column = df[column]
 
     if not column.is_unique:
@@ -248,20 +350,14 @@ def _sub_complete_column(column, df):  # noqa: F811
     for key, value in column.items():
         arr = apply_if_callable(value, df[key])
         if not is_list_like(arr):
-            raise ValueError(
-                f"""
-                value for {key} should be a 1-D array.
-                """
-            )
+            raise ValueError(f"value for {key} should be a 1-D array.")
         if not hasattr(arr, "shape"):
             arr = pd.Series([*arr], name=key)
 
         if not arr.size > 0:
             raise ValueError(
-                f"""
-                Kindly ensure the provided array for {key}
-                has at least one value.
-                """
+                f"Kindly ensure the provided array for {key} "
+                "has at least one value."
             )
 
         if isinstance(arr, pd.Index):
@@ -270,11 +366,7 @@ def _sub_complete_column(column, df):  # noqa: F811
             arr_ndim = arr.ndim
 
         if arr_ndim != 1:
-            raise ValueError(
-                f"""
-                Kindly provide a 1-D array for {key}.
-                """
-            )
+            raise ValueError(f"Kindly provide a 1-D array for {key}.")
 
         if not isinstance(arr, pd.Series):
             arr = pd.Series(arr)
@@ -292,8 +384,10 @@ def _sub_complete_column(column, df):  # noqa: F811
 def _data_checks_complete(
     df: pd.DataFrame,
     columns: List[Union[List, Tuple, Dict, str]],
-    sort: Optional[bool] = False,
-    by: Optional[Union[list, str]] = None,
+    sort: Optional[bool],
+    by: Optional[Union[list, str]],
+    fill_value: Optional[Union[Dict, Any]],
+    explicit: bool,
 ):
     """
     Function to check parameters in the `complete` function.
@@ -303,20 +397,16 @@ def _data_checks_complete(
     Check is conducted to ensure that column names are not repeated.
     Also checks that the names in `columns` actually exist in `df`.
 
-    Returns `df`, `columns`, `column_checker`, and `by` if
-    all checks pass.
+    Returns `df`, `columns`, `column_checker`, `by`, `fill_value`,
+    and `explicit` if all checks pass.
     """
     # TODO: get `complete` to work on MultiIndex columns,
     # if there is sufficient interest with use cases
     if isinstance(df.columns, pd.MultiIndex):
-        raise ValueError(
-            """
-            `complete` does not support MultiIndex columns.
-            """
-        )
+        raise ValueError("`complete` does not support MultiIndex columns.")
 
     columns = [
-        list(grouping) if isinstance(grouping, tuple) else grouping
+        [*grouping] if isinstance(grouping, tuple) else grouping
         for grouping in columns
     ]
     column_checker = []
@@ -333,9 +423,7 @@ def _data_checks_complete(
     column_checker_no_duplicates = set()
     for column in column_checker:
         if column in column_checker_no_duplicates:
-            raise ValueError(
-                f"""{column} column should be in only one group."""
-            )
+            raise ValueError(f"{column} column should be in only one group.")
         column_checker_no_duplicates.add(column)  # noqa: PD005
 
     check_column(df, column_checker)
@@ -349,4 +437,19 @@ def _data_checks_complete(
         check("by", by, [list])
         check_column(df, by)
 
-    return columns, column_checker, sort, by
+    check("explicit", explicit, [bool])
+
+    fill_value_check = is_scalar(fill_value), isinstance(fill_value, dict)
+    if not any(fill_value_check):
+        raise TypeError(
+            "`fill_value` should either be a dictionary or a scalar value."
+        )
+    if fill_value_check[-1]:
+        check_column(df, fill_value)
+        for column_name, value in fill_value.items():
+            if not is_scalar(value):
+                raise ValueError(
+                    f"The value for {column_name} should be a scalar."
+                )
+
+    return columns, column_checker, sort, by, fill_value, explicit
diff --git a/janitor/functions/encode_categorical.py b/janitor/functions/encode_categorical.py
index 016b61e..0800ab3 100644
--- a/janitor/functions/encode_categorical.py
+++ b/janitor/functions/encode_categorical.py
@@ -1,10 +1,12 @@
+import warnings
+from enum import Enum
 from typing import Hashable, Iterable, Union
+
 import pandas_flavor as pf
 import pandas as pd
 from pandas.api.types import is_list_like
-import warnings
+
 from janitor.utils import check, check_column, deprecated_alias
-from enum import Enum
 
 
 @pf.register_dataframe_method
@@ -90,17 +92,15 @@ def encode_categorical(
         >>> enc_df["foo"].cat.ordered
         True
 
-
-
     :param df: A pandas DataFrame object.
     :param column_names: A column name or an iterable (list or tuple)
         of column names.
-    :param kwargs: A mapping from column name to either `None`,
-        `sort` or `appearance`, or a 1-D array. This is useful
+    :param **kwargs: A mapping from column name to either `None`,
+        `'sort'` or `'appearance'`, or a 1-D array. This is useful
         in creating categorical columns that are ordered, or
         if the user needs to explicitly specify the categories.
     :returns: A pandas DataFrame.
-    :raises ValueError: if both `column_names` and `kwargs` are provided.
+    :raises ValueError: If both `column_names` and `kwargs` are provided.
     """  # noqa: E501
 
     if all((column_names, kwargs)):
@@ -112,13 +112,11 @@ def encode_categorical(
     # or user supplies specific categories to create the categorical
     if column_names is not None:
         check("column_names", column_names, [list, tuple, Hashable])
-        if isinstance(column_names, (list, tuple)):
-            check_column(df, column_names)
-            dtypes = {col: "category" for col in column_names}
-            return df.astype(dtypes)
         if isinstance(column_names, Hashable):
-            check_column(df, [column_names])
-            return df.astype({column_names: "category"})
+            column_names = [column_names]
+        check_column(df, column_names)
+        dtypes = {col: "category" for col in column_names}
+        return df.astype(dtypes)
 
     return _computations_as_categorical(df, **kwargs)
 
@@ -167,21 +165,20 @@ def _as_categorical_checks(df: pd.DataFrame, **kwargs) -> dict:
     This function raises errors if columns in `kwargs` are
     absent from the dataframe's columns.
     It also raises errors if the value in `kwargs`
-    is not a string (`appearance` or `sort`), or a 1D array.
+    is not a string (`'appearance'` or `'sort'`), or a 1D array.
 
     This function is executed before proceeding to the computation phase.
 
     If all checks pass, a dictionary of column names and value is returned.
 
     :param df: The pandas DataFrame object.
-    :param kwargs: A pairing of column name and value.
+    :param **kwargs: A pairing of column name and value.
     :returns: A dictionary.
     :raises TypeError: If `value` is not a 1-D array, or a string.
     :raises ValueError: If `value` is a 1-D array, and contains nulls,
         or is non-unique.
     """
 
-    # column checks
     check_column(df, kwargs)
 
     categories_dict = {}
@@ -255,7 +252,7 @@ def _as_categorical_checks(df: pd.DataFrame, **kwargs) -> dict:
             category_order_types = {ent.value for ent in _CategoryOrder}
             if value.lower() not in category_order_types:
                 raise ValueError(
-                    "argument should be one of `appearance` or `sort`."
+                    "Argument should be one of 'appearance' or 'sort'."
                 )
 
         categories_dict[column_name] = value
diff --git a/janitor/functions/factorize_columns.py b/janitor/functions/factorize_columns.py
index 6d340a7..1ecdf88 100644
--- a/janitor/functions/factorize_columns.py
+++ b/janitor/functions/factorize_columns.py
@@ -1,3 +1,4 @@
+"""Implementation of the `factorize_columns` function"""
 from typing import Hashable, Iterable, Union
 import pandas_flavor as pf
 import pandas as pd
@@ -13,52 +14,51 @@ def factorize_columns(
     **kwargs,
 ) -> pd.DataFrame:
     """
-    Converts labels into numerical data
+    Converts labels into numerical data.
 
     This method will create a new column with the string `_enc` appended
     after the original column's name.
     This can be overriden with the suffix parameter.
 
-    Internally this method uses pandas `factorize` method.
+    Internally, this method uses pandas `factorize` method.
     It takes in an optional suffix and keyword arguments also.
     An empty string as suffix will override the existing column.
 
-    This method mutates the original DataFrame.
+    This method does not mutate the original DataFrame.
 
-    Functional usage syntax:
+    Example:
 
-    ```python
-    df = factorize_columns(
-        df,
-        column_names="my_categorical_column",
-        suffix="_enc"
-    )  # one way
-    ```
-
-    Method chaining syntax:
-
-    ```python
-    import pandas as pd
-    import janitor
-    categorical_cols = ['col1', 'col2', 'col4']
-    df = (
-        pd.DataFrame(...)
-        .factorize_columns(
-            column_names=categorical_cols,
-            suffix="_enc"
-        )
-    )
-    ```
+        >>> import pandas as pd
+        >>> import janitor
+        >>> df = pd.DataFrame({
+        ...     "foo": ["b", "b", "a", "c", "b"],
+        ...     "bar": range(4, 9),
+        ... })
+        >>> df
+          foo  bar
+        0   b    4
+        1   b    5
+        2   a    6
+        3   c    7
+        4   b    8
+        >>> df.factorize_columns(column_names="foo")
+          foo  bar  foo_enc
+        0   b    4        0
+        1   b    5        0
+        2   a    6        1
+        3   c    7        2
+        4   b    8        0
 
     :param df: The pandas DataFrame object.
-    :param column_names: A column name or an iterable (list
-        or tuple) of column names.
-    :param suffix: Suffix to be used for the new column. Default value is _enc.
-        An empty string suffix means, it will override the existing column
+    :param column_names: A column name or an iterable (list or tuple) of
+        column names.
+    :param suffix: Suffix to be used for the new column.
+        An empty string suffix means, it will override the existing column.
     :param **kwargs: Keyword arguments. It takes any of the keyword arguments,
-        which the pandas factorize method takes like sort,na_sentinel,size_hint
+        which the pandas factorize method takes like `sort`, `na_sentinel`,
+        `size_hint`.
 
     :returns: A pandas DataFrame.
     """
-    df = _factorize(df, column_names, suffix, **kwargs)
+    df = _factorize(df.copy(), column_names, suffix, **kwargs)
     return df
diff --git a/janitor/functions/label_encode.py b/janitor/functions/label_encode.py
index a192d37..a8b3995 100644
--- a/janitor/functions/label_encode.py
+++ b/janitor/functions/label_encode.py
@@ -1,3 +1,4 @@
+"""Implementation of `label_encode` function"""
 from typing import Hashable, Iterable, Union
 import warnings
 import pandas_flavor as pf
@@ -10,43 +11,62 @@ from janitor.functions.utils import _factorize
 @pf.register_dataframe_method
 @deprecated_alias(columns="column_names")
 def label_encode(
-    df: pd.DataFrame, column_names: Union[str, Iterable[str], Hashable]
+    df: pd.DataFrame,
+    column_names: Union[str, Iterable[str], Hashable],
 ) -> pd.DataFrame:
     """
     Convert labels into numerical data.
 
     This method will create a new column with the string `_enc` appended
-    after the original column's name. Consider this to be syntactic sugar.
+    after the original column's name.
+    Consider this to be syntactic sugar.
+    This function uses the `factorize` pandas function under the hood.
 
-    This method behaves differently from `encode_categorical`. This method
-    creates a new column of numeric data. `encode_categorical` replaces the
-    dtype of the original column with a *categorical* dtype.
+    This method behaves differently from
+    [`encode_categorical`][janitor.functions.encode_categorical.encode_categorical].
+    This method creates a new column of numeric data.
+    [`encode_categorical`][janitor.functions.encode_categorical.encode_categorical]
+    replaces the dtype of the original column with a *categorical* dtype.
 
     This method mutates the original DataFrame.
 
-    Functional usage syntax:
+    Example:
 
-    ```python
-    df = label_encode(df, column_names="my_categorical_column")  # one way
-    ```
+        >>> import pandas as pd
+        >>> import janitor
+        >>> df = pd.DataFrame({
+        ...     "foo": ["b", "b", "a", "c", "b"],
+        ...     "bar": range(4, 9),
+        ... })
+        >>> df
+          foo  bar
+        0   b    4
+        1   b    5
+        2   a    6
+        3   c    7
+        4   b    8
+        >>> df.label_encode(column_names="foo")
+          foo  bar  foo_enc
+        0   b    4        0
+        1   b    5        0
+        2   a    6        1
+        3   c    7        2
+        4   b    8        0
 
-    Method chaining syntax:
+    !!!note
 
-    ```python
-    import pandas as pd
-    import janitor
-    categorical_cols = ['col1', 'col2', 'col4']
-    df = pd.DataFrame(...).label_encode(column_names=categorical_cols)
-    ```
+        This function will be deprecated in a 1.x release.
+        Please use [`factorize_columns`][janitor.functions.factorize_columns.factorize_columns]
+        instead.
 
     :param df: The pandas DataFrame object.
     :param column_names: A column name or an iterable (list
         or tuple) of column names.
     :returns: A pandas DataFrame.
-    """
+    """  # noqa: E501
     warnings.warn(
-        "label_encode will be deprecated in a 1.x release. \
-        Please use factorize_columns instead"
+        "`label_encode` will be deprecated in a 1.x release. "
+        "Please use `factorize_columns` instead."
     )
     df = _factorize(df, column_names, "_enc")
     return df


Test patch:
diff --git a/tests/functions/test_complete.py b/tests/functions/test_complete.py
index 29429c0..77e7d99 100644
--- a/tests/functions/test_complete.py
+++ b/tests/functions/test_complete.py
@@ -4,107 +4,155 @@ import pytest
 from hypothesis import given
 from pandas.testing import assert_frame_equal
 from janitor.testing_utils.strategies import (
-    df_strategy,
     categoricaldf_strategy,
 )
 from string import ascii_lowercase
 
 
-@given(df=df_strategy())
-def test_column_None(df):
+@pytest.fixture
+def fill_df():
+    """pytest fixture"""
+    return pd.DataFrame(
+        dict(
+            group=(1, 2, 1, 2),
+            item_id=(1, 2, 2, 3),
+            item_name=("a", "a", "b", "b"),
+            value1=(1, np.nan, 3, 4),
+            value2=range(4, 8),
+        )
+    )
+
+
+@pytest.fixture
+def taxonomy_df():
+    """pytest fixture"""
+    return pd.DataFrame(
+        {
+            "Year": [1999, 2000, 2004, 1999, 2004],
+            "Taxon": [
+                "Saccharina",
+                "Saccharina",
+                "Saccharina",
+                "Agarum",
+                "Agarum",
+            ],
+            "Abundance": [4, 5, 2, 1, 8],
+        }
+    )
+
+
+def test_column_None(fill_df):
     """Test output if *columns is empty."""
-    assert_frame_equal(df.complete(), df)
+    assert_frame_equal(fill_df.complete(), fill_df)
 
 
-@given(df=df_strategy())
-def test_MultiIndex(df):
+def test_MultiIndex(fill_df):
     """Raise ValueError if `df` has MultiIndex columns."""
-    top = range(df.columns.size)
-    df.columns = pd.MultiIndex.from_arrays([top, df.columns])
+    top = range(fill_df.columns.size)
+    fill_df.columns = pd.MultiIndex.from_arrays([top, fill_df.columns])
     with pytest.raises(ValueError):
-        df.complete("a", "cities")
+        fill_df.complete("group", "item_id")
 
 
-@given(df=df_strategy())
-def test_empty_groups(df):
+def test_empty_groups(fill_df):
     """Raise ValueError if any of the groups is empty."""
     with pytest.raises(ValueError):
-        df.complete("a", {})
+        fill_df.complete("group", {})
 
 
-@given(df=df_strategy())
-def test_dict_not_list_like(df):
+def test_dict_not_list_like(fill_df):
     """
     Raise ValueError if `*columns`
     is a dictionary, and the value
     is not list-like.
     """
     with pytest.raises(ValueError):
-        df.complete("a", {"cities": "cities"})
+        fill_df.complete("group", {"item_id": "cities"})
 
 
-@given(df=df_strategy())
-def test_dict_not_1D(df):
+def test_dict_not_1D(fill_df):
     """
     Raise ValueError if `*columns`
     is a dictionary, and the value
     is not 1D array.
     """
     with pytest.raises(ValueError):
-        df.complete("a", {"cities": df})
+        fill_df.complete("group", {"item_id": fill_df})
 
 
-@given(df=df_strategy())
-def test_dict_empty(df):
+def test_dict_empty(fill_df):
     """
     Raise ValueError if `*columns`
     is a dictionary, and the value
     is an empty array.
     """
     with pytest.raises(ValueError):
-        df.complete("a", {"cities": pd.Series([], dtype=int)})
+        fill_df.complete("item_id", {"group": pd.Series([], dtype=int)})
 
 
-@given(df=df_strategy())
-def test_duplicate_groups(df):
+def test_duplicate_groups(fill_df):
     """Raise ValueError if there are duplicate groups."""
     with pytest.raises(ValueError):
-        df.complete("a", "cities", ("a", "cities"))
+        fill_df.complete("group", "item_id", ("item_id", "item_name"))
 
 
-@given(df=df_strategy())
-def test_type_groups(df):
+def test_type_groups(fill_df):
     """Raise TypeError if grouping is not a permitted type."""
     with pytest.raises(TypeError):
-        df.complete("a", "cities", {1, 2, 3})
+        fill_df.complete("group", "item_id", {1, 2, 3})
 
 
-@given(df=df_strategy())
-def test_type_by(df):
+def test_type_by(fill_df):
     """Raise TypeError if `by` is not a permitted type."""
     with pytest.raises(TypeError):
-        df.complete("a", "cities", by=1)
+        fill_df.complete("group", "item_id", by=1)
 
 
-@given(df=df_strategy())
-def test_type_sort(df):
+def test_type_sort(fill_df):
     """Raise TypeError if `sort` is not boolean."""
     with pytest.raises(TypeError):
-        df.complete("a", "cities", sort=11)
+        fill_df.complete("group", "item_id", sort=11)
 
 
-@given(df=df_strategy())
-def test_groups_not_found(df):
+def test_groups_not_found(fill_df):
     """Raise ValueError if group does not exist."""
     with pytest.raises(ValueError):
-        df.complete("a", "cities", ("a", "cties"))
+        fill_df.complete("group", ("item_id", "name"))
 
 
-@given(df=df_strategy())
-def test_by_not_found(df):
+def test_by_not_found(fill_df):
     """Raise ValueError if `by` does not exist."""
     with pytest.raises(ValueError):
-        df.complete("a", "cities", by=["Bell__Chart", "elephant"])
+        fill_df.complete("group", "item_id", by="name")
+
+
+def test_fill_value(fill_df):
+    """Raise ValueError if `fill_value` is not the right data type."""
+    with pytest.raises(TypeError):
+        fill_df.complete("group", "item_id", fill_value=pd.Series([2, 3, 4]))
+
+
+def test_fill_value_column(fill_df):
+    """Raise ValueError if `fill_value` has a non existent column."""
+    with pytest.raises(ValueError):
+        fill_df.complete("group", "item_id", fill_value={"cities": 0})
+
+
+def test_fill_value_dict_scalar(fill_df):
+    """
+    Raise ValueError if `fill_value` is a dictionary
+    and the value is not a scalar.
+    """
+    with pytest.raises(ValueError):
+        fill_df.complete(
+            "group", "item_id", fill_value={"item_name": pd.Series([2, 3, 4])}
+        )
+
+
+def test_type_explicit(fill_df):
+    """Raise TypeError if `explicit` is not boolean."""
+    with pytest.raises(TypeError):
+        fill_df.complete("group", "item_id", explicit=11)
 
 
 @given(df=categoricaldf_strategy())
@@ -314,38 +362,44 @@ def test_complete_multiple_groupings():
             "meta": ["A", "A", "A", "A", "B", "B", "B", "B", "C", "C"],
             "domain1": ["d", "d", "e", "e", "h", "h", "i", "i", "k", "k"],
             "question_count": [3, 2, 3, 2, 3, 2, 3, 2, 3, 2],
-            "tag_count": [2.0, 1.0, 1.0, 0.0, 3.0, 0.0, 2.0, 1.0, 0.0, 2.0],
+            "tag_count": [2, 1, 1, 0, 3, 0, 2, 1, 0, 2],
         }
     )
 
     result = df3.complete(
-        ("meta", "domain1"), ("project_id", "question_count"), sort=True
-    ).fillna({"tag_count": 0})
+        ("meta", "domain1"),
+        ("project_id", "question_count"),
+        fill_value={"tag_count": 0},
+        sort=True,
+    )
     assert_frame_equal(result, output3)
 
 
+def test_fill_value_scalar(taxonomy_df):
+    """Test output if the fill_value is a scalar."""
+    result = taxonomy_df.complete("Year", "Taxon", fill_value=0, sort=True)
+    expected = pd.DataFrame(
+        [
+            {"Year": 1999, "Taxon": "Agarum", "Abundance": 1},
+            {"Year": 1999, "Taxon": "Saccharina", "Abundance": 4},
+            {"Year": 2000, "Taxon": "Agarum", "Abundance": 0},
+            {"Year": 2000, "Taxon": "Saccharina", "Abundance": 5},
+            {"Year": 2004, "Taxon": "Agarum", "Abundance": 8},
+            {"Year": 2004, "Taxon": "Saccharina", "Abundance": 2},
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
 #  http://imachordata.com/2016/02/05/you-complete-me/
-def test_dict_tuple():
+def test_dict_tuple(taxonomy_df):
     """
-    Test if a dictionary and a tuple/list
+    Test output if a dictionary and a tuple/list
     are included in the `columns` parameter.
     """
 
-    df = pd.DataFrame(
-        {
-            "Year": [1999, 2000, 2004, 1999, 2004],
-            "Taxon": [
-                "Saccharina",
-                "Saccharina",
-                "Saccharina",
-                "Agarum",
-                "Agarum",
-            ],
-            "Abundance": [4, 5, 2, 1, 8],
-        }
-    )
-
-    result = df.complete(
+    result = taxonomy_df.complete(
         {"Year": lambda x: range(x.min(), x.max() + 1)},
         ("Taxon", "Abundance"),
         sort=True,
@@ -427,3 +481,179 @@ def test_complete_groupby():
     expected = expected.reindex(columns=df.columns)
 
     assert_frame_equal(result, expected)
+
+
+def test_explicit_scalar(fill_df):
+    """Test output if fill_value is a scalar, and explicit is False."""
+    result = fill_df.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value=0,
+        explicit=False,
+        sort=True,
+    )
+    expected = pd.DataFrame(
+        [
+            {
+                "group": 1,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 1.0,
+                "value2": 4,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 3.0,
+                "value2": 6,
+            },
+            {
+                "group": 1,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 2,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": np.nan,
+                "value2": 5,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 2,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 4.0,
+                "value2": 7,
+            },
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
+# https://tidyr.tidyverse.org/reference/complete.html
+def test_explicit_dict(fill_df):
+    """Test output if fill_value is a dictionary, and explicit is False."""
+    result = fill_df.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value={"value1": 0, "value2": 99},
+        explicit=False,
+        sort=True,
+    )
+    expected = pd.DataFrame(
+        [
+            {
+                "group": 1,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 1.0,
+                "value2": 4,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 3.0,
+                "value2": 6,
+            },
+            {
+                "group": 1,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": np.nan,
+                "value2": 5,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 4.0,
+                "value2": 7,
+            },
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
+def test_explicit_(fill_df):
+    """
+    Test output if explicit is False,
+    and the columns used for the combination
+    are reused in the fill_value.
+    """
+    trimmed = fill_df.select_columns("value*", invert=True)
+    result = trimmed.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value=0,
+        explicit=False,
+        sort=True,
+    )
+    expected = pd.DataFrame(
+        [
+            {"group": 1, "item_id": 1, "item_name": "a"},
+            {"group": 1, "item_id": 2, "item_name": "a"},
+            {"group": 1, "item_id": 2, "item_name": "b"},
+            {"group": 1, "item_id": 3, "item_name": "b"},
+            {"group": 2, "item_id": 1, "item_name": "a"},
+            {"group": 2, "item_id": 2, "item_name": "a"},
+            {"group": 2, "item_id": 2, "item_name": "b"},
+            {"group": 2, "item_id": 3, "item_name": "b"},
+        ]
+    )
+    assert_frame_equal(result, expected)