ValueNotFoundError

I propose adding ValueNotFoundError as a subclass of ValueError so that not found cases can be differentiated from other sources of value errors. This is the same general idea as when we added ModuleNotFoundError as a subclass of ImportError and
FileNotFoundError as a subclass of OSError.

The new exception addresses a problem facing users of Sequence.index() and operator.indexOf(). Currently, there is no clean way to determine whether a ValueError was caused by missing value or whether there was an unrelated exception in the input sequence or iterable.

For example, the classic approach to locating multiple indices is subtly wrong because it treats ValueError as always meaning “missing value”. However, if there is a ValueError in the underlying Sequence.__getitem__ logic, that exception gets swallowed.

def indices(seq, value, start=0):
    """Return indices where a value occurs in an sequence.

    >>> indices('AABCADEAF', 'A')
    [0, 1, 4, 7]

    """
    result = []
    i = start - 1
    while True:
        try:
            i = seq.index(value, i+1)
        except ValueError:
            return result
        else:
            result.append(i)

@pochmann has created an artificial example that breaks the above code. Another user ran into a presumably real world problem with operator.indexOf.

AFAICT the only ways to fix this are 1) egregious stack frame hacks to determine where the exception was raised, 2) fragile hacks that depend on the exception repr, 3) deeming operator.indexOf() and Sequence.index() to be unusable for user defined types and having to manually loop and compare instead, or 4) adding ValueNotFoundError so that we can reliably, performantly, and elegantly differentiate the cases.

I propose option 4 as the only way to make sure that everyday uses of Sequence.index() and operator.indexOf() aren’t subtly wrong for input types that can raise a ValueError.

3 Likes

The problem is… how to determine whether a ValueNotFoundError was caused by missing value or whether there was an unrelated exception in the input sequence or iterable.

We encountered a similar issue with StopIteration. It was fixed by replacing a StopIteration raised inside a generator function with a RuntimeError. We can do the same with ValueError (or new ValueNotFoundError). Actually, introducing ValueNotFoundError without replacing it with RuntimeError if it was raised inside a generator function will not fix the issue.

On other hand, this issue looks specific for Sequence.index() and operator.indexOf(). Could they simply catch ValueError raised in the iterator and replace it with RuntimeError?

More generally, if some function uses specific exception to signal some result, it should catch that exception raised by the input and replace it with other exception.

The difference is that StopIteration can easily, commonly, and naturally be raised from any level in a chain of iterators. In contrast, a ValueError for a missing value is a very specific case separate from all other kinds of ValueError, math.sqrt(-1), for example.

The other difference is that a RuntimeError is appropriate for the iterator examples because it specifically disallows a formerly acceptable programming practice. We can’t really do that for index() logic because value errors can naturally arise inside normal Python code. It is a rather fundamental and generic kind of exception, indicative of an unusable data input rather than a particular programming practice that can be banned.

Also, RuntimeError is usually reserved for exceptions that amount to “the code cannot continue is a recoverable way, so we’re giving you this unlikely to be caught exception instead of crashing.” In contrast, having a try/except for a ValueError is reasonably common because there may be a reasonable way forward.

All that is being proposed is to make a “not found” case specific enough to distinguished from “values that don’t make sense”. IIRC, this was the exact reasoning behind FileNotFoundError being added as a refinement to OSError; otherwise, we needed to inspect return codes or error messages to make the distinction for recoverable problems — trying an alternate filename — versus unrecoverable problems like permission errors or network errors.

1 Like

I agree with you. ValueError is too general type. It is used in many broad categories of errors and exceptional cases. In some cases it is even used where TypeError could be more appropriate.

But don’t you think that ValueNotFoundError raised by the input should be replaced with RuntimeError to avoid ambiguity?

I don’t think RuntimeError is the right choice, but otherwise, it does seem reasonable to me. In a function whose protocol is to raise an exception, it makes sense to prevent that exception from being raised unintentionally.

1 Like

Catching a generic ValueError and reraising some other exception would create a problem worse than we have now. How could a user reasonably be expected to catch the new exception? Really, the simplest way (and one we’ve done twice before) is to create a new exception subclass.

Also, the issue should not be characterized an “unintended exception”. The code raising the exception almost certainly intends to do so. Our problem is that a position search is also using the same exception type as a “not found” result. There is a big difference between “unintended” which sounds like noise that should suppressed and “ambiguous” which is an easily solvable problem.

1 Like

Don’t we already have IndexError for this?

2 Likes

Given a time machine, that would’ve been ideal (IndexError and KeyError both being subclasses of the often-overlooked LookupError)… unfortunately our API contract is “index raises ValueError when x is not found in s.” (from Built-in Types — Python 3.11.4 documentation)

We could bridge the gap and add a multi-parent class ValueIndexError(ValueError, IndexError) to be raised instead to avoid breaking compatibility while making it more clear what happened.

I’m not a big fan on inheritance diamonds though. I haven’t pondered if there could be noteworthy consequences of that.

6 Likes