diff --git a/ibis/backends/tests/test_dot_sql.py b/ibis/backends/tests/test_dot_sql.py index 9187ae749c21..ac8dc50aa09b 100644 --- a/ibis/backends/tests/test_dot_sql.py +++ b/ibis/backends/tests/test_dot_sql.py @@ -86,7 +86,7 @@ def test_con_dot_sql(backend, con, schema, ftname): ["bigquery"], raises=GoogleBadRequest, reason="requires a qualified name" ) @pytest.mark.notyet( - ["druid"], raises=com.IbisTypeError, reason="druid does not preserve case" + ["druid"], raises=com.FieldsNotFoundError, reason="druid does not preserve case" ) def test_table_dot_sql(backend): alltypes = backend.functional_alltypes @@ -126,7 +126,7 @@ def test_table_dot_sql(backend): ["bigquery"], raises=GoogleBadRequest, reason="requires a qualified name" ) @pytest.mark.notyet( - ["druid"], raises=com.IbisTypeError, reason="druid does not preserve case" + ["druid"], raises=com.FieldsNotFoundError, reason="druid does not preserve case" ) @pytest.mark.notimpl( ["oracle"], diff --git a/ibis/backends/tests/test_generic.py b/ibis/backends/tests/test_generic.py index a4b904d165c2..5b26bf518bd1 100644 --- a/ibis/backends/tests/test_generic.py +++ b/ibis/backends/tests/test_generic.py @@ -514,7 +514,7 @@ def test_mutate_rename(alltypes): def test_drop_null_invalid(alltypes): with pytest.raises( - com.IbisTypeError, match=r"Column 'invalid_col' is not found in table" + com.FieldsNotFoundError, match=r"'invalid_col' not found in Table object" ): alltypes.drop_null(["invalid_col"]) diff --git a/ibis/common/exceptions.py b/ibis/common/exceptions.py index 745614b32484..c494d2e7cec5 100644 --- a/ibis/common/exceptions.py +++ b/ibis/common/exceptions.py @@ -15,10 +15,13 @@ from __future__ import annotations +import difflib from typing import TYPE_CHECKING, Any +from ibis import util + if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable class TableNotFound(Exception): @@ -45,6 +48,40 @@ class RelationError(ExpressionError): """RelationError.""" +class FieldsNotFoundError(IbisError): + """When you try to access `table_or_struct.select("foo", "bar")` or `table_or_struct["foo"]`.""" + + def __init__( + self, + container: object, + names: str | Iterable[str], + existing_options: Iterable[str], + ) -> None: + self.names: tuple[str] = util.promote_tuple(names) + self.existing_options = tuple(existing_options) + + def norm(s: str) -> str: + return s.lower().replace("_", "").replace("-", "") + + msgs = [] + norm2orig = {norm(o): o for o in self.existing_options} + for name in self.names: + typos = tuple( + norm2orig[normed_typo] + for normed_typo in difflib.get_close_matches( + norm(name), norm2orig.keys() + ) + ) + if len(typos) == 1: + msg = f"'{name}' not found in {container.__class__.__name__} object. Did you mean '{next(iter(typos))}'?" + elif len(typos) > 1: + msg = f"'{name}' not found in {container.__class__.__name__} object. Did you mean one of {typos}?" + else: + msg = f"'{name}' not found in {container.__class__.__name__} object. Possible options: {self.existing_options}" + msgs.append(msg) + super().__init__("\n".join(msgs)) + + class TranslationError(IbisError): """TranslationError.""" diff --git a/ibis/expr/operations/relations.py b/ibis/expr/operations/relations.py index 90dc400a8ded..800ac17b8127 100644 --- a/ibis/expr/operations/relations.py +++ b/ibis/expr/operations/relations.py @@ -17,7 +17,7 @@ FrozenDict, FrozenOrderedDict, ) -from ibis.common.exceptions import IbisTypeError, IntegrityError, RelationError +from ibis.common.exceptions import FieldsNotFoundError, IntegrityError, RelationError from ibis.common.grounds import Concrete from ibis.common.patterns import Between, InstanceOf from ibis.common.typing import Coercible, VarTuple @@ -90,13 +90,9 @@ class Field(Value): shape = ds.columnar - def __init__(self, rel, name): + def __init__(self, rel: Relation, name: str): if name not in rel.schema: - columns_formatted = ", ".join(map(repr, rel.schema.names)) - raise IbisTypeError( - f"Column {name!r} is not found in table. " - f"Existing columns: {columns_formatted}." - ) + raise FieldsNotFoundError(rel.to_expr(), name, rel.schema.names) super().__init__(rel=rel, name=name) @attribute diff --git a/ibis/expr/tests/test_reductions.py b/ibis/expr/tests/test_reductions.py index adfcfb95008a..42bf6e40c6e3 100644 --- a/ibis/expr/tests/test_reductions.py +++ b/ibis/expr/tests/test_reductions.py @@ -8,7 +8,7 @@ from ibis import _ from ibis.common.annotations import ValidationError from ibis.common.deferred import Deferred -from ibis.common.exceptions import IbisTypeError +from ibis.common.exceptions import FieldsNotFoundError @pytest.mark.parametrize( @@ -148,7 +148,7 @@ def test_ordered_aggregations(method): q8 = func(order_by=t.b.desc()) assert q7.equals(q8) - with pytest.raises(IbisTypeError): + with pytest.raises(FieldsNotFoundError): func(order_by="oops") diff --git a/ibis/expr/types/relations.py b/ibis/expr/types/relations.py index 765d08deea46..78b12e636864 100644 --- a/ibis/expr/types/relations.py +++ b/ibis/expr/types/relations.py @@ -250,21 +250,37 @@ def _fast_bind(self, *args, **kwargs): args = () else: args = util.promote_list(args[0]) + + values: list[ir.Value] = [] + not_found: list[str] = [] + + def _bind_one(arg, name=None): + try: + # need tuple to cause generator to evaluate + bindings = tuple(bind(self, arg)) + except com.FieldsNotFoundError as e: + not_found.extend(e.names) + except AttributeError as e: + if e.obj is self: + not_found.append(str(e.name)) + else: + raise + else: + if name is not None: + if len(bindings) != 1: + raise com.IbisInputError( + "Keyword arguments cannot produce more than one value" + ) + bindings = tuple(v.name(name) for v in bindings) + values.extend(bindings) + # bind positional arguments - values = [] for arg in args: - values.extend(bind(self, arg)) - - # bind keyword arguments where each entry can produce only one value - # which is then named with the given key + _bind_one(arg) for key, arg in kwargs.items(): - bindings = tuple(bind(self, arg)) - if len(bindings) != 1: - raise com.IbisInputError( - "Keyword arguments cannot produce more than one value" - ) - (value,) = bindings - values.append(value.name(key)) + _bind_one(arg, key) + if not_found: + raise com.FieldsNotFoundError(self, not_found, self.columns) return values def bind(self, *args: Any, **kwargs: Any) -> tuple[Value, ...]: @@ -682,13 +698,18 @@ def __getitem__(self, what: str | int | slice | Sequence[str | int]): """ from ibis.expr.types.logical import BooleanValue - if isinstance(what, str): - return ops.Field(self.op(), what).to_expr() - elif isinstance(what, int): - return ops.Field(self.op(), self.columns[what]).to_expr() - elif isinstance(what, slice): - limit, offset = util.slice_to_limit_offset(what, self.count()) - return self.limit(limit, offset=offset) + try: + if isinstance(what, str): + return ops.Field(self.op(), what).to_expr() + elif isinstance(what, int): + return ops.Field(self.op(), self.columns[what]).to_expr() + elif isinstance(what, slice): + limit, offset = util.slice_to_limit_offset(what, self.count()) + return self.limit(limit, offset=offset) + except com.FieldsNotFoundError: + # the raised FieldsNotFoundError contains the Op, but we want + # to only expose the Expr. + raise com.FieldsNotFoundError(self, what, self.columns) from None columns = self.columns args = [ @@ -755,7 +776,7 @@ def __getattr__(self, key: str) -> ir.Column: """ try: return ops.Field(self, key).to_expr() - except com.IbisTypeError: + except com.FieldsNotFoundError: pass # A mapping of common attribute typos, mapping them to the proper name @@ -769,10 +790,14 @@ def __getattr__(self, key: str) -> ir.Column: if key in common_typos: hint = common_typos[key] raise AttributeError( - f"{type(self).__name__} object has no attribute {key!r}, did you mean {hint!r}" + f"{type(self).__name__} object has no attribute {key!r}, did you mean {hint!r}", + name=key, + obj=self, ) - raise AttributeError(f"'Table' object has no attribute {key!r}") + raise AttributeError( + f"'Table' object has no attribute {key!r}", name=key, obj=self + ) def __dir__(self) -> list[str]: out = set(dir(type(self))) diff --git a/ibis/expr/types/structs.py b/ibis/expr/types/structs.py index 4b47ba52e2e7..e2e3400ac95a 100644 --- a/ibis/expr/types/structs.py +++ b/ibis/expr/types/structs.py @@ -9,7 +9,7 @@ import ibis.expr.operations as ops from ibis import util from ibis.common.deferred import deferrable -from ibis.common.exceptions import IbisError +from ibis.common.exceptions import FieldsNotFoundError, IbisError from ibis.expr.types.generic import Column, Scalar, Value, literal if TYPE_CHECKING: @@ -200,13 +200,13 @@ def __getitem__(self, name: str) -> ir.Value: │ NULL │ │ NULL │ └────────┘ - >>> t.s["foo_bar"] + >>> t.s["foo_bar"] # doctest: +ELLIPSIS Traceback (most recent call last): - ... - KeyError: 'foo_bar' + ... + ibis.common.exceptions.FieldsNotFoundError: 'foo_bar' not found in StructColumn object. Possible options: ('a', 'b') """ if name not in self.names: - raise KeyError(name) + raise FieldsNotFoundError(self, name, self.names) return ops.StructField(self, name).to_expr() def __setstate__(self, instance_dictionary): @@ -267,7 +267,7 @@ def __getattr__(self, name: str) -> ir.Value: """ try: return self[name] - except KeyError: + except FieldsNotFoundError: raise AttributeError(name) from None @property diff --git a/ibis/tests/expr/test_struct.py b/ibis/tests/expr/test_struct.py index 9b6d8914dff6..d688c0216f22 100644 --- a/ibis/tests/expr/test_struct.py +++ b/ibis/tests/expr/test_struct.py @@ -5,6 +5,7 @@ import pytest import ibis +import ibis.common.exceptions as com import ibis.expr.operations as ops import ibis.expr.types as ir from ibis import _ @@ -41,7 +42,21 @@ def test_struct_getattr(): assert isinstance(expr.a, ir.IntegerValue) assert expr.a.get_name() == "a" with pytest.raises(AttributeError, match="bad"): - expr.bad # # noqa: B018 + expr.bad # noqa: B018 + + +def test_struct_getitem(): + expr = ibis.struct({"a": 1, "b": 2}) + assert isinstance(expr.a, ir.IntegerValue) + assert expr["a"].get_name() == "a" + + with pytest.raises(TypeError, match="Deferred") as excinfo: + expr[_.A] + + with pytest.raises(com.FieldsNotFoundError, match="A") as excinfo: + expr["A"] + assert excinfo.value.existing_options == ("a", "b") + assert excinfo.value.names == ("A",) def test_struct_tab_completion(): diff --git a/ibis/tests/expr/test_table.py b/ibis/tests/expr/test_table.py index 9ab9f6412c08..f401524f3ec6 100644 --- a/ibis/tests/expr/test_table.py +++ b/ibis/tests/expr/test_table.py @@ -130,8 +130,22 @@ def test_getitem_attribute(table): def test_getitem_missing_column(table): - with pytest.raises(com.IbisTypeError, match="oops"): + with pytest.raises(com.FieldsNotFoundError, match="oops") as excinfo: table["oops"] + assert excinfo.value.names == ("oops",) + assert excinfo.value.existing_options == tuple(table.columns) + + with pytest.raises(com.FieldsNotFoundError, match="A") as excinfo: + table["A"] + assert excinfo.value.names == ("A",) + assert excinfo.value.existing_options == tuple(table.columns) + + +def test_select_missing_columns(table): + with pytest.raises(com.FieldsNotFoundError, match="foo") as excinfo: + table.select("a", "B", "foo", _.bar.upper(), baz=_.qux) + assert excinfo.value.names == ("B", "foo", "bar", "qux") + assert excinfo.value.existing_options == tuple(table.columns) def test_getattr_missing_column(table): @@ -546,13 +560,13 @@ def test_order_by_scalar(table, key, expected): @pytest.mark.parametrize( - ("key", "exc_type"), + "key", [ - ("bogus", com.IbisTypeError), - (("bogus", False), com.IbisTypeError), - (ibis.desc("bogus"), com.IbisTypeError), - (_.bogus, AttributeError), - (_.bogus.desc(), AttributeError), + "bogus", + ("bogus", False), + ibis.desc("bogus"), + _.bogus, + _.bogus.desc(), ], ) @pytest.mark.parametrize( @@ -563,11 +577,11 @@ def test_order_by_scalar(table, key, expected): param(lambda t: t.group_by("a").agg(new=_.b.sum()), id="aggregation"), ], ) -def test_order_by_nonexistent_column_errors(table, expr_func, key, exc_type): +def test_order_by_nonexistent_column_errors(table, expr_func, key): # `order_by` is implemented on a few different operations, we check them # all in turn here. expr = expr_func(table) - with pytest.raises(exc_type): + with pytest.raises(com.FieldsNotFoundError): expr.order_by(key) @@ -1794,7 +1808,7 @@ def test_drop(): res = t.drop(_.a, "b") assert res.schema() == t.select("c", "d").schema() - with pytest.raises(com.IbisTypeError): + with pytest.raises(com.FieldsNotFoundError): t.drop("e")