On 2021-09-30 10:08 a.m., Chris Angelico wrote:
> On Thu, Sep 30, 2021 at 8:43 PM Soni L. <fakedme...@gmail.com> wrote:
> > You misnderstand exception hygiene. It isn't about "do the least stuff
> > in try blocks", but about "don't shadow unrelated exceptions into your
> > public API".
> >
> > For example, generators don't allow you to manually raise StopIteration
> > anymore:
> >
> > >>> next((next(iter([])) for x in [1, 2, 3]))
> > Traceback (most recent call last):
> >   File "<stdin>", line 1, in <genexpr>
> > StopIteration
> >
> > The above exception was the direct cause of the following exception:
> >
> > Traceback (most recent call last):
> >   File "<stdin>", line 1, in <module>
> > RuntimeError: generator raised StopIteration
> >
> > This is a (limited) form of exception hygiene. Can we generalize it? Can
> > we do better about it? This effectively means all generators *are*
> > wrapped in a try/except, so your point about "too much stuff inside a
> > try block" goes directly against accepted practice and even existing
> > python features as they're implemented.
>
> The reason for this is that StopException is nothing more than an
> *implementation detail* of generators. Look at this code: where is
> StopException?
>
> def gen():
>     yield 5
>     x = (yield 7)
>     yield 9
>     if x: return 11
>     yield 1
>     return 3
>
> The code doesn't raise StopException other than because that's the way
> that iterables are implemented. As a function, it simply does its
> work, with yield points and the ability to return a value. That's why
> a leaking StopException can and should be turned into RuntimeError.
>
> But what you're talking about doesn't have this clear distinction,
> other than in *your own definitions*. You have deemed that, in some
> areas, a certain exception should be turned into a RuntimeError; but
> in other areas, it shouldn't. To me, that sounds like a job for a
> context manager, not a function-level declaration.

But generators *are* iterators. By definition. In fact this had to be a
breaking change *because there was code in the wild that relied on it*!

Imagine if that code could be changed to be:

def gen() with StopIteration:
  try: yield next(foo)
  except StopIteration: raise

and have the StopIteration propagate as a StopIteration instead of
RuntimeError! (altho supporting this *specific* use-case would probably
be painful given that this is mostly a purely syntactic transformation.)

>
> > > My comments asking how the compiler is supposed to know which part of
> > > the code needs to be guarded with a "re-raise the exception" flag still
> > > apply, regardless of whether I have misunderstood your API or not.
> > >
> > > Your syntax has:
> > >
> > >     def a_potentially_recursive_function(some, args) with 
> > > ExceptionWeCareAbout:
> > >         some.user_code()
> > >         code_we_assume_is_safe()
> > >         if args.something and some_condition:
> > >             raise ExceptionWeCareAbout  # Line (A)
> > >
> > > How does the compiler know that *only* ExceptionWeCareAbout originating
> > > in Line (A) should be re-raised, and any other location turned into
> > > RuntimeError?
> >
> > Same way Rust decides whether to propagate or unwrap a Result: you
> > *must* tell the compiler.
>
> Please elaborate. We can already write this:
>
> def foo():
>     with fail_on_exception(ExceptionWeCareAbout):
>         some.user_code()
>     if some_condition:
>         raise ExceptionWeCareAbout
>
> Does that count as telling the compiler? If not, what is it you're
> trying to do, and how is the compiler supposed to know which ones to
> permit and which to wrap in RuntimeError?

With a source transformation, really. that is:

def foo() with exceptions:
  something
  raise ...

always transforms into:

def foo():
  set_to_True_to_pass_through_instead_of_wrapping_in_RuntimeError = False
  try:
    something
    set_to_True_to_pass_through_instead_of_wrapping_in_RuntimeError = True
    raise ...
  except exceptions as exc:
    if set_to_True_to_pass_through_instead_of_wrapping_in_RuntimeError:
      raise
    else:
      raise RuntimeError from exc

that is: the "with exceptions" becomes "except exceptions", and every
"raise" gains an
"set_to_True_to_pass_through_instead_of_wrapping_in_RuntimeError = True"
immediately before it (mostly - kinda glossing over the whole "the
expression of the raise doesn't get to, itself, raise its own
exceptions", but anyway).

It gets clearer/etc if you have a more complex function that isn't a
tiny wrapper. A tiny wrapper with 3 different exceptional exit
conditions is inherently gonna look a little busy, but a larger wrapper
with only one or two would actually look clearer!

For example this: (real code)

    def get_property_values(self, prop):
        try:
            factory = self.get_supported_properties()[prop]
        except KeyError as exc: raise PropertyError from exc
        iterator = factory(self._obj)
        try:
            first = next(iterator)
        except StopIteration: return (x for x in ())
        except abdl.exceptions.ValidationError as exc: raise LookupError
from exc
        except LookupError as exc: raise RuntimeError from exc  # don't
accidentally swallow bugs in the iterator
        return itertools.chain([first], iterator)

vs:

    def get_property_values(self, prop) with PropertyError, LookupError:
        try:
            factory = self.get_supported_properties()[prop]
        except KeyError as exc: raise PropertyError from exc
        iterator = factory(self._obj)
        try:
            first = next(iterator)
        except StopIteration: return (x for x in ())
        except abdl.exceptions.ValidationError as exc: raise LookupError
from exc
        return itertools.chain([first], iterator)

(arguably the call to get_supported_properties should also be moved
outside the try, but that actually doesn't change that a whole line got
removed!)

In this case, not only does it clean stuff up, it also solves potential
maintainability issues. Without this feature, this would need a bunch
more blocks to get the correct exception hygiene.

>
> > > What if I factor out those last two lines and make it:
> > >
> > >     def a_potentially_recursive_function(some, args) with 
> > > ExceptionWeCareAbout:
> > >         some.user_code()
> > >         code_we_assume_is_safe()
> > >         check_condition_or_raise(args.something, some_condition)
> > >
> > > How does the compiler decide to re-raise exceptions originating in the
> > > last line but not the first two?
> >
> > In this case, it explicitly doesn't. You explicitly told it the last
> > line doesn't raise any exceptions that contribute to your API's
> > exception surface.
> >
> > You *must* use try: check_condition_or_raise(args.something,
> > some_condition) except ExceptionWeCareAbout: raise
> >
> > (Verbosity can be improved if this feature gets widely used, but it's
> > beside the point.)
>
> Ewww eww ewww. I have seen horrific Java code that exists solely to
> satisfy arbitrary function exception declarations. It does not improve
> the code.

This is explicitly NOT checked exceptions. Do not mistake these. If
anything this is the *direct opposite* (direct antithesis?) of checked
exceptions.

> > This works fine because any explicit raise will always poke through the
> > generated try/except.
>
> So what you're saying is that the raise statement will always raise an
> exception, but that any exception raised from any other function
> won't. Okay. So you basically want exceptions to... not be exceptions.
> You want to use exceptions as if they're return values.
>
> Why not just use return values?

Because they're "unpythonth". Yes they're a thing in Rust and Rust is
the inspiration for this idea (and a good part of the reason we're
rewriting our code in Rust) but we do think it's possible to have a
pythonic solution to a pythonic problem. We've seen how many exceptions
are accidentally swallowed by python web frameworks and how much of a
debugging nightmare it can make. That's why we write code that guards
against unexpected exceptions. Like the (real code) we pasted here
above. (It's saved us plenty of trouble *in practice*, so this is a real
issue.)

>
> ChrisA
> _______________________________________________
> Python-ideas mailing list -- python-ideas@python.org
> To unsubscribe send an email to python-ideas-le...@python.org
> https://mail.python.org/mailman3/lists/python-ideas.python.org/
> Message archived at 
> https://mail.python.org/archives/list/python-ideas@python.org/message/5CPTIFTJLPVESKVE5SUFZ7TPBFIO32VG/
> Code of Conduct: http://python.org/psf/codeofconduct/

_______________________________________________
Python-ideas mailing list -- python-ideas@python.org
To unsubscribe send an email to python-ideas-le...@python.org
https://mail.python.org/mailman3/lists/python-ideas.python.org/
Message archived at 
https://mail.python.org/archives/list/python-ideas@python.org/message/MMXL4EFDZ3JSYRZO33BMACGB5HRMQ6QL/
Code of Conduct: http://python.org/psf/codeofconduct/

Reply via email to