Repository: python-trio/trio

Issue:
Importing trio inside a pytest test with pytest-qt fails on sys.excepthook.__name__
A full session is available at the end if you expand it.

The short of it seems to be that during tests pytest-qt puts a `functools.partial` in `sys.excepthook` and the direct issue is that the partial doesn't have a `.__name__` attribute like Trio expects.  `getattr()` of course makes a simple solution to the lack of the attribute but does not address general layering of `sys.excepthook`s.

The trivial test file triggering the error is:
```python
def test():
    import trio
```

The error is:
```
>   if sys.excepthook.__name__ == "apport_excepthook":
E   AttributeError: 'functools.partial' object has no attribute '__name__'

venv/lib/python3.8/site-packages/trio/_core/_multierror.py:480: AttributeError
```

https://github.com/python-trio/trio/blob/v0.16.0/trio/_core/_multierror.py#L480

Related pytest-qt code:
https://github.com/pytest-dev/pytest-qt/blob/ac423edee71f642e1b5537c68317199b10fe572b/src/pytestqt/exceptions.py#L40-L54
```python
    def start(self):
        """Start exception capturing by installing a hook into sys.excepthook
        that records exceptions received into ``self.exceptions``.
        """
        self.old_hook = sys.excepthook
        sys.excepthook = functools.partial(_except_hook, exceptions=self.exceptions)

    def finish(self):
        """Stop exception capturing, restoring the original hook.
        Can be called multiple times.
        """
        if self.old_hook is not None:
            sys.excepthook = self.old_hook
            self.old_hook = None
```

<details> <summary>Full terminal session</summary>

```bash
altendky@p1:~/hmm$ python3.8 -m venv venv                                                    
```

