Commit 337de0d7 authored by Kirill Smelkov's avatar Kirill Smelkov

golang, errors, fmt: Error chaining (Python)

Following errors model in Go and fd95c88a (golang, errors, fmt: Error
chaining (C++/Pyx)) let's add support at Python-level for errors to wrap
each other and to be inspected/unwrapped:

- an error can additionally provide way to unwrap itself, if it
  provides .Unwrap() method. .__cause__ is not taken into account yet,
  but will be in a follow-up patch;
- errors.Is(err) tests whether an item in error's chain matches target;
- `fmt.Errorf("... : %w", ... err)` is similar to `"... : %s" % (..., err)`
  but resulting error, when unwrapped, will return err.
- errors.Unwrap is not exposed as chaining through both .Unwrap() and
  .__cause__ will need more than just "current element" as unwrapping
  state (i.e. errors.Unwrap API is insufficient - see next patch), and
  in practice users of errors.Unwrap() are very seldom.

Support for error chaining through .__cause__ will follow in the next
patch.

Top-level documentation is TODO.

See https://blog.golang.org/go1.13-errors for error chaining overview.
parent 78d0c76f
...@@ -35,9 +35,62 @@ def pyNew(text): # -> error ...@@ -35,9 +35,62 @@ def pyNew(text): # -> error
return pyerror.from_error(errors_New_pyexc(pyb(text))) return pyerror.from_error(errors_New_pyexc(pyb(text)))
def _pyUnwrap(err): # -> error
"""_Unwrap tries to unwrap error.
"""
if err is None:
return
if not isinstance(err, BaseException):
raise TypeError("errors.UnwrapIter: err is not exception: type(err)=%r" % type(err))
cdef pyerror pye
cdef error e
if type(err) is not pyerror:
# err is python-level error (pyerror-based or just BaseException child)
eunwrap = getattr(err, 'Unwrap', _missing)
pyw = None
if eunwrap is not _missing:
pyw = eunwrap()
return pyw
else:
# err is wrapper around C-level error
pye = err
e = errors_Unwrap_pyexc(pye.err)
return pyerror.from_error(e)
def pyIs(err, target): # -> bool
"""Is returns whether target matches any error in err's error chain."""
# err and target must be exception or None
if not (isinstance(err, BaseException) or err is None):
raise TypeError("errors.Is: err is not exception or None: type(err)=%r" % type(err))
if not (isinstance(target, BaseException) or target is None):
raise TypeError("errors.Is: target is not exception or None: type(target)=%r" % type(target))
if target is None:
return (err is None)
while 1:
if err is None:
return False
if type(err) is type(target):
if err == target:
return True
err = _pyUnwrap(err)
# ---- misc ---- # ---- misc ----
cdef _missing = object()
cdef nogil: cdef nogil:
error errors_New_pyexc(const char* text) except +topyexc: error errors_New_pyexc(const char* text) except +topyexc:
return errors.New(text) return errors.New(text)
error errors_Unwrap_pyexc(error err) except +topyexc:
return errors.Unwrap(err)
# -*- coding: utf-8 -*-
# cython: language_level=2
# cython: c_string_type=str, c_string_encoding=utf8
# distutils: language=c++
#
# Copyright (C) 2020 Nexedi SA and Contributors.
# Kirill Smelkov <kirr@nexedi.com>
#
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
#
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
#
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
"""_fmt.pyx implements fmt.pyx - see _fmt.pxd for package overview."""
from __future__ import print_function, absolute_import
from golang cimport pyerror
from golang cimport errors, fmt
# _PyWrapError is the pyerror created by pyErrorf("...: %w", pyerr), for case
# when pyerr is general python error - instead of being just pyerror wrapper
# around raw C-level error.
cdef class _PyWrapError(pyerror):
cdef str _prefix
cdef object _errSuffix
def __cinit__(_PyWrapError pywerr, str prefix, object errSuffix):
pywerr._prefix = prefix
pywerr._errSuffix = errSuffix
def Unwrap(_PyWrapError pywerr): # -> error | None
return pywerr._errSuffix
def Error(_PyWrapError pywerr): # -> str
esuff = pywerr._errSuffix
if esuff is None:
esuff = "%!w(<None>)" # mimic go
return "%s: %s" % (pywerr._prefix, esuff)
def pyErrorf(str format, *argv): # -> error
"""Errorf formats text into error.
format suffix ": %w" is additionally handled as in Go with
`Errorf("... : %w", ..., err)` creating error that can be unwrapped back to err.
"""
xpyerr = None
withW = False
if format.endswith(": %w"):
withW = True
format = format[:-4]
xpyerr = argv[-1]
argv = argv[:-1]
# NOTE: this will give TypeError if format vs args is not right.
# NOTE: this will give ValueError if %w is used inside suffix-stripped format.
prefix = format % argv
if not withW:
return pyerror.from_error(errors.New(prefix))
if not (isinstance(xpyerr, BaseException) or xpyerr is None):
raise TypeError("fmt.Errorf: lastarg to wrap is not error: type(argv[-1])=%r" % type(xpyerr))
# xpyerr is arbitrary exception class - not a wrapper around C-level error object
if type(xpyerr) is not pyerror:
return _PyWrapError(prefix, xpyerr)
# xpyerr is wrapper around C-level error object
cdef pyerror pyerr = xpyerr
return pyerror.from_error(fmt.errorf("%s: %w", <const char *>prefix, pyerr.err))
...@@ -20,11 +20,14 @@ ...@@ -20,11 +20,14 @@
"""Package errors mirrors Go package errors. """Package errors mirrors Go package errors.
- `New` creates new error with provided text. - `New` creates new error with provided text.
- `Is` tests whether an item in error's chain matches target.
See also https://golang.org/pkg/errors for Go errors package documentation. See also https://golang.org/pkg/errors for Go errors package documentation.
See also https://blog.golang.org/go1.13-errors for error chaining overview.
""" """
from __future__ import print_function, absolute_import from __future__ import print_function, absolute_import
from golang._errors import \ from golang._errors import \
pyNew as New pyNew as New, \
pyIs as Is
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
from __future__ import print_function, absolute_import from __future__ import print_function, absolute_import
from golang import error, b from golang import error, b
from golang import errors from golang import errors, _errors
from golang.golang_test import import_pyx_tests from golang.golang_test import import_pyx_tests
from golang._errors_test import pyerror_mkchain as error_mkchain from golang._errors_test import pyerror_mkchain as error_mkchain
from pytest import raises from pytest import raises
...@@ -62,6 +62,36 @@ class EError(error): ...@@ -62,6 +62,36 @@ class EError(error):
# NOTE error provides good __eq__ and __hash__ out of the box. # NOTE error provides good __eq__ and __hash__ out of the box.
# EErrorWrap is custom error class that inherits from error and provides .Unwrap()
class EErrorWrap(error):
def __init__(myw, text, err):
myw.text = text
myw.err = err
def Error(myw): return "%s: %s" % (myw.text, myw.err)
def Unwrap(myw): return myw.err
# XWrap is custom error class that provides .Unwrap(), but does not inherit
# from error, nor provides .Error().
class XWrap(BaseException): # NOTE does not inherit from error
def __init__(xw, text, err):
xw.text = text
xw.err = err
def Unwrap(xw): return xw.err
def __str__(xw): return "%s: %s" % (xw.text, xw.err)
# no .Error()
def __eq__(a, b):
return (type(a) is type(b)) and \
(a.text == b.text) and (a.err == b.err)
# Unwrap1(e) is approximate for `errors.Unwrap(e)` in Go.
def Unwrap1(e):
return _errors._pyUnwrap(e)
# test for golang.error class. # test for golang.error class.
def test_error(): def test_error():
assert error_mkchain([]) is None assert error_mkchain([]) is None
...@@ -117,6 +147,23 @@ def test_error(): ...@@ -117,6 +147,23 @@ def test_error():
assertEeq(epy, EError("load", 3)) assertEeq(epy, EError("load", 3))
assertEne(epy, EError("load", 4)) assertEne(epy, EError("load", 4))
wpy = EErrorWrap("mywrap", epy)
assert type(wpy) is EErrorWrap
assert wpy.Error() == "mywrap: my load: 3"
assert str(wpy) == "mywrap: my load: 3"
assert repr(wpy) == "golang.errors_test.EErrorWrap('mywrap', golang.errors_test.EError('load', 3))"
assert wpy.Unwrap() is epy
assert epy.Unwrap() is None
epy = RuntimeError("zzz")
wpy = EErrorWrap("qqq", epy)
assert type(wpy) is EErrorWrap
assert wpy.Error() == "qqq: zzz"
assert str(wpy) == "qqq: zzz"
assert repr(wpy) == "golang.errors_test.EErrorWrap('qqq', %r)" % epy
assert wpy.Unwrap() is epy
with raises(AttributeError): epy.Unwrap
def test_new(): def test_new():
E = errors.New E = errors.New
...@@ -133,3 +180,78 @@ def test_new(): ...@@ -133,3 +180,78 @@ def test_new():
with raises(TypeError): with raises(TypeError):
E(1) E(1)
def test_unwrap():
E = errors.New
Ec = error_mkchain
# err must be exception or None
assert Unwrap1(None) is None
assert Unwrap1(BaseException()) is None
with raises(TypeError): Unwrap1(1)
with raises(TypeError): Unwrap1(object())
ewrap = Ec(["abc", "def", "zzz"])
e1 = Unwrap1(ewrap)
assertEeq(e1, Ec(["def", "zzz"]))
e2 = Unwrap1(e1)
assertEeq(e2, E("zzz"))
assert Unwrap1(e2) is None
# Python-level error class that define .Wrap()
e = EError("try", "fail")
w = EErrorWrap("topic", e)
e1 = Unwrap1(w)
assert e1 is e
assert Unwrap1(e1) is None
# same, but wrapped is !error
e = RuntimeError("zzz")
w = EErrorWrap("qqq", e)
e1 = Unwrap1(w)
assert e1 is e
assert Unwrap1(e1) is None
def test_is():
E = errors.New
Ec = error_mkchain
assert errors.Is(None, None) == True
assert errors.Is(E("a"), None) == False
assert errors.Is(None, E("b")) == False
# don't accept !error err
assert errors.Is(BaseException(), None) == False
with raises(TypeError): errors.Is(1, None)
with raises(TypeError): errors.Is(object(), None)
ewrap = Ec(["hello", "world", "мир"])
assert errors.Is(ewrap, E("мир")) == True
assert errors.Is(ewrap, E("май")) == False
assert errors.Is(ewrap, Ec(["world", "мир"])) == True
assert errors.Is(ewrap, Ec(["hello", "мир"])) == False
assert errors.Is(ewrap, Ec(["hello", "май"])) == False
assert errors.Is(ewrap, Ec(["world", "май"])) == False
assert errors.Is(ewrap, Ec(["hello", "world", "мир"])) == True
assert errors.Is(ewrap, Ec(["a", "world", "мир"])) == False
assert errors.Is(ewrap, Ec(["hello", "b", "мир"])) == False
assert errors.Is(ewrap, Ec(["hello", "world", "c"])) == False
assert errors.Is(ewrap, Ec(["x", "hello", "world", "мир"])) == False
# test with XWrap that defines .Unwrap() but not .Error()
ewrap = XWrap("qqq", Ec(["abc", "привет"]))
assert errors.Is(ewrap, E("zzz")) == False
assert errors.Is(ewrap, E("привет")) == True
assert errors.Is(ewrap, Ec(["abc", "привет"])) == True
assert errors.Is(ewrap, ewrap) == True
w2 = XWrap("qqq", Ec(["abc", "def"]))
assert errors.Is(ewrap, w2) == False
w2 = XWrap("qqq", Ec(["abc", "привет"]))
assert errors.Is(ewrap, w2) == True
# -*- coding: utf-8 -*-
# Copyright (C) 2020 Nexedi SA and Contributors.
# Kirill Smelkov <kirr@nexedi.com>
#
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
#
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
#
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
"""Package fmt mirrors Go package fmt.
- `Errorf` formats text into error.
NOTE: with exception of %w, formatting rules are those of Python, not Go(*).
See also https://golang.org/pkg/fmt for Go fmt package documentation.
(*) Errorf additionally handles Go-like %w to wrap an error similarly to
https://blog.golang.org/go1.13-errors .
"""
from __future__ import print_function, absolute_import
from golang._fmt import \
pyErrorf as Errorf
...@@ -20,6 +20,91 @@ ...@@ -20,6 +20,91 @@
from __future__ import print_function, absolute_import from __future__ import print_function, absolute_import
from golang import error
from golang import errors, fmt, _fmt
from golang.golang_test import import_pyx_tests from golang.golang_test import import_pyx_tests
from golang.errors_test import Unwrap1
from pytest import raises
import_pyx_tests("golang._fmt_test") import_pyx_tests("golang._fmt_test")
# verify fmt.Errorf with focus on %w (error chaining).
# the rest of formatting is served by built-in python %.
def test_errorf():
e = fmt.Errorf("abc")
assert type(e) is error
assert e.Error() == "abc"
assert Unwrap1(e) is None
e = fmt.Errorf("hello %d world %s", 123, "мир")
assert type(e) is error
assert e.Error() == "hello 123 world мир"
assert Unwrap1(e) is None
# %w with !exception
with raises(TypeError): fmt.Errorf(": %w", 1)
with raises(TypeError): fmt.Errorf(": %w", object())
# errorf with chaining
e = errors.New("problem")
w = fmt.Errorf("%s %s: %w", "op", "file", e)
assert type(w) is error
assert w.Error() == "op file: problem"
assert Unwrap1(w) == e
assert errors.Is(w, e) == True
w = fmt.Errorf("%s %s: %s", "op", "file", e)
assert type(w) is error
assert w.Error() == "op file: problem"
assert Unwrap1(w) is None
assert errors.Is(w, e) == False
# chaining to !error
e = RuntimeError("abc")
w = fmt.Errorf("zzz: %w", e)
assert type(w) is _fmt._PyWrapError
assert w.Error() == "zzz: abc"
assert Unwrap1(w) is e # NOTE is
assert errors.Is(w, e) == True
assert Unwrap1(e) is None
# chaining to !error with .Unwrap
class MyError(Exception):
def __init__(myerr, op, path, err):
super(MyError, myerr).__init__(op, path, err)
myerr.op = op
myerr.path = path
myerr.err = err
def Unwrap(myerr): return myerr.err
def __str__(myerr): return "myerror %s %s: %s" % (myerr.op, myerr.path, myerr.err)
# NOTE: no .Error provided
e1 = KeyError("not found")
e = MyError("load", "zzz", e1)
w = fmt.Errorf("yyy: %w", e)
assert type(w) is _fmt._PyWrapError
with raises(AttributeError): e.Error
assert str(e) == "myerror load zzz: 'not found'"
assert w.Error() == "yyy: myerror load zzz: 'not found'"
assert str(w) == "yyy: myerror load zzz: 'not found'"
assert Unwrap1(w) is e # NOTE is
assert Unwrap1(e) is e1 # NOTE is
assert Unwrap1(e1) is None
assert errors.Is(w, e) == True
assert errors.Is(w, e1) == True
assert errors.Is(e, e1) == True
# %w with nil error
e = fmt.Errorf("aaa: %w", None)
assert type(e) is _fmt._PyWrapError
assert e.Error() == "aaa: %!w(<None>)"
assert str(e) == "aaa: %!w(<None>)"
assert Unwrap1(e) is None
# multiple %w or ": %w" not as suffix -> ValueError
with raises(ValueError): fmt.Errorf("%w", e)
with raises(ValueError): fmt.Errorf(":%w", e)
with raises(ValueError): fmt.Errorf("a %w : %w", e, e)
with raises(ValueError): fmt.Errorf("%w hi", e)
...@@ -261,6 +261,8 @@ setup( ...@@ -261,6 +261,8 @@ setup(
['golang/_errors_test.pyx', ['golang/_errors_test.pyx',
'golang/errors_test.cpp']), 'golang/errors_test.cpp']),
Ext('golang._fmt',
['golang/_fmt.pyx']),
Ext('golang._fmt_test', Ext('golang._fmt_test',
['golang/_fmt_test.pyx', ['golang/_fmt_test.pyx',
'golang/fmt_test.cpp']), 'golang/fmt_test.cpp']),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment