Repository: alteryx/woodwork (python)

Issue:
Improve error when calling accessor methods
The new WoodworkTableAccessor methods that were added as part of #660 as well as the `select` method do not check that Woodwork is initialized before continuing. This result in an error message that is unhelpful if called on an uninitialized dataframe: `AttributeError: 'NoneType' object has no attribute 'reset_semantic_tags'`

These methods should be updated:
- select
- set_time_index
- add_semantic_tags
- remove_semantic_tags
- reset_semantic_tags

The following check should be added at the start of the methods:
```python
if self._schema is None:
    _raise_init_error()
```

Pull request:
Improve error when calling accessor methods or properties before init
- Improve error when calling accessor methods or properties before init
- Closes #667 

Adds a check to numerous methods as well as corresponding tests to make sure that a helpful error is raised if a user attempts to access a Woodwork property or method before first initializing Woodwork.

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 4340886..4103602 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -51,8 +51,8 @@ Release Notes
         * Preserve pandas underlying index when not creating a Woodwork index (:pr:`664`)
         * Restrict Koalas version to ``<1.7.0`` due to breaking changes (:pr:`674`)
         * Clean up dtype usage across Woodwork (:pr:`682`)
+        * Improve error when calling accessor properties or methods before init (:pr:`683`)
         * Remove dtype from Schema dictionary (:pr:`685`)
-
     * Documentation Changes
         * Update docstrings and API Reference page (:pr:`660`)
     * Testing Changes
diff --git a/woodwork/column_accessor.py b/woodwork/column_accessor.py
index 9c049d1..6505bb9 100644
--- a/woodwork/column_accessor.py
+++ b/woodwork/column_accessor.py
@@ -71,6 +71,8 @@ class WoodworkColumnAccessor:
 
     @description.setter
     def description(self, description):
+        if self._schema is None:
+            _raise_init_error()
         _validate_description(description)
         self._schema['description'] = description
 
@@ -143,6 +145,8 @@ class WoodworkColumnAccessor:
 
     @metadata.setter
     def metadata(self, metadata):
+        if self._schema is None:
+            _raise_init_error()
         _validate_metadata(metadata)
         self._schema['metadata'] = metadata
 
@@ -233,6 +237,8 @@ class WoodworkColumnAccessor:
         Args:
             semantic_tags (str/list/set): New semantic tag(s) to add
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema['semantic_tags'] = _add_semantic_tags(semantic_tags,
                                                            self.semantic_tags,
                                                            self._series.name)
@@ -243,6 +249,8 @@ class WoodworkColumnAccessor:
         Args:
             semantic_tags (str/list/set): Semantic tag(s) to remove.
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema['semantic_tags'] = _remove_semantic_tags(semantic_tags,
                                                               self.semantic_tags,
                                                               self._series.name,
@@ -257,6 +265,8 @@ class WoodworkColumnAccessor:
         Args:
             None
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema['semantic_tags'] = _reset_semantic_tags(self.logical_type.standard_tags,
                                                              self.use_standard_tags)
 
@@ -270,6 +280,8 @@ class WoodworkColumnAccessor:
         Returns:
             Series: A new series with the updated logical type.
         """
+        if self._schema is None:
+            _raise_init_error()
         # Create a new series without a schema to prevent new series from sharing a common
         # schema with current series
         new_series = self._series.copy()
@@ -289,6 +301,8 @@ class WoodworkColumnAccessor:
         Args:
             semantic_tags (str/list/set): New semantic tag(s) to set
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema['semantic_tags'] = _set_semantic_tags(semantic_tags,
                                                            self.logical_type.standard_tags,
                                                            self.use_standard_tags)
diff --git a/woodwork/table_accessor.py b/woodwork/table_accessor.py
index 9168f61..a9867ed 100644
--- a/woodwork/table_accessor.py
+++ b/woodwork/table_accessor.py
@@ -149,6 +149,8 @@ class WoodworkTableAccessor:
             raise AttributeError(f"Woodwork has no attribute '{attr}'")
 
     def __getitem__(self, key):
+        if self._schema is None:
+            _raise_init_error()
         if isinstance(key, list):
             columns = set(self._dataframe.columns)
             diff = list(set(key).difference(columns))
@@ -248,32 +250,44 @@ class WoodworkTableAccessor:
     @property
     def physical_types(self):
         """A dictionary containing physical types for each column"""
+        if self._schema is None:
+            _raise_init_error()
         return {col_name: _get_valid_dtype(type(self._dataframe[col_name]), self.schema.logical_types[col_name]) for col_name in self._dataframe.columns}
 
     @property
     def types(self):
         """DataFrame containing the physical dtypes, logical types and semantic
         tags for the Schema."""
+        if self._schema is None:
+            _raise_init_error()
         return self._get_typing_info()
 
     @property
     def logical_types(self):
         """A dictionary containing logical types for each column"""
+        if self._schema is None:
+            _raise_init_error()
         return self._schema.logical_types
 
     @property
     def semantic_tags(self):
         """A dictionary containing semantic tags for each column"""
+        if self._schema is None:
+            _raise_init_error()
         return self._schema.semantic_tags
 
     @property
     def index(self):
         """The index column for the table"""
+        if self._schema is None:
+            _raise_init_error()
         return self._schema.index
 
     @property
     def time_index(self):
         """The time index column for the table"""
+        if self._schema is None:
+            _raise_init_error()
         return self._schema.time_index
 
     def set_index(self, new_index):
@@ -288,6 +302,8 @@ class WoodworkTableAccessor:
         Args:
             new_index (str): The name of the column to set as the index
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema.set_index(new_index)
 
         if self._schema.index is not None:
@@ -302,6 +318,8 @@ class WoodworkTableAccessor:
             new_time_index (str): The name of the column to set as the time index.
                 If None, will remove the time_index.
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema.set_time_index(new_time_index)
 
     def set_types(self, logical_types=None, semantic_tags=None, retain_index_tags=True):
@@ -317,6 +335,8 @@ class WoodworkTableAccessor:
                 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.
         """
+        if self._schema is None:
+            _raise_init_error()
         logical_types = logical_types or {}
         logical_types = {col_name: _parse_logical_type(ltype, col_name) for col_name, ltype in logical_types.items()}
 
@@ -346,6 +366,8 @@ class WoodworkTableAccessor:
             logical types and semantic tags in ``include``. Has Woodwork typing
             information initialized.
         """
+        if self._schema is None:
+            _raise_init_error()
         cols_to_include = self._schema._filter_cols(include)
         return self._get_subset_df_with_schema(cols_to_include)
 
@@ -357,6 +379,8 @@ class WoodworkTableAccessor:
             semantic_tags (dict[str -> str/list/set]): A dictionary mapping the columns
                 in the DataFrame to the tags that should be added to the column's semantic tags
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema.add_semantic_tags(semantic_tags)
 
     def remove_semantic_tags(self, semantic_tags):
@@ -368,6 +392,8 @@ class WoodworkTableAccessor:
             semantic_tags (dict[str -> str/list/set]): A dictionary mapping the columns
                 in the DataFrame to the tags that should be removed from the column's semantic tags
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema.remove_semantic_tags(semantic_tags)
 
     def reset_semantic_tags(self, columns=None, retain_index_tags=False):
@@ -383,6 +409,8 @@ class WoodworkTableAccessor:
                 semantic tags set on the column. If False, will clear all semantic tags. Defaults to
                 False.
         """
+        if self._schema is None:
+            _raise_init_error()
         self._schema.reset_semantic_tags(columns=columns, retain_index_tags=retain_index_tags)
 
     def to_dictionary(self):
@@ -447,7 +475,6 @@ class WoodworkTableAccessor:
         """
         if self._schema is None:
             _raise_init_error()
-
         import_error_message = (
             "The pyarrow library is required to serialize to parquet.\n"
             "Install via pip:\n"
@@ -550,6 +577,8 @@ class WoodworkTableAccessor:
         Returns:
             Series: Popped series with Woodwork initialized
         """
+        if self._schema is None:
+            _raise_init_error()
         if column_name not in self._dataframe.columns:
             raise LookupError(f'Column with name {column_name} not found in DataFrame')
 
@@ -578,6 +607,8 @@ class WoodworkTableAccessor:
             DataFrame directly and then reinitialize Woodwork with ``DataFrame.ww.init``
             instead of calling ``DataFrame.ww.drop``.
         """
+        if self._schema is None:
+            _raise_init_error()
         if not isinstance(columns, (list, set)):
             columns = [columns]
 
@@ -596,6 +627,8 @@ class WoodworkTableAccessor:
         Returns:
             DataFrame: DataFrame with the specified columns renamed, maintaining Woodwork typing information.
         """
+        if self._schema is None:
+            _raise_init_error()
         new_schema = self._schema.rename(columns)
         new_df = self._dataframe.rename(columns=columns)
 
@@ -622,6 +655,8 @@ class WoodworkTableAccessor:
             Mutual information values are between 0 (no mutual information) and 1
             (perfect dependency).
         """
+        if self._schema is None:
+            _raise_init_error()
         return _get_mutual_information_dict(self._dataframe, num_bins=num_bins, nrows=nrows)
 
     def mutual_information(self, num_bins=10, nrows=None):
@@ -661,6 +696,8 @@ class WoodworkTableAccessor:
             matching the logical types, semantic tags or column names specified in ``include``, paired
             with a value containing a dictionary containing relevant statistics for that column.
         """
+        if self._schema is None:
+            _raise_init_error()
         return _get_describe_dict(self._dataframe, include=include)
 
     def describe(self, include=None):
@@ -718,6 +755,8 @@ class WoodworkTableAccessor:
             list(dict): a list of dictionaries for each categorical column with keys `count`
             and `value`.
         """
+        if self._schema is None:
+            _raise_init_error()
         return _get_value_counts(self._dataframe, ascending, top_n, dropna)
 
 


Test patch:
diff --git a/woodwork/tests/accessor/test_column_accessor.py b/woodwork/tests/accessor/test_column_accessor.py
index 5214b5b..3379ed5 100644
--- a/woodwork/tests/accessor/test_column_accessor.py
+++ b/woodwork/tests/accessor/test_column_accessor.py
@@ -5,6 +5,7 @@ import pandas as pd
 import pytest
 
 from woodwork.accessor_utils import init_series
+from woodwork.column_accessor import WoodworkColumnAccessor
 from woodwork.exceptions import (
     DuplicateTagsWarning,
     TypeConversionError,
@@ -22,7 +23,12 @@ from woodwork.logical_types import (
     SubRegionCode,
     ZIPCode
 )
-from woodwork.tests.testing_utils import convert_series, to_pandas
+from woodwork.tests.testing_utils import (
+    convert_series,
+    is_property,
+    is_public_method,
+    to_pandas
+)
 from woodwork.utils import import_or_none
 
 dd = import_or_none('dask.dataframe')
@@ -73,23 +79,37 @@ def test_accessor_init_with_semantic_tags(sample_series):
     assert series.ww.semantic_tags == set(semantic_tags)
 
 
-def test_accessor_warnings_accessing_properties_before_init(sample_series):
-    error_message = "Woodwork not initialized for this Series. Initialize by calling Series.ww.init"
+def test_error_accessing_properties_before_init(sample_series):
+    props_to_exclude = ['iloc', 'loc']
+    props = [prop for prop in dir(sample_series.ww) if is_property(WoodworkColumnAccessor, prop) and prop not in props_to_exclude]
 
-    with pytest.raises(AttributeError, match=error_message):
-        sample_series.ww.__repr__()
+    error = "Woodwork not initialized for this Series. Initialize by calling Series.ww.init"
+    for prop in props:
+        with pytest.raises(AttributeError, match=error):
+            getattr(sample_series.ww, prop)
 
-    with pytest.raises(AttributeError, match=error_message):
-        sample_series.ww.description
 
-    with pytest.raises(AttributeError, match=error_message):
-        sample_series.ww.logical_type
+def test_error_accessing_methods_before_init(sample_series):
+    methods_to_exclude = ['init']
+    public_methods = [method for method in dir(sample_series.ww) if is_public_method(WoodworkColumnAccessor, method)]
+    public_methods = [method for method in public_methods if method not in methods_to_exclude]
 
-    with pytest.raises(AttributeError, match=error_message):
-        sample_series.ww.metadata
-
-    with pytest.raises(AttributeError, match=error_message):
-        sample_series.ww.semantic_tags
+    method_args_dict = {
+        'add_semantic_tags': [{'new_tag'}],
+        'remove_semantic_tags': [{'new_tag'}],
+        'reset_semantic_tags': None,
+        'set_logical_type': ['Integer'],
+        'set_semantic_tags': [{'new_tag'}]
+    }
+    error = "Woodwork not initialized for this Series. Initialize by calling Series.ww.init"
+    for method in public_methods:
+        func = getattr(sample_series.ww, method)
+        method_args = method_args_dict[method]
+        with pytest.raises(AttributeError, match=error):
+            if method_args:
+                func(*method_args)
+            else:
+                func()
 
 
 def test_accessor_with_alternate_semantic_tags_input(sample_series):
@@ -141,6 +161,13 @@ def test_accessor_description(sample_series):
     assert series.ww.description == new_description
 
 
+def test_description_setter_error_before_init(sample_series):
+    series = convert_series(sample_series, Categorical)
+    err_msg = "Woodwork not initialized for this Series. Initialize by calling Series.ww.init"
+    with pytest.raises(AttributeError, match=err_msg):
+        series.ww.description = "description"
+
+
 def test_description_error_on_init(sample_series):
     series = convert_series(sample_series, Categorical)
     err_msg = "Column description must be a string"
@@ -168,6 +195,13 @@ def test_accessor_repr(sample_series):
         '(Logical Type = Categorical) (Semantic Tags = set())>'
 
 
+def test_accessor_repr_error_before_init(sample_series):
+    series = convert_series(sample_series, Categorical)
+    err_msg = "Woodwork not initialized for this Series. Initialize by calling Series.ww.init"
+    with pytest.raises(AttributeError, match=err_msg):
+        series.ww.__repr__()
+
+
 def test_set_semantic_tags(sample_series):
     series = convert_series(sample_series, Categorical)
     semantic_tags = {'tag1', 'tag2'}
@@ -678,6 +712,13 @@ def test_accessor_metadata(sample_series):
     assert series.ww.metadata == {'date_created': '1/1/19', 'metadata_field': [1, 2, 3], 'number': 1012034}
 
 
+def test_metadata_setter_error_before_init(sample_series):
+    series = convert_series(sample_series, Categorical)
+    err_msg = "Woodwork not initialized for this Series. Initialize by calling Series.ww.init"
+    with pytest.raises(AttributeError, match=err_msg):
+        series.ww.metadata = {"key": "val"}
+
+
 def test_accessor_metadata_error_on_init(sample_series):
     series = convert_series(sample_series, Categorical)
     err_msg = "Column metadata must be a dictionary"
diff --git a/woodwork/tests/accessor/test_table_accessor.py b/woodwork/tests/accessor/test_table_accessor.py
index 2b65442..9d0c5bd 100644
--- a/woodwork/tests/accessor/test_table_accessor.py
+++ b/woodwork/tests/accessor/test_table_accessor.py
@@ -32,13 +32,19 @@ from woodwork.logical_types import (
 )
 from woodwork.schema import Schema
 from woodwork.table_accessor import (
+    WoodworkTableAccessor,
     _check_index,
     _check_logical_types,
     _check_time_index,
     _check_unique_column_names,
     _get_invalid_schema_message
 )
-from woodwork.tests.testing_utils import to_pandas, validate_subset_schema
+from woodwork.tests.testing_utils import (
+    is_property,
+    is_public_method,
+    to_pandas,
+    validate_subset_schema
+)
 from woodwork.utils import import_or_none
 
 dd = import_or_none('dask.dataframe')
@@ -160,6 +166,54 @@ def test_init_accessor_with_schema(sample_df):
     assert iloc_df.ww.logical_types['id'] == Integer
 
 
+def test_accessor_init_errors_methods(sample_df):
+    methods_to_exclude = ['init']
+    public_methods = [method for method in dir(sample_df.ww) if is_public_method(WoodworkTableAccessor, method)]
+    public_methods = [method for method in public_methods if method not in methods_to_exclude]
+    method_args_dict = {
+        'add_semantic_tags': [{'id': 'new_tag'}],
+        'describe': None,
+        'pop': ['id'],
+        'describe': None,
+        'describe_dict': None,
+        'drop': ['id'],
+        'mutual_information': None,
+        'mutual_information_dict': None,
+        'remove_semantic_tags': [{'id': 'new_tag'}],
+        'rename': [{'id': 'new_id'}],
+        'reset_semantic_tags': None,
+        'select': [['Double']],
+        'set_index': ['id'],
+        'set_time_index': ['signup_date'],
+        'set_types': [{'id': 'Integer'}],
+        'to_csv': ['dir'],
+        'to_dictionary': None,
+        'to_parquet': ['dir'],
+        'to_pickle': ['dir'],
+        'value_counts': None,
+
+    }
+    error = re.escape("Woodwork not initialized for this DataFrame. Initialize by calling DataFrame.ww.init")
+    for method in public_methods:
+        func = getattr(sample_df.ww, method)
+        method_args = method_args_dict[method]
+        with pytest.raises(AttributeError, match=error):
+            if method_args:
+                func(*method_args)
+            else:
+                func()
+
+
+def test_accessor_init_errors_properties(sample_df):
+    props_to_exclude = ['iloc', 'loc', 'schema']
+    props = [prop for prop in dir(sample_df.ww) if is_property(WoodworkTableAccessor, prop) and prop not in props_to_exclude]
+
+    error = re.escape("Woodwork not initialized for this DataFrame. Initialize by calling DataFrame.ww.init")
+    for prop in props:
+        with pytest.raises(AttributeError, match=error):
+            getattr(sample_df.ww, prop)
+
+
 def test_init_accessor_with_schema_errors(sample_df):
     schema_df = sample_df.copy()
     schema_df.ww.init()
@@ -262,6 +316,12 @@ def test_getitem(sample_df):
     assert series.ww.semantic_tags == {'numeric'}
 
 
+def test_getitem_init_error(sample_df):
+    error = re.escape("Woodwork not initialized for this DataFrame. Initialize by calling DataFrame.ww.init")
+    with pytest.raises(AttributeError, match=error):
+        sample_df.ww['age']
+
+
 def test_getitem_invalid_input(sample_df):
     df = sample_df
     df.ww.init()
diff --git a/woodwork/tests/testing_utils/__init__.py b/woodwork/tests/testing_utils/__init__.py
index 9396899..4736c41 100644
--- a/woodwork/tests/testing_utils/__init__.py
+++ b/woodwork/tests/testing_utils/__init__.py
@@ -2,6 +2,8 @@
 from .column_utils import convert_series
 from .datatable_utils import (
     check_column_order,
+    is_property,
+    is_public_method,
     mi_between_cols,
     to_pandas,
     validate_subset_dt,
diff --git a/woodwork/tests/testing_utils/datatable_utils.py b/woodwork/tests/testing_utils/datatable_utils.py
index 9a3ffc2..9c6704a 100644
--- a/woodwork/tests/testing_utils/datatable_utils.py
+++ b/woodwork/tests/testing_utils/datatable_utils.py
@@ -66,3 +66,18 @@ def check_column_order(dt, new_dt):
     assert all(dt.types.index == new_dt.types.index)
     # Confirm types df matches column order
     assert all(new_dt.to_dataframe().columns == new_dt.types.index)
+
+
+def is_public_method(class_to_check, name):
+    """Determine if the specified name is a public method on a class"""
+    if hasattr(class_to_check, name) and name[0] != '_':
+        if callable(getattr(class_to_check, name)):
+            return True
+    return False
+
+
+def is_property(class_to_check, name):
+    """Determine if the specified name is a property on a class"""
+    if hasattr(class_to_check, name) and isinstance(getattr(class_to_check, name), property):
+        return True
+    return False