Repository: pyjanitor-devs/pyjanitor

Issue:
Add "natural" sorting to pyjanitor
# Brief Description

I would like to propose adding natural sorting to pyjanitor, as implemented in [natsort](https://github.com/SethMMorton/natsort).

To be clear, this would not be a reimplementation, but rather a wrapping of the functions available there.

# Example API

```python
df.natural_sort(column_names=['col1'], **natsort_kwargs)
```

# Skeleton Implementation

Still thinking about this, but it probably will need to involve indexing in the process.


Discussion:

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -22,6 +22,7 @@
 import numpy as np
 import pandas as pd
 import pandas_flavor as pf
+from natsort import index_natsorted, natsorted
 from pandas.api.types import union_categoricals
 from pandas.errors import OutOfBoundsDatetime
 from scipy.stats import mode
@@ -147,13 +148,13 @@ def move(
 
     Does not apply to multilevel dataframes.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = move(df, source=3, target=15, position='after', axis=0)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -240,13 +241,13 @@ def clean_names(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = clean_names(df)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -380,13 +381,13 @@ def remove_empty(df: pd.DataFrame) -> pd.DataFrame:
 
     .. _StackOverflow: https://stackoverflow.com/questions/38884538/python-pandas-find-all-rows-where-all-values-are-nan
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = remove_empty(df)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -417,14 +418,14 @@ def get_dupes(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = pd.DataFrame(...)
         df = get_dupes(df)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -454,14 +455,14 @@ def encode_categorical(
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         categorical_cols = ['col1', 'col2', 'col4']
         df = encode_categorical(df, columns=categorical_cols)  # one way
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -510,13 +511,13 @@ def label_encode(
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = label_encode(df, column_names="my_categorical_column")  # one way
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -557,13 +558,13 @@ def rename_column(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = rename_column(df, "old_column_name", "new_column_name")
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -590,13 +591,13 @@ def rename_columns(df: pd.DataFrame, new_column_names: Dict) -> pd.DataFrame:
     """
     Rename columns in place.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = rename_columns(df, {"old_column_name": "new_column_name"})
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -630,7 +631,7 @@ def reorder_columns(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     Given `DataFrame` with column names `col1`, `col2`, `col3`:
 
@@ -638,7 +639,7 @@ def reorder_columns(
 
         df = reorder_columns(df, ['col2', 'col3'])
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -689,13 +690,13 @@ def coalesce(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = coalesce(df, columns=['col1', 'col2'], 'col3')
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -745,13 +746,13 @@ def convert_excel_date(
 
     .. _Stack Overflow: https://stackoverflow.com/questions/38454403/convert-excel-style-date-with-pandas
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = convert_excel_date(df, column_name='date')
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -785,13 +786,13 @@ def convert_matlab_date(
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = convert_matlab_date(df, column_name='date')
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -823,13 +824,13 @@ def convert_unix_date(df: pd.DataFrame, column_name: Hashable) -> pd.DataFrame:
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = convert_unix_date(df, column_name='date')
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -861,13 +862,13 @@ def fill_empty(
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = fill_empty(df, column_names=['col1', 'col2'], value=0)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -910,7 +911,7 @@ def expand_column(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -918,7 +919,7 @@ def expand_column(
                            column_name='col_name',
                            sep=', ')  # note space in sep
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -958,7 +959,7 @@ def concatenate_columns(
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -967,7 +968,7 @@ def concatenate_columns(
                                  new_column_name='id',
                                  sep='-')
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -1051,7 +1052,7 @@ def deconcatenate_column(
 
     This method does not mutate the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -1060,7 +1061,7 @@ def deconcatenate_column(
                 sep='-', preserve_position=True
         )
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -1168,7 +1169,7 @@ def filter_string(
     As can be seen here, the API design allows for a more seamless flow in
     expressing the filtering operations.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -1177,7 +1178,7 @@ def filter_string(
                            search_string='pattern',
                            complement=False)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -1234,7 +1235,7 @@ def filter_on(
     As with the `filter_string` function, a more seamless flow can be expressed
     in the code.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -1242,7 +1243,7 @@ def filter_on(
                        'score < 50',
                        complement=False)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -1545,7 +1546,7 @@ def remove_columns(
 
     Intended to be the method-chaining alternative to `del df[col]`.
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -1580,7 +1581,7 @@ def change_type(
 
         df[col] = df[col].astype(dtype)
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -1628,14 +1629,14 @@ def add_column(
 
         df[column_name] = value
 
-    Method chaining example adding a column with only a single value:
+    Method chaining syntax 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(column_name="new_column", 2)
 
-    Method chaining example adding a column with more than one value:
+    Method chaining syntax adding a column with more than one value:
 
     .. code-block:: python
 
@@ -2372,7 +2373,7 @@ def min_max_scale(
     If a particular column name is specified, then only that column of data
     are scaled. Otherwise, the entire dataframe is scaled.
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -2470,7 +2471,7 @@ def collapse_levels(df: pd.DataFrame, sep: str = "_") -> pd.DataFrame:
     this through a simple string-joining of all the names across different
     levels in a single column.
 
-    Method chaining example given two value columns `['max_speed', 'type']`:
+    Method chaining syntax given two value columns `['max_speed', 'type']`:
 
     .. code-block:: python
 
@@ -3169,13 +3170,13 @@ def to_datetime(
 
     This method mutates the original DataFrame.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
         df = to_datetime(df, 'col1', format='%Y%m%d')
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -3365,7 +3366,7 @@ def drop_duplicate_columns(
     The corresponding tidyverse R's library is:
     `select(-<column_name>_<nth_index + 1>)`
 
-    Method chaining example:
+    Method chaining syntax:
 
     .. code-block:: python
 
@@ -3601,7 +3602,7 @@ def count_cumulative_unique(
     """
     Generates a running total of cumulative unique values in a given column.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -3622,7 +3623,7 @@ def count_cumulative_unique(
     .. code-block:: python
 
         import pandas as pd
-        import janitor.functions
+        import janitor
 
         df = pd.DataFrame(...)
 
@@ -3682,7 +3683,7 @@ def toset(series: pd.Series) -> Set:
     (for str, int, float) or a pandas scalar
     (for Timestamp/Timedelta/Interval/Period)
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -3697,7 +3698,7 @@ def toset(series: pd.Series) -> Set:
     .. code-block:: python
 
         import pandas as pd
-        import janitor.functions
+        import janitor
 
         series = pd.Series(...)
         s = series.toset()
@@ -3721,7 +3722,7 @@ def jitter(
     """
     Adds Gaussian noise (jitter) to the values of a column.
 
-    Functional usage example:
+    Functional usage syntax:
 
     .. code-block:: python
 
@@ -3744,7 +3745,7 @@ def jitter(
     .. code-block:: python
 
         import pandas as pd
-        import janitor.functions
+        import janitor
 
         df = pd.DataFrame(...)
 
@@ -3815,3 +3816,77 @@ def jitter(
     df[dest_column_name] = result
 
     return df
+
+
+@pf.register_dataframe_method
+def sort_naturally(
+    df: pd.DataFrame, column_name: str, **natsorted_kwargs
+) -> pd.DataFrame:
+    """
+    Sort an DataFrame by a column using "natural" sorting.
+
+    Natural sorting is distinct from
+    the default lexiographical sorting provided by ``pandas``.
+    For example, given the following list of items:
+
+        ["A1", "A11", "A3", "A2", "A10"]
+
+    lexicographical sorting would give us:
+
+
+        ["A1", "A10", "A11", "A2", "A3"]
+
+    By contrast, "natural" sorting would give us:
+
+        ["A1", "A2", "A3", "A10", "A11"]
+
+    This function thus provides "natural" sorting
+    on a single column of a dataframe.
+
+    To accomplish this, we do a natural sort
+    on the unique values that are present in the dataframe.
+    Then, we reconstitute the entire dataframe
+    in the naturally sorted order.
+
+    Natural sorting is provided by the Python package natsort_.
+
+    .. _natsort: https://natsort.readthedocs.io/en/master/index.html
+
+    All keyword arguments to ``natsort`` should be provided
+    after the column name to sort by is provided.
+    They are passed through to the ``natsorted`` function.
+
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+
+        df = jn.sort_naturally(
+            df=df,
+            column_name='alphanumeric_column',
+        )
+
+    Method chaining usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor
+
+        df = pd.DataFrame(...)
+
+        df = df.sort_naturally(
+            column_name='alphanumeric_column',
+        )
+
+    :param df: A pandas DataFrame.
+    :param column_name: The column on which natural sorting should take place.
+    :param natsorted_kwargs: Keyword arguments to be passed
+        to natsort's ``natsorted`` function.
+    """
+    new_order = index_natsorted(df[column_name], **natsorted_kwargs)
+    return df.iloc[new_order, :]
diff --git a/janitor/xarray/functions.py b/janitor/xarray/functions.py
--- a/janitor/xarray/functions.py
+++ b/janitor/xarray/functions.py
@@ -7,13 +7,12 @@
 from typing import Union
 
 import numpy as np
+import xarray as xr
 from pandas_flavor import (
     register_xarray_dataarray_method,
     register_xarray_dataset_method,
 )
 
-import xarray as xr
-
 
 @register_xarray_dataarray_method
 def clone_using(


Test patch:
diff --git a/tests/functions/test_sort_naturally.py b/tests/functions/test_sort_naturally.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_sort_naturally.py
@@ -0,0 +1,39 @@
+"""
+Tests for sort_naturally.
+
+Some places where this test suite could be improved:
+
+- Replace example-based test
+with Hypothesis-generated property-based test. [intermediate]
+- Provide another example-based test of something
+that needs to be naturally rather than lexiographically sorted.
+"""
+import pandas as pd
+import pytest
+from natsort import natsorted
+
+import janitor
+
+
+@pytest.fixture
+def well_dataframe():
+    data = {
+        "Well": ["A21", "A3", "A21", "B2", "B51", "B12"],
+        "Value": [1, 2, 13, 3, 4, 7],
+    }
+    df = pd.DataFrame(data)
+    return df
+
+
+def test_sort_naturally(well_dataframe):
+    """Example-based test for sort_naturally.
+
+    We check that:
+
+    - the resultant dataframe is sorted identically
+    to what natsorted would provide,
+    - the data in the dataframe are not corrupted.
+    """
+    sorted_df = well_dataframe.sort_naturally("Well")
+    assert sorted_df["Well"].tolist() == natsorted(well_dataframe["Well"])
+    pd.testing.assert_frame_equal(sorted_df.sort_index(), well_dataframe)
diff --git a/tests/xarray/conftest.py b/tests/xarray/conftest.py
--- a/tests/xarray/conftest.py
+++ b/tests/xarray/conftest.py
@@ -1,6 +1,5 @@
 import numpy as np
 import pytest
-
 import xarray as xr
 
 
diff --git a/tests/xarray/test_clone_using.py b/tests/xarray/test_clone_using.py
--- a/tests/xarray/test_clone_using.py
+++ b/tests/xarray/test_clone_using.py
@@ -1,8 +1,8 @@
 import numpy as np
 import pytest
+import xarray as xr
 
 import janitor  # noqa: F401
-import xarray as xr
 
 
 @pytest.mark.xarray
diff --git a/tests/xarray/test_convert_datetime_to_number.py b/tests/xarray/test_convert_datetime_to_number.py
--- a/tests/xarray/test_convert_datetime_to_number.py
+++ b/tests/xarray/test_convert_datetime_to_number.py
@@ -1,6 +1,5 @@
 import numpy as np
 import pytest
-
 import xarray as xr