Skip to content
This repository was archived by the owner on Nov 2, 2022. It is now read-only.
/ exceptiongroup Public archive

Added tests from trio's multierror test suite #19

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions .appveyor.yml

This file was deleted.

25 changes: 3 additions & 22 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
language: python
sudo: false
dist: trusty
dist: xenial

matrix:
include:
@@ -11,30 +10,12 @@ matrix:
env: CHECK_FORMATTING=1
# The pypy tests are slow, so list them early
- python: pypy3.5
# Uncomment if you want to test on pypy nightly:
# - language: generic
# env: USE_PYPY_NIGHTLY=1
- python: 3.5.0
- python: 3.5.2
- python: 3.6
# As of 2018-07-05, Travis's 3.7 and 3.8 builds only work if you
# use dist: xenial AND sudo: required
# See: https://github.com/python-trio/trio/pull/556#issuecomment-402879391
- python: 3.7
dist: xenial
sudo: required
- python: 3.8-dev
dist: xenial
sudo: required
- os: osx
language: generic
env: MACPYTHON=3.5.4
- os: osx
language: generic
env: MACPYTHON=3.6.6
- os: osx
language: generic
env: MACPYTHON=3.7.0
allow_failures:
- python: 3.8-dev

script:
- ci/travis.sh
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ Python.

This project is currently maintained by the `Trio project
<https://trio.readthedocs.io>`__, but the goal is for some version of
it to be merged into Python 3.8, so it can be used in both Trio and
it to be merged into Python 3.9, so it can be used in both Trio and
asyncio. For more information, see:
https://github.com/python-trio/trio/issues/611

49 changes: 0 additions & 49 deletions ci/travis.sh
Original file line number Diff line number Diff line change
@@ -2,55 +2,6 @@

set -ex

if [ "$TRAVIS_OS_NAME" = "osx" ]; then
curl -Lo macpython.pkg https://www.python.org/ftp/python/${MACPYTHON}/python-${MACPYTHON}-macosx10.6.pkg
sudo installer -pkg macpython.pkg -target /
ls /Library/Frameworks/Python.framework/Versions/*/bin/
PYTHON_EXE=/Library/Frameworks/Python.framework/Versions/*/bin/python3
# The pip in older MacPython releases doesn't support a new enough TLS
curl https://bootstrap.pypa.io/get-pip.py | sudo $PYTHON_EXE
sudo $PYTHON_EXE -m pip install virtualenv
$PYTHON_EXE -m virtualenv testenv
source testenv/bin/activate
fi

if [ "$USE_PYPY_NIGHTLY" = "1" ]; then
curl -fLo pypy.tar.bz2 http://buildbot.pypy.org/nightly/py3.5/pypy-c-jit-latest-linux64.tar.bz2
if [ ! -s pypy.tar.bz2 ]; then
# We know:
# - curl succeeded (200 response code; -f means "exit with error if
# server returns 4xx or 5xx")
# - nonetheless, pypy.tar.bz2 does not exist, or contains no data
# This isn't going to work, and the failure is not informative of
# anything involving this package.
ls -l
echo "PyPy3 nightly build failed to download – something is wrong on their end."
echo "Skipping testing against the nightly build for right now."
exit 0
fi
tar xaf pypy.tar.bz2
# something like "pypy-c-jit-89963-748aa3022295-linux64"
PYPY_DIR=$(echo pypy-c-jit-*)
PYTHON_EXE=$PYPY_DIR/bin/pypy3
($PYTHON_EXE -m ensurepip \
&& $PYTHON_EXE -m pip install virtualenv \
&& $PYTHON_EXE -m virtualenv testenv) \
|| (echo "pypy nightly is broken; skipping tests"; exit 0)
source testenv/bin/activate
fi

if [ "$USE_PYPY_RELEASE_VERSION" != "" ]; then
curl -fLo pypy.tar.bz2 https://bitbucket.org/squeaky/portable-pypy/downloads/pypy3.5-${USE_PYPY_RELEASE_VERSION}-linux_x86_64-portable.tar.bz2
tar xaf pypy.tar.bz2
# something like "pypy3.5-5.7.1-beta-linux_x86_64-portable"
PYPY_DIR=$(echo pypy3.5-*)
PYTHON_EXE=$PYPY_DIR/bin/pypy3
$PYTHON_EXE -m ensurepip
$PYTHON_EXE -m pip install virtualenv
$PYTHON_EXE -m virtualenv testenv
source testenv/bin/activate
fi

pip install -U pip setuptools wheel

if [ "$CHECK_FORMATTING" = "1" ]; then
84 changes: 0 additions & 84 deletions exceptiongroup/_tests/test_exceptiongroup.py

This file was deleted.

File renamed without changes.
20 changes: 20 additions & 0 deletions exceptiongroup/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import pytest