```bash
altendky@p1:~/hmm$ venv/bin/pip install --upgrade pip setuptools wheel trio pytest pyside2
Collecting pip
  Using cached https://files.pythonhosted.org/packages/43/84/23ed6a1796480a6f1a2d38f2802901d078266bda38388954d01d3f2e821d/pip-20.1.1-py2.py3-none-any.whl
Collecting setuptools
  Using cached https://files.pythonhosted.org/packages/e9/93/4860cebd5ad3ff2664ad3c966490ccb46e3b88458b2095145bca11727ca4/setuptools-47.3.1-py3-none-any.whl
Collecting wheel
  Using cached https://files.pythonhosted.org/packages/8c/23/848298cccf8e40f5bbb59009b32848a4c38f4e7f3364297ab3c3e2e2cd14/wheel-0.34.2-py2.py3-none-any.whl
Collecting trio
  Using cached https://files.pythonhosted.org/packages/a4/0e/852cf47866cda25e09b6bc0b0a71f56b095199e493f74982f0da04b3e4f5/trio-0.16.0-py3-none-any.whl
Collecting pytest
  Using cached https://files.pythonhosted.org/packages/9f/f3/0a83558da436a081344aa6c8b85ea5b5f05071214106036ce341b7769b0b/pytest-5.4.3-py3-none-any.whl
Collecting pyside2
  Using cached https://files.pythonhosted.org/packages/24/39/348e72c1607ac12915e1809e9092e09aa239bc48ace41d51253179e635bb/PySide2-5.15.0-5.15.0-cp35.cp36.cp37.cp38-abi3-manylinux1_x86_64.whl
Collecting sortedcontainers (from trio)
  Using cached https://files.pythonhosted.org/packages/23/8c/22a47a4bf8c5289e4ed946d2b0e4df62bca385b9599cc1e46878f2e2529c/sortedcontainers-2.2.2-py2.py3-none-any.whl
Collecting sniffio (from trio)
  Using cached https://files.pythonhosted.org/packages/b3/82/4bd4b7d9c0d1dc0fbfbc2a1e00138e7f3ab85bc239358fe9b78aa2ab586d/sniffio-1.1.0-py3-none-any.whl
Collecting idna (from trio)
  Using cached https://files.pythonhosted.org/packages/89/e3/afebe61c546d18fb1709a61bee788254b40e736cff7271c7de5de2dc4128/idna-2.9-py2.py3-none-any.whl
Collecting attrs>=19.2.0 (from trio)
  Using cached https://files.pythonhosted.org/packages/a2/db/4313ab3be961f7a763066401fb77f7748373b6094076ae2bda2806988af6/attrs-19.3.0-py2.py3-none-any.whl
Collecting async-generator>=1.9 (from trio)
  Using cached https://files.pythonhosted.org/packages/71/52/39d20e03abd0ac9159c162ec24b93fbcaa111e8400308f2465432495ca2b/async_generator-1.10-py3-none-any.whl
Collecting outcome (from trio)
  Using cached https://files.pythonhosted.org/packages/ff/c7/c4ac99243794a6159ae9335bb26b021e104215390e12e95e40d51007c79b/outcome-1.0.1-py2.py3-none-any.whl
Collecting wcwidth (from pytest)
  Using cached https://files.pythonhosted.org/packages/ef/94/a17155b400812f0558093c6fb99f92ba823e24757d5884e3ec60f5e81319/wcwidth-0.2.4-py2.py3-none-any.whl
Collecting more-itertools>=4.0.0 (from pytest)
  Using cached https://files.pythonhosted.org/packages/ef/9b/6c35ec5483710492e21252465160ee508170159f7e0e2d6cd769022f52f6/more_itertools-8.4.0-py3-none-any.whl
Collecting pluggy<1.0,>=0.12 (from pytest)
  Using cached https://files.pythonhosted.org/packages/a0/28/85c7aa31b80d150b772fbe4a229487bc6644da9ccb7e427dd8cc60cb8a62/pluggy-0.13.1-py2.py3-none-any.whl
Collecting packaging (from pytest)
  Using cached https://files.pythonhosted.org/packages/46/19/c5ab91b1b05cfe63cccd5cfc971db9214c6dd6ced54e33c30d5af1d2bc43/packaging-20.4-py2.py3-none-any.whl
Collecting py>=1.5.0 (from pytest)
  Using cached https://files.pythonhosted.org/packages/ae/12/76710702ccf77dab01246ecb55fbe43175131c0738d0be29f3de50d31071/py-1.8.2-py2.py3-none-any.whl
Collecting shiboken2==5.15.0 (from pyside2)
  Using cached https://files.pythonhosted.org/packages/34/09/89ea79363e71233ee39f08c497efd8b34b1dd1e26f1905f18555b3cb1b1a/shiboken2-5.15.0-5.15.0-cp35.cp36.cp37.cp38-abi3-manylinux1_x86_64.whl
Collecting pyparsing>=2.0.2 (from packaging->pytest)
  Using cached https://files.pythonhosted.org/packages/8a/bb/488841f56197b13700afd5658fc279a2025a39e22449b7cf29864669b15d/pyparsing-2.4.7-py2.py3-none-any.whl
Collecting six (from packaging->pytest)
  Using cached https://files.pythonhosted.org/packages/ee/ff/48bde5c0f013094d729fe4b0316ba2a24774b3ff1c52d924a8a4cb04078a/six-1.15.0-py2.py3-none-any.whl
Installing collected packages: pip, setuptools, wheel, sortedcontainers, sniffio, idna, attrs, async-generator, outcome, trio, wcwidth, more-itertools, pluggy, pyparsing, six, packaging, py, pytest, shiboken2, pyside2
  Found existing installation: pip 19.2.3
    Uninstalling pip-19.2.3:
      Successfully uninstalled pip-19.2.3
  Found existing installation: setuptools 41.2.0
    Uninstalling setuptools-41.2.0:
      Successfully uninstalled setuptools-41.2.0
Successfully installed async-generator-1.10 attrs-19.3.0 idna-2.9 more-itertools-8.4.0 outcome-1.0.1 packaging-20.4 pip-20.1.1 pluggy-0.13.1 py-1.8.2 pyparsing-2.4.7 pyside2-5.15.0 pytest-5.4.3 setuptools-47.3.1 shiboken2-5.15.0 six-1.15.0 sniffio-1.1.0 sortedcontainers-2.2.2 trio-0.16.0 wcwidth-0.2.4 wheel-0.34.2
```

```bash
altendky@p1:~/hmm$ cat test_importtrio.py                                         
def test():
    import trio
```

```bash
altendky@p1:~/hmm$ venv/bin/pytest test_importtrio.py                             
================================================= test session starts ==================================================
platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
rootdir: /home/altendky/hmm
collected 1 item                                                                                                       

test_importtrio.py .                                                                                             [100%]

================================================== 1 passed in 0.06s ===================================================
```

```bash
altendky@p1:~/hmm$ venv/bin/pip install pytest-qt                                 
Collecting pytest-qt
  Using cached pytest_qt-3.3.0-py2.py3-none-any.whl (35 kB)
Requirement already satisfied: pytest>=3.0.0 in ./venv/lib/python3.8/site-packages (from pytest-qt) (5.4.3)
Requirement already satisfied: attrs>=17.4.0 in ./venv/lib/python3.8/site-packages (from pytest>=3.0.0->pytest-qt) (19.3.0)
Requirement already satisfied: more-itertools>=4.0.0 in ./venv/lib/python3.8/site-packages (from pytest>=3.0.0->pytest-qt) (8.4.0)
Requirement already satisfied: wcwidth in ./venv/lib/python3.8/site-packages (from pytest>=3.0.0->pytest-qt) (0.2.4)
Requirement already satisfied: packaging in ./venv/lib/python3.8/site-packages (from pytest>=3.0.0->pytest-qt) (20.4)
Requirement already satisfied: pluggy<1.0,>=0.12 in ./venv/lib/python3.8/site-packages (from pytest>=3.0.0->pytest-qt) (0.13.1)
Requirement already satisfied: py>=1.5.0 in ./venv/lib/python3.8/site-packages (from pytest>=3.0.0->pytest-qt) (1.8.2)
Requirement already satisfied: six in ./venv/lib/python3.8/site-packages (from packaging->pytest>=3.0.0->pytest-qt) (1.15.0)
Requirement already satisfied: pyparsing>=2.0.2 in ./venv/lib/python3.8/site-packages (from packaging->pytest>=3.0.0->pytest-qt) (2.4.7)
Installing collected packages: pytest-qt
Successfully installed pytest-qt-3.3.0
```

