Skip to content

Commit

Permalink
feat(api): add FieldNotFoundError
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.

Still need to add tests, but I wanted to get this up here for some initial thoughts before I invested more time. Is this something we want to pursue?
  • Loading branch information
NickCrews committed Nov 1, 2024
1 parent a189c5a commit c5f58d5
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 21 deletions.
20 changes: 19 additions & 1 deletion ibis/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@

from __future__ import annotations

import difflib
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Iterable


class TableNotFound(Exception):
Expand All @@ -45,6 +46,23 @@ class RelationError(ExpressionError):
"""RelationError."""


class FieldNotFoundError(AttributeError, IbisError):
"""When you try to access `table_or_struct.does_not_exist`."""

def __init__(self, obj: object, name: str, options: Iterable[str]) -> None:
self.obj = obj
self.name = name
self.options = set(options)
self.typos = set(difflib.get_close_matches(name, self.options))
if len(self.typos) == 1:
msg = f"'{name}' not found in {obj.__class__.__name__} object. Did you mean '{next(iter(self.typos))}'?"
elif len(self.typos) > 1:
msg = f"'{name}' not found in {obj.__class__.__name__} object. Did you mean one of {self.typos}?"
else:
msg = f"'{name}' not found in {obj.__class__.__name__} object. Possible options: {self.options}"
super().__init__(msg)


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 FieldNotFoundError, 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 FieldNotFoundError(rel.to_expr(), name, rel.schema.names)
super().__init__(rel=rel, name=name)

@attribute
Expand Down
29 changes: 24 additions & 5 deletions ibis/expr/types/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,21 +240,39 @@ def _fast_bind(self, *args, **kwargs):
args = ()
else:
args = util.promote_list(args[0])
# bind positional arguments

values = []
errs = []
# bind positional arguments
for arg in args:
values.extend(bind(self, arg))
try:
# need tuple to cause generator to evaluate
bindings = tuple(bind(self, arg))
except com.FieldNotFoundError as e:
errs.append(e)
continue
values.extend(bindings)

# bind keyword arguments where each entry can produce only one value
# which is then named with the given key
for key, arg in kwargs.items():
bindings = tuple(bind(self, arg))
try:
# need tuple to cause generator to evaluate
bindings = tuple(bind(self, arg))
except com.FieldNotFoundError as e:
errs.append(e)
continue
if len(bindings) != 1:
raise com.IbisInputError(
"Keyword arguments cannot produce more than one value"
)
(value,) = bindings
values.append(value.name(key))
if errs:
raise com.IbisError(
"Error binding arguments to table expression: "
+ "; ".join(str(e) for e in errs)
)
return values

def bind(self, *args: Any, **kwargs: Any) -> tuple[Value, ...]:
Expand Down Expand Up @@ -739,8 +757,9 @@ def __getattr__(self, key: str) -> ir.Column:
"""
try:
return ops.Field(self, key).to_expr()
except com.IbisTypeError:
pass
except com.FieldNotFoundError as e:
if e.typos:
raise e

# A mapping of common attribute typos, mapping them to the proper name
common_typos = {
Expand Down
13 changes: 5 additions & 8 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 FieldNotFoundError, IbisError
from ibis.expr.types.generic import Column, Scalar, Value, literal

if TYPE_CHECKING:
Expand Down Expand Up @@ -202,10 +202,10 @@ def __getitem__(self, name: str) -> ir.Value:
>>> t.s["foo_bar"]
Traceback (most recent call last):
...
KeyError: 'foo_bar'
ibis.common.exceptions.FieldNotFoundError: 'foo_bar' not found in StructColumn object. Possible options: {'a', 'b'}
"""
if name not in self.names:
raise KeyError(name)
raise FieldNotFoundError(self, name, self.names)
return ops.StructField(self, name).to_expr()

def __setstate__(self, instance_dictionary):
Expand Down Expand Up @@ -262,12 +262,9 @@ def __getattr__(self, name: str) -> ir.Value:
>>> t.s.foo_bar
Traceback (most recent call last):
...
AttributeError: foo_bar
ibis.common.exceptions.FieldNotFoundError: 'foo_bar' not found in StructColumn object. Possible options: {'a', 'b'}
"""
try:
return self[name]
except KeyError:
raise AttributeError(name) from None
return self[name]

@property
def names(self) -> Sequence[str]:
Expand Down

0 comments on commit c5f58d5

Please sign in to comment.