Repository: alteryx/woodwork (python)

Issue:
Add WoodworkTableAccessor.rename
- Allows users to rename columns in their DataFrame, returning a new DataFrame with the same Woodwork typing info initialized. 

Pull request description:
Add rename to WoodworkTableAccessor
- Adds `Schema.rename` that handles input validation and updating the schema and `WoodworkTableAccessor.rename` that updates the schema and the dataframe
- Closes #616 

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index ac2c7f9..8e1ee70 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -35,6 +35,7 @@ Release Notes
         * Add KoalasColumnAccessor (:pr:`634`)
         * Add ``pop`` to WoodworkTableAccessor (:pr:`636`)
         * Add ``drop`` to WoodworkTableAccessor (:pr:`640`)
+        * Add ``rename`` to WoodworkTableAccessor (:pr:`646`)
     * Fixes
         * Create new Schema object when performing pandas operation on Accessors (:pr:`595`)
     * Changes
diff --git a/woodwork/exceptions.py b/woodwork/exceptions.py
index 2e6f887..3c6a5b3 100644
--- a/woodwork/exceptions.py
+++ b/woodwork/exceptions.py
@@ -42,3 +42,7 @@ class TypeConversionError(Exception):
 
 class ParametersIgnoredWarning(UserWarning):
     pass
+
+
+class ColumnNotPresentError(KeyError):
+    pass
diff --git a/woodwork/schema.py b/woodwork/schema.py
index e9cc8a0..0eaa3b5 100644
--- a/woodwork/schema.py
+++ b/woodwork/schema.py
@@ -4,6 +4,7 @@ import copy
 import pandas as pd
 
 import woodwork as ww