```bash
altendky@p1:~/hmm$ venv/bin/pytest test_importtrio.py                             
================================================= test session starts ==================================================
platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
PySide2 5.15.0 -- Qt runtime 5.15.0 -- Qt compiled 5.15.0
rootdir: /home/altendky/hmm
plugins: qt-3.3.0
collected 1 item                                                                                                       

test_importtrio.py F                                                                                             [100%]

======================================================= FAILURES =======================================================
_________________________________________________________ test _________________________________________________________

    def test():
>       import trio

test_importtrio.py:2: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
venv/lib/python3.8/site-packages/trio/__init__.py:18: in <module>
    from ._core import (
venv/lib/python3.8/site-packages/trio/_core/__init__.py:18: in <module>
    from ._multierror import MultiError
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

    import sys
    import traceback
    import textwrap
    import warnings
    
    import attr
    
    # python traceback.TracebackException < 3.6.4 does not support unhashable exceptions
    # see https://github.com/python/cpython/pull/4014 for details
    if sys.version_info < (3, 6, 4):
        exc_key = lambda exc: exc
    else:
        exc_key = id
    
    ################################################################
    # MultiError
    ################################################################
    
    
    def _filter_impl(handler, root_exc):
        # We have a tree of MultiError's, like:
        #
        #  MultiError([
        #      ValueError,
        #      MultiError([
        #          KeyError,
        #          ValueError,
        #      ]),
        #  ])
        #
        # or similar.
        #
        # We want to
        # 1) apply the filter to each of the leaf exceptions -- each leaf
        #    might stay the same, be replaced (with the original exception
        #    potentially sticking around as __context__ or __cause__), or
        #    disappear altogether.
        # 2) simplify the resulting tree -- remove empty nodes, and replace
        #    singleton MultiError's with their contents, e.g.:
        #        MultiError([KeyError]) -> KeyError
        #    (This can happen recursively, e.g. if the two ValueErrors above
        #    get caught then we'll just be left with a bare KeyError.)
        # 3) preserve sensible tracebacks
        #
        # It's the tracebacks that are most confusing. As a MultiError
        # propagates through the stack, it accumulates traceback frames, but
        # the exceptions inside it don't. Semantically, the traceback for a
        # leaf exception is the concatenation the tracebacks of all the
        # exceptions you see when traversing the exception tree from the root
        # to that leaf. Our correctness invariant is that this concatenated
        # traceback should be the same before and after.
        #
        # The easy way to do that would be to, at the beginning of this
        # function, "push" all tracebacks down to the leafs, so all the
        # MultiErrors have __traceback__=None, and all the leafs have complete
        # tracebacks. But whenever possible, we'd actually prefer to keep
        # tracebacks as high up in the tree as possible, because this lets us
        # keep only a single copy of the common parts of these exception's
        # tracebacks. This is cheaper (in memory + time -- tracebacks are
        # unpleasantly quadratic-ish to work with, and this might matter if
        # you have thousands of exceptions, which can happen e.g. after
        # cancelling a large task pool, and no-one will ever look at their
        # tracebacks!), and more importantly, factoring out redundant parts of
        # the tracebacks makes them more readable if/when users do see them.
        #
        # So instead our strategy is:
        # - first go through and construct the new tree, preserving any
        #   unchanged subtrees
        # - then go through the original tree (!) and push tracebacks down
        #   until either we hit a leaf, or we hit a subtree which was
        #   preserved in the new tree.
    
        # This used to also support async handler functions. But that runs into:
        #   https://bugs.python.org/issue29600
        # which is difficult to fix on our end.
    
        # Filters a subtree, ignoring tracebacks, while keeping a record of
        # which MultiErrors were preserved unchanged
        def filter_tree(exc, preserved):
            if isinstance(exc, MultiError):
                new_exceptions = []
                changed = False
                for child_exc in exc.exceptions:
                    new_child_exc = filter_tree(child_exc, preserved)
                    if new_child_exc is not child_exc:
                        changed = True
                    if new_child_exc is not None:
                        new_exceptions.append(new_child_exc)
                if not new_exceptions:
                    return None
                elif changed:
                    return MultiError(new_exceptions)
                else:
                    preserved.add(id(exc))
                    return exc
            else:
                new_exc = handler(exc)
                # Our version of implicit exception chaining
                if new_exc is not None and new_exc is not exc:
                    new_exc.__context__ = exc
                return new_exc
    
        def push_tb_down(tb, exc, preserved):
            if id(exc) in preserved:
                return
            new_tb = concat_tb(tb, exc.__traceback__)
            if isinstance(exc, MultiError):
                for child_exc in exc.exceptions:
                    push_tb_down(new_tb, child_exc, preserved)
                exc.__traceback__ = None
            else:
                exc.__traceback__ = new_tb
    
        preserved = set()
        new_root_exc = filter_tree(root_exc, preserved)
        push_tb_down(None, root_exc, preserved)
        return new_root_exc
    
    
    # Normally I'm a big fan of (a)contextmanager, but in this case I found it
    # easier to use the raw context manager protocol, because it makes it a lot
    # easier to reason about how we're mutating the traceback as we go. (End
    # result: if the exception gets modified, then the 'raise' here makes this
    # frame show up in the traceback; otherwise, we leave no trace.)
    @attr.s(frozen=True)
    class MultiErrorCatcher:
        _handler = attr.ib()
    
        def __enter__(self):
            pass
    
        def __exit__(self, etype, exc, tb):
            if exc is not None:
                filtered_exc = MultiError.filter(self._handler, exc)
                if filtered_exc is exc:
                    # Let the interpreter re-raise it
                    return False
                if filtered_exc is None:
                    # Swallow the exception
                    return True
                # When we raise filtered_exc, Python will unconditionally blow
                # away its __context__ attribute and replace it with the original
                # exc we caught. So after we raise it, we have to pause it while
                # it's in flight to put the correct __context__ back.
                old_context = filtered_exc.__context__
                try:
                    raise filtered_exc
                finally:
                    _, value, _ = sys.exc_info()
                    assert value is filtered_exc
                    value.__context__ = old_context
    
    
    class MultiError(BaseException):
        """An exception that contains other exceptions; also known as an
        "inception".
    
        It's main use is to represent the situation when multiple child tasks all
        raise errors "in parallel".
    
        Args:
          exceptions (list): The exceptions
    
        Returns:
          If ``len(exceptions) == 1``, returns that exception. This means that a
          call to ``MultiError(...)`` is not guaranteed to return a
          :exc:`MultiError` object!
    
          Otherwise, returns a new :exc:`MultiError` object.
    
        Raises:
          TypeError: if any of the passed in objects are not instances of
              :exc:`BaseException`.
    
        """
    
        def __init__(self, exceptions):
            # Avoid recursion when exceptions[0] returned by __new__() happens
            # to be a MultiError and subsequently __init__() is called.
            if hasattr(self, "exceptions"):
                # __init__ was already called on this object
                assert len(exceptions) == 1 and exceptions[0] is self
                return
            self.exceptions = exceptions
    
        def __new__(cls, exceptions):
            exceptions = list(exceptions)
            for exc in exceptions:
                if not isinstance(exc, BaseException):
                    raise TypeError("Expected an exception object, not {!r}".format(exc))
            if len(exceptions) == 1:
                # If this lone object happens to itself be a MultiError, then
                # Python will implicitly call our __init__ on it again.  See
                # special handling in __init__.
                return exceptions[0]
            else:
                # The base class __new__() implicitly invokes our __init__, which
                # is what we want.
                #
                # In an earlier version of the code, we didn't define __init__ and
                # simply set the `exceptions` attribute directly on the new object.
                # However, linters expect attributes to be initialized in __init__.
                return BaseException.__new__(cls, exceptions)
    
        def __str__(self):
            return ", ".join(repr(exc) for exc in self.exceptions)
    
        def __repr__(self):
            return "<MultiError: {}>".format(self)
    
        @classmethod
        def filter(cls, handler, root_exc):
            """Apply the given ``handler`` to all the exceptions in ``root_exc``.
    
            Args:
              handler: A callable that takes an atomic (non-MultiError) exception
                  as input, and returns either a new exception object or None.
              root_exc: An exception, often (though not necessarily) a
                  :exc:`MultiError`.
    
            Returns:
              A new exception object in which each component exception ``exc`` has
              been replaced by the result of running ``handler(exc)`` – or, if
              ``handler`` returned None for all the inputs, returns None.
    
            """
    
            return _filter_impl(handler, root_exc)
    
        @classmethod
        def catch(cls, handler):
            """Return a context manager that catches and re-throws exceptions
            after running :meth:`filter` on them.
    
            Args:
              handler: as for :meth:`filter`
    
            """
    
            return MultiErrorCatcher(handler)
    
    
    # Clean up exception printing:
    MultiError.__module__ = "trio"
    
    ################################################################
    # concat_tb
    ################################################################
    
    # We need to compute a new traceback that is the concatenation of two existing
    # tracebacks. This requires copying the entries in 'head' and then pointing
    # the final tb_next to 'tail'.
    #
    # NB: 'tail' might be None, which requires some special handling in the ctypes
    # version.
    #
    # The complication here is that Python doesn't actually support copying or
    # modifying traceback objects, so we have to get creative...
    #
    # On CPython, we use ctypes. On PyPy, we use "transparent proxies".
    #
    # Jinja2 is a useful source of inspiration:
    #   https://github.com/pallets/jinja/blob/master/jinja2/debug.py
    
    try:
        import tputil
    except ImportError:
        have_tproxy = False
    else:
        have_tproxy = True
    
    if have_tproxy:
        # http://doc.pypy.org/en/latest/objspace-proxies.html
        def copy_tb(base_tb, tb_next):
            def controller(operation):
                # Rationale for pragma: I looked fairly carefully and tried a few
                # things, and AFAICT it's not actually possible to get any
                # 'opname' that isn't __getattr__ or __getattribute__. So there's
                # no missing test we could add, and no value in coverage nagging
                # us about adding one.
                if operation.opname in [
                    "__getattribute__",
                    "__getattr__",
                ]:  # pragma: no cover
                    if operation.args[0] == "tb_next":
                        return tb_next
                return operation.delegate()
    
            return tputil.make_proxy(controller, type(base_tb), base_tb)
    
    
    else:
        # ctypes it is
        import ctypes
    
        # How to handle refcounting? I don't want to use ctypes.py_object because
        # I don't understand or trust it, and I don't want to use
        # ctypes.pythonapi.Py_{Inc,Dec}Ref because we might clash with user code
        # that also tries to use them but with different types. So private _ctypes
        # APIs it is!
        import _ctypes
    
        class CTraceback(ctypes.Structure):
            _fields_ = [
                ("PyObject_HEAD", ctypes.c_byte * object().__sizeof__()),
                ("tb_next", ctypes.c_void_p),
                ("tb_frame", ctypes.c_void_p),
                ("tb_lasti", ctypes.c_int),
                ("tb_lineno", ctypes.c_int),
            ]
    
        def copy_tb(base_tb, tb_next):
            # TracebackType has no public constructor, so allocate one the hard way
            try:
                raise ValueError
            except ValueError as exc:
                new_tb = exc.__traceback__
            c_new_tb = CTraceback.from_address(id(new_tb))
    
            # At the C level, tb_next either pointer to the next traceback or is
            # NULL. c_void_p and the .tb_next accessor both convert NULL to None,
            # but we shouldn't DECREF None just because we assigned to a NULL
            # pointer! Here we know that our new traceback has only 1 frame in it,
            # so we can assume the tb_next field is NULL.
            assert c_new_tb.tb_next is None
            # If tb_next is None, then we want to set c_new_tb.tb_next to NULL,
            # which it already is, so we're done. Otherwise, we have to actually
            # do some work:
            if tb_next is not None:
                _ctypes.Py_INCREF(tb_next)
                c_new_tb.tb_next = id(tb_next)
    
            assert c_new_tb.tb_frame is not None
            _ctypes.Py_INCREF(base_tb.tb_frame)
            old_tb_frame = new_tb.tb_frame
            c_new_tb.tb_frame = id(base_tb.tb_frame)
            _ctypes.Py_DECREF(old_tb_frame)
    
            c_new_tb.tb_lasti = base_tb.tb_lasti
            c_new_tb.tb_lineno = base_tb.tb_lineno
    
            return new_tb
    
    
    def concat_tb(head, tail):
        # We have to use an iterative algorithm here, because in the worst case
        # this might be a RecursionError stack that is by definition too deep to
        # process by recursion!
        head_tbs = []
        pointer = head
        while pointer is not None:
            head_tbs.append(pointer)
            pointer = pointer.tb_next
        current_head = tail
        for head_tb in reversed(head_tbs):
            current_head = copy_tb(head_tb, tb_next=current_head)
        return current_head
    
    
    ################################################################
    # MultiError traceback formatting
    #
    # What follows is terrible, terrible monkey patching of
    # traceback.TracebackException to add support for handling
    # MultiErrors
    ################################################################
    
    traceback_exception_original_init = traceback.TracebackException.__init__
    
    
    def traceback_exception_init(
        self,
        exc_type,
        exc_value,
        exc_traceback,
        *,
        limit=None,
        lookup_lines=True,
        capture_locals=False,
        _seen=None,
    ):
        if _seen is None:
            _seen = set()
    
        # Capture the original exception and its cause and context as TracebackExceptions
        traceback_exception_original_init(
            self,
            exc_type,
            exc_value,
            exc_traceback,
            limit=limit,
            lookup_lines=lookup_lines,
            capture_locals=capture_locals,
            _seen=_seen,
        )
    
        # Capture each of the exceptions in the MultiError along with each of their causes and contexts
        if isinstance(exc_value, MultiError):
            embedded = []
            for exc in exc_value.exceptions:
                if exc_key(exc) not in _seen:
                    embedded.append(
                        traceback.TracebackException.from_exception(
                            exc,
                            limit=limit,
                            lookup_lines=lookup_lines,
                            capture_locals=capture_locals,
                            # copy the set of _seen exceptions so that duplicates
                            # shared between sub-exceptions are not omitted
                            _seen=set(_seen),
                        )
                    )
            self.embedded = embedded
        else:
            self.embedded = []
    
    
    traceback.TracebackException.__init__ = traceback_exception_init
    traceback_exception_original_format = traceback.TracebackException.format
    
    
    def traceback_exception_format(self, *, chain=True):
        yield from traceback_exception_original_format(self, chain=chain)
    
        for i, exc in enumerate(self.embedded):
            yield "\nDetails of embedded exception {}:\n\n".format(i + 1)
            yield from (textwrap.indent(line, " " * 2) for line in exc.format(chain=chain))
    
    
    traceback.TracebackException.format = traceback_exception_format
    
    
    def trio_excepthook(etype, value, tb):
        for chunk in traceback.format_exception(etype, value, tb):
            sys.stderr.write(chunk)
    
    
    monkeypatched_or_warned = False
    
    if "IPython" in sys.modules:
        import IPython
    
        ip = IPython.get_ipython()
        if ip is not None:
            if ip.custom_exceptions != ():
                warnings.warn(
                    "IPython detected, but you already have a custom exception "
                    "handler installed. I'll skip installing Trio's custom "
                    "handler, but this means MultiErrors will not show full "
                    "tracebacks.",
                    category=RuntimeWarning,
                )
                monkeypatched_or_warned = True
            else:
    
                def trio_show_traceback(self, etype, value, tb, tb_offset=None):
                    # XX it would be better to integrate with IPython's fancy
                    # exception formatting stuff (and not ignore tb_offset)
                    trio_excepthook(etype, value, tb)
    
                ip.set_custom_exc((MultiError,), trio_show_traceback)
                monkeypatched_or_warned = True
    
    if sys.excepthook is sys.__excepthook__:
        sys.excepthook = trio_excepthook
        monkeypatched_or_warned = True
    
    # Ubuntu's system Python has a sitecustomize.py file that import
    # apport_python_hook and replaces sys.excepthook.
    #
    # The custom hook captures the error for crash reporting, and then calls
    # sys.__excepthook__ to actually print the error.
    #
    # We don't mind it capturing the error for crash reporting, but we want to
    # take over printing the error. So we monkeypatch the apport_python_hook
    # module so that instead of calling sys.__excepthook__, it calls our custom
    # hook.
    #
    # More details: https://github.com/python-trio/trio/issues/1065
>   if sys.excepthook.__name__ == "apport_excepthook":
E   AttributeError: 'functools.partial' object has no attribute '__name__'

venv/lib/python3.8/site-packages/trio/_core/_multierror.py:480: AttributeError
=============================================== short test summary info ================================================
FAILED test_importtrio.py::test - AttributeError: 'functools.partial' object has no attribute '__name__'
================================================== 1 failed in 0.12s ===================================================
```

```bash
altendky@p1:~/hmm$ cat test_importtrio_outside.py
import trio


def test():
    pass
```

```bash
altendky@p1:~/hmm$ venv/bin/pytest test_importtrio_outside.py
================================================= test session starts ==================================================
platform linux -- Python 3.8.3, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
PySide2 5.15.0 -- Qt runtime 5.15.0 -- Qt compiled 5.15.0
rootdir: /home/altendky/hmm
plugins: qt-3.3.0
collected 1 item                                                                                                       

test_importtrio_outside.py .                                                                                     [100%]

================================================== 1 passed in 0.07s ===================================================
```

</details>


Discussion:
Ick.

Obviously we should fix the actual crash, with the obvious getattr hack
like you said. (Want to send a quick PR?)

Beyond that... I dunno. I guess the workaround is just to import trio at
the top level, instead of inside a test? Does that work? That might be as
good a solution as anything else we can do...

Yeah, I'll plan on a PR.

And yeah, this came up while trying to write a test for an import-time exception in (pre)qtrio.  Immediate solution was to import trio outside the test and qtrio inside the test.  Longer term solution may be to not have import-time side effects that you need to test for failure of.  :]

Resolving patch:
diff --git a/trio/_core/_multierror.py b/trio/_core/_multierror.py
--- a/trio/_core/_multierror.py
+++ b/trio/_core/_multierror.py
@@ -477,7 +477,7 @@ def trio_show_traceback(self, etype, value, tb, tb_offset=None):
 # hook.
 #
 # More details: https://github.com/python-trio/trio/issues/1065
