diff --git a/doc/source/whatsnew/v0.24.0.rst b/doc/source/whatsnew/v0.24.0.rst index a84fd118061bc..f51235e1e1f58 100644 --- a/doc/source/whatsnew/v0.24.0.rst +++ b/doc/source/whatsnew/v0.24.0.rst @@ -1440,7 +1440,7 @@ Numeric - :meth:`Series.agg` can now handle numpy NaN-aware methods like :func:`numpy.nansum` (:issue:`19629`) - Bug in :meth:`Series.rank` and :meth:`DataFrame.rank` when ``pct=True`` and more than 2:sup:`24` rows are present resulted in percentages greater than 1.0 (:issue:`18271`) - Calls such as :meth:`DataFrame.round` with a non-unique :meth:`CategoricalIndex` now return expected data. Previously, data would be improperly duplicated (:issue:`21809`). -- Added ``log10`` to the list of supported functions in :meth:`DataFrame.eval` (:issue:`24139`) +- Added ``log10``, `floor` and `ceil` to the list of supported functions in :meth:`DataFrame.eval` (:issue:`24139`, :issue:`24353`) - Logical operations ``&, |, ^`` between :class:`Series` and :class:`Index` will no longer raise ``ValueError`` (:issue:`22092`) - Checking PEP 3141 numbers in :func:`~pandas.api.types.is_scalar` function returns ``True`` (:issue:`22903`) - Reduction methods like :meth:`Series.sum` now accept the default value of ``keepdims=False`` when called from a NumPy ufunc, rather than raising a ``TypeError``. Full support for ``keepdims`` has not been implemented (:issue:`24356`). diff --git a/pandas/core/computation/check.py b/pandas/core/computation/check.py index d2d5e018063ff..da89bde56fe18 100644 --- a/pandas/core/computation/check.py +++ b/pandas/core/computation/check.py @@ -3,11 +3,13 @@ _NUMEXPR_INSTALLED = False _MIN_NUMEXPR_VERSION = "2.6.1" +_NUMEXPR_VERSION = None try: import numexpr as ne ver = LooseVersion(ne.__version__) _NUMEXPR_INSTALLED = ver >= LooseVersion(_MIN_NUMEXPR_VERSION) + _NUMEXPR_VERSION = ver if not _NUMEXPR_INSTALLED: warnings.warn( @@ -19,4 +21,4 @@ except ImportError: # pragma: no cover pass -__all__ = ['_NUMEXPR_INSTALLED'] +__all__ = ['_NUMEXPR_INSTALLED', '_NUMEXPR_VERSION'] diff --git a/pandas/core/computation/ops.py b/pandas/core/computation/ops.py index cbdb3525d5e88..8c3218a976b6b 100644 --- a/pandas/core/computation/ops.py +++ b/pandas/core/computation/ops.py @@ -2,6 +2,7 @@ """ from datetime import datetime +from distutils.version import LooseVersion from functools import partial import operator as op @@ -23,8 +24,11 @@ _unary_math_ops = ('sin', 'cos', 'exp', 'log', 'expm1', 'log1p', 'sqrt', 'sinh', 'cosh', 'tanh', 'arcsin', 'arccos', - 'arctan', 'arccosh', 'arcsinh', 'arctanh', 'abs', 'log10') + 'arctan', 'arccosh', 'arcsinh', 'arctanh', 'abs', 'log10', + 'floor', 'ceil' + ) _binary_math_ops = ('arctan2',) + _mathops = _unary_math_ops + _binary_math_ops @@ -539,11 +543,17 @@ def __unicode__(self): class FuncNode(object): - def __init__(self, name): - if name not in _mathops: + from pandas.core.computation.check import (_NUMEXPR_INSTALLED, + _NUMEXPR_VERSION) + if name not in _mathops or ( + _NUMEXPR_INSTALLED and + _NUMEXPR_VERSION < LooseVersion('2.6.9') and + name in ('floor', 'ceil') + ): raise ValueError( "\"{0}\" is not a supported function".format(name)) + self.name = name self.func = getattr(np, name) diff --git a/pandas/tests/computation/test_eval.py b/pandas/tests/computation/test_eval.py index 52945edb14e58..1649c99384ef2 100644 --- a/pandas/tests/computation/test_eval.py +++ b/pandas/tests/computation/test_eval.py @@ -1,5 +1,6 @@ import warnings import operator +from distutils.version import LooseVersion from itertools import product import pytest @@ -14,6 +15,7 @@ from pandas.util.testing import makeCustomDataframe as mkdf from pandas.core.computation import pytables +from pandas.core.computation.check import _NUMEXPR_VERSION from pandas.core.computation.engines import _engines, NumExprClobberingError from pandas.core.computation.expr import PythonExprVisitor, PandasExprVisitor from pandas.core.computation.expressions import ( @@ -32,6 +34,7 @@ assert_produces_warning) from pandas.compat import PY3, reduce + _series_frame_incompatible = _bool_ops_syms _scalar_skip = 'in', 'not in' @@ -54,6 +57,25 @@ def parser(request): return request.param +@pytest.fixture +def ne_lt_2_6_9(): + if _NUMEXPR_INSTALLED and _NUMEXPR_VERSION >= LooseVersion('2.6.9'): + pytest.skip("numexpr is >= 2.6.9") + return 'numexpr' + + +@pytest.fixture +def unary_fns_for_ne(): + if _NUMEXPR_INSTALLED: + if _NUMEXPR_VERSION >= LooseVersion('2.6.9'): + return _unary_math_ops + else: + return tuple(x for x in _unary_math_ops + if x not in ("floor", "ceil")) + else: + pytest.skip("numexpr is not present") + + def engine_has_neg_frac(engine): return _engines[engine].has_neg_frac @@ -1622,16 +1644,26 @@ def eval(self, *args, **kwargs): kwargs['level'] = kwargs.pop('level', 0) + 1 return pd.eval(*args, **kwargs) - def test_unary_functions(self): + def test_unary_functions(self, unary_fns_for_ne): df = DataFrame({'a': np.random.randn(10)}) a = df.a - for fn in self.unary_fns: + + for fn in unary_fns_for_ne: expr = "{0}(a)".format(fn) got = self.eval(expr) with np.errstate(all='ignore'): expect = getattr(np, fn)(a) tm.assert_series_equal(got, expect, check_names=False) + def test_floor_and_ceil_functions_raise_error(self, + ne_lt_2_6_9, + unary_fns_for_ne): + for fn in ('floor', 'ceil'): + msg = "\"{0}\" is not a supported function".format(fn) + with pytest.raises(ValueError, match=msg): + expr = "{0}(100)".format(fn) + self.eval(expr) + def test_binary_functions(self): df = DataFrame({'a': np.random.randn(10), 'b': np.random.randn(10)})