Skip to content

Commit

Permalink
feat(api): add FieldsNotFoundError
Browse files Browse the repository at this point in the history
I have been getting sick of typing some_table_or_struct.field_that_doesnt_exist_or_has_a_small_typo and then getting a useless error message. This PR makes that UX much better.
  • Loading branch information
NickCrews committed Feb 21, 2025
1 parent c99cc23 commit 434fe52
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 52 deletions.
4 changes: 2 additions & 2 deletions ibis/backends/tests/test_dot_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion ibis/backends/tests/test_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
39 changes: 38 additions & 1 deletion ibis/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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."""

Expand Down
10 changes: 3 additions & 7 deletions ibis/expr/operations/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ibis/expr/tests/test_reductions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")


Expand Down
69 changes: 47 additions & 22 deletions ibis/expr/types/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Check warning on line 267 in ibis/expr/types/relations.py

View check run for this annotation

Codecov / codecov/patch

ibis/expr/types/relations.py#L267

Added line #L267 was not covered by tests
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, ...]:
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand All @@ -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)))
Expand Down
12 changes: 6 additions & 6 deletions ibis/expr/types/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion ibis/tests/expr/test_struct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -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():
Expand Down
34 changes: 24 additions & 10 deletions ibis/tests/expr/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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)


Expand Down Expand Up @@ -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")


Expand Down

0 comments on commit 434fe52

Please sign in to comment.