def pytest_addoption(parser):
parser.addoption("--run-slow", action="store_true", help="run slow tests")


def pytest_configure(config):
config.addinivalue_line("markers", "slow: mark a time consuming test")


def pytest_collection_modifyitems(config, items):
if config.getoption("--run-slow", True):
# --runslow given in cli: do not skip slow tests
return

skip_slow = pytest.mark.skip(reason="need --run-slow option to run")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
374 changes: 374 additions & 0 deletions exceptiongroup/tests/test_exceptiongroup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,374 @@
import copy
import logging
import sys
from traceback import format_exception, _cause_message

import pytest

from .. import ExceptionGroup
from .tutil import assert_match_in_seq


class NotHashableException(Exception):
code = None

def __init__(self, code):
super().__init__()
self.code = code

def __eq__(self, other):
if not isinstance(other, NotHashableException):
return False
return self.code == other.code


def einfo(exc):
return type(exc), exc, exc.__traceback__


def raiser1():
raiser1_2()


def raiser1_2():
raiser1_3()


def raiser1_3():
raise ValueError("raiser1_string")


def raiser2():
raiser2_2()


def raiser2_2():
raise KeyError("raiser2_string")


def raiser3():
raise NameError


def get_exc(raiser):
try:
raiser()
except Exception as exc:
return exc


def make_tree():
# Returns an object like:
# ExceptionGroup([
# ExceptionGroup([
# ValueError,
# KeyError,
# ]),
# NameError,
# ])
# where all exceptions except the root have a non-trivial traceback.
exc1 = get_exc(raiser1)
exc2 = get_exc(raiser2)
exc3 = get_exc(raiser3)

# Give m12 a non-trivial traceback
try:
raise ExceptionGroup("message", [exc1, exc2], ["exc1", "exc2"])
except BaseException as m12:
return ExceptionGroup("message", [m12, exc3], ["m12", "exc3"])


def raise_group():
try:
1 / 0
except Exception as e:
raise ExceptionGroup("ManyError", [e], [str(e)]) from e


def test_exception_group_init():
memberA = ValueError("A")
memberB = RuntimeError("B")
group = ExceptionGroup(
"many error.", [memberA, memberB], [str(memberA), str(memberB)]
)
assert group.exceptions == [memberA, memberB]
assert group.message == "many error."
assert group.sources == [str(memberA), str(memberB)]
assert group.args == (
"many error.",
[memberA, memberB],
[str(memberA), str(memberB)],
)


def test_exception_group_when_members_are_not_exceptions():
with pytest.raises(TypeError):
ExceptionGroup(
"error",
[RuntimeError("RuntimeError"), "error2"],
["RuntimeError", "error2"],
)


def test_exception_group_init_when_exceptions_messages_not_equal():
with pytest.raises(ValueError):
ExceptionGroup(
"many error.", [ValueError("A"), RuntimeError("B")], ["A"]
)


def test_exception_group_str():
memberA = ValueError("memberA")
memberB = ValueError("memberB")
group = ExceptionGroup(
"many error.", [memberA, memberB], [str(memberA), str(memberB)]
)
assert "memberA" in str(group)
assert "memberB" in str(group)

assert "ExceptionGroup: " in repr(group)
assert "memberA" in repr(group)
assert "memberB" in repr(group)


def test_exception_group_copy():
try:
raise_group() # the exception is raise by `raise...from..`
except ExceptionGroup as e:
group = e

another_group = copy.copy(group)
assert another_group.message == group.message
assert another_group.exceptions == group.exceptions
assert another_group.sources == group.sources
assert another_group.__traceback__ is group.__traceback__
assert another_group.__cause__ is group.__cause__
assert another_group.__context__ is group.__context__
assert another_group.__suppress_context__ is group.__suppress_context__
assert another_group.__cause__ is not None
assert another_group.__context__ is not None
assert another_group.__suppress_context__ is True

# doing copy when __suppress_context__ is False
group.__suppress_context__ = False
another_group = copy.copy(group)
assert another_group.__cause__ is group.__cause__
assert another_group.__context__ is group.__context__
assert another_group.__suppress_context__ is group.__suppress_context__
assert another_group.__suppress_context__ is False


def test_traceback_recursion():
exc1 = RuntimeError()
exc2 = KeyError()
exc3 = NotHashableException(42)
# Note how this creates a loop, where exc1 refers to exc1
# This could trigger an infinite recursion; the 'seen' set is supposed to prevent
# this.
exc1.__cause__ = ExceptionGroup(
"message", [exc1, exc2, exc3], ["exc1", "exc2", "exc3"]
)
# python traceback.TracebackException < 3.6.4 does not support unhashable exceptions
# and raises a TypeError exception
if sys.version_info < (3, 6, 4):
with pytest.raises(TypeError):
format_exception(*einfo(exc1))
else:
format_exception(*einfo(exc1))


def test_format_exception():
exc = get_exc(raiser1)
formatted = "".join(format_exception(*einfo(exc)))
assert "raiser1_string" in formatted
assert "in raiser1_3" in formatted
assert "raiser2_string" not in formatted
assert "in raiser2_2" not in formatted
assert "direct cause" not in formatted
assert "During handling" not in formatted

exc = get_exc(raiser1)
exc.__cause__ = get_exc(raiser2)
formatted = "".join(format_exception(*einfo(exc)))
assert "raiser1_string" in formatted
assert "in raiser1_3" in formatted
assert "raiser2_string" in formatted
assert "in raiser2_2" in formatted
assert "direct cause" in formatted
assert "During handling" not in formatted
# ensure cause included
assert _cause_message in formatted

exc = get_exc(raiser1)
exc.__context__ = get_exc(raiser2)
formatted = "".join(format_exception(*einfo(exc)))
assert "raiser1_string" in formatted
assert "in raiser1_3" in formatted
assert "raiser2_string" in formatted
assert "in raiser2_2" in formatted
assert "direct cause" not in formatted
assert "During handling" in formatted

exc.__suppress_context__ = True
formatted = "".join(format_exception(*einfo(exc)))
assert "raiser1_string" in formatted
assert "in raiser1_3" in formatted
assert "raiser2_string" not in formatted
assert "in raiser2_2" not in formatted
assert "direct cause" not in formatted
assert "During handling" not in formatted

# chain=False
exc = get_exc(raiser1)
exc.__context__ = get_exc(raiser2)
formatted = "".join(format_exception(*einfo(exc), chain=False))
assert "raiser1_string" in formatted
assert "in raiser1_3" in formatted
assert "raiser2_string" not in formatted
assert "in raiser2_2" not in formatted
assert "direct cause" not in formatted
assert "During handling" not in formatted

# limit
exc = get_exc(raiser1)
exc.__context__ = get_exc(raiser2)
# get_exc adds a frame that counts against the limit, so limit=2 means we
# get 1 deep into the raiser stack
formatted = "".join(format_exception(*einfo(exc), limit=2))
print(formatted)
assert "raiser1_string" in formatted
assert "in raiser1" in formatted
assert "in raiser1_2" not in formatted
assert "raiser2_string" in formatted
assert "in raiser2" in formatted
assert "in raiser2_2" not in formatted

exc = get_exc(raiser1)
exc.__context__ = get_exc(raiser2)
formatted = "".join(format_exception(*einfo(exc), limit=1))
print(formatted)
assert "raiser1_string" in formatted
assert "in raiser1" not in formatted
assert "raiser2_string" in formatted
assert "in raiser2" not in formatted

# handles loops
exc = get_exc(raiser1)
exc.__cause__ = exc
formatted = "".join(format_exception(*einfo(exc)))
assert "raiser1_string" in formatted
assert "in raiser1_3" in formatted
assert "raiser2_string" not in formatted
assert "in raiser2_2" not in formatted
# ensure duplicate exception is not included as cause
assert _cause_message not in formatted

# ExceptionGroup
formatted = "".join(format_exception(*einfo(make_tree())))
print(formatted)

assert_match_in_seq(
[
# Outer exception is ExceptionGroup
r"ExceptionGroup:",
# First embedded exception is the embedded ExceptionGroup
r"\n m12:",
# Which has a single stack frame from make_tree raising it
r"in make_tree",
# Then it has two embedded exceptions
r" exc1:",
r"in raiser1_2",
# for some reason ValueError has no quotes
r"ValueError: raiser1_string",
r" exc2:",
r"in raiser2_2",
# But KeyError does have quotes
r"KeyError: 'raiser2_string'",
# And finally the NameError, which is a sibling of the embedded
# ExceptionGroup
r"\n exc3:",
r"in raiser3",
r"NameError",
],
formatted,
)

# Prints duplicate exceptions in sub-exceptions
exc1 = get_exc(raiser1)

def raise1_raiser1():
try:
raise exc1
except:
raise ValueError("foo")

def raise2_raiser1():
try:
raise exc1
except:
raise KeyError("bar")

exc2 = get_exc(raise1_raiser1)
exc3 = get_exc(raise2_raiser1)

try:
raise ExceptionGroup("message", [exc2, exc3], ["exc2", "exc3"])
except ExceptionGroup as e:
exc = e

formatted = "".join(format_exception(*einfo(exc)))
print(formatted)

assert_match_in_seq(
[
r"Traceback",
# Outer exception is ExceptionGroup
r"ExceptionGroup:",
# First embedded exception is the embedded ValueError with cause of raiser1
r"\n exc2:",
# Print details of exc1
r" Traceback",
r"in get_exc",
r"in raiser1",
r"ValueError: raiser1_string",
# Print details of exc2
r"\n During handling of the above exception, another exception occurred:",
r" Traceback",
r"in get_exc",
r"in raise1_raiser1",
r" ValueError: foo",
# Second embedded exception is the embedded KeyError with cause of raiser1
r"\n exc3:",
# Print details of exc1 again
r" Traceback",
r"in get_exc",
r"in raiser1",
r"ValueError: raiser1_string",
# Print details of exc3
r"\n During handling of the above exception, another exception occurred:",
r" Traceback",
r"in get_exc",
r"in raise2_raiser1",
r" KeyError: 'bar'",
],
formatted,
)


def test_logging(caplog):
exc1 = get_exc(raiser1)
exc2 = get_exc(raiser2)

m = ExceptionGroup("message", [exc1, exc2], ["exc1", "exc2"])

message = "test test test"
try:
raise m
except ExceptionGroup as exc:
logging.getLogger().exception(message)
# Join lines together
formatted = "".join(
format_exception(type(exc), exc, exc.__traceback__)
)
assert message in caplog.text
assert formatted in caplog.text
58 changes: 58 additions & 0 deletions exceptiongroup/tests/test_ipython.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import pytest

from .tutil import run_script, assert_match_in_seq

try:
import IPython
except ImportError: # pragma: no cover
pytestmark = pytest.mark.skip(reason="need IPython")


def check_simple_excepthook(completed):
assert_match_in_seq(
[
"in <module>",
"ExceptionGroup",
"a:",
"in exc1_fn",
"ValueError",
"b:",
"in exc2_fn",
"KeyError",
],
completed.stdout.decode("utf-8"),
)


@pytest.mark.slow
def test_ipython_exc_handler():
completed = run_script("simple_excepthook.py", use_ipython=True)
check_simple_excepthook(completed)


@pytest.mark.slow
def test_ipython_imported_but_unused():
completed = run_script("simple_excepthook_IPython.py")
check_simple_excepthook(completed)


@pytest.mark.slow
def test_ipython_custom_exc_handler():
# Check we get a nice warning (but only one!) if the user is using IPython
# and already has some other set_custom_exc handler installed.
completed = run_script("ipython_custom_exc.py", use_ipython=True)
assert_match_in_seq(
[
# The warning
"RuntimeWarning",
"IPython detected",
"skip installing exceptiongroup",
# The ExceptionGroup
"ExceptionGroup",
"ValueError",
"KeyError",
],
completed.stdout.decode("utf-8"),
)
# Make sure our other warning doesn't show up
assert "custom sys.excepthook" not in completed.stdout.decode("utf-8")
File renamed without changes.
58 changes: 58 additions & 0 deletions exceptiongroup/tests/tutil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import os
import re
import subprocess
import sys
from pathlib import Path

import pytest

import exceptiongroup


def run_script(name, use_ipython=False):
project_path = Path(exceptiongroup.__file__).parent.parent
script_path = Path(__file__).parent / "test_scripts" / name

env = dict(os.environ)
print("parent PYTHONPATH:", env.get("PYTHONPATH"))
if "PYTHONPATH" in env: # pragma: no cover
pp = env["PYTHONPATH"].split(os.pathsep)
else:
pp = []
pp.insert(0, str(project_path))
pp.insert(0, str(script_path.parent))
env["PYTHONPATH"] = os.pathsep.join(pp)
print("subprocess PYTHONPATH:", env.get("PYTHONPATH"))

if use_ipython:
lines = [script_path.open().read(), "exit()"]

cmd = [
sys.executable,
"-u",
"-m",
"IPython",
# no startup files
"--quick",
"--TerminalIPythonApp.code_to_run=" + "\n".join(lines),
]
else:
cmd = [sys.executable, "-u", str(script_path)]
print("running:", cmd)
completed = subprocess.run(
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
print("process output:")
print(completed.stdout.decode("utf-8"))
return completed


def assert_match_in_seq(pattern_list, string):
offset = 0
print("looking for pattern matches...")
for pattern in pattern_list:
print("checking pattern:", pattern)
reobj = re.compile(pattern)
match = reobj.search(string, offset)
assert match is not None
offset = match.end()
6 changes: 0 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
@@ -14,17 +14,11 @@
author_email="njs@pobox.com",
license="MIT -or- Apache License 2.0",
packages=find_packages(),
install_requires=["trio"],
keywords=["async", "exceptions", "error handling"],
python_requires=">=3.5",
classifiers=[
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License",
"Framework :: Trio",
"Framework :: AsyncIO",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
1 change: 1 addition & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pytest
pytest-cov
ipython