-if sys.excepthook.__name__ == "apport_excepthook":
+if getattr(sys.excepthook, "__name__", None) == "apport_excepthook":
     import apport_python_hook
 
     assert sys.excepthook is apport_python_hook.apport_excepthook


Test patch:
diff --git a/trio/_core/tests/test_multierror.py b/trio/_core/tests/test_multierror.py
--- a/trio/_core/tests/test_multierror.py
+++ b/trio/_core/tests/test_multierror.py
@@ -694,6 +694,15 @@ def test_ipython_imported_but_unused():
     check_simple_excepthook(completed)
 
 
+@slow
+def test_partial_imported_but_unused():
+    # Check that a functools.partial as sys.excepthook doesn't cause an exception when
+    # importing trio.  This was a problem due to the lack of a .__name__ attribute and
+    # happens when inside a pytest-qt test case for example.
+    completed = run_script("simple_excepthook_partial.py")
+    completed.check_returncode()
+
+
 @slow
 @need_ipython
 def test_ipython_custom_exc_handler():
diff --git a/trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py b/trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py
new file mode 100644
--- /dev/null
+++ b/trio/_core/tests/test_multierror_scripts/simple_excepthook_partial.py
@@ -0,0 +1,13 @@
+import functools
+import sys
+
+import _common
+
+# just making sure importing Trio doesn't fail if sys.excepthook doesn't have a
+# .__name__ attribute
+
+sys.excepthook = functools.partial(sys.excepthook)
+
+assert not hasattr(sys.excepthook, "__name__")
+
+import trio