Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): add FieldNotFoundError #10412

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
Copy link
Contributor Author

@NickCrews NickCrews Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is somewhat orthogonal to the FieldNotFoundError stuff. But if I have multiple bogus columns, eg table.select("bogus", "also_bogus") I only see the first error. I want to see them all.

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(
Copy link
Contributor Author

@NickCrews NickCrews Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if IbisError is the best type for this.

"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:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a slight difference in ux here that I'd love to unify

  • if I do Table.totally_bogus, then I get AttributeError: 'Table' object has no attribute 'bogus'
  • if I do Table["totally_bogus"], then I get FieldNotFoundError: 'bogus' not found in Table object. Possible options: {'x'}

I'm not sure which is better. If we say Possible options:... then that only includes the field names, and misses all the Table methods. But, all methods are 1. in the docs and 2. should have tab completion in many cases, so I bet typos are a lot more likely on column typos than method typos. So I think the FieldNotFoundError with the suggestion might be better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also a difference between Tables and Structs. I'd love for them to have the same behavior too.

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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's OK to be breaking here and not raise a KeyError anymore?

Copy link
Contributor Author

@NickCrews NickCrews Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyError seems semantically a little wrong. I think KeyError should be for collections with a dynamic set of keys, such as a vanilla python dict. But structs have a static set of keys, so FieldNotFoundError, as a subclass of AttributeError, seems better to me.

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
Loading