Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] improve encode_categoricals
# Brief Description

- Simplify logic for `_encode_categoricals` in utils.py. A simple for-loop possibly
    - If user provides `categories` use as-is (order defaults to `appearance`)
    - `order` should be relevant only if `categories` is not provided.  
- Get rid of `As_categorical` helper function (tuple of `categories, order` is sufficient)



Discussion:

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -13,7 +13,6 @@
     Hashable,
     Iterable,
     List,
-    NamedTuple,
     Optional,
     Pattern,
     Set,
@@ -52,7 +51,6 @@
     _replace_original_empty_string_with_none,
     _select_columns,
     _strip_underscores,
-    asCategorical,
     check,
     check_column,
     deprecated_alias,
@@ -473,23 +471,6 @@ def get_dupes(
     return df[dupes == True]  # noqa: E712
 
 
-def As_Categorical(
-    categories: Optional[List] = None,
-    order: Optional[str] = None,
-) -> NamedTuple:
-    """
-    Helper function for `encode_categorical`. It makes creating the
-    `categories` and `order` more explicit. Inspired by pd.NamedAgg.
-    :param categories: list-like object to create new categorical column.
-    :param order: string object that can be either "sort" or "appearance".
-        If "sort", the `categories` argument will be sorted with np.sort;
-        if "apperance", the `categories` argument will be used as is.
-    :returns: A namedtuple of (`categories`, `order`).
-    """
-
-    return asCategorical(categories=categories, order=order)
-
-
 @pf.register_dataframe_method
 @deprecated_alias(columns="column_names")
 def encode_categorical(
@@ -503,9 +484,6 @@ def encode_categorical(
     Categories and order can be explicitly specified via the `kwargs` option, which is a
     pairing of column name and a tuple of (categories, order).
 
-    The `janitor.As_Categorical` function is provided to make it clearer what the arguments
-    to the function are.
-
     It is syntactic sugar around `pd.Categorical`.
 
     This method does not mutate the original DataFrame.
@@ -594,21 +572,6 @@ def encode_categorical(
     if the `order` is "sort", the categories argument is sorted in ascending order;
     if `order` is ``None``, then the categories argument is applied unordered.
 
-    The ``janitor.As_Categorical`` function can also be used to make clearer
-    what the arguments to the function are::
-
-        df = (pd.DataFrame(...)
-                .encode_categorical(
-                    col1 = As_Categorical(
-                                categories = [3, 2, 1, 4],
-                                order = "appearance"
-                                ),
-                    col2 = As_Categorical(
-                                categories = ['a','d','c','b'],
-                                order = "sort"
-                                )
-                    )
-            )
 
     A User Warning will be generated if some or all of the unique values
     in the column are not present in the provided `categories` argument.
@@ -617,7 +580,7 @@ def encode_categorical(
 
         df = (pd.DataFrame(...)
                 .encode_categorical(
-                    col1 = As_Categorical(
+                    col1 = (
                             categories = [4, 5, 6],
                             order = "appearance"
                             )
@@ -663,10 +626,9 @@ def encode_categorical(
         df = jn.encode_categorical(
                     df,
                     col1 = (categories, order),
-                    col2 = jn.As_Categorical(
-                                categories = [values],
-                                order="sort"/"appearance"/None
-                                )
+                    col2 = (categories = [values],
+                    order="sort"  # or "appearance" or None
+
                 )
 
     Method chaining syntax:
@@ -684,10 +646,9 @@ def encode_categorical(
             pd.DataFrame(...)
             .encode_categorical(
                 col1 = (categories, order),
-                col2 = jn.As_Categorical(
-                            categories = [values]/None,
-                            order="sort"/"appearance"/None
-                            )
+                col2 = (categories = [values]/None,
+                        order="sort"  # or "appearance" or None
+                        )
         )
 
 
@@ -695,20 +656,12 @@ def encode_categorical(
     :param column_names: A column name or an iterable (list or
         tuple) of column names.
     :param kwargs: A pairing of column name to a tuple of (`categories`, `order`).
-        There is also the `janitor.As_Categorical` function, which creates a
-        namedtuple of (`categories`, `order`) to make it clearer what the arguments
-        are. This is useful in creating categorical columns that are ordered, or
+        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 JanitorError: if a column specified within ``column_names``
-        is not found in the DataFrame.
-    :raises JanitorError: if ``column_names`` is not hashable
-        nor iterable.
     :raises ValueError: if both ``column_names`` and ``kwargs`` are provided.
     """  # noqa: E501
 
-    df = df.copy()
-
     if all((column_names, kwargs)):
         raise ValueError(
             """
@@ -719,28 +672,17 @@ def encode_categorical(
     # column_names deal with only category dtype (unordered)
     # kwargs takes care of scenarios where user wants an ordered category
     # or user supplies specific categories to create the categorical
-    if column_names:
-        if isinstance(column_names, (list, Tuple)):
-            for col in column_names:
-                if col not in df.columns:
-                    raise JanitorError(
-                        f"{col} missing from DataFrame columns!"
-                    )
-                df[col] = pd.Categorical(df[col])
-        elif isinstance(column_names, Hashable):
-            if column_names not in df.columns:
-                raise JanitorError(
-                    f"{column_names} missing from DataFrame columns!"
-                )
-            df[column_names] = pd.Categorical(df[column_names])
-        else:
-            raise JanitorError(
-                "kwarg `column_names` must be hashable or iterable!"
-            )
-        return df
+    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"})
 
-    df = _computations_as_categorical(df, **kwargs)
-    return df
+    return _computations_as_categorical(df, **kwargs)
 
 
 @pf.register_dataframe_method
@@ -6359,10 +6301,43 @@ def conditional_join(
     The join is done only on the columns.
     MultiIndex columns are not supported.
 
-    For non-equi joins, onnly numeric and date columns are supported.
+    For non-equi joins, only numeric and date columns are supported.
 
     Only `inner`, `left`, and `right` joins are supported.
 
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+        right = pd.DataFrame(...)
+
+        df = jn.conditional_join(
+                df,
+                right,
+                (col_from_df, col_from_right, join_operator),
+                (col_from_df, col_from_right, join_operator),
+                ...,
+                how = 'inner' # or left/right
+                sort_by_appearance = True # or False
+                )
+
+    Method chaining syntax:
+
+    .. code-block:: python
+
+        df.conditional_join(
+            right,
+            (col_from_df, col_from_right, join_operator),
+            (col_from_df, col_from_right, join_operator),
+            ...,
+            how = 'inner' # or left/right
+            sort_by_appearance = True # or False
+            )
+
 
     :param df: A Pandas DataFrame.
     :param right: Named Series or DataFrame to join to.
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -16,7 +16,6 @@
     Dict,
     Iterable,
     List,
-    NamedTuple,
     Optional,
     Pattern,
     Tuple,
@@ -42,6 +41,7 @@
 from pandas.core.common import apply_if_callable
 from enum import Enum
 
+
 from .errors import JanitorError
 
 
@@ -1796,23 +1796,16 @@ def _computations_pivot_wider(
     return df
 
 
-class asCategorical(NamedTuple):
+class CategoryOrder(Enum):
     """
-    Helper class for `encode_categorical`. It makes creating the
-    `categories` and `order` more explicit. Inspired by pd.NamedAgg.
-
-    :param categories: list-like object to create new categorical column.
-    :param order: string object that can be either "sort" or "appearance".
-        If "sort", the `categories` argument will be sorted with np.sort;
-        if "apperance", the `categories` argument will be used as is.
-    :returns: A namedtuple of (`categories`, `order`).
+    order types for encode_categorical.
     """
 
-    categories: list = None
-    order: str = None
+    SORT = "sort"
+    APPEARANCE = "appearance"
 
 
-def as_categorical_checks(df: pd.DataFrame, **kwargs) -> tuple:
+def as_categorical_checks(df: pd.DataFrame, **kwargs) -> dict:
     """
     This function raises errors if columns in `kwargs` are
     absent in the the dataframe's columns.
@@ -1824,15 +1817,14 @@ def as_categorical_checks(df: pd.DataFrame, **kwargs) -> tuple:
 
     This function is executed before proceeding to the computation phase.
 
-    If all checks pass, the dataframe,
-    and a pairing of column names and namedtuple
+    If all checks pass, a dictionary of column names and tuple
     of (categories, order) is returned.
 
     :param df: The pandas DataFrame object.
     :param kwargs: A pairing of column name
         to a tuple of (`categories`, `order`).
-    :returns: A tuple (pandas DataFrame, dictionary).
-    :raises TypeError: if ``kwargs`` is not a tuple.
+    :returns: A dictionary.
+    :raises TypeError: if the value in ``kwargs`` is not a tuple.
     :raises ValueError: if ``categories`` is not a 1-D array.
     :raises ValueError: if ``order`` is not one of
         `sort`, `appearance`, or `None`.
@@ -1843,65 +1835,162 @@ def as_categorical_checks(df: pd.DataFrame, **kwargs) -> tuple:
 
     categories_dict = {}
 
-    # type checks
     for column_name, value in kwargs.items():
+        # type check
         check("Pair of `categories` and `order`", value, [tuple])
-        if len(value) != 2:
-            raise ValueError("Must provide tuple of (categories, order).")
-        value = asCategorical(*value)
-        value_categories = value.categories
-        if value_categories is not None:
-            if not is_list_like(value_categories):
-                raise TypeError(f"{value_categories} should be list-like.")
-            value_categories = [*value_categories]
-            arr_ndim = np.asarray(value_categories).ndim
-            if any((arr_ndim < 1, arr_ndim > 1)):
+
+        len_value = len(value)
+
+        if len_value != 2:
+            raise ValueError(
+                f"""
+                The tuple of (categories, order) for {column_name}
+                should be length 2; the tuple provided is
+                length {len_value}.
+                """
+            )
+
+        cat, order = value
+        if cat is not None:
+            if not is_list_like(cat):
+                raise TypeError(f"{cat} should be list-like.")
+
+            if not hasattr(cat, "shape"):
+                checker = pd.Index([*cat])
+            else:
+                checker = cat
+
+            arr_ndim = checker.ndim
+            if (arr_ndim != 1) or isinstance(checker, pd.MultiIndex):
                 raise ValueError(
                     f"""
-                    {value_categories} is not a 1-D array.
+                    {cat} is not a 1-D array.
+                    Kindly provide a 1-D array-like object to `categories`.
+                    """
+                )
+
+            if not isinstance(checker, (pd.Series, pd.Index)):
+                checker = pd.Index(checker)
+
+            if checker.hasnans:
+                raise ValueError(
+                    "Kindly ensure there are no nulls in `categories`."
+                )
+
+            if not checker.is_unique:
+                raise ValueError(
+                    """
+                    Kindly provide unique,
+                    non-null values for `categories`.
+                    """
+                )
+
+            if checker.empty:
+                raise ValueError(
+                    """
+                    Kindly ensure there is at least
+                    one non-null value in `categories`.
                     """
                 )
-        value_order = value.order
-        if value_order is not None:
-            check("order", value_order, [str])
-            if value_order not in ("appearance", "sort"):
+
+            # uniques, without nulls
+            uniques = df[column_name].factorize(sort=False)[-1]
+            if uniques.empty:
+                raise ValueError(
+                    f"""
+                     Kindly ensure there is at least
+                     one non-null value in {column_name}.
+                     """
+                )
+
+            missing = uniques.difference(checker, sort=False)
+            if not missing.empty and (uniques.size > missing.size):
+                warnings.warn(
+                    f"""
+                     Values {tuple(missing)} are missing from
+                     the provided categories {cat}
+                     for {column_name}; this may create nulls
+                     in the new categorical column.
+                     """,
+                    UserWarning,
+                    stacklevel=2,
+                )
+
+            elif uniques.equals(missing):
+                warnings.warn(
+                    f"""
+                     None of the values in {column_name} are in
+                     {cat};
+                     this might create nulls for all values
+                     in the new categorical column.
+                     """,
+                    UserWarning,
+                    stacklevel=2,
+                )
+
+        if order is not None:
+            check("order", order, [str])
+
+            category_order_types = [ent.value for ent in CategoryOrder]
+            if order.lower() not in category_order_types:
                 raise ValueError(
                     """
                     `order` argument should be one of
                     "appearance", "sort" or `None`.
                     """
                 )
-        categories_dict[column_name] = asCategorical(
-            categories=value_categories, order=value_order
-        )
 
-    return df, categories_dict
+        categories_dict[column_name] = value
+
+    return categories_dict
 
 
 def _computations_as_categorical(df: pd.DataFrame, **kwargs) -> pd.DataFrame:
     """
-    This function handles cases where categorical columns are created with
-    an order, or specific values supplied for the categories. It uses a kwarg,
-    with a namedtuple - `column_name: (categories, order)`, with the idea
-    inspired by Pandas' NamedAggregation. The defaults for the namedtuple are
-    (None, None) and will return a categorical dtype with no order and
-    categories inferred from the column.
+    This function handles cases where
+    categorical columns are created with an order,
+    or specific values supplied for the categories.
+    It uses a kwarg, where the key is the column name,
+    and the value is a tuple of categories, order.
+    The defaults for the tuple are (None, None)
+    and will return a categorical dtype
+    with no order and categories inferred from the column.
+    A DataFrame, with catetorical columns, is returned.
     """
 
-    df, categories_dict = as_categorical_checks(df, **kwargs)
+    categories_dict = as_categorical_checks(df, **kwargs)
 
     categories_dtypes = {}
-    for column_name, ascategorical in categories_dict.items():
-        categories = _encode_categories(
-            ascategorical.categories, df, column_name
-        )
-        categories_dtypes[column_name] = _encode_order(
-            ascategorical.order, categories
-        )
 
-    df = df.astype(categories_dtypes)
+    for column_name, (
+        cat,
+        order,
+    ) in categories_dict.items():
+        error_msg = f"""
+                     Kindly ensure there is at least
+                     one non-null value in {column_name}.
+                     """
+        if (cat is None) and (order is None):
+            cat_dtype = pd.CategoricalDtype()
 
-    return df
+        elif (cat is None) and (order is CategoryOrder.SORT.value):
+            cat = df[column_name].factorize(sort=True)[-1]
+            if cat.empty:
+                raise ValueError(error_msg)
+            cat_dtype = pd.CategoricalDtype(categories=cat, ordered=True)
+
+        elif (cat is None) and (order is CategoryOrder.APPEARANCE.value):
+            cat = df[column_name].factorize(sort=False)[-1]
+            if cat.empty:
+                raise ValueError(error_msg)
+            cat_dtype = pd.CategoricalDtype(categories=cat, ordered=True)
+
+        elif cat is not None:  # order is irrelevant if cat is provided
+            cat_dtype = pd.CategoricalDtype(categories=cat, ordered=True)
+
+        categories_dtypes[column_name] = cat_dtype
+
+    return df.astype(categories_dtypes)
 
 
 def is_connected(url: str) -> bool:
@@ -1936,112 +2025,6 @@ def is_connected(url: str) -> bool:
     return False
 
 
-@functools.singledispatch
-def _encode_categories(cat, df, column):
-    """
-    base function for processing `categories`
-    in `_computations_as_categorical`.
-    Returns a Series.
-    """
-    raise TypeError("This type is not supported in `categories`.")
-
-
-@_encode_categories.register(type(None))  # noqa: F811
-def _sub_categories(cat, df, column):  # noqa: F811
-    """
-    base function for processing `categories`
-    in `_computations_as_categorical`.
-    Apllies to only NoneType.
-    Returns a Series.
-    """
-    column = df[column]
-    if column.hasnans:
-        column = column.dropna()
-    return column
-
-
-@_encode_categories.register(list)  # noqa: F811
-def _sub_categories(cat, df, column):  # noqa: F811
-    """
-    base function for processing `categories`
-    in `_computations_as_categorical`.
-    Apllies to only list type.
-    Returns a Series.
-    """
-    if pd.isna(cat).any():
-        raise ValueError("""`categories` cannot have null values.""")
-    col = df[column]
-    check_presence = col.isin(cat)
-    check_presence_sum = check_presence.sum()
-
-    if check_presence_sum == 0:
-        warnings.warn(
-            f"""
-            None of the values in `{column}` are in
-            {cat};
-            this might create nulls for all your values
-            in the new categorical column.
-            """,
-            UserWarning,
-            stacklevel=2,
-        )
-    elif check_presence_sum != check_presence.size:
-        missing_values = col[~check_presence]
-        if missing_values.hasnans:
-            missing_values = missing_values.dropna()
-        warnings.warn(
-            f"""
-            Values {tuple(missing_values)} are missing from
-            categories {cat}
-            for {column}; this may create nulls
-            in the new categorical column.
-            """,
-            UserWarning,
-            stacklevel=2,
-        )
-    return pd.Series(cat)
-
-
-@functools.singledispatch
-def _encode_order(order, categories):
-    """
-    base function for processing `order`
-    in `_computations_as_categorical`.
-    Returns a pd.CategoricalDtype().
-    """
-    raise TypeError("This type is not supported in `order`.")
-
-
-@_encode_order.register(type(None))  # noqa: F811
-def _sub_encode_order(order, categories):  # noqa: F811
-    """
-    base function for processing `order`
-    in `_computations_as_categorical`.
-    Apllies to only NoneType.
-    Returns a pd.CategoricalDtype().
-    """
-    if not categories.is_unique:
-        categories = categories.unique()
-
-    return pd.CategoricalDtype(categories=categories, ordered=False)
-
-
-@_encode_order.register(str)  # noqa: F811
-def _sub_encode_order(order, categories):  # noqa: F811
-    """
-    base function for processing `order`
-    in `_computations_as_categorical`.
-    Apllies to only strings.
-    Returns a pd.CategoricalDtype().
-    """
-    if (order == "sort") and (not categories.is_monotonic_increasing):
-        categories = categories.sort_values()
-    if not categories.is_unique:
-        categories = categories.unique()
-
-    return pd.CategoricalDtype(categories=categories, ordered=True)
-
-
 @functools.singledispatch
 def _select_columns(columns_to_select, df):
     """
@@ -2282,7 +2265,7 @@ def _column_sel_dispatch(columns_to_select, df):  # noqa: F811
     return filtered_columns
 
 
-class JOINOPERATOR(Enum):
+class JoinOperator(Enum):
     """
     List of operators used in conditional_join.
     """
@@ -2295,7 +2278,7 @@ class JOINOPERATOR(Enum):
     NOT_EQUAL = "!="
 
 
-class JOINTYPES(Enum):
+class JoinTypes(Enum):
     """
     List of join types for conditional_join.
     """
@@ -2312,7 +2295,7 @@ def _check_operator(op: str):
 
     Used in `conditional_join`.
     """
-    sequence_of_operators = {op.value for op in JOINOPERATOR}
+    sequence_of_operators = {op.value for op in JoinOperator}
     if op not in sequence_of_operators:
         raise ValueError(
             f"""
@@ -2416,7 +2399,7 @@ def _conditional_join_preliminary_checks(
 
     check("how", how, [str])
 
-    join_types = {jointype.value for jointype in JOINTYPES}
+    join_types = {jointype.value for jointype in JoinTypes}
     if how not in join_types:
         raise ValueError(f"`how` should be one of {', '.join(join_types)}.")
 
@@ -2468,7 +2451,7 @@ def _conditional_join_type_check(
 
     if (
         is_categorical_dtype(left_column)
-        and op != JOINOPERATOR.STRICTLY_EQUAL.value
+        and op != JoinOperator.STRICTLY_EQUAL.value
     ):
         raise ValueError(
             """
@@ -2479,7 +2462,7 @@ def _conditional_join_type_check(
 
     if (
         is_string_dtype(left_column)
-        and op != JOINOPERATOR.STRICTLY_EQUAL.value
+        and op != JoinOperator.STRICTLY_EQUAL.value
     ):
         raise ValueError(
             """
@@ -2784,14 +2767,14 @@ def _create_conditional_join_empty_frame(
     df.columns = pd.MultiIndex.from_product([["left"], df.columns])
     right.columns = pd.MultiIndex.from_product([["right"], right.columns])
 
-    if how == JOINTYPES.INNER.value:
+    if how == JoinTypes.INNER.value:
         df = df.dtypes.to_dict()
         right = right.dtypes.to_dict()
         df = {**df, **right}
         df = {key: pd.Series([], dtype=value) for key, value in df.items()}
         return pd.DataFrame(df)
 
-    if how == JOINTYPES.LEFT.value:
+    if how == JoinTypes.LEFT.value:
         right = right.dtypes.to_dict()
         right = {
             key: float if dtype.kind == "i" else dtype
@@ -2803,7 +2786,7 @@ def _create_conditional_join_empty_frame(
         right = pd.DataFrame(right)
         return df.join(right, how=how, sort=False)
 
-    if how == JOINTYPES.RIGHT.value:
+    if how == JoinTypes.RIGHT.value:
         df = df.dtypes.to_dict()
         df = {
             key: float if dtype.kind == "i" else dtype
@@ -2834,31 +2817,31 @@ def _create_conditional_join_frame(
     df.columns = pd.MultiIndex.from_product([["left"], df.columns])
     right.columns = pd.MultiIndex.from_product([["right"], right.columns])
 
-    if how == JOINTYPES.INNER.value:
+    if how == JoinTypes.INNER.value:
         df = df.loc[left_index]
         right = right.loc[right_index]
         df.index = pd.RangeIndex(start=0, stop=left_index.size)
         right.index = df.index
         return pd.concat([df, right], axis="columns", join=how, sort=False)
 
-    if how == JOINTYPES.LEFT.value:
+    if how == JoinTypes.LEFT.value:
         right = right.loc[right_index]
         right.index = left_index
         return df.join(right, how=how, sort=False).reset_index(drop=True)
 
-    if how == JOINTYPES.RIGHT.value:
+    if how == JoinTypes.RIGHT.value:
         df = df.loc[left_index]
         df.index = right_index
         return df.join(right, how=how, sort=False).reset_index(drop=True)
 
 
 less_than_join_types = {
-    JOINOPERATOR.LESS_THAN.value,
-    JOINOPERATOR.LESS_THAN_OR_EQUAL.value,
+    JoinOperator.LESS_THAN.value,
+    JoinOperator.LESS_THAN_OR_EQUAL.value,
 }
 greater_than_join_types = {
-    JOINOPERATOR.GREATER_THAN.value,
-    JOINOPERATOR.GREATER_THAN_OR_EQUAL.value,
+    JoinOperator.GREATER_THAN.value,
+    JoinOperator.GREATER_THAN_OR_EQUAL.value,
 }
 
 
@@ -2873,9 +2856,9 @@ def _generic_func_cond_join(
     strict = False
 
     if op in {
-        JOINOPERATOR.GREATER_THAN.value,
-        JOINOPERATOR.LESS_THAN.value,
-        JOINOPERATOR.NOT_EQUAL.value,
+        JoinOperator.GREATER_THAN.value,
+        JoinOperator.LESS_THAN.value,
+        JoinOperator.NOT_EQUAL.value,
     }:
         strict = True
 
@@ -2883,7 +2866,7 @@ def _generic_func_cond_join(
         return _less_than_indices(left_c, right_c, strict, len_conditions)
     elif op in greater_than_join_types:
         return _greater_than_indices(left_c, right_c, strict, len_conditions)
-    elif op == JOINOPERATOR.NOT_EQUAL.value:
+    elif op == JoinOperator.NOT_EQUAL.value:
         return _not_equal_indices(left_c, right_c)
     else:
         return _equal_indices(left_c, right_c, len_conditions)
@@ -2923,7 +2906,7 @@ def _conditional_join_compute(
 
         _conditional_join_type_check(left_c, right_c, op)
 
-        if op == JOINOPERATOR.STRICTLY_EQUAL.value:
+        if op == JoinOperator.STRICTLY_EQUAL.value:
             eq_check = True
         elif op in less_greater_types:
             less_great = True
@@ -2972,12 +2955,12 @@ def _conditional_join_compute(
 
 
 operator_map = {
-    JOINOPERATOR.STRICTLY_EQUAL.value: operator.eq,
-    JOINOPERATOR.LESS_THAN.value: operator.lt,
-    JOINOPERATOR.LESS_THAN_OR_EQUAL.value: operator.le,
-    JOINOPERATOR.GREATER_THAN.value: operator.gt,
-    JOINOPERATOR.GREATER_THAN_OR_EQUAL.value: operator.ge,
-    JOINOPERATOR.NOT_EQUAL.value: operator.ne,
+    JoinOperator.STRICTLY_EQUAL.value: operator.eq,
+    JoinOperator.LESS_THAN.value: operator.lt,
+    JoinOperator.LESS_THAN_OR_EQUAL.value: operator.le,
+    JoinOperator.GREATER_THAN.value: operator.gt,
+    JoinOperator.GREATER_THAN_OR_EQUAL.value: operator.ge,
+    JoinOperator.NOT_EQUAL.value: operator.ne,
 }
 
 
@@ -2994,12 +2977,12 @@ def _multiple_conditional_join_eq(
     eq_cond = [
         cond
         for cond in conditions
-        if cond[-1] == JOINOPERATOR.STRICTLY_EQUAL.value
+        if cond[-1] == JoinOperator.STRICTLY_EQUAL.value
     ]
     rest = [
         cond
         for cond in conditions
-        if cond[-1] != JOINOPERATOR.STRICTLY_EQUAL.value
+        if cond[-1] != JoinOperator.STRICTLY_EQUAL.value
     ]
 
     # get rid of nulls, if any
@@ -3032,7 +3015,7 @@ def _multiple_conditional_join_eq(
     # get join indices
     # these are positional, not label indices
     result = _generic_func_cond_join(
-        left_c, right_c, JOINOPERATOR.STRICTLY_EQUAL.value, 2
+        left_c, right_c, JoinOperator.STRICTLY_EQUAL.value, 2
     )
 
     if result is None:
@@ -3126,7 +3109,7 @@ def _multiple_conditional_join_le_lt(
             lt_gt = left_on, right_on, op
         # no point checking for `!=`, since best case scenario
         # they'll have the same no of rows for the less/greater operators
-        elif op == JOINOPERATOR.NOT_EQUAL.value:
+        elif op == JoinOperator.NOT_EQUAL.value:
             continue
 
         left_c = df.loc[df_index, left_on]


Test patch:
diff --git a/tests/functions/test_encode_categorical.py b/tests/functions/test_encode_categorical.py
--- a/tests/functions/test_encode_categorical.py
+++ b/tests/functions/test_encode_categorical.py
@@ -2,11 +2,8 @@
 import pandas as pd
 import pytest
 from hypothesis import given
-from pandas.api.types import CategoricalDtype
 from pandas.testing import assert_frame_equal
 
-from janitor import As_Categorical
-from janitor.errors import JanitorError
 from janitor.testing_utils.strategies import (
     categoricaldf_strategy,
     df_strategy,
@@ -24,11 +21,11 @@ def test_encode_categorical(df):
 @given(df=df_strategy())
 def test_encode_categorical_missing_column(df):
     """
-    Raise JanitorError for missing columns
+    Raise ValueError for missing columns
     when only one arguments is provided to
     `column_names`.
     """
-    with pytest.raises(JanitorError):
+    with pytest.raises(ValueError):
         df.encode_categorical("aloha")
 
 
@@ -36,11 +33,11 @@ def test_encode_categorical_missing_column(df):
 @given(df=df_strategy())
 def test_encode_categorical_missing_columns(df):
     """
-    Raise JanitorError for missing columns
+    Raise ValueError for missing columns
     when the number of arguments to `column_names`
     is more than one.
     """
-    with pytest.raises(JanitorError):
+    with pytest.raises(ValueError):
         df.encode_categorical(["animals@#$%^", "cities", "aloha"])
 
 
@@ -48,10 +45,10 @@ def test_encode_categorical_missing_columns(df):
 @given(df=df_strategy())
 def test_encode_categorical_invalid_input(df):
     """
-    Raise JanitorError for wrong input type
+    Raise ValueError for wrong input type
     for `column_names`.
     """
-    with pytest.raises(JanitorError):
+    with pytest.raises(ValueError):
         df.encode_categorical(1)
 
 
@@ -59,75 +56,100 @@ def test_encode_categorical_invalid_input(df):
 @given(df=df_strategy())
 def test_encode_categorical_invalid_input_2(df):
     """
-    Raise JanitorError for wrong input type
+    Raise TypeError for wrong input type
     for `column_names`.
     """
-    with pytest.raises(JanitorError):
+    with pytest.raises(TypeError):
         df.encode_categorical({"names"})
 
 
-@pytest.fixture
-def df_categorical():
-    return pd.DataFrame(
-        {
-            "col1": [2, 1, 3, 1],
-            "col2": ["a", "b", "c", "d"],
-            "col3": pd.date_range("1/1/2020", periods=4),
-        }
-    )
-
-
-def test_encode_categorical_multiple_column_names(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_encode_categorical_multiple_column_names(df):
     """
     Test output when more than one column is provided
     to `column_names`.
     """
-    result = df_categorical.astype({"col1": "category", "col2": "category"})
+    result = df.astype({"a": "category", "cities": "category"})
     assert_frame_equal(
-        df_categorical.encode_categorical(column_names=["col1", "col2"]),
+        df.encode_categorical(column_names=["a", "cities"]),
         result,
     )
 
 
-def test_both_column_names_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_both_column_names_kwargs(df):
     """
     Raise Error if both `column_names`
     and kwargs are provided.
     """
     with pytest.raises(ValueError):
-        df_categorical.encode_categorical(
-            column_names=["col1", "col2"], col1=(None, "sort")
+        df.encode_categorical(
+            column_names=["a", "cities"], Bell__Chart=(None, "sort")
         )
 
 
-def test_check_presence_column_names_in_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_check_presence_column_names_in_kwargs(df):
     """
     Raise ValueError if column names in `kwargs`
     do not exist in the dataframe.
     """
     with pytest.raises(ValueError):
-        df_categorical.encode_categorical(col_1=(None, "sort"))
+        df.encode_categorical(col_1=(None, "sort"))
 
 
-def test_check_type_tuple_in_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_check_type_tuple_in_kwargs(df):
     """
     Raise TypeError if the categories, order pairing
     in `kwargs` is not a tuple.
     """
     with pytest.raises(TypeError):
-        df_categorical.encode_categorical(col1=[None, "sort"])
+        df.encode_categorical(a=[None, "sort"])
 
 
-def test_categories_type_in_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_tuple_length_in_kwargs(df):
+    """
+    Raise ValueError if the length of the tuple
+    in kwargs is not equal to 2.
+    """
+    with pytest.raises(ValueError):
+        df.encode_categorical(a=(None, None, 2))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_categories_type_in_kwargs(df):
     """
     Raise TypeError if the wrong argument is supplied to
     the `categories` parameter in kwargs.
     """
     with pytest.raises(TypeError):
-        df_categorical.encode_categorical(col1=("category", None))
+        df.encode_categorical(a=("category", None))
 
 
-def test_categories_ndim_gt_1_in_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_categories_ndim_array_gt_1_in_kwargs(df):
+    """
+    Raise ValueError if the argument supplied to
+    the `categories` parameter in kwargs is not
+    1-D array-like.
+    """
+    arrays = [[1, 1, 2, 2], ["red", "blue", "red", "blue"]]
+    with pytest.raises(ValueError):
+        df.encode_categorical(a=(arrays, None))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_categories_ndim_MultiIndex_gt_1_in_kwargs(df):
     """
     Raise ValueError if the argument supplied to
     the `categories` parameter in kwargs is not
@@ -136,341 +158,179 @@ def test_categories_ndim_gt_1_in_kwargs(df_categorical):
     arrays = [[1, 1, 2, 2], ["red", "blue", "red", "blue"]]
     arrays = pd.MultiIndex.from_arrays(arrays, names=("number", "color"))
     with pytest.raises(ValueError):
-        df_categorical.encode_categorical(col1=(arrays, None))
+        df.encode_categorical(a=(arrays, None))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_categories_ndim_DataFrame_gt_1_in_kwargs(df):
+    """
+    Raise ValueError if the argument supplied to
+    the `categories` parameter in kwargs is not
+    1-D array-like.
+    """
+    arrays = {"name": [1, 1, 2, 2], "number": ["red", "blue", "red", "blue"]}
+    arrays = pd.DataFrame(arrays)
+    with pytest.raises(ValueError):
+        df.encode_categorical(a=(arrays, None))
 
 
-def test_categories_null_in_categories(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_categories_null_in_categories(df):
     """
     Raise ValueError if there are nulls in the `categories`.
     """
     with pytest.raises(ValueError):
-        df_categorical.encode_categorical(col1=([None, 2, 3], None))
+        df.encode_categorical(a=([None, 2, 3], None))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_non_unique_cat(df):
+    """Raise ValueError if `categories` is not unique."""
+    with pytest.raises(ValueError):
+        df.encode_categorical(a=([1, 2, 3, 3], "sort"))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_empty_cat(df):
+    """Raise ValueError if `categories` is empty."""
+    with pytest.raises(ValueError):
+        df.encode_categorical(a=([], "sort"))
 
 
-def test_order_type_in_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_empty_col(df):
+    """
+    Raise ValueError if `categories`,
+    and the relevant column is empty, or all nulls.
+    """
+    with pytest.raises(ValueError):
+        df["col1"] = np.nan
+        df.encode_categorical(col1=([1, 2, 3], "sort"))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_empty_col_2(df):
+    """
+    Raise ValueError if `categories` is None,
+    and the relevant column is empty, or all nulls.
+    """
+    with pytest.raises(ValueError):
+        df["col1"] = np.nan
+        df.encode_categorical(col1=(None, "sort"))
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_empty_col_3(df):
+    """
+    Raise ValueError if `categories` is None,
+    and the relevant column is empty, or all nulls.
+    """
+    with pytest.raises(ValueError):
+        df["col1"] = pd.Series([], dtype="object")
+        df.encode_categorical(col1=(None, "appearance"))
+
+
+@pytest.mark.functions
+@given(df=categoricaldf_strategy())
+def test_warnings(df):
+    """
+    Test that warnings are raised if `categories` is provided, and
+    the categories do not match the unique values in the column, or
+    some values in the column are missing in `categories`.
+    """
+    with pytest.warns(UserWarning):
+        df.encode_categorical(
+            numbers=([4, 5, 6], None), names=(["John", "Mark", "Luke"], "sort")
+        )
+
+
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_order_type_in_kwargs(df):
     """
     Raise TypeError if the wrong argument is supplied to
     the `order` parameter in kwargs.
     """
     with pytest.raises(TypeError):
-        df_categorical.encode_categorical(col1=({1, 2, 3, 3}, {"sort"}))
+        df.encode_categorical(a=({1, 2, 3, 3}, {"sort"}))
 
 
-def test_order_wrong_option_in_kwargs(df_categorical):
+@pytest.mark.functions
+@given(df=df_strategy())
+def test_order_wrong_option_in_kwargs(df):
     """
     Raise ValueError if the value supplied to the `order`
     parameter in kwargs is not one of None, 'sort', or 'appearance'.
     """
     with pytest.raises(ValueError):
-        df_categorical.encode_categorical(col1=({1, 2, 3, 3}, "sorted"))
-
-
-as_categorical_tuple_length = [
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        (None,),
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        (None, "sort", "appearance"),
-    ),
-]
-
-
-@pytest.mark.parametrize(
-    "df,as_categorical_tuple", as_categorical_tuple_length
-)
-def test_tuple_length_in_kwargs(df, as_categorical_tuple):
+        df.encode_categorical(a=({1, 2, 3, 3}, "sorted"))
+
+
+# directly comparing columns is safe -
+# if the columns have differing categories
+# (especially for ordered True) it will fail.
+# if both categories are unordered, then the
+# order is not considered.
+# comparing with assert_frame_equal fails
+# for unordered categoricals, as internally
+# the order of the categories are compared
+# which is irrelevant for unordered categoricals
+
+
+@pytest.mark.functions
+@given(df=categoricaldf_strategy())
+def test_all_None(df):
     """
-    Raise ValueError if the length of the tuple
-    in kwargs is not equal to 2.
+    Test output where `categories` and `order` are None.
     """
-    with pytest.raises(ValueError):
-        df.encode_categorical(col1=as_categorical_tuple)
-
-
-test_various_df = [
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ).astype({"col1": "category", "col2": "category"}),
-        {
-            "col1": As_Categorical(categories=None, order=None),
-            "col2": As_Categorical(order=None, categories=None),
-        },
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ).astype({"col1": "category", "col2": "category"}),
-        {"col1": (None, None), "col2": (None, None)},
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ).astype(
-            {
-                "col3": "category",
-                "col1": CategoricalDtype(categories=[1, 2, 3], ordered=True),
-            }
-        ),
-        {"col3": (None, None), "col1": (None, "sort")},
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, np.nan],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2.0, 1, 3, np.nan],
-                "col2": ["a", "b", "c", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ).astype(
-            {
-                "col3": "category",
-                "col1": CategoricalDtype(categories=[1, 2.0, 3], ordered=True),
-            }
-        ),
-        {"col3": (None, None), "col1": (None, "sort")},
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, np.nan],
-                "col2": ["b", "c", "a", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2.0, 1, 3, np.nan],
-                "col2": ["b", "c", "a", "d"],
-                "col3": pd.date_range("1/1/2020", periods=4),
-            }
-        ).astype(
-            {
-                "col2": CategoricalDtype(
-                    categories=["b", "c", "a", "d"], ordered=True
-                ),
-                "col1": CategoricalDtype(categories=[2.0, 1, 3], ordered=True),
-            }
-        ),
-        {"col2": (None, "appearance"), "col1": (None, "appearance")},
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ).astype(
-            {
-                "col1": CategoricalDtype(
-                    categories=[2.0, 1.0, 3.0], ordered=True
-                ),
-                "col2": CategoricalDtype(
-                    categories=["a", "b", "c", "d"], ordered=False
-                ),
-            }
-        ),
-        {
-            "col2": (["a", "b", "c", "d"], None),
-            "col1": ([2.0, 1.0, 3.0], "appearance"),
-        },
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ).astype(
-            {
-                "col1": CategoricalDtype(
-                    categories=[2.0, 1.0, 3.0], ordered=True
-                ),
-                "col2": CategoricalDtype(
-                    categories=["a", "b", "c", "d"], ordered=True
-                ),
-            }
-        ),
-        {
-            "col2": (["a", "b", "c", "d"], "sort"),
-            "col1": ([2.0, 1.0, 3.0], "appearance"),
-        },
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ),
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ).astype(
-            {
-                "col1": CategoricalDtype(
-                    categories=[1.0, 2.0, 3.0], ordered=True
-                ),
-                "col2": CategoricalDtype(
-                    categories=["a", "b", "c", "d"], ordered=True
-                ),
-                "col3": CategoricalDtype(
-                    categories=pd.to_datetime(
-                        [
-                            "2020-01-01",
-                            "2020-01-02",
-                            "2020-01-03",
-                            "2020-01-04",
-                            "2020-01-05",
-                        ]
-                    ),
-                    ordered=True,
-                ),
-            }
-        ),
-        {
-            "col3": (None, "sort"),
-            "col1": (None, "sort"),
-            "col2": (None, "appearance"),
-        },
-    ),
-]
-
-
-@pytest.mark.parametrize("df_in, df_out, kwargs", test_various_df)
-def test_various(df_in, df_out, kwargs):
-    """
-    Test output for various combinations.
-    """
-    # result = df_in.encode_categorical(
-    #     **kwargs,
-    # )
-    # assert_frame_equal(result, df_out)
-    result = df_in.encode_categorical(
-        **kwargs,
+    result = df.encode_categorical(names=(None, None))
+
+    expected = df.astype({"names": "category"})
+    assert expected["names"].equals(result["names"])
+
+
+@pytest.mark.functions
+@given(df=categoricaldf_strategy())
+def test_all_cat_None_1(df):
+    """
+    Test output where `categories` is None.
+    """
+    result = df.encode_categorical(names=(None, "sort"))
+    categories = pd.CategoricalDtype(
+        categories=df.names.factorize(sort=True)[-1], ordered=True
     )
-    # directly comparing columns is safe -
-    # if the columns have differing categories
-    # (especially for ordered True) it will fail.
-    # if both categories are unordered, then the
-    # order is not considered.
-    # comparing with assert_frame_equal fails
-    # for unordered categoricals, as internally
-    # the order of the categories are compared
-    # which is irrelevant for unordered categoricals
-    for key, _ in kwargs.items():
-        assert df_out[key].equals(result[key])
-
-
-df_warnings = [
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ),
-        {
-            "col1": ([4, 5, 6], "appearance"),
-            "col2": (["a", "b", "c", "d"], "sort"),
-        },
-    ),
-    (
-        pd.DataFrame(
-            {
-                "col1": [2, 1, 3, 1, np.nan],
-                "col2": ["a", "b", "c", "d", "a"],
-                "col3": pd.date_range("1/1/2020", periods=5),
-            }
-        ),
-        {
-            "col1": ([2.0, 1.0, 3.0], "appearance"),
-            "col2": (["a", "b", "c"], "sort"),
-        },
-    ),
-]
-
-
-@pytest.mark.parametrize("df_in,kwargs", df_warnings)
-def test_warnings(df_in, kwargs):
+    expected = df.astype({"names": categories})
+    assert expected["names"].equals(result["names"])
+
+
+@pytest.mark.functions
+@given(df=categoricaldf_strategy())
+def test_all_cat_None_2(df):
     """
-    Test that warnings are raised if `categories` is provided, and
-    the categories do not match the unique values in the column, or
-    some values in the column are missing in `categories`.
+    Test output where `categories` is None.
     """
-    with pytest.warns(UserWarning):
-        df_in.encode_categorical(**kwargs)
+    result = df.encode_categorical(names=(None, "appearance"))
+    categories = pd.CategoricalDtype(
+        categories=df.names.factorize(sort=False)[-1], ordered=True
+    )
+    expected = df.astype({"names": categories})
+    assert expected["names"].equals(result["names"])
+
+
+@pytest.mark.functions
+@given(df=categoricaldf_strategy())
+def test_all_cat_not_None(df):
+    """
+    Test output where `categories` is  not None.
+    """
+    result = df.encode_categorical(numbers=(np.array([3, 1, 2]), "appearance"))
+    categories = pd.CategoricalDtype(categories=[3, 1, 2], ordered=True)
+    expected = df.astype({"numbers": categories})
+    assert expected["numbers"].equals(result["numbers"])