From c5f58d50d7114f8e3df432ce76e6f31b419a5c03 Mon Sep 17 00:00:00 2001 From: Nick Crews Date: Fri, 1 Nov 2024 11:51:18 -0800 Subject: [PATCH] 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? --- ibis/common/exceptions.py | 20 +++++++++++++++++++- ibis/expr/operations/relations.py | 10 +++------- ibis/expr/types/relations.py | 29 ++++++++++++++++++++++++----- ibis/expr/types/structs.py | 13 +++++-------- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/ibis/common/exceptions.py b/ibis/common/exceptions.py index 745614b32484..e5b6296a476a 100644 --- a/ibis/common/exceptions.py +++ b/ibis/common/exceptions.py @@ -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): @@ -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.""" diff --git a/ibis/expr/operations/relations.py b/ibis/expr/operations/relations.py index 8bd06eac215c..0825ca76e831 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 FieldNotFoundError, 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 FieldNotFoundError(rel.to_expr(), name, rel.schema.names) super().__init__(rel=rel, name=name) @attribute diff --git a/ibis/expr/types/relations.py b/ibis/expr/types/relations.py index 50f56ddeeda3..3173359325d3 100644 --- a/ibis/expr/types/relations.py +++ b/ibis/expr/types/relations.py @@ -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, ...]: @@ -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 = { diff --git a/ibis/expr/types/structs.py b/ibis/expr/types/structs.py index 4c7cdc197094..67e519faf93f 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 FieldNotFoundError, IbisError from ibis.expr.types.generic import Column, Scalar, Value, literal if TYPE_CHECKING: @@ -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): @@ -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]: