From 2584331b6145124be10852d0b37e107dc22c3f1f Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Tue, 25 Dec 2018 16:15:00 -0800 Subject: [PATCH 01/10] Implement PandasMeta --- pandas/core/base.py | 71 +++++++++++++++++++++++++++++++++ pandas/core/indexes/base.py | 1 + pandas/core/indexes/interval.py | 7 +++- pandas/core/ops.py | 3 ++ pandas/core/series.py | 1 + 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/pandas/core/base.py b/pandas/core/base.py index 46f61c353056e..2f8ca77561e45 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -1,6 +1,7 @@ """ Base and utility classes for pandas objects. """ +import inspect import textwrap import warnings @@ -29,6 +30,75 @@ unique='IndexOpsMixin', duplicated='IndexOpsMixin') +class PandasMeta(type): + """ + Metaclass for pandas objects to systematically handle: + - docstrings + - class ordering + - names for inherited methods + """ + def __new__(cls, name, bases, dct): + obj = type.__new__(cls, name, bases, dct) + + PandasMeta._check_pinned_names(obj) + return obj + + def __lt__(self, other): + # define comparison methods so we can compare classes, not just + # instances + for generic in [ABCDataFrame, ABCSeries, ABCIndexClass]: + if issubclass(self, generic): + return False + elif issubclass(other, generic): + return True + return False + + def __gt__(self, other): + # define comparison methods so we can compare classes, not just + # instances + for generic in [ABCDataFrame, ABCSeries, ABCIndexClass]: + if issubclass(other, generic): + return False + elif issubclass(self, generic): + return True + return True + + @staticmethod + def _check_pinned_names(cls): + """ + Check that the any dynamically-defined methods have the correct + names, i.e. not 'wrapper'. + """ + special_cases = { + "isnull": "isna", + "notnull": "notna", + "take": "take_nd", # Categorical + "to_list": "tolist", # Categorical + "iteritems": "items", + "__bool__": "__nonzero__", + } + ignore = { + "_create_comparison_method", + } + if 'Subclassed' in cls.__name__: + return + for name in dir(cls): + try: + # e.g. properties may not be accessible on the class + attr = getattr(cls, name) + except Exception: + continue + if name in ignore: + continue + if inspect.ismethod(attr): + expected = special_cases.get(name, name) + result = attr.__name__ + if result != expected and result != name: + print(result, expected, name, cls.__name__) + # assert result == expected, (result, expected, name, + # cls.__name__) + + class StringMixin(object): """implements string methods so long as object defines a `__unicode__` method. @@ -80,6 +150,7 @@ def __repr__(self): class PandasObject(StringMixin, DirNamesMixin): """baseclass for various pandas objects""" + __metaclass__ = PandasMeta @property def _constructor(self): diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index cc6f182fadce6..5c38e158ceae6 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -5079,6 +5079,7 @@ def _evaluate_numeric_unary(self): attrs = self._maybe_update_attributes(attrs) return Index(op(self.values), **attrs) + _evaluate_numeric_unary.__name__ = opstr return _evaluate_numeric_unary cls.__neg__ = _make_evaluate_unary(operator.neg, '__neg__') diff --git a/pandas/core/indexes/interval.py b/pandas/core/indexes/interval.py index 14e73b957d519..f4343436e2641 100644 --- a/pandas/core/indexes/interval.py +++ b/pandas/core/indexes/interval.py @@ -20,6 +20,7 @@ from pandas.core.dtypes.missing import isna from pandas.core.arrays.interval import IntervalArray, _interval_shared_docs +from pandas.core.base import PandasMeta import pandas.core.common as com from pandas.core.config import get_option import pandas.core.indexes.base as ibase @@ -99,6 +100,10 @@ def _new_IntervalIndex(cls, d): return cls.from_arrays(**d) +class IntervalMetaClass(_WritableDoc, PandasMeta): + pass + + @Appender(_interval_shared_docs['class'] % dict( klass="IntervalIndex", summary="Immutable index of intervals that are closed on the same side.", @@ -126,7 +131,7 @@ def _new_IntervalIndex(cls, d): """), )) -@add_metaclass(_WritableDoc) +@add_metaclass(IntervalMetaClass) class IntervalIndex(IntervalMixin, Index): _typ = 'intervalindex' _comparables = ['name'] diff --git a/pandas/core/ops.py b/pandas/core/ops.py index 61bf73cbc280f..7cab52ddda87f 100644 --- a/pandas/core/ops.py +++ b/pandas/core/ops.py @@ -1390,6 +1390,7 @@ def f(self, other): return self + f.__name__ = "__i{name}__".format(name=method.__name__.strip("__")) return f new_methods.update( @@ -1574,6 +1575,7 @@ def wrapper(left, right): return construct_result(left, result, index=left.index, name=res_name, dtype=None) + wrapper.__name__ = op_name return wrapper @@ -1762,6 +1764,7 @@ def wrapper(self, other, axis=None): return self._constructor(res_values, index=self.index, name=res_name, dtype='bool') + wrapper.__name__ = op_name return wrapper diff --git a/pandas/core/series.py b/pandas/core/series.py index 773f2d17cf0fc..eebe6ca8da6df 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -91,6 +91,7 @@ def wrapper(self): raise TypeError("cannot convert the series to " "{0}".format(str(converter))) + wrapper.__name__ = "__{name}__".format(name=converter.__name__) return wrapper # ---------------------------------------------------------------------- From 904223004d393fa67f086a88e50dcab1a9d13499 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Tue, 25 Dec 2018 16:15:26 -0800 Subject: [PATCH 02/10] assert instead of printing --- pandas/core/base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pandas/core/base.py b/pandas/core/base.py index 2f8ca77561e45..25f742376f03d 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -93,10 +93,8 @@ def _check_pinned_names(cls): if inspect.ismethod(attr): expected = special_cases.get(name, name) result = attr.__name__ - if result != expected and result != name: - print(result, expected, name, cls.__name__) - # assert result == expected, (result, expected, name, - # cls.__name__) + assert result == expected, (result, expected, name, + cls.__name__) class StringMixin(object): From 3db445711318ad97662eead2b289133e2e023922 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Tue, 25 Dec 2018 16:16:46 -0800 Subject: [PATCH 03/10] use type comparison --- pandas/core/ops.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pandas/core/ops.py b/pandas/core/ops.py index 7cab52ddda87f..7670efd1a182e 100644 --- a/pandas/core/ops.py +++ b/pandas/core/ops.py @@ -1528,7 +1528,8 @@ def safe_na_op(lvalues, rvalues): raise def wrapper(left, right): - if isinstance(right, ABCDataFrame): + if type(right) < type(left): + # i.e. other is a DataFrame return NotImplemented left, right = _align_method_SERIES(left, right) From 3ada68d25343f877d0d9e236bf16ae7723f2a252 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Wed, 26 Dec 2018 09:14:54 -0800 Subject: [PATCH 04/10] use __subclasses__ instead of metaclass --- pandas/core/base.py | 39 --------------------------------- pandas/tests/test_base.py | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/pandas/core/base.py b/pandas/core/base.py index 3f0267a163d35..f5de7ab8a6d0a 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -37,12 +37,6 @@ class PandasMeta(type): - class ordering - names for inherited methods """ - def __new__(cls, name, bases, dct): - obj = type.__new__(cls, name, bases, dct) - - PandasMeta._check_pinned_names(obj) - return obj - def __lt__(self, other): # define comparison methods so we can compare classes, not just # instances @@ -63,39 +57,6 @@ def __gt__(self, other): return True return True - @staticmethod - def _check_pinned_names(cls): - """ - Check that the any dynamically-defined methods have the correct - names, i.e. not 'wrapper'. - """ - special_cases = { - "isnull": "isna", - "notnull": "notna", - "take": "take_nd", # Categorical - "to_list": "tolist", # Categorical - "iteritems": "items", - "__bool__": "__nonzero__", - } - ignore = { - "_create_comparison_method", - } - if 'Subclassed' in cls.__name__: - return - for name in dir(cls): - try: - # e.g. properties may not be accessible on the class - attr = getattr(cls, name) - except Exception: - continue - if name in ignore: - continue - if inspect.ismethod(attr): - expected = special_cases.get(name, name) - result = attr.__name__ - assert result == expected, (result, expected, name, - cls.__name__) - class StringMixin(object): """implements string methods so long as object defines a `__unicode__` diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index 91e1af5c8887c..34a588cdbfe3b 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import print_function +import inspect import re import sys from datetime import datetime, timedelta @@ -1273,3 +1274,47 @@ def test_to_numpy_dtype(as_series): expected = np.array(['2000-01-01T05', '2001-01-01T05'], dtype='M8[ns]') tm.assert_numpy_array_equal(result, expected) + + +def check_pinned_names(cls): + """ + Check that the any dynamically-defined methods have the correct + names, i.e. not 'wrapper'. + """ + special_cases = { + "isnull": "isna", + "notnull": "notna", + "take": "take_nd", # Categorical + "to_list": "tolist", # Categorical + "iteritems": "items", + "__bool__": "__nonzero__", + } + ignore = { + "_create_comparison_method", + } + if 'Subclassed' in cls.__name__: + # dummy classes defined in tests + return + for name in dir(cls): + try: + # e.g. properties may not be accessible on the class + attr = getattr(cls, name) + except Exception: + continue + if name in ignore: + continue + if inspect.ismethod(attr) or isinstance(attr, property): + expected = special_cases.get(name, name) + result = attr.__name__ + assert result == expected, (result, expected, name, + cls.__name__) + + +@pytest.mark.parametrize('klass', + PandasObject.__subclasses__() + [ + pd.Timestamp, + pd.Period, + pd.Timedelta, + pd.Interval]) +def test_pinned_names(klass): + check_pinned_names(klass) From 04b56066a28551e8c1952b435743e9f1f5456467 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Wed, 26 Dec 2018 09:18:30 -0800 Subject: [PATCH 05/10] flake8 fixup --- pandas/tests/test_base.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index 34a588cdbfe3b..1b9b0ac887625 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -1311,10 +1311,11 @@ def check_pinned_names(cls): @pytest.mark.parametrize('klass', - PandasObject.__subclasses__() + [ - pd.Timestamp, - pd.Period, - pd.Timedelta, - pd.Interval]) + PandasObject.__subclasses__() + [ + pd.Timestamp, + pd.Period, + pd.Timedelta, + pd.Interval + ]) def test_pinned_names(klass): check_pinned_names(klass) From e247ec8ee8bffd30b921e3031c2a6095f0ac57a9 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Thu, 27 Dec 2018 09:07:29 -0800 Subject: [PATCH 06/10] revert non-central changes --- pandas/core/base.py | 30 ------------------------------ pandas/core/indexes/interval.py | 7 +------ pandas/core/ops.py | 3 +-- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/pandas/core/base.py b/pandas/core/base.py index f5de7ab8a6d0a..0a4111b51ba4e 100644 --- a/pandas/core/base.py +++ b/pandas/core/base.py @@ -1,7 +1,6 @@ """ Base and utility classes for pandas objects. """ -import inspect import textwrap import warnings @@ -30,34 +29,6 @@ unique='IndexOpsMixin', duplicated='IndexOpsMixin') -class PandasMeta(type): - """ - Metaclass for pandas objects to systematically handle: - - docstrings - - class ordering - - names for inherited methods - """ - def __lt__(self, other): - # define comparison methods so we can compare classes, not just - # instances - for generic in [ABCDataFrame, ABCSeries, ABCIndexClass]: - if issubclass(self, generic): - return False - elif issubclass(other, generic): - return True - return False - - def __gt__(self, other): - # define comparison methods so we can compare classes, not just - # instances - for generic in [ABCDataFrame, ABCSeries, ABCIndexClass]: - if issubclass(other, generic): - return False - elif issubclass(self, generic): - return True - return True - - class StringMixin(object): """implements string methods so long as object defines a `__unicode__` method. @@ -109,7 +80,6 @@ def __repr__(self): class PandasObject(StringMixin, DirNamesMixin): """baseclass for various pandas objects""" - __metaclass__ = PandasMeta @property def _constructor(self): diff --git a/pandas/core/indexes/interval.py b/pandas/core/indexes/interval.py index f4343436e2641..14e73b957d519 100644 --- a/pandas/core/indexes/interval.py +++ b/pandas/core/indexes/interval.py @@ -20,7 +20,6 @@ from pandas.core.dtypes.missing import isna from pandas.core.arrays.interval import IntervalArray, _interval_shared_docs -from pandas.core.base import PandasMeta import pandas.core.common as com from pandas.core.config import get_option import pandas.core.indexes.base as ibase @@ -100,10 +99,6 @@ def _new_IntervalIndex(cls, d): return cls.from_arrays(**d) -class IntervalMetaClass(_WritableDoc, PandasMeta): - pass - - @Appender(_interval_shared_docs['class'] % dict( klass="IntervalIndex", summary="Immutable index of intervals that are closed on the same side.", @@ -131,7 +126,7 @@ class IntervalMetaClass(_WritableDoc, PandasMeta): """), )) -@add_metaclass(IntervalMetaClass) +@add_metaclass(_WritableDoc) class IntervalIndex(IntervalMixin, Index): _typ = 'intervalindex' _comparables = ['name'] diff --git a/pandas/core/ops.py b/pandas/core/ops.py index 7670efd1a182e..7cab52ddda87f 100644 --- a/pandas/core/ops.py +++ b/pandas/core/ops.py @@ -1528,8 +1528,7 @@ def safe_na_op(lvalues, rvalues): raise def wrapper(left, right): - if type(right) < type(left): - # i.e. other is a DataFrame + if isinstance(right, ABCDataFrame): return NotImplemented left, right = _align_method_SERIES(left, right) From 94205483dd6b4df7237af497ac4569bf47054ddd Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Thu, 27 Dec 2018 19:15:08 -0800 Subject: [PATCH 07/10] either fix or kludge all the names --- pandas/core/arrays/sparse.py | 11 ++++----- pandas/core/indexes/frozen.py | 42 ++++++++++++++++++++------------ pandas/tests/test_base.py | 46 ++++++++++++++++++++++++++++++----- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/pandas/core/arrays/sparse.py b/pandas/core/arrays/sparse.py index e4a8c21bbb839..72282c6b77300 100644 --- a/pandas/core/arrays/sparse.py +++ b/pandas/core/arrays/sparse.py @@ -1696,12 +1696,11 @@ def sparse_arithmetic_method(self, other): @classmethod def _create_comparison_method(cls, op): - def cmp_method(self, other): - op_name = op.__name__ - - if op_name in {'and_', 'or_'}: - op_name = op_name[:-1] + op_name = op.__name__ + if op_name in {'and_', 'or_'}: + op_name = op_name[:-1] + def cmp_method(self, other): if isinstance(other, (ABCSeries, ABCIndexClass)): # Rely on pandas to unbox and dispatch to us. return NotImplemented @@ -1730,7 +1729,7 @@ def cmp_method(self, other): fill_value=fill_value, dtype=np.bool_) - name = '__{name}__'.format(name=op.__name__) + name = '__{name}__'.format(name=op_name) return compat.set_function_name(cmp_method, name, cls) @classmethod diff --git a/pandas/core/indexes/frozen.py b/pandas/core/indexes/frozen.py index 982645ebd5124..4ae6008f61f4e 100644 --- a/pandas/core/indexes/frozen.py +++ b/pandas/core/indexes/frozen.py @@ -21,8 +21,17 @@ from pandas.io.formats.printing import pprint_thing -class FrozenList(PandasObject, list): +def _make_disabled(name): + def _disabled(self, *args, **kwargs): + """This method will not function because object is immutable.""" + raise TypeError("'{cls}' does not support mutable operations." + .format(cls=self.__class__.__name__)) + _disabled.__name__ = name + return _disabled + + +class FrozenList(PandasObject, list): """ Container that doesn't allow setting item *but* because it's technically non-hashable, will be used @@ -103,11 +112,6 @@ def __reduce__(self): def __hash__(self): return hash(tuple(self)) - def _disabled(self, *args, **kwargs): - """This method will not function because object is immutable.""" - raise TypeError("'%s' does not support mutable operations." % - self.__class__.__name__) - def __unicode__(self): return pprint_thing(self, quote_strings=True, escape_chars=('\t', '\r', '\n')) @@ -116,8 +120,16 @@ def __repr__(self): return "%s(%s)" % (self.__class__.__name__, str(self)) - __setitem__ = __setslice__ = __delitem__ = __delslice__ = _disabled - pop = append = extend = remove = sort = insert = _disabled + __setitem__ = _make_disabled("__setitem__") + __setslice__ = _make_disabled("__setslice__") + __delitem__ = _make_disabled("__delitem__") + __delslice__ = _make_disabled("__delslice__") + pop = _make_disabled("pop") + append = _make_disabled("append") + extend = _make_disabled("extend") + remove = _make_disabled("remove") + sort = _make_disabled("sort") + insert = _make_disabled("insert") class FrozenNDArray(PandasObject, np.ndarray): @@ -133,13 +145,13 @@ def __new__(cls, data, dtype=None, copy=False): res = np.array(data, dtype=dtype, copy=copy).view(cls) return res - def _disabled(self, *args, **kwargs): - """This method will not function because object is immutable.""" - raise TypeError("'%s' does not support mutable operations." % - self.__class__) - - __setitem__ = __setslice__ = __delitem__ = __delslice__ = _disabled - put = itemset = fill = _disabled + __setitem__ = _make_disabled("__setitem__") + __setslice__ = _make_disabled("__setslice__") + __delitem__ = _make_disabled("__delitem__") + __delslice__ = _make_disabled("__delslice__") + put = _make_disabled("put") + itemset = _make_disabled("itemset") + fill = _make_disabled("fill") def _shallow_copy(self): return self.view() diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index 1b9b0ac887625..b127567edcba0 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -1284,10 +1284,31 @@ def check_pinned_names(cls): special_cases = { "isnull": "isna", "notnull": "notna", - "take": "take_nd", # Categorical - "to_list": "tolist", # Categorical "iteritems": "items", "__bool__": "__nonzero__", + "__div__": "__truediv__", + "__rdiv__": "__rtruediv__", + "__rmul__": "__mul__", + "__req__": "__eq__", + + "T": "transpose", + "_unpickle_compat": "__setstate__", + + # Questionable + "get_level_values": "_get_level_values", + "_xs": "xs", + + # _Window + "agg": "aggregate", + + # Categorical + "take": "take_nd", + "to_list": "tolist", + + # Timestamp + "daysinmonth": "days_in_month", + "astimezone": "tz_convert", + "weekofyear": "week", } ignore = { "_create_comparison_method", @@ -1303,15 +1324,28 @@ def check_pinned_names(cls): continue if name in ignore: continue - if inspect.ismethod(attr) or isinstance(attr, property): + if inspect.ismethod(attr) or inspect.isfunction(attr): + # isfunction check is needed in py3 expected = special_cases.get(name, name) result = attr.__name__ - assert result == expected, (result, expected, name, - cls.__name__) + + # kludges for special cases that we need to make decisions about + if (cls.__name__ == "SparseArray" and + result == "to_dense" and name == "get_values"): + continue + if (cls.__name__ == "FrozenList" and + result == "union" and name in ["__add__", "__iadd__"]): + continue + if (cls.__name__ == "FrozenList" and + result == "__mul__" and name == "__imul__"): + continue + + assert result in [name, expected], (result, expected, name, + cls.__name__) @pytest.mark.parametrize('klass', - PandasObject.__subclasses__() + [ + sorted(PandasObject.__subclasses__()) + [ pd.Timestamp, pd.Period, pd.Timedelta, From bf1a6bda9c25139d8990351f396fb7c5d62d35af Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Thu, 27 Dec 2018 20:21:38 -0800 Subject: [PATCH 08/10] py3 compat for sorted --- pandas/tests/test_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index b127567edcba0..477ab2967f6b9 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -1345,7 +1345,8 @@ def check_pinned_names(cls): @pytest.mark.parametrize('klass', - sorted(PandasObject.__subclasses__()) + [ + sorted(PandasObject.__subclasses__(), + key=lambda x: x.__name__) + [ pd.Timestamp, pd.Period, pd.Timedelta, From 07ef007fcc69d720007e5d714cb34a049aff7d75 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Fri, 4 Jan 2019 16:40:48 -0800 Subject: [PATCH 09/10] make cls_kludge prettier --- pandas/tests/test_base.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index a313db96d5858..e0c5b2bb7aa16 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -1382,6 +1382,17 @@ def check_pinned_names(cls): "astimezone": "tz_convert", "weekofyear": "week", } + # special cases that we are more restrictive about, possibly fixing + # them at some point. + class_special_cases = { + "SparseArray": { + "get_values": "to_dense", + }, + "FrozenList": { + "__add__": "union", + "__iadd__": "union", + }, + } ignore = { "_create_comparison_method", } @@ -1401,16 +1412,8 @@ def check_pinned_names(cls): expected = special_cases.get(name, name) result = attr.__name__ - # kludges for special cases that we need to make decisions about - if (cls.__name__ == "SparseArray" and - result == "to_dense" and name == "get_values"): - continue - if (cls.__name__ == "FrozenList" and - result == "union" and name in ["__add__", "__iadd__"]): - continue - if (cls.__name__ == "FrozenList" and - result == "__mul__" and name == "__imul__"): - continue + cls_kludge = class_special_cases.get(cls.__name__, {}) + expected = cls_kludge.get(name, expected) assert result in [name, expected], (result, expected, name, cls.__name__) From 0a2aeac41df836639abab173d8baa173c4f75de0 Mon Sep 17 00:00:00 2001 From: Brock Mendel Date: Wed, 9 Jan 2019 18:40:53 -0800 Subject: [PATCH 10/10] one more special --- pandas/tests/test_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pandas/tests/test_base.py b/pandas/tests/test_base.py index e0c5b2bb7aa16..ca3391303b626 100644 --- a/pandas/tests/test_base.py +++ b/pandas/tests/test_base.py @@ -1391,6 +1391,7 @@ def check_pinned_names(cls): "FrozenList": { "__add__": "union", "__iadd__": "union", + "__imul__": "__mul__", }, } ignore = {