Repository: unionai-oss/pandera

Issue:
Serialization to YAML with custom checks (statistic has a different type than the variable itself (datetime, pandas freq))
**Describe the bug**

Stacktrace
```
~/miniconda3/lib/python3.8/site-packages/pandera/io.py in handle_stat_dtype(stat)
     34     def handle_stat_dtype(stat):
     35         if pandas_dtype == PandasDtype.DateTime:
---> 36             return stat.strftime(DATETIME_FORMAT)
     37         elif pandas_dtype == PandasDtype.Timedelta:
     38             # serialize to int in nanoseconds

AttributeError: 'str' object has no attribute 'strftime'
```
I am trying to serialize a schema with a custom check.
I have tried both `.to_yaml()` and `to_script()`

Above is the produced stacktrace.
`stat` is the statistic from the custom check decorator, which is not from the same datatype as the column.
Here the statistic has to be different than the type of the variable.
Is there any way to decouple this?

- [x] I have checked that this issue has not already been reported.
- [x] I have confirmed this bug exists on the latest version of pandera.
- [ ] (optional) I have confirmed this bug exists on the master branch of pandera.


#### Code Sample, a copy-pastable example

```python
import pandas as pd
import pandera as pa
import pandera.extensions as extensions
import numpy as np

# data to validate
df = pd.DataFrame({
    "col": [1, 4, 0, np.nan, np.nan],
    "timestamp": [pd.Timestamp('2021-01-01 12:00:00'), pd.Timestamp('2021-01-01 12:10:00'), pd.Timestamp('2021-01-01 12:20:00'), pd.Timestamp('2021-01-01 12:30:00'), pd.Timestamp('2021-01-01 12:40:00'),]
})
df = df.set_index('timestamp')

CFG = {

    "col": {
        "dtype": "int64",
                         "index": False,

        "validation": {
            "clean": {
                "missing_pct": {
                    "max_value": 0.2, "tolerance": 0.10, "raise_warning": True
                }
            }
            
        }
    },
    "timestamp": {
        "dtype": 'datetime64[ns]',
        "index": True,
        "validation": {
            "clean": {
                "monotonic_datetime": {
                    "freqq": '10T', "raise_warning": True
                }
            }
        }
    }
}

@extensions.register_check_method(
    statistics=['freqq']
    #strategy=nan_pct_strategy
)
def monotonic_datetime(pandas_obj, *, freqq):
    """TEST DOCUMNETATION"""
    in_ns = pd.to_timedelta(freqq).to_timedelta64()
    
    res = pandas_obj - pandas_obj.shift(1)
    # Skip first NaT - results from shifting
    mask = np.equal(in_ns, res.values[1:]) 
    return np.all(mask)

@extensions.register_check_method(
    statistics=["max_value", "tolerance"],
    #strategy=nan_pct_strategy
)
def missing_pct(pandas_obj, *, max_value, tolerance):
    """TEST DOCUMNETATION"""
    nan_pct = pandas_obj.isnull().sum() / len(pandas_obj)
    return nan_pct < max_value + tolerance
    #return (min_value <= pandas_obj) & (pandas_obj <= max_value)
    

    
def checks_from_dict(dct):
    chks = []
    for k, v in dct.items():
        chk_cls = get_check(k)
        chk = chk_cls(**v)
        chks.append(chk)
    return chks
    
def get_check(name):
    return getattr(pa.Check, name)

def schema_from_dict(dct, stage):
    ndct = {}
    for col, v in dct.items():
        dtype = v["dtype"]
        chks = checks_from_dict(v["validation"][stage])

        if v['index']:
            idx_col = pa.Index(dtype, name=col, checks=chks, nullable=False, allow_duplicates=False)
        else:
            ndct[col] = pa.Column(dtype, checks=chks, nullable=True)
    return pa.DataFrameSchema(
        columns=ndct,
        index=idx_col
    )

schema = schema_from_dict(CFG, "clean")
print(schema.to_yaml())
```



#### Expected behavior
I expect to be able to output a serialization to yaml, even if a statistic from a check is not from the same datatype as the column. This is a big assumption.




Discussion:

Resolving patch:
diff --git a/pandera/io.py b/pandera/io.py
--- a/pandera/io.py
+++ b/pandera/io.py
@@ -41,10 +41,17 @@ def _serialize_check_stats(check_stats, dtype=None):
     """Serialize check statistics into json/yaml-compatible format."""
 
     def handle_stat_dtype(stat):
-        if pandas_engine.Engine.dtype(dtypes.DateTime).check(dtype):
+        if pandas_engine.Engine.dtype(dtypes.DateTime).check(
+            dtype
+        ) and hasattr(stat, "strftime"):
+            # try serializing stat as a string if it's datetime-like,
+            # otherwise return original value
             return stat.strftime(DATETIME_FORMAT)
-        elif pandas_engine.Engine.dtype(dtypes.Timedelta).check(dtype):
-            # serialize to int in nanoseconds
+        elif pandas_engine.Engine.dtype(dtypes.Timedelta).check(
+            dtype
+        ) and hasattr(stat, "delta"):
+            # try serializing stat into an int in nanoseconds if it's
+            # timedelta-like, otherwise return original value
             return stat.delta
 
         return stat
@@ -146,11 +153,14 @@ def _serialize_schema(dataframe_schema):
 
 def _deserialize_check_stats(check, serialized_check_stats, dtype=None):
     def handle_stat_dtype(stat):
-        if pandas_engine.Engine.dtype(dtypes.DateTime).check(dtype):
-            return pd.to_datetime(stat, format=DATETIME_FORMAT)
-        elif pandas_engine.Engine.dtype(dtypes.Timedelta).check(dtype):
-            # serialize to int in nanoseconds
-            return pd.to_timedelta(stat, unit="ns")
+        try:
+            if pandas_engine.Engine.dtype(dtypes.DateTime).check(dtype):
+                return pd.to_datetime(stat, format=DATETIME_FORMAT)
+            elif pandas_engine.Engine.dtype(dtypes.Timedelta).check(dtype):
+                # serialize to int in nanoseconds
+                return pd.to_timedelta(stat, unit="ns")
+        except (TypeError, ValueError):
+            return stat
         return stat
 
     if isinstance(serialized_check_stats, dict):


Test patch:
diff --git a/tests/io/test_io.py b/tests/io/test_io.py
--- a/tests/io/test_io.py
+++ b/tests/io/test_io.py
@@ -609,8 +609,8 @@ def test_to_yaml_custom_dataframe_check():
     # `test_to_yaml_lambda_check`
 
 
-def test_to_yaml_bugfix_419():
-    """Ensure that GH#419 is fixed"""
+def test_to_yaml_bugfix_warn_unregistered_global_checks():
+    """Ensure that unregistered global checks raises a warning."""
     # pylint: disable=no-self-use
 
     class CheckedSchemaModel(pandera.SchemaModel):
@@ -628,6 +628,34 @@ def unregistered_check(self, _):
         CheckedSchemaModel.to_yaml()
 
 
+def test_serialize_deserialize_custom_datetime_checks():
+    """
+    Test that custom checks for datetime columns can be serialized and
+    deserialized
+    """
+
+    # pylint: disable=unused-variable,unused-argument
+    @pandera.extensions.register_check_method(statistics=["stat"])
+    def datetime_check(pandas_obj, *, stat):
+        ...
+
+    schema = pandera.DataFrameSchema(
+        {
+            "dt_col": pandera.Column(
+                pandera.DateTime,
+                checks=pandera.Check.datetime_check("foobar"),
+            ),
+            "td_col": pandera.Column(
+                pandera.Timedelta,
+                checks=pandera.Check.datetime_check("foobar"),
+            ),
+        }
+    )
+    yaml_schema = schema.to_yaml()
+    schema_from_yaml = schema.from_yaml(yaml_schema)
+    assert schema_from_yaml == schema
+
+
 FRICTIONLESS_YAML = yaml.safe_load(
     """
 fields: