Repository: ericmjl/pyjanitor (python)

Issue:
Add an `also` method akin to Kotlins `also` 
# Brief Description
I would like to propose a new function `also` that takes a function with a single dataframe for input, runs the function on the chained dataframe, and returns the input dataframe.  

I would love to take a whack at implementing this if you're interested incorporating it into the library.

As I've been trying to adopt chaining as much as possible, I've routinely bumped into scenarios where I need to break the chain for one reason or another. This API would eliminate most reasons for breaking the chain

This function is inspired by Kotlin's `also` function.  If we were to mimic Kotlin's `also`, the signature would look something like

`also: (f: T -> None) -> T` 

It would behave very similar to `pipe` or `then`, but can be used on functions that do not return anything. This can be very useful for any sort of side-effect operations within a long chain, such as periodic logging or debugging.

# Example API

<!-- One of the selling points of pyjanitor is the API. Hence, we guard the API very carefully, and want to
make sure that it is accessible and understandable to many people. Please provide a few examples of what the API
of the new function you're proposing will look like. We have provided an example that you should modify. -->

Below are some examples of how this API would simplify code I've written in the past. 

### print the df while processing.
```python
# instead of defining an extra function
def print_and(df):
    print(df)
    return df

df.groupby(["column1"]).agg(sum).then(print_and).then(my_transform)

# or breaking the chain up
df_tmp = df.groupby(["column1"]).agg(sum)
print(df_tmp)
df_tmp.then(my_transform)

# you could just inline the print statement
df.groupby(["column1"]).agg(sum).also(print).then(my_transform)
```

### Insert a breakpoint while debugging, but keep going when you're done
Similar to above, to add a breakpoint for debugging, you would either have to create a new `pipe` compatible function or break the chain into smaller steps.

Instead, you could chain a breakpoint like so

```python
df.groupby(["column1"]).agg(sum).also(lambda x: breakpoint()).then(my_transform)
```

### Save a midpoint of a processing chain and then keep going

Another possibility would be running IO in the middle of the chain.  Often, a midpoint in the chain is just as important as the end result.  `also` would allow me to save the results and keep going without breaking a chain.

```
df.groupby(["column1"]).agg(sum).also(lambda x: x.to_csv("filename.csv)).then(my_transform)
```

### pipe-dream: Save a midpoint as a variable

I haven't figured out how to get this to work given Python's scoping rules, maybe the new `:=` operator will make this possible. 

This could be really helpful in scenarios where you're running groupbys or other aggregations and want to join those back into an ungrouped frame.

```
df.groupby(["column1"]).agg(sum).also(lambda x: df_mid = x).then(my_transform).assign(original_var = df_mid)
```


Pull request description:
[ENH] Add `also`
<!-- Thank you for your PR!

BEFORE YOU CONTINUE! Please add the appropriate three-letter abbreviation to your title.

- [ENH]: Code contributions and new features.

Also, do not forget to tag the relevant issue here as well.

Finally, as commits come in, don't forget to regularly rebase!
-->

# PR Description

Please describe the changes proposed in the pull request:

- Adds `also` method as discussed in #731 
- Adds tests for `also`
- Adds docstring for `also`

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #731.**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.rst`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.rst` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

## Quick Check

To do a very quick check that everything is correct, follow these steps below:

- [x] Run the command `make check` from pyjanitor's top-level directory. This will automatically run:
    - black formatting
    - flake8 checking
    - running the test suite
    - docs build

Once done, please check off the check-box above.

If `make check` does not work for you, you can execute the commands listed in the Makefile individually.

## Code Changes

<!-- If you have not made code changes, please feel free to delete this section. -->

If you are adding code changes, please ensure the following:

- [x] Ensure that you have added tests.
- [x] Run all tests (`$ pytest .`) locally on your machine.
    - [x] Check to ensure that test coverage covers the lines of code that you have added.
    - [x] Ensure that all tests pass.

## Documentation Changes

<!-- If you have not made documentation changes, please feel free to delete this section. -->

If you are adding documentation changes, please ensure the following:

- [x] Build the docs locally.
- [x] View the docs to check that it renders correctly.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index ca89d46..dc5b4ac 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -92,3 +92,4 @@ Contributors
 - `@VPerrollaz <https://github.com/VPerrollaz>`_ | `contributions <https://github.com/ericmjl/pyjanitor/issues?q=is%3Aclosed+mentions%3AVPerrollaz>`_
 - `@UGuntupalli <https://github.com/UGuntupalli>`_ | `contributions < https://github.com/ericmjl/pyjanitor/issues?q=is%3Aclosed+mentions%3AUGuntupalli >`_
 - `@mphirke <https://github.com/mphirke>`_ | `contributions <https://github.com/ericmjl/pyjanitor/issues?q=is%3Aclosed+mentions%3Amphirke>`_
+- `@sauln <https://github.com/sauln>`_ | `contributions <https://github.com/ericmjl/pyjanitor/issues?q=is%3Aclosed+mentions%3Asauln`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f2bd671..604b0e4 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,10 +1,11 @@
 new version (on deck)
 =====================
 - [ENH] Added function ``sort_timestamps_monotonically`` to timeseries functions @UGuntupalli
-- [ENH] Added the complete function for converting implicit missing values 
+- [ENH] Added the complete function for converting implicit missing values
   to explicit ones. @samukweku
 - [ENH] Further simplification of expand_grid. @samukweku
 - [BUGFIX] Added copy() method to original dataframe, to avoid mutation. Issue #729. @samukweku
+- [ENH] Added `also` method for running functions in chain with no return values.
 
 v0.20.9
 =======
diff --git a/docs/reference/general_functions.rst b/docs/reference/general_functions.rst
index 07f4ab0..0def9ea 100644
--- a/docs/reference/general_functions.rst
+++ b/docs/reference/general_functions.rst
@@ -80,6 +80,7 @@ Other
     :toctree: janitor.functions/
 
     then
+    also
     shuffle
     count_cumulative_unique
     sort_naturally
diff --git a/janitor/functions.py b/janitor/functions.py
index 1b000f7..b7bf2cf 100644
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -2963,6 +2963,43 @@ def then(df: pd.DataFrame, func: Callable) -> pd.DataFrame:
     return df
 
 
+@pf.register_dataframe_method
+def also(df: pd.DataFrame, func: Callable, *args, **kwargs) -> pd.DataFrame:
+    """Add an arbitrary function with no return value to run in the
+    ``pyjanitor`` method chain. This returns the input dataframe instead,
+    not the output of `func`.
+
+    This method does not mutate the original DataFrame.
+
+    Example usage:
+
+    .. code-block:: python
+
+        df = (
+            pd.DataFrame(...)
+            .query(...)
+            .also(lambda df: print(f"DataFrame shape is: {df.shape}"))
+            .transform_column(...)
+            .also(lambda df: df.to_csv("midpoint.csv"))
+            .also(
+                lambda df: print(
+                    f"Column col_name has these values: {set(df['col_name'].unique())}"
+                )
+            )
+            .group_add(...)
+        )
+
+    :param df: A pandas dataframe.
+    :param func: A function you would like to run in the method chain.
+        It should take one DataFrame object as a parameter and have no return.
+        If there is a return, it will be ignored.
+    :param args, kwargs: Optional arguments and keyword arguments for `func`.
+    :returns: The input pandas DataFrame.
+    """  # noqa: E501
+    func(df.copy(), *args, **kwargs)
+    return df
+
+
 @pf.register_dataframe_method
 @deprecated_alias(column="column_name")
 def dropnotnull(df: pd.DataFrame, column_name: Hashable) -> pd.DataFrame:


Test patch:
diff --git a/tests/functions/test_also.py b/tests/functions/test_also.py
new file mode 100644
index 0000000..24c684f
--- /dev/null
+++ b/tests/functions/test_also.py
@@ -0,0 +1,83 @@
+"""Unit tests for `.also()`."""
+from unittest.mock import Mock
+import pytest
+
+
+def remove_first_two_letters_from_col_names(df):
+    """Helper function to mutate dataframe by changing column names."""
+    col_names = df.columns
+    col_names = [name[2:] for name in col_names]
+    df.columns = col_names
+    return df
+
+
+def remove_rows_3_and_4(df):
+    """Helper function to mutate dataframe by removing rows."""
+    df = df.drop(3, axis=0)
+    df = df.drop(4, axis=0)
+    return df
+
+
+def drop_inplace(df):
+    """
+    Helper function to mutate dataframe by dropping a column.
+
+    We usually would not use `inplace=True` in a block,
+    but the intent here is to test that
+    the in-place modification of a dataframe
+    doesn't get passed through in the `.also()` function.
+    Hence, we tell Flake8 to skip checking `PD002` on that line.
+    """
+    df.drop(columns=[df.columns[0]], inplace=True)  # noqa: PD002
+
+
+@pytest.mark.functions
+def test_also_column_manipulation_no_change(dataframe):
+    """Test that changed dataframe inside `.also()` doesn't get returned."""
+    cols = tuple(dataframe.columns)
+    df = dataframe.also(remove_first_two_letters_from_col_names)
+    assert dataframe is df
+    assert cols == tuple(df.columns)
+
+
+@pytest.mark.functions
+def test_also_remove_rows_no_change(dataframe):
+    """Test that changed dataframe inside `.also()` doesn't get returned."""
+    df = dataframe.also(remove_rows_3_and_4)
+    rows = tuple(df.index)
+    assert rows == (0, 1, 2, 3, 4, 5, 6, 7, 8)
+
+
+@pytest.mark.functions
+def test_also_runs_function(dataframe):
+    """Test that `.also()` executes the function."""
+    method = Mock(return_value=None)
+    df = dataframe.also(method)
+    assert id(df) == id(dataframe)
+    assert method.call_count == 1
+
+
+@pytest.mark.functions
+def test_also_args(dataframe):
+    """Test that the args are passed through to the function."""
+    method = Mock(return_value=None)
+    _ = dataframe.also(method, 5)
+
+    assert method.call_args[0][1] == 5
+
+
+@pytest.mark.functions
+def test_also_kwargs(dataframe):
+    """Test that the kwargs are passed through to the function."""
+    method = Mock(return_value=None)
+    _ = dataframe.also(method, n=5)
+
+    assert method.call_args[1] == {"n": 5}
+
+
+@pytest.mark.functions
+def test_also_drop_inplace(dataframe):
+    """Test that in-place modification of dataframe does not pass through."""
+    cols = tuple(dataframe.columns)
+    df = dataframe.also(drop_inplace)
+    assert tuple(df.columns) == cols