+from woodwork.exceptions import ColumnNotPresentError
 from woodwork.schema_column import (
     _add_semantic_tags,
     _get_column_dict,
@@ -327,6 +328,37 @@ class Schema(object):
             _check_time_index(self.columns.keys(), new_time_index, self.logical_types.get(new_time_index))
             self._set_time_index_tags(new_time_index)
 
+    def rename(self, columns):
+        """Renames columns in a Schema
+
+        Args:
+            columns (dict[str -> str]): A dictionary mapping current column names to new column names.
+
+        Returns:
+            woodwork.Schema: Schema with the specified columns renamed.
+        """
+        if not isinstance(columns, dict):
+            raise TypeError('columns must be a dictionary')
+
+        for old_name, new_name in columns.items():
+            if old_name not in self.columns:
+                raise ColumnNotPresentError(f"Column to rename must be present. {old_name} cannot be found.")
+            if new_name in self.columns and new_name not in columns.keys():
+                raise ValueError(f"The column {new_name} is already present. Please choose another name to rename {old_name} to or also rename {old_name}.")
+
+        if len(columns) != len(set(columns.values())):
+            raise ValueError('New columns names must be unique from one another.')
+
+        new_schema = self._get_subset_schema(list(self.columns.keys()))
+
+        cols_to_update = {}
+        for old_name, new_name in columns.items():
+            col = new_schema.columns.pop(old_name)
+            cols_to_update[new_name] = col
+
+        new_schema.columns.update(cols_to_update)
+        return new_schema
+
     def _set_index_tags(self, index):
         '''
         Updates the semantic tags of the index by removing any standard tags
diff --git a/woodwork/table_accessor.py b/woodwork/table_accessor.py
index e34994b..349dfea 100644
--- a/woodwork/table_accessor.py
+++ b/woodwork/table_accessor.py
@@ -461,7 +461,7 @@ class WoodworkTableAccessor:
         """Drop specified columns from a DataFrame.
 
         Args:
-            columns (str or list[str]): Column name or names to drop. Must be present in the DataTable.
+            columns (str or list[str]): Column name or names to drop. Must be present in the DataFrame.
 
         Returns:
             (pd.DataFrame): DataFrame with the specified columns removed, maintaining Woodwork typing information.
@@ -484,6 +484,21 @@ class WoodworkTableAccessor:
 
         return new_df
 
+    def rename(self, columns):
+        """Renames columns in a DataFrame, maintaining Woodwork typing information.
+
+        Args:
+            columns (dict[str -> str]): A dictionary mapping current column names to new column names.
+
+        Returns:
+            DataFrame: DataFrame with the specified columns renamed, maintaining Woodwork typing information.
+        """
+        new_schema = self._schema.rename(columns)
+        new_df = self._dataframe.rename(columns=columns)
+
+        new_df.ww.init(schema=new_schema)
+        return new_df
+
     def mutual_information_dict(self, num_bins=10, nrows=None):
         """
         Calculates mutual information between all pairs of columns in the DataFrame that


Test patch:
diff --git a/woodwork/tests/accessor/test_table_accessor.py b/woodwork/tests/accessor/test_table_accessor.py
index f3b1df0..933054a 100644
--- a/woodwork/tests/accessor/test_table_accessor.py
+++ b/woodwork/tests/accessor/test_table_accessor.py
@@ -825,15 +825,17 @@ def test_accessor_with_falsy_column_names(falsy_names_df):
     assert '' not in schema_df.ww.columns
     assert schema_df.ww.time_index is None
 
+    schema_df.ww.set_index(None)
+    assert schema_df.ww.index is None
+
     # --> add back in if setitem is implemented
     # dt[''] = popped_col
     # assert dt[''].name == ''
     # assert dt['']._series.name == ''
 
-    # --> add back in once rename is implemented on the accessor
-    # dt = dt.rename({'': 'col_with_name'})
-    # assert '' not in dt.columns
-    # assert 'col_with_name' in dt.columns
+    renamed_df = schema_df.ww.rename({0: 'col_with_name'})
+    assert 0 not in renamed_df.columns
+    assert 'col_with_name' in renamed_df.columns
 
 
 def test_accessor_repr(sample_df, sample_column_names, sample_inferred_logical_types):
@@ -947,11 +949,11 @@ def test_dataframe_methods_on_accessor_inplace(sample_df):
 
     pd.testing.assert_frame_equal(to_pandas(schema_df), to_pandas(df_pre_sort.sort_values(['full_name'])))
 
-    warning = "Operation performed by rename has invalidated the Woodwork typing information:\n "
+    warning = "Operation performed by insert has invalidated the Woodwork typing information:\n "
     "The following columns in the DataFrame were missing from the typing information: {'new_name'}.\n "
     "Please initialize Woodwork with DataFrame.ww.init"
     with pytest.warns(TypingInfoMismatchWarning, match=warning):
-        schema_df.ww.rename({'id': 'new_name'}, inplace=True, axis=1)
+        schema_df.ww.insert(loc=0, column="new_name", value=[1, 2, 3, 4])
     assert 'new_name' in schema_df.columns
     assert schema_df.ww.schema is None
 
@@ -1509,3 +1511,64 @@ def test_accessor_drop_errors(sample_df):
     error = re.escape("['not_present1', 4] not found in DataFrame")
     with pytest.raises(ValueError, match=error):
         sample_df.ww.drop(['not_present1', 4])
+
+
+def test_accessor_rename(sample_df):
+    xfail_dask_and_koalas(sample_df)
+
+    table_metadata = {'table_info': 'this is text'}
+    id_description = 'the id of the row'
+    sample_df.ww.init(index='id',
+                      time_index='signup_date',
+                      table_metadata=table_metadata,
+                      column_descriptions={'id': id_description},
+                      semantic_tags={'age': 'test_tag'},
+                      logical_types={'age': Double})
+    original_df = sample_df.ww.copy()
+
+    new_df = sample_df.ww.rename({'age': 'birthday'})
+
+    assert to_pandas(sample_df.rename({'age': 'birthday'}, axis=1)).equals(to_pandas(new_df))
+    # Confirm original dataframe hasn't changed
+    assert to_pandas(sample_df).equals(original_df)
+    assert sample_df.ww.schema == original_df.ww.schema
+
+    assert original_df.columns.get_loc('age') == new_df.columns.get_loc('birthday')
+    pd.testing.assert_series_equal(original_df['age'], new_df['birthday'], check_names=False)
+
+    # confirm that metadata and descriptions are there
+    assert new_df.ww.metadata == table_metadata
+    assert new_df.ww.columns['id']['description'] == id_description
+
+    old_col = sample_df.ww.columns['age']
+    new_col = new_df.ww.columns['birthday']
+    assert old_col['logical_type'] == new_col['logical_type']
+    assert old_col['semantic_tags'] == new_col['semantic_tags']
+    assert old_col['dtype'] == new_col['dtype']
+
+    new_df = sample_df.ww.rename({'age': 'full_name', 'full_name': 'age'})
+
+    pd.testing.assert_series_equal(original_df['age'], new_df['full_name'], check_names=False)
+    pd.testing.assert_series_equal(original_df['full_name'], new_df['age'], check_names=False)
+
+    assert original_df.columns.get_loc('age') == new_df.columns.get_loc('full_name')
+    assert original_df.columns.get_loc('full_name') == new_df.columns.get_loc('age')
+
+
+def test_accessor_rename_indices(sample_df):
+    xfail_dask_and_koalas(sample_df)
+
+    sample_df.ww.init(
+        index='id',
+        time_index='signup_date')
+
+    renamed_df = sample_df.ww.rename({'id': 'renamed_index', 'signup_date': 'renamed_time_index'})
+    assert 'id' not in renamed_df.columns
+    assert 'signup_date' not in renamed_df.columns
+    assert 'renamed_index' in renamed_df.columns
+    assert 'renamed_time_index' in renamed_df.columns
+
+    assert all(renamed_df.index == renamed_df['renamed_index'])
+
+    assert renamed_df.ww.index == 'renamed_index'
+    assert renamed_df.ww.time_index == 'renamed_time_index'
diff --git a/woodwork/tests/schema/test_schema.py b/woodwork/tests/schema/test_schema.py
index 3d1e408..cb60d23 100644
--- a/woodwork/tests/schema/test_schema.py
+++ b/woodwork/tests/schema/test_schema.py
@@ -5,6 +5,7 @@ import pandas as pd
 import pytest
 
 from woodwork import type_system
+from woodwork.exceptions import ColumnNotPresentError
 from woodwork.logical_types import (
     Boolean,
     Categorical,
@@ -786,3 +787,72 @@ def test_set_index_twice(sample_column_names, sample_inferred_logical_types):
     assert schema.time_index == 'signup_date'
     assert schema.semantic_tags['signup_date'] == {'time_index'}
     assert schema == original_schema
+
+
+def test_schema_rename_errors(sample_column_names, sample_inferred_logical_types):
+    schema = Schema(sample_column_names, sample_inferred_logical_types, index='id', time_index='signup_date')
+
+    error = "columns must be a dictionary"
+    with pytest.raises(TypeError, match=error):
+        schema.rename(1)
+
+    error = 'New columns names must be unique from one another.'
+    with pytest.raises(ValueError, match=error):
+        schema.rename({'age': 'test', 'full_name': 'test'})
+
+    error = 'Column to rename must be present. not_present cannot be found.'
+    with pytest.raises(ColumnNotPresentError, match=error):
+        schema.rename({'not_present': 'test'})
+
+    error = 'The column email is already present. Please choose another name to rename age to or also rename age.'
+    with pytest.raises(ValueError, match=error):
+        schema.rename({'age': 'email'})
+
+
+def test_schema_rename(sample_column_names, sample_inferred_logical_types):
+
+    table_metadata = {'table_info': 'this is text'}
+    id_description = 'the id of the row'
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    index='id',
+                    time_index='signup_date',
+                    table_metadata=table_metadata,
+                    column_descriptions={'id': id_description})
+    original_schema = schema._get_subset_schema(list(schema.columns.keys()))
+
+    renamed_schema = schema.rename({'age': 'birthday'})
+
+    # Confirm original schema hasn't changed
+    assert schema == original_schema
+
+    assert 'age' not in renamed_schema.columns
+    assert 'birthday' in renamed_schema.columns
+
+    # confirm that metadata and descriptions are there
+    assert renamed_schema.metadata == table_metadata
+    assert schema.columns['id']['description'] == id_description
+
+    old_col = schema.columns['age']
+    new_col = renamed_schema.columns['birthday']
+    assert old_col['logical_type'] == new_col['logical_type']
+    assert old_col['semantic_tags'] == new_col['semantic_tags']
+    assert old_col['dtype'] == new_col['dtype']
+
+    swapped_schema = schema.rename({'age': 'full_name', 'full_name': 'age'})
+    swapped_back_schema = swapped_schema.rename({'age': 'full_name', 'full_name': 'age'})
+    assert swapped_back_schema == schema
+
+
+def test_schema_rename_indices(sample_column_names, sample_inferred_logical_types):
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    index='id',
+                    time_index='signup_date')
+
+    renamed_schema = schema.rename({'id': 'renamed_index', 'signup_date': 'renamed_time_index'})
+    assert 'id' not in renamed_schema.columns
+    assert 'signup_date' not in renamed_schema.columns
+    assert 'renamed_index' in renamed_schema.columns
+    assert 'renamed_time_index' in renamed_schema.columns
+
+    assert renamed_schema.index == 'renamed_index'
+    assert renamed_schema.time_index == 'renamed_time_index'