Skip to content

Commit 2fae0d4

Browse files
committed
feat(api): add FieldNotFoundError
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?
1 parent a189c5a commit 2fae0d4

File tree

4 files changed

+50
-19
lines changed

4 files changed

+50
-19
lines changed

ibis/common/exceptions.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515

1616
from __future__ import annotations
1717

18+
import difflib
1819
from typing import TYPE_CHECKING, Any
1920

2021
if TYPE_CHECKING:
21-
from collections.abc import Callable
22+
from collections.abc import Callable, Iterable
2223

2324

2425
class TableNotFound(Exception):
@@ -45,6 +46,24 @@ class RelationError(ExpressionError):
4546
"""RelationError."""
4647

4748

49+
class FieldNotFoundError(AttributeError, IbisError):
50+
"""When you try to access `table_or_struct.does_not_exist`."""
51+
52+
def __init__(self, obj: object, name: str, options: Iterable[str]) -> None:
53+
# Be a little more restrictive with the cutoff
54+
self.obj = obj
55+
self.name = name
56+
self.options = set(options)
57+
self.typos = set(difflib.get_close_matches(name, self.options, cutoff=0.6))
58+
if len(self.typos) == 1:
59+
msg = f"'{name}' not found in {obj.__class__.__name__} object. Did you mean '{next(iter(self.typos))}'?"
60+
elif len(self.typos) > 1:
61+
msg = f"'{name}' not found in {obj.__class__.__name__} object. Did you mean one of {self.typos}?"
62+
else:
63+
msg = f"'{name}' not found in {obj.__class__.__name__} object. Possible options: {self.options}"
64+
super().__init__(msg)
65+
66+
4867
class TranslationError(IbisError):
4968
"""TranslationError."""
5069

ibis/expr/operations/relations.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
FrozenDict,
1818
FrozenOrderedDict,
1919
)
20-
from ibis.common.exceptions import IbisTypeError, IntegrityError, RelationError
20+
from ibis.common.exceptions import FieldNotFoundError, IntegrityError, RelationError
2121
from ibis.common.grounds import Concrete
2222
from ibis.common.patterns import Between, InstanceOf
2323
from ibis.common.typing import Coercible, VarTuple
@@ -90,13 +90,9 @@ class Field(Value):
9090

9191
shape = ds.columnar
9292

93-
def __init__(self, rel, name):
93+
def __init__(self, rel: Relation, name: str):
9494
if name not in rel.schema:
95-
columns_formatted = ", ".join(map(repr, rel.schema.names))
96-
raise IbisTypeError(
97-
f"Column {name!r} is not found in table. "
98-
f"Existing columns: {columns_formatted}."
99-
)
95+
raise FieldNotFoundError(rel.to_expr(), name, rel.schema.names)
10096
super().__init__(rel=rel, name=name)
10197

10298
@attribute

ibis/expr/types/relations.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,21 +240,39 @@ def _fast_bind(self, *args, **kwargs):
240240
args = ()
241241
else:
242242
args = util.promote_list(args[0])
243-
# bind positional arguments
243+
244244
values = []
245+
errs = []
246+
# bind positional arguments
245247
for arg in args:
246-
values.extend(bind(self, arg))
248+
try:
249+
# need tuple to cause generator to evaluate
250+
bindings = tuple(bind(self, arg))
251+
except com.FieldNotFoundError as e:
252+
errs.append(e)
253+
continue
254+
values.extend(bindings)
247255

248256
# bind keyword arguments where each entry can produce only one value
249257
# which is then named with the given key
250258
for key, arg in kwargs.items():
251-
bindings = tuple(bind(self, arg))
259+
try:
260+
# need tuple to cause generator to evaluate
261+
bindings = tuple(bind(self, arg))
262+
except com.FieldNotFoundError as e:
263+
errs.append(e)
264+
continue
252265
if len(bindings) != 1:
253266
raise com.IbisInputError(
254267
"Keyword arguments cannot produce more than one value"
255268
)
256269
(value,) = bindings
257270
values.append(value.name(key))
271+
if errs:
272+
raise com.IbisError(
273+
"Error binding arguments to table expression: "
274+
+ "; ".join(str(e) for e in errs)
275+
)
258276
return values
259277

260278
def bind(self, *args: Any, **kwargs: Any) -> tuple[Value, ...]:
@@ -739,8 +757,9 @@ def __getattr__(self, key: str) -> ir.Column:
739757
"""
740758
try:
741759
return ops.Field(self, key).to_expr()
742-
except com.IbisTypeError:
743-
pass
760+
except com.FieldNotFoundError as e:
761+
if e.typos:
762+
raise e
744763

745764
# A mapping of common attribute typos, mapping them to the proper name
746765
common_typos = {

ibis/expr/types/structs.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import ibis.expr.operations as ops
1010
from ibis import util
1111
from ibis.common.deferred import deferrable
12-
from ibis.common.exceptions import IbisError
12+
from ibis.common.exceptions import FieldNotFoundError, IbisError
1313
from ibis.expr.types.generic import Column, Scalar, Value, literal
1414

1515
if TYPE_CHECKING:
@@ -205,7 +205,7 @@ def __getitem__(self, name: str) -> ir.Value:
205205
KeyError: 'foo_bar'
206206
"""
207207
if name not in self.names:
208-
raise KeyError(name)
208+
raise FieldNotFoundError(self, name, self.names)
209209
return ops.StructField(self, name).to_expr()
210210

211211
def __setstate__(self, instance_dictionary):
@@ -264,10 +264,7 @@ def __getattr__(self, name: str) -> ir.Value:
264264
...
265265
AttributeError: foo_bar
266266
"""
267-
try:
268-
return self[name]
269-
except KeyError:
270-
raise AttributeError(name) from None
267+
return self[name]
271268

272269
@property
273270
def names(self) -> Sequence[str]:

0 commit comments

Comments
 (0)