Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Naming conventions
I wanted to discuss naming conventions for the various functions and arguments for consistency. `expand_column` has a parameter `column` whereas `add_column` had `col_name`.

Also, is `_column` necessary in each function? Would it be ok to just have an `add()` or `transform()` method? In general I'm more on the side of more verbose function names, but just wanted to throw the idea out there.

Similarly, when following the format of `limit_column_characters` functions like `change_type` should probably be named `change_column_type`.

I'm not married to any of this (except the function arguments, those should be consistent) but wanted to get peoples' ideas.


Discussion:
Thanks for raising this issue, @szuckerman! I’m also in favor of consistent naming schemes. 

Breaking this down a bit:

### Columns

I vote in favor of “col_name”. It is more explicit than “column”.

### Verbose vs Concise Functions

Zen of Python states that explicit is better than implicit, with no qualifiers afterwards. For that reason, I vote in favor of verbose names. 
Labelling this a "good first issue" for newcomers, as it basically involves a find-and-replace.
I'd be interested in taking on this issue if no one has done so yet. Would this need some support to warn users that names have changed, and that previous names will no longer work in a future version?
@HectorM14 yes, that would be great! Please do go ahead.

> Would this need some support to warn users that names have changed, and that previous names will no longer work in a future version?

Yes agreed. Since we're at version 0.16.x right now, the responsible way to evolve the API is to keep it through the next two minor versions, and the deprecate it at the next major or 3rd minor version. Perhaps raising a warning if and only if certain functions are called/keyword arguments are used.

Personally, I would prefer one function at a time, which makes it easier to review, but at the same time, I think we should trust your judgment - especially if there's things that are logically groupable, feel free to PR in them together. Looking forward to what you've got; also, don't hesitate to ping in here!
Great, I'll use my best judgement for PR scope. Is there a standard way to deprecate/discourage old argument names? I was thinking of using decorators to keep the method APIs clear about their usage going forward.
Thanks @HectorM14!

> Is there a standard way to deprecate/discourage old argument names? 

To the best of my knowledge, the standard way to do this should be to insert a conditional checking to see if that old argument name was called or not, and then inside the conditional:

1. Raise a warning.
2. Assign the value of the old argument to a new variable that has the same name as the new argument.

For example:

```python
from warning import warning

def deprecation_warning(old_arg, new_arg):
    """generic warning function."""
    warning(f"{argname.__name__} is deprecated, and will be replaced with {new_arg.__name__}")

def func(df, old_arg1, arg2, arg3, new_arg):
    """docstring here"""
    if old_arg1 is not None:
        deprecation_warning(old_arg1, new_arg)
        new_arg = old_arg1
    # continue code
```

> I was thinking of using decorators to keep the method APIs clear about their usage going forward.

That might actually be better than what I had up there! I'd like to see the pattern in the first PR, it'd be educational for myself.
[Relevant PyCon talk on refactoring and adding `warnings` for legacy code](https://youtu.be/D_6ybDcU5gc?t=787). The actual code for warnings comes in around 17:00.
Oh cool, thanks for sharing that, @szuckerman! 
Following advice in that talk, we could update `expand_column()` as follows:

```python
def expand_column(df, sep, column_name=None, concat=True, **kwargs):
    """
    Expand a categorical column with multiple labels into dummy-coded columns.

    Super sugary syntax that wraps :py:meth:`pandas.Series.str.get_dummies`.

    Functional usage example:

    .. code-block:: python

        df = expand_column(df, column_name='col_name',
                           sep=', ')  # note space in sep

    Method chaining example:

    .. code-block:: python

        import pandas as pd
        import janitor
        df = pd.DataFrame(...).expand_column(df, column_name='col_name', sep=', ')

    :param df: A pandas DataFrame.
    :param column_name: A `str` indicating which column to expand.
    :param sep: The delimiter. Example delimiters include `|`, `, `, `,` etc.
    :param bool concat: Whether to return the expanded column concatenated to
        the original dataframe (`concat=True`), or to return it standalone
        (`concat=False`).
    """
    if kwargs and column_name is not None:
        raise TypeError('Mixed usage of column and column_name')
    if column_name is None:
        warnings.warn('Use of column is deprecated. You should use column_name.')
        column_name = kwargs['column']
    expanded = df[column_name].str.get_dummies(sep=sep)
    if concat:
        df = df.join(expanded)
        return df
    else:
        return expanded
```
One notable consequence of this, is having to set column_name as None, which requires us to move its position in the list of arguments (during the deprecation period). 

The way it's written also assumes that additional arguments received would only relate to deprecated usage, and include `column`.

Another issue is to decide what is the proper variable form of "column". Even when the variable names are explicit, `*_col_*` and `*_column_*` are both used throughout the code.
@HectorM14 those are great points you've raised :smile:. 

> Another issue is to decide what is the proper variable form of "column". Even when the variable names are explicit, `*_col_*` and `*_column_*` are both used throughout the code.

I am guilty of being inconsistent here, as I sometimes use `col` and sometimes use `column` and sometimes use `colname`. Yes, being consistent is a great thing. I would personally vote for `column_name`, in adherence to "explicit is better than implicit", but if there are better reasons (brevity, perhaps?) for other conventions, not a problem! I have no strong opinions on this one, as long as we can make it towards consistency in the end!
I like column_name too. I can aim for being explicit with these names unless they become too unwieldy. I'll start making individual PRs for each updated function. Updating variables relating columns is easy, but if anyone finds other variables that should be updated, they can list them here.
Updating names is becoming a bit more complicated. For example we currently have:
```python
def coalesce(df, columns, new_column_name):
```
Following the current style we've been using, we would update this to:
```python
def coalesce(df, new_column_name, columns=None, **kwargs):
```
But quick, intuitive usage of this function from `example.py` is:
```python
df = (
    df
    .coalesce(["certification", "certification_1"], "certification")
```
With the proposed changes, this would become (due to rearranging of argument order):
```python
df = (
    df
    .coalesce(column_names=["certification", "certification_1"], new_column_name="certification")
)
```
Which is excessively more complicated. So our fix will have to be more complex/clever to keep it clean for the user.
@HectorM14 I wouldn't worry too much about the kwargs. In Python, explicit is better than implicit, generally speaking. I find myself writing out kwarg names even though it is sometimes easier to not do so, because I recognize that it makes the code more readable, actually. I would encourage you to keep going with the PRs :smile:. 
Thanks @ericmjl , explicit is good!
I actually came across this example on [SO](https://stackoverflow.com/questions/49802412/how-to-implement-deprecation-in-python-with-argument-alias):

```python
import functools
import warnings

def _deprecated_alias(**aliases):
    def deco(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            _rename_kwargs(f.__name__, kwargs, aliases)
            return f(*args, **kwargs)
        return wrapper
    return deco

def _rename_kwargs(func_name, kwargs, aliases):
    for alias, new in aliases.items():
        if alias in kwargs:
            if new in kwargs:
                raise TypeError('{} received both {} and {}'.format(
                    func_name, alias, new))
            warnings.warn('{} is deprecated; use {}'.format(alias, new),
                          DeprecationWarning)
            kwargs[new] = kwargs.pop(alias)
```
using this as a decorator we would be able to do:
```python
@_deprecated_alias(a='alpha', b='beta')
def simple_sum(alpha, beta):
    return alpha + beta

print(simple_sum(alpha=6, beta=9))
print(simple_sum(a=6, b=9))
```
With this method, updating the argument names would be much faster, and when the deprecation period has expired, we only need to delete one/two lines instead of 6+ lines currently. If we used this approach, I would make the above functions more human-readable.

What do you think?
This is a really great find! I'm all for reducing the maintenance burden :smile:. 

Perhaps this function can be inserted into `utils.py`? Be sure to acknowledge the source by linking to SO!

Also, does this mean you'd redo the name changes already PR'd? I don't want to put too much pressure on you, but if you'd do so, that'd be superb for maintenance purposes!
Cool! `utils.py` sounds like a good place. 

I'll link to the SO post in the `_deprecated_alias` docstring. 
I'll start with a PR that includes these new functions, and include some tests to make sure it works/fails/warns as expected. After that, I'm fine updating the previously changed functions and moving forward. I actually was considering revisiting those functions anyway after learning that there was an actual `DeprecationWarning` subclass.
I have been merging from `upstream` before committing, should I be rebasing instead?
Rebasing works as well! It's a suggestion from @zbarry, which, frankly speaking, I rarely do, because it's just easier to remember `git fetch upstream; git merge upstream/dev`. :smile:

That said, good practices are good to use, so I'm going to try moving to rebasing now.

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -1201,9 +1201,10 @@ def filter_column_isin(
 
 
 @pf.register_dataframe_method
-def remove_columns(df: pd.DataFrame, columns: List):
+@deprecated_alias(columns="column_names")
+def remove_columns(df: pd.DataFrame, column_names: List):
     """
-    Removes the set of columns specified in cols.
+    Removes the set of columns specified in `column_names`.
 
     Intended to be the method-chaining alternative to `del df[col]`.
 
@@ -1211,17 +1212,18 @@ def remove_columns(df: pd.DataFrame, columns: List):
 
     .. code-block:: python
 
-        df = pd.DataFrame(...).remove_columns(cols=['col1', ['col2']])
+        df = pd.DataFrame(...).remove_columns(column_names=['col1', ['col2']])
 
     :param df: A pandas DataFrame
-    :param columns: The columns to remove.
+    :param column_names: The columns to remove.
     :returns: A pandas DataFrame.
     """
-    return df.drop(columns=columns)
+    return df.drop(columns=column_names)
 
 
 @pf.register_dataframe_method
-def change_type(df, column: str, dtype, ignore_exception=False):
+@deprecated_alias(column="column_name")
+def change_type(df, column_name: str, dtype, ignore_exception=False):
     """
     Changes the type of a column.
 
@@ -1241,16 +1243,16 @@ def change_type(df, column: str, dtype, ignore_exception=False):
         df = pd.DataFrame(...).change_type('col1', str)
 
     :param df: A pandas dataframe.
-    :param column: A column in the dataframe.
+    :param column_name: A column in the dataframe.
     :param dtype: The datatype to convert to. Should be one of the standard
         Python types, or a numpy datatype.
     :param ignore_exception: one of {False, "fillna", "keep_values"}.
     :returns: A pandas DataFrame with changed column types.
     """
     if not ignore_exception:
-        df[column] = df[column].astype(dtype)
+        df[column_name] = df[column_name].astype(dtype)
     elif ignore_exception == "keep_values":
-        df[column] = df[column].astype(dtype, errors="ignore")
+        df[column_name] = df[column_name].astype(dtype, errors="ignore")
     elif ignore_exception == "fillna":
         # returns None when conversion
         def convert(x, dtype):
@@ -1259,27 +1261,28 @@ def convert(x, dtype):
             except ValueError:
                 return None
 
-        df[column] = df[column].apply(lambda x: convert(x, dtype))
+        df[column_name] = df[column_name].apply(lambda x: convert(x, dtype))
     else:
         raise ValueError("unknown option for ignore_exception")
     return df
 
 
 @pf.register_dataframe_method
-def add_column(df, col_name: str, value, fill_remaining: bool = False):
+@deprecated_alias(col_name="column_name")
+def add_column(df, column_name: str, value, fill_remaining: bool = False):
     """
     Adds a column to the dataframe.
 
     Intended to be the method-chaining alternative to::
 
-        df[col_name] = value
+        df[column_name] = value
 
     Method chaining example adding a column with only a single value:
 
     .. code-block:: python
 
         # This will add a column with only one value.
-        df = pd.DataFrame(...).add_column(col_name="new_column", 2)
+        df = pd.DataFrame(...).add_column(column_name="new_column", 2)
 
     Method chaining example adding a column with more than one value:
 
@@ -1287,10 +1290,10 @@ def add_column(df, col_name: str, value, fill_remaining: bool = False):
 
         # This will add a column with an iterable of values.
         vals = [1, 2, 5, ..., 3, 4]  # of same length as the dataframe.
-        df = pd.DataFrame(...).add_column(col_name="new_column", vals)
+        df = pd.DataFrame(...).add_column(column_name="new_column", vals)
 
     :param df: A pandas dataframe.
-    :param col_name: Name of the new column. Should be a string, in order
+    :param column_name: Name of the new column. Should be a string, in order
         for the column name to be compatible with the Feather binary
         format (this is a useful thing to have).
     :param value: Either a single value, or a list/tuple of values.
@@ -1380,11 +1383,11 @@ def add_column(df, col_name: str, value, fill_remaining: bool = False):
 
     """
 
-    check("col_name", col_name, [str])
+    check("column_name", column_name, [str])
 
-    if col_name in df.columns:
+    if column_name in df.columns:
         raise ValueError(
-            f"Attempted to add column that already exists: " f"{col_name}."
+            f"Attempted to add column that already exists: " f"{column_name}."
         )
 
     nrows = df.shape[0]
@@ -1422,9 +1425,9 @@ def add_column(df, col_name: str, value, fill_remaining: bool = False):
 
         fill_values = list(value) * times_to_loop
 
-        df[col_name] = fill_values[:nrows]
+        df[column_name] = fill_values[:nrows]
     else:
-        df[col_name] = value
+        df[column_name] = value
 
     return df
 
@@ -1957,8 +1960,9 @@ def transform_columns(
 
 
 @pf.register_dataframe_method
+@deprecated_alias(col_name="column_name")
 def min_max_scale(
-    df, old_min=None, old_max=None, col_name=None, new_min=0, new_max=1
+    df, old_min=None, old_max=None, column_name=None, new_min=0, new_max=1
 ):
     """
     Scales data to between a minimum and maximum value.
@@ -1978,7 +1982,7 @@ def min_max_scale(
 
     .. code-block:: python
 
-        df = pd.DataFrame(...).min_max_scale(col_name="a")
+        df = pd.DataFrame(...).min_max_scale(column_name="a")
 
     Setting custom minimum and maximum:
 
@@ -1987,7 +1991,7 @@ def min_max_scale(
         df = (
             pd.DataFrame(...)
             .min_max_scale(
-                col_name="a",
+                column_name="a",
                 new_min=2,
                 new_max=10
             )
@@ -2019,7 +2023,7 @@ def min_max_scale(
         maximum values of the data to be transformed.
     :param new_min, new_max (optional): The minimum and maximum values of the
         data after it has been scaled.
-    :param col_name (optional): The column on which to perform scaling.
+    :param column_name (optional): The column on which to perform scaling.
     :returns: A pandas DataFrame with scaled data.
     """
     if (
@@ -2034,14 +2038,14 @@ def min_max_scale(
 
     new_range = new_max - new_min
 
-    if col_name:
+    if column_name:
         if old_min is None:
-            old_min = df[col_name].min()
+            old_min = df[column_name].min()
         if old_max is None:
-            old_max = df[col_name].max()
+            old_max = df[column_name].max()
         old_range = old_max - old_min
-        df[col_name] = (
-            df[col_name] - old_min
+        df[column_name] = (
+            df[column_name] - old_min
         ) * new_range / old_range + new_min
     else:
         if old_min is None:


Test patch:
diff --git a/tests/functions/test_add_column.py b/tests/functions/test_add_column.py
--- a/tests/functions/test_add_column.py
+++ b/tests/functions/test_add_column.py
@@ -12,7 +12,7 @@
 def test_add_column_add_integer(df):
     # col_name wasn't a string
     with pytest.raises(TypeError):
-        df.add_column(col_name=42, value=42)
+        df.add_column(column_name=42, value=42)
 
 
 @pytest.mark.functions
diff --git a/tests/functions/test_change_type.py b/tests/functions/test_change_type.py
--- a/tests/functions/test_change_type.py
+++ b/tests/functions/test_change_type.py
@@ -5,7 +5,7 @@
 
 @pytest.mark.functions
 def test_change_type(dataframe):
-    df = dataframe.change_type(column="a", dtype=float)
+    df = dataframe.change_type(column_name="a", dtype=float)
     assert df["a"].dtype == float
 
 
@@ -13,7 +13,7 @@ def test_change_type(dataframe):
 def test_change_type_keep_values():
     df = pd.DataFrame(["a", 1, True], columns=["col1"])
     df = df.change_type(
-        column="col1", dtype=float, ignore_exception="keep_values"
+        column_name="col1", dtype=float, ignore_exception="keep_values"
     )
     assert df.equals(pd.DataFrame(["a", 1, True], columns=["col1"]))
 
@@ -21,7 +21,9 @@ def test_change_type_keep_values():
 @pytest.mark.functions
 def test_change_type_fillna():
     df = pd.DataFrame(["a", 1, True], columns=["col1"])
-    df = df.change_type(column="col1", dtype=float, ignore_exception="fillna")
+    df = df.change_type(
+        column_name="col1", dtype=float, ignore_exception="fillna"
+    )
     assert np.isnan(df.col1[0])
 
 
@@ -30,7 +32,7 @@ def test_change_type_unknown_option():
     df = pd.DataFrame(["a", 1, True], columns=["col1"])
     with pytest.raises(Exception):
         df = df.change_type(
-            column="col1", dtype=float, ignore_exception="blabla"
+            column_name="col1", dtype=float, ignore_exception="blabla"
         )
 
 
@@ -38,4 +40,6 @@ def test_change_type_unknown_option():
 def test_change_type_raise_exception():
     df = pd.DataFrame(["a", 1, True], columns=["col1"])
     with pytest.raises(Exception):
-        df = df.change_type(column="col1", dtype=float, ignore_exception=False)
+        df = df.change_type(
+            column_name="col1", dtype=float, ignore_exception=False
+        )
diff --git a/tests/functions/test_min_max_scale.py b/tests/functions/test_min_max_scale.py
--- a/tests/functions/test_min_max_scale.py
+++ b/tests/functions/test_min_max_scale.py
@@ -3,14 +3,14 @@
 
 @pytest.mark.functions
 def test_min_max_scale(dataframe):
-    df = dataframe.min_max_scale(col_name="a")
+    df = dataframe.min_max_scale(column_name="a")
     assert df["a"].min() == 0
     assert df["a"].max() == 1
 
 
 @pytest.mark.functions
 def test_min_max_scale_custom_new_min_max(dataframe):
-    df = dataframe.min_max_scale(col_name="a", new_min=1, new_max=2)
+    df = dataframe.min_max_scale(column_name="a", new_min=1, new_max=2)
     assert df["a"].min() == 1
     assert df["a"].max() == 2
 
@@ -18,10 +18,10 @@ def test_min_max_scale_custom_new_min_max(dataframe):
 @pytest.mark.functions
 def test_min_max_old_min_max_errors(dataframe):
     with pytest.raises(ValueError):
-        dataframe.min_max_scale(col_name="a", old_min=10, old_max=0)
+        dataframe.min_max_scale(column_name="a", old_min=10, old_max=0)
 
 
 @pytest.mark.functions
 def test_min_max_new_min_max_errors(dataframe):
     with pytest.raises(ValueError):
-        dataframe.min_max_scale(col_name="a", new_min=10, new_max=0)
+        dataframe.min_max_scale(column_name="a", new_min=10, new_max=0)
diff --git a/tests/functions/test_remove_columns.py b/tests/functions/test_remove_columns.py
--- a/tests/functions/test_remove_columns.py
+++ b/tests/functions/test_remove_columns.py
@@ -3,26 +3,26 @@
 
 @pytest.mark.functions
 def test_remove_columns_one_col(dataframe):
-    df = dataframe.remove_columns(columns=["a"])
+    df = dataframe.remove_columns(column_names=["a"])
     assert len(df.columns) == 4
 
 
 @pytest.mark.functions
 def test_remove_columns_mult_cols(dataframe):
-    df = dataframe.remove_columns(columns=["a", "Bell__Chart"])
+    df = dataframe.remove_columns(column_names=["a", "Bell__Chart"])
     assert len(df.columns) == 3
 
 
 @pytest.mark.functions
 def test_remove_columns_no_cols(dataframe):
-    df = dataframe.remove_columns(columns=[])
+    df = dataframe.remove_columns(column_names=[])
     assert len(df.columns) == 5
 
 
 @pytest.mark.functions
 def test_remove_columns_all_cols(dataframe):
     df = dataframe.remove_columns(
-        columns=[
+        column_names=[
             "a",
             "Bell__Chart",
             "decorated-elephant",
@@ -36,7 +36,7 @@ def test_remove_columns_all_cols(dataframe):
 @pytest.mark.functions
 def test_remove_columns_strange_cols(dataframe):
     df = dataframe.remove_columns(
-        columns=[
+        column_names=[
             "a",
             ["Bell__Chart", "decorated-elephant", "animals@#$%^", "cities"],
         ]
@@ -52,7 +52,7 @@ def test_remove_columns_strange_cols(multilevel_dataframe):
 
     # If 2 columns (2 tuples = 4 codes) are removed
     df = multilevel_dataframe.remove_columns(
-        columns=[("bar", "one"), ("baz", "two")]
+        column_names=[("bar", "one"), ("baz", "two")]
     )
 
     # Then the total number of codes must be 12 (16-4)