Repository: pyjanitor-devs/pyjanitor

Issue:
[DOC] Clarify original-dataframe mutation behavior in pyjanitor function docstrings
# Brief Description of Fix
Currently, some pyjanitor functions mutate the original dataframe and others return a copy. Solutions are heavily discussed in #79 but no conclusion has been reached. At the moment, it is unclear, without experimentation from the user, which behavior applies in each function. 

In the interim, I propose to explicitly clarify this behavior in each function's docstring so the user has a clear idea regarding the function's mutating behavior. Below is a sample of what this could look like for `.clean_names()`:

"""
Clean column names.
    Takes all column names, converts them to lowercase, then replaces all
    spaces with underscores. <b>Does not mutate original dataframe.</b>
"""

Happy to add this line somewhere else in the docstring if inappropriate here. 

- [Link to documentation page](https://pyjanitor.readthedocs.io/reference/index.html)
- [Link to exact file to be edited](https://github.com/ericmjl/pyjanitor/blob/dev/janitor/functions.py)



Discussion:

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -37,7 +37,7 @@
 
 now = datetime.datetime.now()
 CurrentYear = str(now.year)
-copyright = CurrentYear+", PyJanitor devs"
+copyright = CurrentYear + ", PyJanitor devs"
 author = "Eric J. Ma"
 
 # The short X.Y version
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -39,6 +39,8 @@ def clean_names(
     Takes all column names, converts them to lowercase, then replaces all
     spaces with underscores.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -124,6 +126,8 @@ def remove_empty(df: pd.DataFrame) -> pd.DataFrame:
     This method also resets the index(by default) since it doesn't make sense
     to preserve the index of a completely empty row.
 
+    This method mutates the original DataFrame.
+
     Implementation is inspired from `StackOverflow`_.
 
     .. _StackOverflow: https://stackoverflow.com/questions/38884538/python-pandas-find-all-rows-where-all-values-are-nan  # noqa: E501
@@ -163,6 +167,8 @@ def get_dupes(
     """
     Return all duplicate rows.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -199,6 +205,8 @@ def encode_categorical(
     `category <http://pandas.pydata.org/pandas-docs/stable/user_guide/categorical.html>`  # noqa: E501
     dtype.
 
+    This method mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -248,13 +256,15 @@ def label_encode(
     """
     Convert labels into numerical data.
 
-    This function will create a new column with the string "_enc" appended
+    This method will create a new column with the string "_enc" appended
     after the original column's name. Consider this to be syntactic sugar.
 
-    This function behaves differently from `encode_categorical`. This function
+    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 mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -307,6 +317,8 @@ def get_features_targets(
     """
     Get the features and targets as separate DataFrames/Series.
 
+    This method does not mutate the original DataFrame.
+
     The behaviour is as such:
 
     - ``target_column_names`` is mandatory.
@@ -364,6 +376,8 @@ def rename_column(
     """
     Rename a column in place.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -406,6 +420,8 @@ def reorder_columns(
 
     Validates column_order to ensure columns are all present in DataFrame.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     Given `DataFrame` with column names `col1`, `col2`, `col3`:
@@ -462,6 +478,8 @@ def coalesce(
 
     Coalesces two or more columns of data in order of column names provided.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -508,6 +526,8 @@ def convert_excel_date(df: pd.DataFrame, column_name) -> pd.DataFrame:
     """
     Convert Excel's serial date format into Python datetime format.
 
+    This method mutates the original DataFrame.
+
     Implementation is also from `Stack Overflow`.
 
     .. _Stack Overflow: https://stackoverflow.com/questions/38454403/convert-excel-style-date-with-pandas  # noqa: E501
@@ -546,6 +566,8 @@ def convert_matlab_date(df: pd.DataFrame, column_name) -> pd.DataFrame:
 
     Implementation is also from `Stack Overflow <https://stackoverflow.com/questions/13965740/converting-matlabs-datenum-format-to-python>`.  # noqa: E501
 
+    This method mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -582,6 +604,8 @@ def convert_unix_date(df: pd.DataFrame, column_name) -> pd.DataFrame:
     Note that this ignores local tz and convert all timestamps to naive
     datetime based on UTC!
 
+    This method mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -622,6 +646,8 @@ def fill_empty(
 
     Super sugary syntax that wraps :py:meth:`pandas.DataFrame.fillna`.
 
+    This method mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -669,6 +695,8 @@ def expand_column(
 
     Super sugary syntax that wraps :py:meth:`pandas.Series.str.get_dummies`.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -716,6 +744,8 @@ def concatenate_columns(
 
     Used to quickly generate an index based on a group of columns.
 
+    This method mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -767,6 +797,8 @@ def deconcatenate_column(
 
     Used to quickly split columns out of a single column.
 
+    This method does not mutate the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -817,6 +849,8 @@ def filter_string(
     regex string to be passed into it, thus `search_string` can also be a regex
     pattern.
 
+    This method does not mutate the original DataFrame.
+
     This function allows us to method chain filtering operations:
 
     .. code-block:: python
@@ -875,6 +909,8 @@ def filter_on(
     """
     Return a dataframe filtered on a particular criteria.
 
+    This method does not mutate the original DataFrame.
+
     This is super-sugary syntax that wraps the pandas `.query()` API, enabling
     users to use strings to quickly specify filters for filtering their
     dataframe. The intent is that `filter_on` as a verb better matches the
@@ -946,6 +982,8 @@ def filter_date(
     """
     Filter a date-based column based on certain criteria.
 
+    This method does not mutate the original DataFrame.
+
     Dates may be finicky and this function builds on top of the "magic" from
     the pandas `to_datetime` function that is able to parse dates well.
 
@@ -1163,6 +1201,8 @@ def filter_column_isin(
     """
     Filter a dataframe for values in a column that exist in another iterable.
 
+    This method does not mutate the original DataFrame.
+
     Assumes exact matching; fuzzy matching not implemented.
 
     The below example syntax will filter the DataFrame such that we only get
@@ -1214,6 +1254,8 @@ def remove_columns(
     """
     Remove the set of columns specified in `column_names`.
 
+    This method does not mutate the original DataFrame.
+
     Intended to be the method-chaining alternative to `del df[col]`.
 
     Method chaining example:
@@ -1237,6 +1279,8 @@ def change_type(
     """
     Change the type of a column.
 
+    This method mutates the original DataFrame.
+
     Exceptions that are raised can be ignored. For example, if one has a mixed
     dtype column that has non-integer strings and integers, and you want to
     coerce everything to integers, you can optionally ignore the non-integer
@@ -1285,6 +1329,8 @@ def add_column(
     """
     Add a column to the dataframe.
 
+    This method does not mutate the original DataFrame.
+
     Intended to be the method-chaining alternative to::
 
         df[column_name] = value
@@ -1452,6 +1498,8 @@ def add_columns(
     """
     Add multiple columns to the dataframe.
 
+    This method does not mutate the original DataFrame.
+
     Method to augment `add_column` with ability to add multiple columns in
     one go. This replaces the need for multiple `add_column` calls.
 
@@ -1491,6 +1539,8 @@ def limit_column_characters(
     """
     Truncate column sizes to a specific length.
 
+    This method mutates the original DataFrame.
+
     Method chaining will truncate all columns to a given length and append
     a given separator character with the index of duplicate columns, except
     for the first distinct column name.
@@ -1610,6 +1660,8 @@ def row_to_names(
     """
     Elevates a row to be the column names of a DataFrame.
 
+    This method mutates the original DataFrame.
+
     Contains options to remove the elevated row from the DataFrame along with
     removing the rows above the selected row.
 
@@ -1736,6 +1788,8 @@ def round_to_fraction(
     """
     Round all values in a column to a fraction.
 
+    This method mutates the original DataFrame.
+
     Taken from https://github.com/sfirke/janitor/issues/235.
 
     Also, optionally round to a specified number of digits.
@@ -1862,6 +1916,8 @@ def transform_column(
     """
     Transform the given column in-place using the provided function.
 
+    This method mutates the original DataFrame.
+
     Let's say we wanted to apply a log10 transform a column of data.
 
     Originally one would write code like this:
@@ -1913,6 +1969,8 @@ def transform_columns(
     """
     Transform multiple columns through the same transformation.
 
+    This method mutates the original DataFrame.
+
     Super syntactic sugar!
 
     Basically wraps `transform_column` and calls it repeatedly over all column
@@ -2010,6 +2068,8 @@ def min_max_scale(
     """
     Scales data to between a minimum and maximum value.
 
+    This method mutates the original DataFrame.
+
     If `minimum` and `maximum` are provided, the true min/max of the
     `DataFrame` or column is ignored in the scaling process and replaced with
     these values, instead.
@@ -2105,6 +2165,8 @@ def collapse_levels(df: pd.DataFrame, sep: str = "_") -> pd.DataFrame:
     """
     Flatten multi-level column dataframe to a single level.
 
+    This method mutates the original DataFrame.
+
     Given a `DataFrame` containing multi-level columns, flatten to single-
     level by string-joining the column labels in each level.
 
@@ -2170,6 +2232,8 @@ def reset_index_inplace(df: pd.DataFrame, *args, **kwargs) -> pd.DataFrame:
     """
     Return the dataframe with an inplace resetting of the index.
 
+    This method mutates the original DataFrame.
+
     Compared to non-inplace resetting, this avoids data copying, thus
     providing a potential speedup.
 
@@ -2236,6 +2300,8 @@ def currency_column_to_numeric(
     """
     Convert currency column to numeric.
 
+    This method does not mutate the original DataFrame.
+
     This method allows one to take a column containing currency values,
     inadvertently imported as a string, and cast it as a float. This is
     usually the case when reading CSV files that were modified in Excel.
@@ -2257,7 +2323,7 @@ def currency_column_to_numeric(
         make everything that doesn't coerce to a currency 1.
     :param remove_non_numeric: Will remove rows of a DataFrame that contain
         non-numeric values in the `column_name` column. Defaults to `False`.
-    :returns: A mutated DataFrame.
+    :returns: A pandas DataFrame.
     """
     # TODO: Convert this to a notebook.
     # :Example Setup:
@@ -2456,6 +2522,8 @@ def select_columns(
     """
     Method-chainable selection of columns.
 
+    This method does not mutate the original DataFrame.
+
     Optional ability to invert selection of columns available as well.
 
     Method-chaining example:
@@ -2495,6 +2563,8 @@ def impute(
     """
     Method-chainable imputation of values in a column.
 
+    This method mutates the original DataFrame.
+
     Underneath the hood, this function calls the ``.fillna()`` method available
     to every pandas.Series object.
 
@@ -2583,6 +2653,8 @@ def then(df: pd.DataFrame, func: Callable) -> pd.DataFrame:
     """
     Add an arbitrary function to run in the ``pyjanitor`` method chain.
 
+    This method does not mutate the original DataFrame.
+
     :param df: A pandas dataframe.
     :param func: A function you would like to run in the method chain.
         It should take one parameter and return one parameter, each being the
@@ -2600,6 +2672,8 @@ def dropnotnull(df: pd.DataFrame, column_name) -> pd.DataFrame:
     """
     Drop rows that do not have null values in the given column.
 
+    This method does not mutate the original DataFrame.
+
     Example usage:
 
     .. code-block:: python
@@ -2619,6 +2693,8 @@ def find_replace(df: pd.DataFrame, column_name, mapper: Dict) -> pd.DataFrame:
     """
     Perform a find-and-replace action on a column of data.
 
+    This method mutates the original DataFrame.
+
     For example, let's say we have a column for which we want to replace all
     of the values 'a' with 1, 'b' with 2, 'c' with 3. We would use the
     following function call:
@@ -2654,6 +2730,8 @@ def update_where(
     """
     Add multiple conditions to update a column in the dataframe.
 
+    This method mutates the original DataFrame.
+
     Example usage:
 
     .. code-block:: python
@@ -2692,6 +2770,8 @@ def to_datetime(df: pd.DataFrame, column_name, **kwargs) -> pd.DataFrame:
     """
     Method-chainable to_datetime.
 
+    This method mutates the original DataFrame.
+
     Functional usage example:
 
     .. code-block:: python
@@ -2729,6 +2809,8 @@ def groupby_agg(
     """
     Method-chain a groupby and a merge in a single step.
 
+    This method does not mutate the original DataFrame.
+
     Without this function, we would have to break out of method chaining:
 
     .. code-block:: python
@@ -2843,6 +2925,8 @@ def bin_numeric(
     """
     Generate a new column that labels bins for a specified numeric column.
 
+    This method mutates the original DataFrame.
+
     Makes use of pandas cut() function to bin data of one column, generating a
     new column with the results.
 
@@ -2890,6 +2974,8 @@ def drop_duplicate_columns(
     """
     Remove a duplicated column specified by column_name, its index.
 
+    This method does not mutate the original DataFrame.
+
     Column order 0 is to remove the first column,
            order 1 is to remove the second column, and etc
 
@@ -2943,6 +3029,8 @@ def take_first(
     """
     Take the first row within each group specified by `subset`.
 
+    This method does not mutate the original DataFrame.
+
     .. code-block:: python
 
         import pandas as pd
@@ -2974,6 +3062,8 @@ def shuffle(df: pd.DataFrame, random_state=None) -> pd.DataFrame:
     """
     Shuffle the rows of the DataFrame.
 
+    This method does not mutate the original DataFrame.
+
     Super-sugary syntax! Underneath the hood, we use ``df.sample(frac=1)``,
     with the option to set the random state.
 
@@ -2994,6 +3084,8 @@ def join_apply(df, func, new_column_name):
     """
     Join the result of applying a function across dataframe rows.
 
+    This method does not mutate the original DataFrame.
+
     This is a convenience function that allows us to apply arbitrary functions
     that take any combination of information from any of the columns. The only
     requirement is that the function signature takes in a row from the


Test patch: