diff --git a/pandas/core/accessors.py b/pandas/core/accessors.py new file mode 100644 index 0000000000000..7e831c8a485fc --- /dev/null +++ b/pandas/core/accessors.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from pandas.core.base import PandasObject +from pandas.core.common import AbstractMethodError + + +class PandasDelegate(PandasObject): + """ an abstract base class for delegating methods/properties + + Usage: To make a custom accessor, subclass `PandasDelegate`, overriding + the methods below. Then decorate this subclass with + `accessors.wrap_delegate_names` describing the methods and properties + that should be delegated. + + Examples can be found in: + + pandas.core.accessors.CategoricalAccessor + pandas.core.indexes.accessors (complicated example) + pandas.core.indexes.category.CategoricalIndex + pandas.core.strings.StringMethods + pandas.tests.test_accessors + + """ + + def __init__(self, values): + """ + The subclassed constructor will generally only be called by + _make_accessor. See _make_accessor.__doc__. + """ + self.values = values + + @classmethod + def _make_accessor(cls, data): # pragma: no cover + """ + _make_accessor should implement any necessary validation on the + data argument to ensure that the properties/methods being + accessed will be available. + + _make_accessor should return cls(data). If necessary, the arguments + to the constructor can be expanded. In this case, __init__ will + need to be overrided as well. + + Parameters + ---------- + data : the underlying object being accessed, usually Series or Index + + Returns + ------- + Delegate : instance of PandasDelegate or subclass + + """ + raise AbstractMethodError( + 'It is up to subclasses to implement ' + '_make_accessor. This does input validation on the object to ' + 'which the accessor is being pinned. ' + 'It should return an instance of `cls`.') + # return cls(data) + + def _delegate_property_get(self, name, *args, **kwargs): + raise TypeError("You cannot access the " + "property {name}".format(name=name)) + + def _delegate_property_set(self, name, value, *args, **kwargs): + """ + Overriding _delegate_property_set is discouraged. It is generally + better to directly interact with the underlying data than to + alter it via the accessor. + + An example that ignores this advice can be found in + tests.test_accessors.TestVectorizedAccessor + """ + raise TypeError("The property {name} cannot be set".format(name=name)) + + def _delegate_method(self, name, *args, **kwargs): + raise TypeError("You cannot call method {name}".format(name=name)) + + +class AccessorProperty(object): + """Descriptor for implementing accessor properties like Series.str + """ + + def __init__(self, accessor_cls, construct_accessor=None): + self.accessor_cls = accessor_cls + + if construct_accessor is None: + # accessor_cls._make_accessor must be a classmethod + construct_accessor = accessor_cls._make_accessor + + self.construct_accessor = construct_accessor + self.__doc__ = accessor_cls.__doc__ + + def __get__(self, instance, owner=None): + if instance is None: + # this ensures that Series.str. is well defined + return self.accessor_cls + return self.construct_accessor(instance) + + def __set__(self, instance, value): + raise AttributeError("can't set attribute") + + def __delete__(self, instance): + raise AttributeError("can't delete attribute") + + +class Delegator(object): + """ Delegator class contains methods that are used by PandasDelegate + and Accesor subclasses, but that so not ultimately belong in + the namespaces of user-facing classes. + + Many of these methods *could* be module-level functions, but are + retained as staticmethods for organization purposes. + """ + + @staticmethod + def create_delegator_property(name, delegate): + # Note: we really only need the `delegate` here for the docstring + + def _getter(self): + return self._delegate_property_get(name) + + def _setter(self, new_values): + return self._delegate_property_set(name, new_values) + # TODO: not hit in tests; not sure this is something we + # really want anyway + + _getter.__name__ = name + _setter.__name__ = name + _doc = getattr(delegate, name).__doc__ + return property(fget=_getter, fset=_setter, doc=_doc) + + @staticmethod + def create_delegator_method(name, delegate): + # Note: we really only need the `delegate` here for the docstring + + def func(self, *args, **kwargs): + return self._delegate_method(name, *args, **kwargs) + + func.__name__ = name + func.__doc__ = getattr(delegate, name).__doc__ + return func + + @staticmethod + def delegate_names(delegate, accessors, typ, overwrite=False): + """ + delegate_names decorates class definitions, e.g: + + @delegate_names(Categorical, ["categories", "ordered"], "property") + class CategoricalAccessor(PandasDelegate): + + @classmethod + def _make_accessor(cls, data): + [...] + + + The motivation is that we would like to keep as much of a class's + internals inside the class definition. For things that we cannot + keep directly in the class definition, a decorator is more directly + tied to the definition than a method call outside the definition. + + """ + # Note: we really only need the `delegate` here for the docstring + + def add_delegate_accessors(cls): + """ + add accessors to cls from the delegate class + + Parameters + ---------- + cls : the class to add the methods/properties to + delegate : the class to get methods/properties & doc-strings + acccessors : string list of accessors to add + typ : 'property' or 'method' + overwrite : boolean, default False + overwrite the method/property in the target class if it exists + """ + for name in accessors: + if typ == "property": + func = Delegator.create_delegator_property(name, delegate) + else: + func = Delegator.create_delegator_method(name, delegate) + + # don't overwrite existing methods/properties unless + # specifically told to do so + if overwrite or not hasattr(cls, name): + setattr(cls, name, func) + + return cls + + return add_delegate_accessors + + +wrap_delegate_names = Delegator.delegate_names +# TODO: the `delegate` arg to `wrap_delegate_names` is really only relevant +# for a docstring. It'd be nice if we didn't require it and could duck-type +# instead. + +# TODO: There are 2-3 implementations of `_delegate_method` +# and `_delegate_property` that are common enough that we should consider +# making them the defaults. First, if the series being accessed has `name` +# method/property: +# +# def _delegate_method(self, name, *args, **kwargs): +# result = getattr(self.values, name)(*args, **kwargs) +# return result +# +# def _delegate_property_get(self, name): +# result = getattr(self.values, name) +# return result +# +# +# Alternately if the series being accessed does not have this attribute, +# but is a series of objects that do have the attribute: +# +# def _delegate_method(self, name, *args, **kwargs): +# meth = lambda x: getattr(x, name)(*args, **kwargs) +# return self.values.apply(meth) +# +# def _delegate_property_get(self, name): +# prop = lambda x: getattr(x, name) +# return self.values.apply(prop) +# +# +# `apply` would need to be changed to `map` if self.values is an Index. +# +# The third thing to consider moving into the general case is +# core.strings.StringMethods._wrap_result, which handles a bunch of cases +# for how to wrap delegated outputs. diff --git a/pandas/core/base.py b/pandas/core/base.py index be021f3621c73..19f6728642645 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -153,100 +153,6 @@ def __setattr__(self, key, value): object.__setattr__(self, key, value) -class PandasDelegate(PandasObject): - """ an abstract base class for delegating methods/properties """ - - @classmethod - def _make_accessor(cls, data): - raise AbstractMethodError("_make_accessor should be implemented" - "by subclass and return an instance" - "of `cls`.") - - def _delegate_property_get(self, name, *args, **kwargs): - raise TypeError("You cannot access the " - "property {name}".format(name=name)) - - def _delegate_property_set(self, name, value, *args, **kwargs): - raise TypeError("The property {name} cannot be set".format(name=name)) - - def _delegate_method(self, name, *args, **kwargs): - raise TypeError("You cannot call method {name}".format(name=name)) - - @classmethod - def _add_delegate_accessors(cls, delegate, accessors, typ, - overwrite=False): - """ - add accessors to cls from the delegate class - - Parameters - ---------- - cls : the class to add the methods/properties to - delegate : the class to get methods/properties & doc-strings - acccessors : string list of accessors to add - typ : 'property' or 'method' - overwrite : boolean, default False - overwrite the method/property in the target class if it exists - """ - - def _create_delegator_property(name): - - def _getter(self): - return self._delegate_property_get(name) - - def _setter(self, new_values): - return self._delegate_property_set(name, new_values) - - _getter.__name__ = name - _setter.__name__ = name - - return property(fget=_getter, fset=_setter, - doc=getattr(delegate, name).__doc__) - - def _create_delegator_method(name): - - def f(self, *args, **kwargs): - return self._delegate_method(name, *args, **kwargs) - - f.__name__ = name - f.__doc__ = getattr(delegate, name).__doc__ - - return f - - for name in accessors: - - if typ == 'property': - f = _create_delegator_property(name) - else: - f = _create_delegator_method(name) - - # don't overwrite existing methods/properties - if overwrite or not hasattr(cls, name): - setattr(cls, name, f) - - -class AccessorProperty(object): - """Descriptor for implementing accessor properties like Series.str - """ - - def __init__(self, accessor_cls, construct_accessor=None): - self.accessor_cls = accessor_cls - self.construct_accessor = (construct_accessor or - accessor_cls._make_accessor) - self.__doc__ = accessor_cls.__doc__ - - def __get__(self, instance, owner=None): - if instance is None: - # this ensures that Series.str. is well defined - return self.accessor_cls - return self.construct_accessor(instance) - - def __set__(self, instance, value): - raise AttributeError("can't set attribute") - - def __delete__(self, instance): - raise AttributeError("can't delete attribute") - - class GroupByError(Exception): pass diff --git a/pandas/core/categorical.py b/pandas/core/categorical.py index ddca93f07ad5e..14c867dd169ba 100644 --- a/pandas/core/categorical.py +++ b/pandas/core/categorical.py @@ -29,7 +29,8 @@ from pandas.core.common import is_null_slice, _maybe_box_datetimelike from pandas.core.algorithms import factorize, take_1d, unique1d -from pandas.core.base import (PandasObject, PandasDelegate, +from pandas.core import accessors +from pandas.core.base import (PandasObject, NoNewAttributesMixin, _shared_docs) import pandas.core.common as com from pandas.core.missing import interpolate_2d @@ -2031,7 +2032,20 @@ def repeat(self, repeats, *args, **kwargs): # The Series.cat accessor -class CategoricalAccessor(PandasDelegate, NoNewAttributesMixin): +@accessors.wrap_delegate_names(delegate=Categorical, + accessors=["rename_categories", + "reorder_categories", + "add_categories", + "remove_categories", + "remove_unused_categories", + "set_categories", + "as_ordered", + "as_unordered"], + typ="method") +@accessors.wrap_delegate_names(delegate=Categorical, + accessors=["categories", "ordered"], + typ="property") +class CategoricalDelegate(accessors.PandasDelegate, NoNewAttributesMixin): """ Accessor object for categorical properties of the Series values. @@ -2054,10 +2068,17 @@ class CategoricalAccessor(PandasDelegate, NoNewAttributesMixin): """ - def __init__(self, values, index, name): - self.categorical = values - self.index = index - self.name = name + + @classmethod + def _make_accessor(cls, values): + if not is_categorical_dtype(values.dtype): + msg = "Can only use .cat accessor with a 'category' dtype" + raise AttributeError(msg) + return CategoricalDelegate(values) + + def __init__(self, values): + self.categorical = values.values + self.index = values.index self._freeze() def _delegate_property_get(self, name): @@ -2066,11 +2087,6 @@ def _delegate_property_get(self, name): def _delegate_property_set(self, name, new_values): return setattr(self.categorical, name, new_values) - @property - def codes(self): - from pandas import Series - return Series(self.categorical.codes, index=self.index) - def _delegate_method(self, name, *args, **kwargs): from pandas import Series method = getattr(self.categorical, name) @@ -2086,19 +2102,14 @@ def _make_accessor(cls, data): return CategoricalAccessor(data.values, data.index, getattr(data, 'name', None),) + @property + def codes(self): + from pandas import Series + return Series(self.categorical.codes, index=self.index) -CategoricalAccessor._add_delegate_accessors(delegate=Categorical, - accessors=["categories", - "ordered"], - typ='property') -CategoricalAccessor._add_delegate_accessors(delegate=Categorical, accessors=[ - "rename_categories", "reorder_categories", "add_categories", - "remove_categories", "remove_unused_categories", "set_categories", - "as_ordered", "as_unordered"], typ='method') # utility routines - def _get_codes_for_values(values, categories): """ utility routine to turn values into codes given the specified categories diff --git a/pandas/core/frame.py b/pandas/core/frame.py index dd5d490ea66a8..c7f8d9778041b 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -90,10 +90,9 @@ from pandas.core.indexes.datetimes import DatetimeIndex from pandas.core.indexes.timedeltas import TimedeltaIndex -import pandas.core.base as base +from pandas.core import accessors, nanops, ops import pandas.core.common as com -import pandas.core.nanops as nanops -import pandas.core.ops as ops + import pandas.io.formats.format as fmt import pandas.io.formats.console as console from pandas.io.formats.printing import pprint_thing @@ -5893,7 +5892,8 @@ def isin(self, values): # ---------------------------------------------------------------------- # Add plotting methods to DataFrame - plot = base.AccessorProperty(gfx.FramePlotMethods, gfx.FramePlotMethods) + plot = accessors.AccessorProperty(gfx.FramePlotMethods, + gfx.FramePlotMethods) hist = gfx.hist_frame boxplot = gfx.boxplot_frame diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index 88297ac70984d..e04a7b5b2eef4 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -11,7 +11,9 @@ is_timedelta64_dtype, is_categorical_dtype, is_list_like) -from pandas.core.base import PandasDelegate, NoNewAttributesMixin +from pandas.core import accessors + +from pandas.core.base import NoNewAttributesMixin from pandas.core.indexes.datetimes import DatetimeIndex from pandas._libs.period import IncompatibleFrequency # noqa from pandas.core.indexes.period import PeriodIndex @@ -61,27 +63,28 @@ def maybe_to_datetimelike(data, copy=False): data = orig.values.categories if is_datetime64_dtype(data.dtype): - return DatetimeProperties(DatetimeIndex(data, copy=copy), - index, name=name, orig=orig) + return DatetimeDelegate(DatetimeIndex(data, copy=copy), + index, name=name, orig=orig) elif is_datetime64tz_dtype(data.dtype): - return DatetimeProperties(DatetimeIndex(data, copy=copy), - index, data.name, orig=orig) + return DatetimeDelegate(DatetimeIndex(data, copy=copy), + index, data.name, orig=orig) elif is_timedelta64_dtype(data.dtype): - return TimedeltaProperties(TimedeltaIndex(data, copy=copy), index, - name=name, orig=orig) + return TimedeltaDelegate(TimedeltaIndex(data, copy=copy), index, + name=name, orig=orig) + else: if is_period_arraylike(data): - return PeriodProperties(PeriodIndex(data, copy=copy), index, - name=name, orig=orig) + return PeriodDelegate(PeriodIndex(data, copy=copy), index, + name=name, orig=orig) if is_datetime_arraylike(data): - return DatetimeProperties(DatetimeIndex(data, copy=copy), index, - name=name, orig=orig) + return DatetimeDelegate(DatetimeIndex(data, copy=copy), index, + name=name, orig=orig) raise TypeError("cannot convert an object of type {0} to a " "datetimelike index".format(type(data))) -class Properties(PandasDelegate, NoNewAttributesMixin): +class BaseDatetimeDelegate(accessors.PandasDelegate, NoNewAttributesMixin): def __init__(self, values, index, name, orig=None): self.values = values @@ -91,7 +94,7 @@ def __init__(self, values, index, name, orig=None): self._freeze() def _delegate_property_get(self, name): - from pandas import Series + from pandas import Series, DataFrame result = getattr(self.values, name) @@ -101,6 +104,9 @@ def _delegate_property_get(self, name): result = result.astype('int64') elif not is_list_like(result): return result + elif isinstance(result, DataFrame): + # e.g. TimedeltaDelegate.components + return result.set_index(self.index) result = np.asarray(result) @@ -142,7 +148,18 @@ def _delegate_method(self, name, *args, **kwargs): return result -class DatetimeProperties(Properties): +# An alternative to decorating with @accessors.wrap_delegate_names +# is to define each method individually, e.g.: +# to_period = PandasDelegate._make_delegate_accessor(delegate=DatetimeIndex, +# name='to_period', +# typ='method') +@accessors.wrap_delegate_names(delegate=DatetimeIndex, + accessors=DatetimeIndex._datetimelike_ops, + typ='property') +@accessors.wrap_delegate_names(delegate=DatetimeIndex, + accessors=DatetimeIndex._datetimelike_methods, + typ='method') +class DatetimeDelegate(BaseDatetimeDelegate): """ Accessor object for datetimelike properties of the Series values. @@ -164,17 +181,13 @@ def freq(self): return self.values.inferred_freq -DatetimeProperties._add_delegate_accessors( - delegate=DatetimeIndex, - accessors=DatetimeIndex._datetimelike_ops, - typ='property') -DatetimeProperties._add_delegate_accessors( - delegate=DatetimeIndex, - accessors=DatetimeIndex._datetimelike_methods, - typ='method') - - -class TimedeltaProperties(Properties): +@accessors.wrap_delegate_names(delegate=TimedeltaIndex, + accessors=TimedeltaIndex._datetimelike_ops, + typ='property') +@accessors.wrap_delegate_names(delegate=TimedeltaIndex, + accessors=TimedeltaIndex._datetimelike_methods, + typ='method') +class TimedeltaDelegate(BaseDatetimeDelegate): """ Accessor object for datetimelike properties of the Series values. @@ -190,6 +203,7 @@ class TimedeltaProperties(Properties): def to_pytimedelta(self): return self.values.to_pytimedelta() + # TODO: Do this with wrap_delegate_names @property def components(self): """ @@ -208,17 +222,13 @@ def freq(self): return self.values.inferred_freq -TimedeltaProperties._add_delegate_accessors( - delegate=TimedeltaIndex, - accessors=TimedeltaIndex._datetimelike_ops, - typ='property') -TimedeltaProperties._add_delegate_accessors( - delegate=TimedeltaIndex, - accessors=TimedeltaIndex._datetimelike_methods, - typ='method') - - -class PeriodProperties(Properties): +@accessors.wrap_delegate_names(delegate=PeriodIndex, + accessors=PeriodIndex._datetimelike_ops, + typ='property') +@accessors.wrap_delegate_names(delegate=PeriodIndex, + accessors=PeriodIndex._datetimelike_methods, + typ='method') +class PeriodDelegate(BaseDatetimeDelegate): """ Accessor object for datetimelike properties of the Series values. @@ -233,26 +243,20 @@ class PeriodProperties(Properties): """ -PeriodProperties._add_delegate_accessors( - delegate=PeriodIndex, - accessors=PeriodIndex._datetimelike_ops, - typ='property') -PeriodProperties._add_delegate_accessors( - delegate=PeriodIndex, - accessors=PeriodIndex._datetimelike_methods, - typ='method') - - -class CombinedDatetimelikeProperties(DatetimeProperties, TimedeltaProperties): +class CombinedDatetimelikeDelegate(DatetimeDelegate, TimedeltaDelegate): # This class is never instantiated, and exists solely for the benefit of # the Series.dt class property. For Series objects, .dt will always be one # of the more specific classes above. - __doc__ = DatetimeProperties.__doc__ + __doc__ = DatetimeDelegate.__doc__ @classmethod - def _make_accessor(cls, data): + def _make_accessor(cls, values): try: - return maybe_to_datetimelike(data) + return maybe_to_datetimelike(values) except Exception: - raise AttributeError("Can only use .dt accessor with " - "datetimelike values") + msg = "Can only use .dt accessor with datetimelike values" + raise AttributeError(msg) + + +DatetimeAccessor = CombinedDatetimelikeDelegate +# Alias to mirror CategoricalAccessor diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 008828cf4f309..92d93d3a4389e 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -45,18 +45,17 @@ _asarray_tuplesafe) from pandas.core.base import PandasObject, IndexOpsMixin -import pandas.core.base as base -from pandas.util._decorators import ( - Appender, Substitution, cache_readonly, deprecate_kwarg) + +from pandas.core import base, accessors, missing, sorting, strings +from pandas.util._decorators import (Appender, Substitution, + cache_readonly, deprecate_kwarg) + from pandas.core.indexes.frozen import FrozenList import pandas.core.common as com import pandas.core.dtypes.concat as _concat -import pandas.core.missing as missing import pandas.core.algorithms as algos -import pandas.core.sorting as sorting from pandas.io.formats.printing import pprint_thing from pandas.core.ops import _comp_method_OBJECT_ARRAY -from pandas.core import strings from pandas.core.config import get_option @@ -158,7 +157,8 @@ class Index(IndexOpsMixin, PandasObject): _accessors = frozenset(['str']) # String Methods - str = base.AccessorProperty(strings.StringMethods) + str = base.AccessorProperty(strings.StringDelegate) + def __new__(cls, data=None, dtype=None, copy=False, name=None, fastpath=False, tupleize_cols=True, **kwargs): diff --git a/pandas/core/indexes/category.py b/pandas/core/indexes/category.py index ef1dc4d971f37..0aeb61370d5bb 100644 --- a/pandas/core/indexes/category.py +++ b/pandas/core/indexes/category.py @@ -19,15 +19,30 @@ from pandas.util._decorators import Appender, cache_readonly from pandas.core.config import get_option from pandas.core.indexes.base import Index, _index_shared_docs -import pandas.core.base as base -import pandas.core.missing as missing + +from pandas.core import base, accessors, missing import pandas.core.indexes.base as ibase +from pandas.core.categorical import Categorical + _index_doc_kwargs = dict(ibase._index_doc_kwargs) _index_doc_kwargs.update(dict(target_klass='CategoricalIndex')) -class CategoricalIndex(Index, base.PandasDelegate): +@accessors.wrap_delegate_names(delegate=Categorical, + accessors=["rename_categories", + "reorder_categories", + "add_categories", + "remove_categories", + "remove_unused_categories", + "set_categories", + "as_ordered", + "as_unordered", + "min", + "max"], + typ='method', + overwrite=True) +class CategoricalIndex(Index, accessors.PandasDelegate): """ Immutable Index implementing an ordered, sliceable set. CategoricalIndex @@ -54,6 +69,11 @@ class CategoricalIndex(Index, base.PandasDelegate): _engine_type = libindex.Int64Engine _attributes = ['name'] + def __init__(self, *args, **kwargs): + # Override to prevent accessors.PandasDelegate.__init__ from + # executing + pass + def __new__(cls, data=None, categories=None, ordered=None, dtype=None, copy=False, name=None, fastpath=False, **kwargs): @@ -101,8 +121,6 @@ def _create_from_codes(self, codes, categories=None, ordered=None, ------- CategoricalIndex """ - - from pandas.core.categorical import Categorical if categories is None: categories = self.categories if ordered is None: @@ -136,7 +154,6 @@ def _create_categorical(self, data, categories=None, ordered=None): if not isinstance(data, ABCCategorical): ordered = False if ordered is None else ordered - from pandas.core.categorical import Categorical data = Categorical(data, categories=categories, ordered=ordered) else: if categories is not None: @@ -397,7 +414,6 @@ def where(self, cond, other=None): other = self._na_value values = np.where(cond, self.values, other) - from pandas.core.categorical import Categorical cat = Categorical(values, categories=self.categories, ordered=self.ordered) @@ -696,35 +712,19 @@ def _evaluate_compare(self, other): cls.__le__ = _make_compare('__le__') cls.__ge__ = _make_compare('__ge__') + # TODO: Can we de-duplicate this with core.categorical Delegate? def _delegate_method(self, name, *args, **kwargs): """ method delegation to the ._values """ method = getattr(self._values, name) - if 'inplace' in kwargs: + if kwargs.get('inplace', False): raise ValueError("cannot use inplace with CategoricalIndex") res = method(*args, **kwargs) if is_scalar(res): return res return CategoricalIndex(res, name=self.name) - @classmethod - def _add_accessors(cls): - """ add in Categorical accessor methods """ - - from pandas.core.categorical import Categorical - CategoricalIndex._add_delegate_accessors( - delegate=Categorical, accessors=["rename_categories", - "reorder_categories", - "add_categories", - "remove_categories", - "remove_unused_categories", - "set_categories", - "as_ordered", "as_unordered", - "min", "max"], - typ='method', overwrite=True) - CategoricalIndex._add_numeric_methods_add_sub_disabled() CategoricalIndex._add_numeric_methods_disabled() CategoricalIndex._add_logical_methods_disabled() CategoricalIndex._add_comparison_methods() -CategoricalIndex._add_accessors() diff --git a/pandas/core/series.py b/pandas/core/series.py index ac11c5f908fdc..4fcf5f6a49a8f 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -13,6 +13,10 @@ import numpy as np import numpy.ma as ma +from pandas import compat +from pandas.compat import zip, u, OrderedDict, StringIO +from pandas.compat.numpy import function as nv + from pandas.core.dtypes.common import ( is_categorical_dtype, is_bool, @@ -36,6 +40,9 @@ maybe_cast_to_datetime, maybe_castable) from pandas.core.dtypes.missing import isna, notna, remove_na_arraylike +from pandas.core import (generic, base, accessors, strings, + algorithms, ops, nanops) +import pandas.core.common as com from pandas.core.common import (is_bool_indexer, _default_index, _asarray_tuplesafe, @@ -49,24 +56,16 @@ from pandas.core.index import (Index, MultiIndex, InvalidIndexError, Float64Index, _ensure_index) from pandas.core.indexing import check_bool_indexer, maybe_convert_indices -from pandas.core import generic, base + from pandas.core.internals import SingleBlockManager -from pandas.core.categorical import Categorical, CategoricalAccessor -import pandas.core.strings as strings -from pandas.core.indexes.accessors import CombinedDatetimelikeProperties +from pandas.core.categorical import Categorical, CategoricalDelegate + +from pandas.core.indexes.accessors import CombinedDatetimelikeDelegate from pandas.core.indexes.datetimes import DatetimeIndex from pandas.core.indexes.timedeltas import TimedeltaIndex from pandas.core.indexes.period import PeriodIndex -from pandas import compat -from pandas.io.formats.terminal import get_terminal_size -from pandas.compat import zip, u, OrderedDict, StringIO -from pandas.compat.numpy import function as nv - -import pandas.core.ops as ops -import pandas.core.algorithms as algorithms -import pandas.core.common as com -import pandas.core.nanops as nanops +from pandas.io.formats.terminal import get_terminal_size import pandas.io.formats.format as fmt from pandas.util._decorators import Appender, deprecate_kwarg, Substitution from pandas.util._validators import validate_bool_kwarg @@ -143,9 +142,19 @@ class Series(base.IndexOpsMixin, generic.NDFrame): Copy input data """ _metadata = ['name'] - _accessors = frozenset(['dt', 'cat', 'str']) _allow_index_ops = True + _accessors = frozenset(['dt', 'cat', 'str']) + + # Datetimelike delegation methods + dt = accessors.AccessorProperty(CombinedDatetimelikeDelegate) + + # Categorical methods + cat = accessors.AccessorProperty(CategoricalDelegate) + + # string methods + str = accessors.AccessorProperty(strings.StringDelegate) + def __init__(self, data=None, index=None, dtype=None, name=None, copy=False, fastpath=False): @@ -2903,14 +2912,14 @@ def to_period(self, freq=None, copy=True): # ------------------------------------------------------------------------- # Datetimelike delegation methods - dt = base.AccessorProperty(CombinedDatetimelikeProperties) + dt = base.AccessorProperty(CombinedDatetimelikeDelegate) # ------------------------------------------------------------------------- # Categorical methods - cat = base.AccessorProperty(CategoricalAccessor) + cat = base.AccessorProperty(CategoricalDelegate) # String Methods - str = base.AccessorProperty(strings.StringMethods) + str = base.AccessorProperty(strings.StringDelegate) # ---------------------------------------------------------------------- # Add plotting methods to Series @@ -2919,6 +2928,7 @@ def to_period(self, freq=None, copy=True): hist = gfx.hist_series + Series._setup_axes(['index'], info_axis=0, stat_axis=0, aliases={'rows': 0}) Series._add_numeric_operations() Series._add_series_only_operations() diff --git a/pandas/core/strings.py b/pandas/core/strings.py index 021f88d1aec00..62b4db1e075cc 100644 --- a/pandas/core/strings.py +++ b/pandas/core/strings.py @@ -16,6 +16,7 @@ from pandas.core.algorithms import take_1d import pandas.compat as compat + from pandas.core.base import NoNewAttributesMixin from pandas.util._decorators import Appender import re @@ -937,6 +938,34 @@ def str_find(arr, sub, start=0, end=None, side='left'): return _na_map(f, arr, dtype=int) +_shared_docs['index'] = textwrap.dedent(""" + Return %(side)s indexes in each strings where the substring is + fully contained between [start:end]. This is the same as + ``str.%(similar)s`` except instead of returning -1, it raises a ValueError + when the substring is not found. Equivalent to standard ``str.%(method)s``. + + Parameters + ---------- + sub : str + Substring being searched + start : int + Left edge index + end : int + Right edge index + + Returns + ------- + found : Series/Index of objects + + See Also + -------- + %(also)s + """) + + +@Appender(_shared_docs['index'] % + dict(side='lowest', similar='find', method='index', + also='rindex : Return highest indexes in each strings')) def str_index(arr, sub, start=0, end=None, side='left'): if not isinstance(sub, compat.string_types): msg = 'expected a string object, not {0}' @@ -1119,6 +1148,18 @@ def f(x): return _na_map(f, arr) +_shared_docs['str_strip'] = textwrap.dedent(""" + Strip whitespace (including newlines) from each string in the + Series/Index from %(side)s. Equivalent to :meth:`str.%(method)s`. + + Returns + ------- + stripped : Series/Index of objects + """) + + +@Appender(_shared_docs['str_strip'] % dict(side='left and right sides', + method='strip')) def str_strip(arr, to_strip=None, side='both'): """ Strip whitespace (including newlines) from each string in the @@ -1311,6 +1352,27 @@ def str_encode(arr, encoding, errors="strict"): return _na_map(f, arr) +def str_normalize(arr, form): + """ + Return the Unicode normal form for the strings in the Series/Index. + For more information on the forms, see the + :func:`unicodedata.normalize`. + + Parameters + ---------- + form : {'NFC', 'NFKC', 'NFD', 'NFKD'} + Unicode form + + Returns + ------- + normalized : Series/Index of objects + """ + import unicodedata + f = lambda x: unicodedata.normalize(form, compat.u_safe(x)) + result = _na_map(f, arr) + return result + + def _noarg_wrapper(f, docstring=None, **kargs): def wrapper(self): result = _na_map(f, self._data, **kargs) @@ -1347,7 +1409,7 @@ def wrapper3(self, pat, na=np.nan): return wrapper -def copy(source): +def copy_doc(source): "Copy a docstring from another source function (if present)" def do_copy(target): @@ -1358,7 +1420,7 @@ def do_copy(target): return do_copy -class StringMethods(NoNewAttributesMixin): +class StringMethods(accessors.PandasDelegate, NoNewAttributesMixin): """ Vectorized string functions for Series and Index. NAs stay NA unless handled otherwise by a particular method. Patterned after Python's string @@ -1372,7 +1434,11 @@ class StringMethods(NoNewAttributesMixin): def __init__(self, data): self._is_categorical = is_categorical_dtype(data) - self._data = data.cat.categories if self._is_categorical else data + if self._is_categorical: + self._data = data.cat.categories + else: + self._data = data + # save orig to blow up categoricals to the right type self._orig = data self._freeze() @@ -1391,8 +1457,7 @@ def __iter__(self): i += 1 g = self.get(i) - def _wrap_result(self, result, use_codes=True, - name=None, expand=None): + def _wrap_result(self, result, use_codes=True, name=None, expand=None): from pandas.core.index import Index, MultiIndex @@ -1410,7 +1475,7 @@ def _wrap_result(self, result, use_codes=True, if expand is None: # infer from ndim if expand is not specified - expand = False if result.ndim == 1 else True + expand = result.ndim != 1 elif expand is True and not isinstance(self._orig, Index): # required when expand=True is explicitly specified @@ -1465,18 +1530,21 @@ def cons_row(x): cons = self._orig._constructor return cons(result, name=name, index=index) - @copy(str_cat) + @copy_doc(str_cat) def cat(self, others=None, sep=None, na_rep=None): - data = self._orig if self._is_categorical else self._data + if self._is_categorical: + data = self._orig + else: + data = self._data result = str_cat(data, others=others, sep=sep, na_rep=na_rep) return self._wrap_result(result, use_codes=(not self._is_categorical)) - @copy(str_split) + @copy_doc(str_split) def split(self, pat=None, n=-1, expand=False): result = str_split(self._data, pat, n=n) return self._wrap_result(result, expand=expand) - @copy(str_rsplit) + @copy_doc(str_rsplit) def rsplit(self, pat=None, n=-1, expand=False): result = str_rsplit(self._data, pat, n=n) return self._wrap_result(result, expand=expand) @@ -1547,40 +1615,40 @@ def rpartition(self, pat=' ', expand=True): result = _na_map(f, self._data) return self._wrap_result(result, expand=expand) - @copy(str_get) + @copy_doc(str_get) def get(self, i): result = str_get(self._data, i) return self._wrap_result(result) - @copy(str_join) + @copy_doc(str_join) def join(self, sep): result = str_join(self._data, sep) return self._wrap_result(result) - @copy(str_contains) + @copy_doc(str_contains) def contains(self, pat, case=True, flags=0, na=np.nan, regex=True): result = str_contains(self._data, pat, case=case, flags=flags, na=na, regex=regex) return self._wrap_result(result) - @copy(str_match) + @copy_doc(str_match) def match(self, pat, case=True, flags=0, na=np.nan, as_indexer=None): result = str_match(self._data, pat, case=case, flags=flags, na=na, as_indexer=as_indexer) return self._wrap_result(result) - @copy(str_replace) + @copy_doc(str_replace) def replace(self, pat, repl, n=-1, case=None, flags=0): result = str_replace(self._data, pat, repl, n=n, case=case, flags=flags) return self._wrap_result(result) - @copy(str_repeat) + @copy_doc(str_repeat) def repeat(self, repeats): result = str_repeat(self._data, repeats) return self._wrap_result(result) - @copy(str_pad) + @copy_doc(str_pad) def pad(self, width, side='left', fillchar=' '): result = str_pad(self._data, width, side=side, fillchar=fillchar) return self._wrap_result(result) @@ -1633,37 +1701,27 @@ def zfill(self, width): result = str_pad(self._data, width, side='left', fillchar='0') return self._wrap_result(result) - @copy(str_slice) + @copy_doc(str_slice) def slice(self, start=None, stop=None, step=None): result = str_slice(self._data, start, stop, step) return self._wrap_result(result) - @copy(str_slice_replace) + @copy_doc(str_slice_replace) def slice_replace(self, start=None, stop=None, repl=None): result = str_slice_replace(self._data, start, stop, repl) return self._wrap_result(result) - @copy(str_decode) + @copy_doc(str_decode) def decode(self, encoding, errors="strict"): result = str_decode(self._data, encoding, errors) return self._wrap_result(result) - @copy(str_encode) + @copy_doc(str_encode) def encode(self, encoding, errors="strict"): result = str_encode(self._data, encoding, errors) return self._wrap_result(result) - _shared_docs['str_strip'] = (""" - Strip whitespace (including newlines) from each string in the - Series/Index from %(side)s. Equivalent to :meth:`str.%(method)s`. - - Returns - ------- - stripped : Series/Index of objects - """) - - @Appender(_shared_docs['str_strip'] % dict(side='left and right sides', - method='strip')) + @copy_doc(str_strip) def strip(self, to_strip=None): result = str_strip(self._data, to_strip, side='both') return self._wrap_result(result) @@ -1680,21 +1738,24 @@ def rstrip(self, to_strip=None): result = str_strip(self._data, to_strip, side='right') return self._wrap_result(result) - @copy(str_wrap) + @copy_doc(str_wrap) def wrap(self, width, **kwargs): result = str_wrap(self._data, width, **kwargs) return self._wrap_result(result) - @copy(str_get_dummies) + @copy_doc(str_get_dummies) def get_dummies(self, sep='|'): # we need to cast to Series of strings as only that has all # methods available for making the dummies... - data = self._orig.astype(str) if self._is_categorical else self._data + if self._is_categorical: + data = self._orig.astype(str) + else: + data = self._data result, name = str_get_dummies(data, sep) return self._wrap_result(result, use_codes=(not self._is_categorical), name=name, expand=True) - @copy(str_translate) + @copy_doc(str_translate) def translate(self, table, deletechars=None): result = str_translate(self._data, table, deletechars) return self._wrap_result(result) @@ -1704,11 +1765,11 @@ def translate(self, table, deletechars=None): endswith = _pat_wrapper(str_endswith, na=True) findall = _pat_wrapper(str_findall, flags=True) - @copy(str_extract) + @copy_doc(str_extract) def extract(self, pat, flags=0, expand=None): return str_extract(self, pat, flags=flags, expand=expand) - @copy(str_extractall) + @copy_doc(str_extractall) def extractall(self, pat, flags=0): return str_extractall(self._orig, pat, flags=flags) @@ -1749,52 +1810,12 @@ def rfind(self, sub, start=0, end=None): result = str_find(self._data, sub, start=start, end=end, side='right') return self._wrap_result(result) + @copy_doc(str_normalize) def normalize(self, form): - """Return the Unicode normal form for the strings in the Series/Index. - For more information on the forms, see the - :func:`unicodedata.normalize`. - - Parameters - ---------- - form : {'NFC', 'NFKC', 'NFD', 'NFKD'} - Unicode form - - Returns - ------- - normalized : Series/Index of objects - """ - import unicodedata - f = lambda x: unicodedata.normalize(form, compat.u_safe(x)) - result = _na_map(f, self._data) + result = str_normalize(self._data, form) return self._wrap_result(result) - _shared_docs['index'] = (""" - Return %(side)s indexes in each strings where the substring is - fully contained between [start:end]. This is the same as - ``str.%(similar)s`` except instead of returning -1, it raises a ValueError - when the substring is not found. Equivalent to standard ``str.%(method)s``. - - Parameters - ---------- - sub : str - Substring being searched - start : int - Left edge index - end : int - Right edge index - - Returns - ------- - found : Series/Index of objects - - See Also - -------- - %(also)s - """) - - @Appender(_shared_docs['index'] % - dict(side='lowest', similar='find', method='index', - also='rindex : Return highest indexes in each strings')) + @copy_doc(str_index) def index(self, sub, start=0, end=None): result = str_index(self._data, sub, start=start, end=end, side='left') return self._wrap_result(result) @@ -1920,4 +1941,10 @@ def _make_accessor(cls, data): message = ("Can only use .str accessor with Index, not " "MultiIndex") raise AttributeError(message) + return cls(data) + + +StringDelegate = StringMethods +# Alias to mirror CategoricalDelegate and CombinedDatetimelikeDelegate + diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py index e810eadd2dee9..974eb03e1f218 100644 --- a/pandas/tests/series/test_datetime_values.py +++ b/pandas/tests/series/test_datetime_values.py @@ -364,11 +364,11 @@ def test_valid_dt_with_missing_values(self): def test_dt_accessor_api(self): # GH 9322 from pandas.core.indexes.accessors import ( - CombinedDatetimelikeProperties, DatetimeProperties) - assert Series.dt is CombinedDatetimelikeProperties + CombinedDatetimelikeDelegate, DatetimeDelegate) + assert Series.dt is CombinedDatetimelikeDelegate s = Series(date_range('2000-01-01', periods=3)) - assert isinstance(s.dt, DatetimeProperties) + assert isinstance(s.dt, DatetimeDelegate) for s in [Series(np.arange(5)), Series(list('abcde')), Series(np.random.randn(5))]: diff --git a/pandas/tests/test_accessors.py b/pandas/tests/test_accessors.py new file mode 100644 index 0000000000000..3726a74bce452 --- /dev/null +++ b/pandas/tests/test_accessors.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + +An example/recipe/test for implementing custom accessors. + +""" +import unittest +import pandas.util.testing as tm + +import pandas as pd + +from pandas.core.accessors import (wrap_delegate_names, + PandasDelegate, AccessorProperty) + +# Example 1: +# An accessor for attributes of custom class in a Series with object dtype. + + +class State(object): + """ + A dummy class for which only two states have the attributes implemented. + """ + def __repr__(self): + return repr(self.name) + + def __init__(self, name): + self.name = name + self._abbrev_dict = {'California': 'CA', 'Alabama': 'AL'} + + @property + def abbrev(self): + return self._abbrev_dict[self.name] + + @abbrev.setter + def abbrev(self, value): + self._abbrev_dict[self.name] = value + + def fips(self): + return {'California': 6, 'Alabama': 1}[self.name] + + +@wrap_delegate_names(delegate=State, + accessors=["fips"], + typ="method") +@wrap_delegate_names(delegate=State, + accessors=["abbrev"], + typ="property") +class StateDelegate(PandasDelegate): + + def __init__(self, values): + self.values = values + + @classmethod + def _make_accessor(cls, data): + """ + When implementing custom accessors, `_make_accessor` is the place + to do validation that the attributes be accessed will actually be + present in the underlying data. + """ + if not isinstance(data, pd.Series): + raise ValueError('Input must be a Series of States') + elif not data.apply(lambda x: isinstance(x, State)).all(): + raise ValueError('All entries must be State objects') + return StateDelegate(data) + + def _delegate_method(self, name, *args, **kwargs): + state_method = lambda x: getattr(x, name)(*args, **kwargs) + return self.values.apply(state_method) + + def _delegate_property_get(self, name): + state_property = lambda x: getattr(x, name) + return self.values.apply(state_property) + + def _delegate_property_set(self, name, new_values): + """ + Setting properties via accessors is permitted but discouraged. + """ + for (obj, val) in zip(self.values, new_values): + setattr(obj, name, val) + + +class TestVectorizedAccessor(unittest.TestCase): + + @classmethod + def setup_class(cls): + pd.Series.state = AccessorProperty(StateDelegate) + + cls.ser = pd.Series([State('Alabama'), State('California')]) + + @classmethod + def teardown_class(cls): + del pd.Series.state + # TODO: is there a nicer way to do this with `mock`? + + def test_method(self): + ser = self.ser + fips = pd.Series([1, 6]) + tm.assert_series_equal(ser.state.fips(), fips) + + def test_property_get(self): + ser = self.ser + abbrev = pd.Series(['AL', 'CA']) + tm.assert_series_equal(ser.state.abbrev, abbrev) + + def test_property_set(self): + ser = self.ser.copy() + + ser.state.abbrev = ['Foo', 'Bar'] + new_abbrev = pd.Series(['Foo', 'Bar']) + tm.assert_series_equal(ser.state.abbrev, new_abbrev) + + +@wrap_delegate_names(delegate=pd.Series, + accessors=["real", "imag"], + typ="property") +@wrap_delegate_names(delegate=pd.Series, + accessors=["abs"], + typ="method") +class ForgotToOverride(PandasDelegate): + # A case where the relevant methods were not overridden. Everything + # should raise NotImplementedError or TypeError + @classmethod + def _make_accessor(cls, data): + return cls(data) + + +class TestUnDelegated(unittest.TestCase): + + @classmethod + def setup_class(cls): + pd.Series.forgot = AccessorProperty(ForgotToOverride) + + cls.ser = pd.Series(range(-2, 2)) + + @classmethod + def teardown_class(cls): + del pd.Series.forgot + + def test_get_fails(self): + forgot = self.ser.forgot + with self.assertRaises(TypeError): + forgot.real + + with self.assertRaises(TypeError): + forgot.imag + + def test_set_fails(self): + forgot = self.ser.forgot + with self.assertRaises(TypeError): + forgot.real = range(5) + + # Check that the underlying hasn't been affected + tm.assert_series_equal(self.ser, pd.Series(range(-2, 2))) + + def test_method_fails(self): + forgot = self.ser.forgot + with self.assertRaises(TypeError): + forgot.abs() diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index 38d78b12b31aa..9a11adea26161 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -18,7 +18,9 @@ CategoricalIndex, Timestamp) from pandas.compat import StringIO, PYPY, long from pandas.compat.numpy import np_array_datetime64_compat -from pandas.core.base import PandasDelegate, NoNewAttributesMixin +from pandas.core import accessors + +from pandas.core.base import NoNewAttributesMixin from pandas.core.indexes.datetimelike import DatetimeIndexOpsMixin from pandas._libs.tslib import iNaT @@ -105,7 +107,7 @@ def bar(self, *args, **kwargs): """ a test bar method """ pass - class Delegate(PandasDelegate): + class Delegate(accessors.PandasDelegate): def __init__(self, obj): self.obj = obj @@ -113,20 +115,21 @@ def __init__(self, obj): def setup_method(self, method): pass - def test_invalida_delgation(self): + def test_invalid_delegation(self): # these show that in order for the delegation to work # the _delegate_* methods need to be overriden to not raise a TypeError - self.Delegate._add_delegate_accessors( - delegate=self.Delegator, - accessors=self.Delegator._properties, - typ='property' - ) - self.Delegate._add_delegate_accessors( - delegate=self.Delegator, - accessors=self.Delegator._methods, - typ='method' - ) + for name in self.Delegator._properties: + func = accessors.Delegator.create_delegator_property(name, + self.Delegator + ) + setattr(self.Delegate, name, func) + + for name in self.Delegator._methods: + func = accessors.Delegator.create_delegator_method(name, + self.Delegator + ) + setattr(self.Delegate, name, func) delegate = self.Delegate(self.Delegator()) diff --git a/pandas/tests/test_categorical.py b/pandas/tests/test_categorical.py index c361b430cfd8a..2fa55ab20a761 100644 --- a/pandas/tests/test_categorical.py +++ b/pandas/tests/test_categorical.py @@ -4348,10 +4348,10 @@ def get_dir(s): def test_cat_accessor_api(self): # GH 9322 - from pandas.core.categorical import CategoricalAccessor - assert Series.cat is CategoricalAccessor + from pandas.core.categorical import CategoricalDelegate + assert Series.cat is CategoricalDelegate s = Series(list('aabbcde')).astype('category') - assert isinstance(s.cat, CategoricalAccessor) + assert isinstance(s.cat, CategoricalDelegate) invalid = Series([1]) with tm.assert_raises_regex(AttributeError, @@ -4368,11 +4368,11 @@ def test_cat_accessor_no_new_attributes(self): def test_str_accessor_api_for_categorical(self): # https://github.com/pandas-dev/pandas/issues/10661 - from pandas.core.strings import StringMethods + from pandas.core.strings import StringDelegate s = Series(list('aabb')) s = s + " " + s c = s.astype('category') - assert isinstance(c.str, StringMethods) + assert isinstance(c.str, StringDelegate) # str functions, which need special arguments special_func_defs = [ @@ -4444,7 +4444,7 @@ def test_str_accessor_api_for_categorical(self): def test_dt_accessor_api_for_categorical(self): # https://github.com/pandas-dev/pandas/issues/10661 - from pandas.core.indexes.accessors import Properties + from pandas.core.indexes.accessors import BaseDatetimeDelegate s_dr = Series(date_range('1/1/2015', periods=5, tz="MET")) c_dr = s_dr.astype("category") @@ -4464,7 +4464,7 @@ def test_dt_accessor_api_for_categorical(self): ("Period", get_ops(PeriodIndex), s_pr, c_pr), ("Timedelta", get_ops(TimedeltaIndex), s_tdr, c_tdr)] - assert isinstance(c_dr.dt, Properties) + assert isinstance(c_dr.dt, BaseDatetimeDelegate) special_func_defs = [ ('strftime', ("%Y-%m-%d",), {}), diff --git a/pandas/tests/test_strings.py b/pandas/tests/test_strings.py index ec2b0b75b9eed..a8bae826dc191 100644 --- a/pandas/tests/test_strings.py +++ b/pandas/tests/test_strings.py @@ -24,8 +24,8 @@ class TestStringMethods(object): def test_api(self): # GH 6106, GH 9322 - assert Series.str is strings.StringMethods - assert isinstance(Series(['']).str, strings.StringMethods) + assert Series.str is strings.StringDelegate + assert isinstance(Series(['']).str, strings.StringDelegate) # GH 9184 invalid = Series([1]) @@ -2690,8 +2690,6 @@ def test_str_cat_raises_intuitive_error(self): s.str.cat(' ') def test_index_str_accessor_visibility(self): - from pandas.core.strings import StringMethods - if not compat.PY3: cases = [(['a', 'b'], 'string'), (['a', u('b')], 'mixed'), ([u('a'), u('b')], 'unicode'), @@ -2708,14 +2706,14 @@ def test_index_str_accessor_visibility(self): (['aa', datetime(2011, 1, 1)], 'mixed')] for values, tp in cases: idx = Index(values) - assert isinstance(Series(values).str, StringMethods) - assert isinstance(idx.str, StringMethods) + assert isinstance(Series(values).str, strings.StringDelegate) + assert isinstance(idx.str, strings.StringDelegate) assert idx.inferred_type == tp for values, tp in cases: idx = Index(values) - assert isinstance(Series(values).str, StringMethods) - assert isinstance(idx.str, StringMethods) + assert isinstance(Series(values).str, strings.StringDelegate) + assert isinstance(idx.str, strings.StringDelegate) assert idx.inferred_type == tp cases = [([1, np.nan], 'floating'),