Repository: alteryx/woodwork (python)

Issue:
Implement `set_types` on Table Accessor
This allows for updating logical types on the Table Accessor.

As this will cause changes to the underlying DataFrame, we might want to consider returning a new DataFrame and Schema here instead of doing things inplace

Pull request:
Update set_types to also set logical types
- Updates `df.ww.set_types` so that it can also take in a logical types parameter
- Updates underlying dtype of DataFrame is logical type is changed
- Also removes underlying index if index has been removed from the Schema 
- Closes #587  

Patch:
diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst
index 20345a5..45bad72 100644
--- a/docs/source/api_reference.rst
+++ b/docs/source/api_reference.rst
@@ -19,6 +19,7 @@ WoodworkTableAccessor
     WoodworkTableAccessor.mutual_information
     WoodworkTableAccessor.select
     WoodworkTableAccessor.set_index
+    WoodworkTableAccessor.set_types
 
 WoodworkColumnAccessor
 ======================
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 057bc60..860f04d 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -27,6 +27,7 @@ Release Notes
         * Implement ``loc`` and ``iloc`` for WoodworkColumnAccessor (:pr:`613`)
         * Add ``set_time_index`` to WoodworkTableAccessor (:pr:`612`)
         * Implement ``loc`` and ``iloc`` for WoodworkTableAccessor (:pr:`618`)
+        * Allow updating logical types with ``set_types`` and make relevant DataFrame changes (:pr:`619`)
     * Fixes
         * Create new Schema object when performing pandas operation on Accessors (:pr:`595`)
     * Changes
diff --git a/woodwork/schema.py b/woodwork/schema.py
index b8bd6c2..e9cc8a0 100644
--- a/woodwork/schema.py
+++ b/woodwork/schema.py
@@ -154,27 +154,44 @@ class Schema(object):
                 return col_name
         return None
 
-    def set_types(self, semantic_tags=None, retain_index_tags=True):
-        """Update the semantic tags for any columns names in the provided types dictionary,
-        updating the Woodwork typing information for the DataFrame.
+    def set_types(self, logical_types=None, semantic_tags=None, retain_index_tags=True):
+        """Update the logical type and semantic tags for any columns names in the provided types dictionaries,
+        updating the Schema at those columns.
 
         Args:
+            logical_types (dict[str -> LogicalType], optional): A dictionary defining the new logical types for the
+                specified columns.
             semantic_tags (dict[str -> str/list/set], optional): A dictionary defining the new semantic_tags for the
                 specified columns.
             retain_index_tags (bool, optional): If True, will retain any index or time_index
-                semantic tags set on the column. If False, will replace all semantic tags. Defaults to
-                True.
-
+                semantic tags set on the column. If False, will replace all semantic tags any time a column's
+                semantic tags or logical type changes. Defaults to True.
         """
+        logical_types = logical_types or {}
+        _check_logical_types(self.columns.keys(), logical_types, require_all_cols=False)
+
         semantic_tags = semantic_tags or {}
         _check_semantic_tags(self.columns.keys(), semantic_tags)
 
-        for col_name, new_tags in semantic_tags.items():
+        for col_name in (logical_types.keys() | semantic_tags.keys()):
             original_tags = self.semantic_tags[col_name]
-            new_semantic_tags = _set_semantic_tags(new_tags,
-                                                   self.logical_types[col_name].standard_tags,
-                                                   self.use_standard_tags)
-            _validate_not_setting_index_tags(new_semantic_tags, col_name)
+
+            # Update Logical Type for the Schema, getting new semantic tags
+            new_logical_type = logical_types.get(col_name)
+            if new_logical_type is not None:
+                self.columns[col_name]['logical_type'] = new_logical_type
+
+            # Get new semantic tags, removing existing tags
+            new_semantic_tags = semantic_tags.get(col_name)
+            if new_semantic_tags is None:
+                new_semantic_tags = _reset_semantic_tags(new_logical_type.standard_tags, self.use_standard_tags)
+            else:
+                new_semantic_tags = _set_semantic_tags(new_semantic_tags,
+                                                       self.logical_types[col_name].standard_tags,
+                                                       self.use_standard_tags)
+                _validate_not_setting_index_tags(new_semantic_tags, col_name)
+
+            # Update the tags for the Schema
             self.columns[col_name]['semantic_tags'] = new_semantic_tags
 
             if retain_index_tags and 'index' in original_tags:
@@ -468,7 +485,7 @@ def _check_time_index(column_names, time_index, logical_type):
         raise TypeError('Time index column must be a Datetime or numeric column.')
 
 
-def _check_logical_types(column_names, logical_types):
+def _check_logical_types(column_names, logical_types, require_all_cols=True):
     if not isinstance(logical_types, dict):
         raise TypeError('logical_types must be a dictionary')
     cols_in_ltypes = set(logical_types.keys())
@@ -479,10 +496,16 @@ def _check_logical_types(column_names, logical_types):
         raise LookupError('logical_types contains columns that are not present in '
                           f'Schema: {sorted(list(cols_not_found_in_schema))}')
     cols_not_found_in_ltypes = cols_in_schema.difference(cols_in_ltypes)
-    if cols_not_found_in_ltypes:
+    if cols_not_found_in_ltypes and require_all_cols:
         raise LookupError(f'logical_types is missing columns that are present in '
                           f'Schema: {sorted(list(cols_not_found_in_ltypes))}')
 
+    for col_name, logical_type in logical_types.items():
+        if _get_ltype_class(logical_type) not in ww.type_system.registered_types:
+            raise TypeError("Logical Types must be of the LogicalType class "
+                            "and registered in Woodwork's type system. "
+                            f"{logical_type} does not meet that criteria.")
+
 
 def _check_semantic_tags(column_names, semantic_tags):
     if not isinstance(semantic_tags, dict):
@@ -492,6 +515,10 @@ def _check_semantic_tags(column_names, semantic_tags):
         raise LookupError('semantic_tags contains columns that do not exist: '
                           f'{sorted(list(cols_not_found))}')
 
+    for col_name, col_tags in semantic_tags.items():
+        if not isinstance(col_tags, (str, list, set)):
+            raise TypeError(f"semantic_tags for {col_name} must be a string, set or list")
+
 
 def _check_column_descriptions(column_names, column_descriptions):
     if not isinstance(column_descriptions, dict):
diff --git a/woodwork/table_accessor.py b/woodwork/table_accessor.py
index 9fc7247..db4586d 100644
--- a/woodwork/table_accessor.py
+++ b/woodwork/table_accessor.py
@@ -19,7 +19,11 @@ from woodwork.type_sys.utils import (
     _is_numeric_series,
     col_is_datetime
 )
-from woodwork.utils import _get_column_logical_type, import_or_none
+from woodwork.utils import (
+    _get_column_logical_type,
+    _parse_logical_type,
+    import_or_none
+)
 
 dd = import_or_none('dask.dataframe')
 ks = import_or_none('databricks.koalas')
@@ -218,6 +222,32 @@ class WoodworkTableAccessor:
             _check_index(self._dataframe, self._schema.index)
         self._set_underlying_index()
 
+    def set_types(self, logical_types=None, semantic_tags=None, retain_index_tags=True):
+        """Update the logical type and semantic tags for any columns names in the provided types dictionaries,
+        updating the Woodwork typing information for the DataFrame.
+
+        Args:
+            logical_types (dict[str -> str], optional): A dictionary defining the new logical types for the
+                specified columns.
+            semantic_tags (dict[str -> str/list/set], optional): A dictionary defining the new semantic_tags for the
+                specified columns.
+            retain_index_tags (bool, optional): If True, will retain any index or time_index
+                semantic tags set on the column. If False, will replace all semantic tags any time a column's
+                semantic tags or logical type changes. Defaults to True.
+        """
+        logical_types = logical_types or {}
+        logical_types = {col_name: _parse_logical_type(ltype, col_name) for col_name, ltype in logical_types.items()}
+
+        self._schema.set_types(logical_types=logical_types,
+                               semantic_tags=semantic_tags,
+                               retain_index_tags=retain_index_tags)
+        # go through changed ltypes and update dtype if necessary
+        for col_name, logical_type in logical_types.items():
+            series = self._dataframe[col_name]
+            updated_series = _update_column_dtype(series, logical_type)
+            if updated_series is not series:
+                self._dataframe[col_name] = updated_series
+
     def select(self, include):
         """Create a DataFrame with Woodowork typing information initialized
         that includes only columns whose Logical Type and semantic tags are
diff --git a/woodwork/utils.py b/woodwork/utils.py
index a093a7e..9084ee8 100644
--- a/woodwork/utils.py
+++ b/woodwork/utils.py
@@ -320,14 +320,18 @@ def get_valid_mi_types():
 
 def _get_column_logical_type(series, logical_type, name):
     if logical_type:
-        if isinstance(logical_type, str):
-            logical_type = ww.type_system.str_to_logical_type(logical_type)
-        ltype_class = ww.type_sys.utils._get_ltype_class(logical_type)
-        if ltype_class == ww.logical_types.Ordinal and not isinstance(logical_type, ww.logical_types.Ordinal):
-            raise TypeError("Must use an Ordinal instance with order values defined")
-        if ltype_class in ww.type_system.registered_types:
-            return logical_type
-        else:
-            raise TypeError(f"Invalid logical type specified for '{name}'")
+        return _parse_logical_type(logical_type, name)
     else:
         return ww.type_system.infer_logical_type(series)
+
+
+def _parse_logical_type(logical_type, name):
+    if isinstance(logical_type, str):
+        logical_type = ww.type_system.str_to_logical_type(logical_type)
+    ltype_class = ww.type_sys.utils._get_ltype_class(logical_type)
+    if ltype_class == ww.logical_types.Ordinal and not isinstance(logical_type, ww.logical_types.Ordinal):
+        raise TypeError("Must use an Ordinal instance with order values defined")
+    if ltype_class in ww.type_system.registered_types:
+        return logical_type
+    else:
+        raise TypeError(f"Invalid logical type specified for '{name}'")


Test patch:
diff --git a/woodwork/tests/accessor/test_table_accessor.py b/woodwork/tests/accessor/test_table_accessor.py
index a7d8178..0353203 100644
--- a/woodwork/tests/accessor/test_table_accessor.py
+++ b/woodwork/tests/accessor/test_table_accessor.py
@@ -634,17 +634,14 @@ def test_invalid_dtype_casting():
     with pytest.raises(TypeConversionError, match=err_msg):
         pd.DataFrame(series).ww.init(logical_types=ltypes)
 
-    # --> add back when schema updates are implemented
-    # # Cannot cast Datetime to Double
-    # series = pd.Series(['2020-01-01', '2020-01-02', '2020-01-03'], name=column_name)
-    # ltypes = {
-    #     column_name: Datetime,
-    # }
-    # schema = Schema(pd.DataFrame(series), logical_types=ltypes)
-    # err_msg = 'Error converting datatype for column test_series from type datetime64[ns] to type ' \
-    #     'float64. Please confirm the underlying data is consistent with logical type Double.'
-    # with pytest.raises(TypeError, match=re.escape(err_msg)):
-    #     schema.set_types(logical_types={column_name: Double})
+    # Cannot cast Datetime to Double
+    df = pd.DataFrame({column_name: ['2020-01-01', '2020-01-02', '2020-01-03']})
+    df.ww.init(logical_types={column_name: Datetime})
+
+    err_msg = 'Error converting datatype for test_series from type datetime64[ns] to type ' \
+        'float64. Please confirm the underlying data is consistent with logical type Double.'
+    with pytest.raises(TypeConversionError, match=re.escape(err_msg)):
+        df.ww.set_types(logical_types={column_name: Double})
 
     # Cannot cast invalid strings to integers
     series = pd.Series(['1', 'two', '3'], name=column_name)
@@ -777,11 +774,13 @@ def test_ordinal_with_order(sample_series):
     assert isinstance(column_logical_type, Ordinal)
     assert column_logical_type.order == ['a', 'b', 'c']
 
-    # --> add back when schema updates are implemented
-    # dc = DataColumn(sample_series, logical_type="NaturalLanguage")
-    # new_dc = dc.set_logical_type(ordinal_with_order)
-    # assert isinstance(new_dc.logical_type, Ordinal)
-    # assert new_dc.logical_type.order == ['a', 'b', 'c']
+    schema_df = pd.DataFrame(sample_series)
+    schema_df.ww.init()
+
+    schema_df.ww.set_types(logical_types={'sample_series': ordinal_with_order})
+    logical_type = schema_df.ww.logical_types['sample_series']
+    assert isinstance(logical_type, Ordinal)
+    assert logical_type.order == ['a', 'b', 'c']
 
 
 def test_ordinal_with_incomplete_ranking(sample_series):
@@ -791,10 +790,16 @@ def test_ordinal_with_incomplete_ranking(sample_series):
     ordinal_incomplete_order = Ordinal(order=['a', 'b'])
     error_msg = re.escape("Ordinal column sample_series contains values that are not "
                           "present in the order values provided: ['c']")
+
+    schema_df = pd.DataFrame(sample_series)
+
     with pytest.raises(ValueError, match=error_msg):
-        schema_df = pd.DataFrame(sample_series)
         schema_df.ww.init(logical_types={'sample_series': ordinal_incomplete_order})
 
+    schema_df.ww.init()
+    with pytest.raises(ValueError, match=error_msg):
+        schema_df.ww.set_types(logical_types={'sample_series': ordinal_incomplete_order})
+
 
 def test_ordinal_with_nan_values():
     nan_df = pd.DataFrame(pd.Series(['a', 'b', np.nan, 'a'], name='nan_series'))
@@ -1350,3 +1355,45 @@ def test_accessor_set_index_errors(sample_df):
     error = "Index column must be unique"
     with pytest.raises(LookupError, match=error):
         sample_df.ww.set_index('age')
+
+
+def test_set_types(sample_df):
+    xfail_dask_and_koalas(sample_df)
+
+    sample_df.ww.init(index='full_name', time_index='signup_date')
+
+    original_df = sample_df.ww.copy()
+
+    sample_df.ww.set_types()
+    assert original_df.ww.schema == sample_df.ww.schema
+    pd.testing.assert_frame_equal(to_pandas(original_df), to_pandas(sample_df))
+
+    sample_df.ww.set_types(logical_types={'is_registered': 'Integer'})
+    assert sample_df['is_registered'].dtype == 'Int64'
+
+    sample_df.ww.set_types(semantic_tags={'signup_date': ['new_tag']},
+                           logical_types={'full_name': 'Categorical'},
+                           retain_index_tags=False)
+    assert sample_df.ww.index is None
+    assert sample_df.ww.time_index is None
+
+
+def test_set_types_errors(sample_df):
+    xfail_dask_and_koalas(sample_df)
+
+    sample_df.ww.init(index='full_name')
+
+    error = "String invalid is not a valid logical type"
+    with pytest.raises(ValueError, match=error):
+        sample_df.ww.set_types(logical_types={'id': 'invalid'})
+
+    error = 'Error converting datatype for email from type string ' \
+        'to type float64. Please confirm the underlying data is consistent with ' \
+        'logical type Double.'
+    with pytest.raises(TypeConversionError, match=error):
+        sample_df.ww.set_types(logical_types={'email': 'Double'})
+
+    error = re.escape("Cannot add 'index' tag directly for column email. To set a column as the index, "
+                      "use DataFrame.ww.set_index() instead.")
+    with pytest.raises(ValueError, match=error):
+        sample_df.ww.set_types(semantic_tags={'email': 'index'})
diff --git a/woodwork/tests/schema/test_schema.py b/woodwork/tests/schema/test_schema.py
index 975e9f1..3d1e408 100644
--- a/woodwork/tests/schema/test_schema.py
+++ b/woodwork/tests/schema/test_schema.py
@@ -13,7 +13,8 @@ from woodwork.logical_types import (
     EmailAddress,
     FullName,
     Integer,
-    NaturalLanguage
+    NaturalLanguage,
+    PhoneNumber
 )
 from woodwork.schema import Schema
 
@@ -309,6 +310,140 @@ def test_get_subset_schema_all_params(sample_column_names, sample_inferred_logic
     assert schema is not copy_schema
 
 
+def test_set_logical_types(sample_column_names, sample_inferred_logical_types):
+    semantic_tags = {
+        'full_name': 'tag1',
+        'email': ['tag2'],
+        'phone_number': ['tag3', 'tag2'],
+        'signup_date': {'secondary_time_index'},
+    }
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    semantic_tags=semantic_tags, use_standard_tags=True)
+
+    schema.set_types(logical_types={
+        'full_name': Categorical,
+        'email': EmailAddress,
+        'phone_number': PhoneNumber,
+        'age': Double,
+    })
+
+    assert schema.logical_types['full_name'] == Categorical
+    assert schema.logical_types['email'] == EmailAddress
+    assert schema.logical_types['phone_number'] == PhoneNumber
+    assert schema.logical_types['age'] == Double
+
+    # Verify semantic tags were reset to standard tags in columns with Logical Type changes
+    assert schema.semantic_tags['full_name'] == {'category'}
+    assert schema.semantic_tags['email'] == set()
+    assert schema.semantic_tags['phone_number'] == set()
+    assert schema.semantic_tags['age'] == {'numeric'}
+
+    # Verify signup date column was unchanged
+    assert schema.logical_types['signup_date'] == Datetime
+    assert schema.semantic_tags['signup_date'] == {'secondary_time_index'}
+
+
+def test_set_logical_types_empty(sample_column_names, sample_inferred_logical_types):
+    semantic_tags = {
+        'full_name': 'tag1',
+        'age': 'test_tag'
+    }
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    index='full_name',
+                    semantic_tags=semantic_tags, use_standard_tags=True)
+
+    # An empty set should reset the tags
+    schema.set_types(semantic_tags={'full_name': set()}, retain_index_tags=False)
+    assert schema.logical_types['full_name'] == NaturalLanguage
+    assert schema.semantic_tags['full_name'] == set()
+
+    schema.set_types(semantic_tags={'age': set()})
+    assert schema.logical_types['age'] == Integer
+    assert schema.semantic_tags['age'] == {'numeric'}
+
+
+def test_set_logical_types_invalid_data(sample_column_names, sample_inferred_logical_types):
+    schema = Schema(sample_column_names, sample_inferred_logical_types)
+
+    error_message = re.escape("logical_types contains columns that are not present in Schema: ['birthday']")
+    with pytest.raises(LookupError, match=error_message):
+        schema.set_types(logical_types={'birthday': Double})
+
+    error_message = ("Logical Types must be of the LogicalType class "
+                     "and registered in Woodwork's type system. "
+                     "Double does not meet that criteria.")
+    with pytest.raises(TypeError, match=error_message):
+        schema.set_types(logical_types={'id': 'Double'})
+
+    error_message = ("Logical Types must be of the LogicalType class "
+                     "and registered in Woodwork's type system. "
+                     "<class 'int'> does not meet that criteria.")
+    with pytest.raises(TypeError, match=error_message):
+        schema.set_types(logical_types={'age': int})
+
+    error_message = "semantic_tags for full_name must be a string, set or list"
+    with pytest.raises(TypeError, match=error_message):
+        schema.set_types(semantic_tags={'full_name': None})
+
+
+def test_set_types_combined(sample_column_names, sample_inferred_logical_types):
+    # test that the resetting of indices when ltype changes doesnt touch index??
+    semantic_tags = {
+        'id': 'tag1',
+        'email': ['tag2'],
+        'phone_number': ['tag3', 'tag2'],
+        'signup_date': {'secondary_time_index'},
+    }
+    # use standard tags and keep index tags
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    semantic_tags=semantic_tags, use_standard_tags=True,
+                    index='id')
+
+    schema.set_types(semantic_tags={'id': 'new_tag', 'age': 'new_tag', 'email': 'new_tag'},
+                     logical_types={'id': Double, 'age': Double, 'email': Categorical})
+
+    assert schema.semantic_tags['id'] == {'new_tag', 'index'}
+    assert schema.semantic_tags['age'] == {'numeric', 'new_tag'}
+    assert schema.semantic_tags['email'] == {'new_tag', 'category'}
+
+    assert schema.logical_types['id'] == Double
+    assert schema.logical_types['age'] == Double
+    assert schema.logical_types['email'] == Categorical
+
+    # use standard tags and lose index tags
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    semantic_tags=semantic_tags, use_standard_tags=True,
+                    index='id', time_index='age')
+
+    schema.set_types(semantic_tags={'age': 'new_tag'},
+                     logical_types={'id': Double},
+                     retain_index_tags=False)
+
+    assert schema.semantic_tags['id'] == {'numeric'}
+    assert schema.semantic_tags['age'] == {'numeric', 'new_tag'}
+
+    # don't use standard tags and lose index tags
+    schema = Schema(sample_column_names, sample_inferred_logical_types,
+                    semantic_tags=semantic_tags, use_standard_tags=False,
+                    index='id', time_index='signup_date')
+
+    schema.set_types(semantic_tags={'id': 'new_tag', 'age': 'new_tag', 'is_registered': 'new_tag'},
+                     logical_types={'id': Double, 'age': Double, 'email': Categorical}, retain_index_tags=False)
+
+    assert schema.index is None
+    assert schema.time_index == 'signup_date'
+
+    assert schema.semantic_tags['id'] == {'new_tag'}
+    assert schema.semantic_tags['age'] == {'new_tag'}
+    assert schema.semantic_tags['is_registered'] == {'new_tag'}
+    assert schema.semantic_tags['email'] == set()
+    assert schema.semantic_tags['signup_date'] == {'time_index', 'secondary_time_index'}
+
+    assert schema.logical_types['id'] == Double
+    assert schema.logical_types['age'] == Double
+    assert schema.logical_types['email'] == Categorical
+
+
 def test_set_semantic_tags(sample_column_names, sample_inferred_logical_types):
     semantic_tags = {
         'full_name': 'tag1',
diff --git a/woodwork/tests/schema/test_schema_init.py b/woodwork/tests/schema/test_schema_init.py
index eef02fe..08709e6 100644
--- a/woodwork/tests/schema/test_schema_init.py
+++ b/woodwork/tests/schema/test_schema_init.py
@@ -90,6 +90,24 @@ def test_check_logical_types_errors(sample_column_names):
     with pytest.raises(LookupError, match=error_message):
         _check_logical_types(sample_column_names, bad_logical_types_keys)
 
+    bad_logical_types_keys = {'email': 1}
+    error_message = ("Logical Types must be of the LogicalType class "
+                     "and registered in Woodwork's type system. "
+                     "1 does not meet that criteria.")
+
+    with pytest.raises(TypeError, match=error_message):
+        _check_logical_types(sample_column_names, bad_logical_types_keys, require_all_cols=False)
+
+    bad_logical_types_keys = {
+        'email': 'NaturalLanguage',
+    }
+    error_message = ("Logical Types must be of the LogicalType class "
+                     "and registered in Woodwork's type system. "
+                     "NaturalLanguage does not meet that criteria.")
+
+    with pytest.raises(TypeError, match=error_message):
+        _check_logical_types(sample_column_names, bad_logical_types_keys, require_all_cols=False)
+
 
 def test_check_semantic_tags_errors(sample_column_names):
     error_message = 'semantic_tags must be a dictionary'
@@ -106,6 +124,10 @@ def test_check_semantic_tags_errors(sample_column_names):
     with pytest.raises(LookupError, match=error_message):
         _check_semantic_tags(sample_column_names, bad_semantic_tags_keys)
 
+    error_message = "semantic_tags for id must be a string, set or list"
+    with pytest.raises(TypeError, match=error_message):
+        _check_semantic_tags(sample_column_names, {'id': 1})
+
 
 def test_check_table_metadata_errors():
     error_message = 'Table metadata must be a dictionary.'
diff --git a/woodwork/tests/test_utils.py b/woodwork/tests/test_utils.py
index 080fe5d..b312c07 100644
--- a/woodwork/tests/test_utils.py
+++ b/woodwork/tests/test_utils.py
@@ -35,6 +35,7 @@ from woodwork.utils import (
     _is_valid_latlong_series,
     _is_valid_latlong_value,
     _new_dt_including,
+    _parse_logical_type,
     _reformat_to_latlong,
     _to_latlong_float,
     camel_to_snake,
@@ -489,23 +490,27 @@ def test_get_valid_mi_types():
 
 
 def test_get_column_logical_type(sample_series):
-    assert _get_column_logical_type(sample_series, 'Datetime', 'col_name') == Datetime
+    assert _get_column_logical_type(sample_series, None, 'col_name') == Categorical
+
     assert _get_column_logical_type(sample_series, Datetime, 'col_name') == Datetime
 
-    ymd_format = Datetime(datetime_format='%Y-%m-%d')
-    assert _get_column_logical_type(sample_series, ymd_format, 'col_name') == ymd_format
 
-    assert _get_column_logical_type(sample_series, None, 'col_name') == Categorical
+def test_parse_logical_type():
+    assert _parse_logical_type('Datetime', 'col_name') == Datetime
+    assert _parse_logical_type(Datetime, 'col_name') == Datetime
+
+    ymd_format = Datetime(datetime_format='%Y-%m-%d')
+    assert _parse_logical_type(ymd_format, 'col_name') == ymd_format
 
 
-def test_get_column_logical_type_errors(sample_series):
+def test_parse_logical_type_errors():
     error = 'Must use an Ordinal instance with order values defined'
     with pytest.raises(TypeError, match=error):
-        _get_column_logical_type(sample_series, 'Ordinal', 'col_name')
+        _parse_logical_type('Ordinal', 'col_name')
 
     with pytest.raises(TypeError, match=error):
-        _get_column_logical_type(sample_series, Ordinal, 'col_name')
+        _parse_logical_type(Ordinal, 'col_name')
 
     error = "Invalid logical type specified for 'col_name'"
     with pytest.raises(TypeError, match=error):
-        _get_column_logical_type(sample_series, int, 'col_name')
+        _parse_logical_type(int, 'col_name')