diff --git a/doc/source/reference/testing.rst b/doc/source/reference/testing.rst index 68e0555afc916..9d9fb91821ab1 100644 --- a/doc/source/reference/testing.rst +++ b/doc/source/reference/testing.rst @@ -45,6 +45,7 @@ Exceptions and warnings errors.SettingWithCopyError errors.SettingWithCopyWarning errors.SpecificationError + errors.UndefinedVariableError errors.UnsortedIndexError errors.UnsupportedFunctionCall diff --git a/doc/source/whatsnew/v1.5.0.rst b/doc/source/whatsnew/v1.5.0.rst index 55bfb044fb31d..f3c5d7e8d64c4 100644 --- a/doc/source/whatsnew/v1.5.0.rst +++ b/doc/source/whatsnew/v1.5.0.rst @@ -152,7 +152,7 @@ Other enhancements - A :class:`errors.PerformanceWarning` is now thrown when using ``string[pyarrow]`` dtype with methods that don't dispatch to ``pyarrow.compute`` methods (:issue:`42613`) - Added ``numeric_only`` argument to :meth:`Resampler.sum`, :meth:`Resampler.prod`, :meth:`Resampler.min`, :meth:`Resampler.max`, :meth:`Resampler.first`, and :meth:`Resampler.last` (:issue:`46442`) - ``times`` argument in :class:`.ExponentialMovingWindow` now accepts ``np.timedelta64`` (:issue:`47003`) -- :class:`DataError`, :class:`SpecificationError`, :class:`SettingWithCopyError`, :class:`SettingWithCopyWarning`, and :class:`NumExprClobberingError` are now exposed in ``pandas.errors`` (:issue:`27656`) +- :class:`DataError`, :class:`SpecificationError`, :class:`SettingWithCopyError`, :class:`SettingWithCopyWarning`, :class:`NumExprClobberingError`, :class:`UndefinedVariableError` are now exposed in ``pandas.errors`` (:issue:`27656`) - Added ``check_like`` argument to :func:`testing.assert_series_equal` (:issue:`47247`) .. --------------------------------------------------------------------------- diff --git a/pandas/core/computation/expr.py b/pandas/core/computation/expr.py index ae55e61ab01a6..4b037ab564a87 100644 --- a/pandas/core/computation/expr.py +++ b/pandas/core/computation/expr.py @@ -18,6 +18,7 @@ import numpy as np from pandas.compat import PY39 +from pandas.errors import UndefinedVariableError import pandas.core.common as com from pandas.core.computation.ops import ( @@ -35,7 +36,6 @@ Op, Term, UnaryOp, - UndefinedVariableError, is_term, ) from pandas.core.computation.parsing import ( diff --git a/pandas/core/computation/ops.py b/pandas/core/computation/ops.py index 9c54065de0353..3a556b57ea5a5 100644 --- a/pandas/core/computation/ops.py +++ b/pandas/core/computation/ops.py @@ -65,20 +65,6 @@ LOCAL_TAG = "__pd_eval_local_" -class UndefinedVariableError(NameError): - """ - NameError subclass for local variables. - """ - - def __init__(self, name: str, is_local: bool | None = None) -> None: - base_msg = f"{repr(name)} is not defined" - if is_local: - msg = f"local variable {base_msg}" - else: - msg = f"name {base_msg}" - super().__init__(msg) - - class Term: def __new__(cls, name, env, side=None, encoding=None): klass = Constant if not isinstance(name, str) else cls diff --git a/pandas/core/computation/pytables.py b/pandas/core/computation/pytables.py index 91a8505fad8c5..29af322ba0b42 100644 --- a/pandas/core/computation/pytables.py +++ b/pandas/core/computation/pytables.py @@ -13,6 +13,7 @@ ) from pandas._typing import npt from pandas.compat.chainmap import DeepChainMap +from pandas.errors import UndefinedVariableError from pandas.core.dtypes.common import is_list_like @@ -24,10 +25,7 @@ ) from pandas.core.computation.common import ensure_decoded from pandas.core.computation.expr import BaseExprVisitor -from pandas.core.computation.ops import ( - UndefinedVariableError, - is_term, -) +from pandas.core.computation.ops import is_term from pandas.core.construction import extract_array from pandas.core.indexes.base import Index diff --git a/pandas/core/computation/scope.py b/pandas/core/computation/scope.py index 52169b034603d..5188b44618b4d 100644 --- a/pandas/core/computation/scope.py +++ b/pandas/core/computation/scope.py @@ -15,6 +15,7 @@ from pandas._libs.tslibs import Timestamp from pandas.compat.chainmap import DeepChainMap +from pandas.errors import UndefinedVariableError def ensure_scope( @@ -207,9 +208,6 @@ def resolve(self, key: str, is_local: bool): # e.g., df[df > 0] return self.temps[key] except KeyError as err: - # runtime import because ops imports from scope - from pandas.core.computation.ops import UndefinedVariableError - raise UndefinedVariableError(key, is_local) from err def swapkey(self, old_key: str, new_key: str, new_value=None) -> None: diff --git a/pandas/errors/__init__.py b/pandas/errors/__init__.py index 1918065d855a5..e30e5019c3568 100644 --- a/pandas/errors/__init__.py +++ b/pandas/errors/__init__.py @@ -1,6 +1,7 @@ """ Expose public exceptions & warnings """ +from __future__ import annotations from pandas._config.config import OptionError # noqa:F401 @@ -326,3 +327,29 @@ class NumExprClobberingError(NameError): >>> pd.eval("sin + a", engine='numexpr') # doctest: +SKIP ... # NumExprClobberingError: Variables in expression "(sin) + (a)" overlap... """ + + +class UndefinedVariableError(NameError): + """ + Exception is raised when trying to use an undefined variable name in a method + like query or eval. It will also specific whether the undefined variable is + local or not. + + Examples + -------- + >>> df = pd.DataFrame({'A': [1, 1, 1]}) + >>> df.query("A > x") # doctest: +SKIP + ... # UndefinedVariableError: name 'x' is not defined + >>> df.query("A > @y") # doctest: +SKIP + ... # UndefinedVariableError: local variable 'y' is not defined + >>> pd.eval('x + 1') # doctest: +SKIP + ... # UndefinedVariableError: name 'x' is not defined + """ + + def __init__(self, name: str, is_local: bool | None = None) -> None: + base_msg = f"{repr(name)} is not defined" + if is_local: + msg = f"local variable {base_msg}" + else: + msg = f"name {base_msg}" + super().__init__(msg) diff --git a/pandas/tests/computation/test_eval.py b/pandas/tests/computation/test_eval.py index e70d493d23515..b0ad2f69a75b9 100644 --- a/pandas/tests/computation/test_eval.py +++ b/pandas/tests/computation/test_eval.py @@ -12,6 +12,7 @@ from pandas.errors import ( NumExprClobberingError, PerformanceWarning, + UndefinedVariableError, ) import pandas.util._test_decorators as td @@ -44,7 +45,6 @@ from pandas.core.computation.ops import ( ARITH_OPS_SYMS, SPECIAL_CASE_ARITH_OPS_SYMS, - UndefinedVariableError, _binary_math_ops, _binary_ops_dict, _unary_math_ops, diff --git a/pandas/tests/frame/test_query_eval.py b/pandas/tests/frame/test_query_eval.py index fe3b04e8e27e6..3d703b54ca301 100644 --- a/pandas/tests/frame/test_query_eval.py +++ b/pandas/tests/frame/test_query_eval.py @@ -495,7 +495,7 @@ def test_query_syntax_error(self): df.query("i - +", engine=engine, parser=parser) def test_query_scope(self): - from pandas.core.computation.ops import UndefinedVariableError + from pandas.errors import UndefinedVariableError engine, parser = self.engine, self.parser skip_if_no_pandas_parser(parser) @@ -522,7 +522,7 @@ def test_query_scope(self): df.query("@a > b > c", engine=engine, parser=parser) def test_query_doesnt_pickup_local(self): - from pandas.core.computation.ops import UndefinedVariableError + from pandas.errors import UndefinedVariableError engine, parser = self.engine, self.parser n = m = 10 @@ -618,7 +618,7 @@ def test_nested_scope(self): tm.assert_frame_equal(result, expected) def test_nested_raises_on_local_self_reference(self): - from pandas.core.computation.ops import UndefinedVariableError + from pandas.errors import UndefinedVariableError df = DataFrame(np.random.randn(5, 3)) @@ -678,7 +678,7 @@ def test_at_inside_string(self): tm.assert_frame_equal(result, expected) def test_query_undefined_local(self): - from pandas.core.computation.ops import UndefinedVariableError + from pandas.errors import UndefinedVariableError engine, parser = self.engine, self.parser skip_if_no_pandas_parser(parser) @@ -838,7 +838,7 @@ def test_date_index_query_with_NaT_duplicates(self): df.query("index < 20130101 < dates3", engine=engine, parser=parser) def test_nested_scope(self): - from pandas.core.computation.ops import UndefinedVariableError + from pandas.errors import UndefinedVariableError engine = self.engine parser = self.parser diff --git a/pandas/tests/test_errors.py b/pandas/tests/test_errors.py index 827c5767c514f..d4e3fff5c2f86 100644 --- a/pandas/tests/test_errors.py +++ b/pandas/tests/test_errors.py @@ -1,6 +1,9 @@ import pytest -from pandas.errors import AbstractMethodError +from pandas.errors import ( + AbstractMethodError, + UndefinedVariableError, +) import pandas as pd @@ -48,6 +51,24 @@ def test_catch_oob(): pd.Timestamp("15000101") +@pytest.mark.parametrize( + "is_local", + [ + True, + False, + ], +) +def test_catch_undefined_variable_error(is_local): + variable_name = "x" + if is_local: + msg = f"local variable '{variable_name}' is not defined" + else: + msg = f"name '{variable_name}' is not defined" + + with pytest.raises(UndefinedVariableError, match=msg): + raise UndefinedVariableError(variable_name, is_local) + + class Foo: @classmethod def classmethod(cls): diff --git a/scripts/pandas_errors_documented.py b/scripts/pandas_errors_documented.py index 3e5bf34db4fe8..18db5fa10a8f9 100644 --- a/scripts/pandas_errors_documented.py +++ b/scripts/pandas_errors_documented.py @@ -22,7 +22,7 @@ def get_defined_errors(content: str) -> set[str]: for node in ast.walk(ast.parse(content)): if isinstance(node, ast.ClassDef): errors.add(node.name) - elif isinstance(node, ast.ImportFrom): + elif isinstance(node, ast.ImportFrom) and node.module != "__future__": for alias in node.names: errors.add(alias.name) return errors @@ -41,7 +41,7 @@ def main(argv: Sequence[str] | None = None) -> None: missing = file_errors.difference(doc_errors) if missing: sys.stdout.write( - f"The follow exceptions and/or warnings are not documented " + f"The following exceptions and/or warnings are not documented " f"in {API_PATH}: {missing}" ) sys.exit(1)