diff --git a/.gitignore b/.gitignore index 949b543..2ce8425 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ effect.egg-info/ dist/ *~ +.pytest_cache diff --git a/.travis.yml b/.travis.yml index c5958f5..7c1b3b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,17 +2,20 @@ sudo: false language: python python: - - "2.7" - - "2.6" - - "3.4" - - "pypy" + - "3.6" + - "3.7" + - "3.8" + - "pypy3" install: - pip install . - pip install -r dev-requirements.txt - - pip install sphinx + - pip install sphinx sphinx_rtd_theme + # black isn't installing on pypy3, so just skip it + - 'if [ "$TRAVIS_PYTHON_VERSION" != "pypy3" ]; then pip install black; fi' script: - - make lint - - py.test + - flake8 + - 'if [ "$TRAVIS_PYTHON_VERSION" != "pypy3" ]; then black --check .; fi' + - pytest - make doc notifications: diff --git a/Makefile b/Makefile index f532fff..9da87da 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,3 @@ -lint: - flake8 --ignore=E131,E301,E302,E731,W503,E701,E704 --max-line-length=100 effect/ - build-dist: rm -rf dist python setup.py sdist bdist_wheel @@ -8,13 +5,15 @@ build-dist: upload-dist: twine upload dist/* echo - echo "Don't forget to add a git tag." - echo "And don't forget to bump the version in setup.py and docs/source/conf.py." + echo "Don't forget to:" + echo "- add a git tag." + echo "- add release notes to GitHub" + echo "- bump the version in setup.py and docs/source/conf.py." doc: rm -rf docs/build rm -rf docs/source/api - cd docs; sphinx-apidoc -e -o source/api ../effect ../setup.py ../examples ../effect/test_*.py + cd docs; sphinx-apidoc -e -o source/api ../effect ../setup.py ../examples ../effect/test_*.py ../effect/async.py rm docs/source/api/modules.rst rm docs/source/api/effect.rst # can't use sed -i on both linux and mac, so... diff --git a/README.rst b/README.rst index 33bdd40..d485daf 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ the effects (that is, IO or state manipulation) in your code. Documentation is available at https://effect.readthedocs.org/, and its PyPI page is https://pypi.python.org/pypi/effect. -It `supports`_ both Python 2.6 and up, and 3.4 and up, as well as PyPy. +It `supports`_ 3.6 and above. .. _`supports`: https://travis-ci.org/python-effect/effect @@ -44,7 +44,6 @@ A very quick example of using Effects: .. code:: python - from __future__ import print_function from effect import sync_perform, sync_performer, Effect, TypeDispatcher class ReadLine(object): @@ -106,16 +105,6 @@ Some talks have been given about Effect. .. _`Functionalish Programming in Python with Effect`: https://www.youtube.com/watch?v=fM5d_2BS6FY .. _`Kiwi PyCon`: https://nzpug.org/ - -Thanks -====== - -Thanks to Rackspace for allowing me to work on this project, and having an -*excellent* `open source employee contribution policy`_ - -.. _`open source employee contribution policy`: https://www.rackspace.com/blog/rackspaces-policy-on-contributing-to-open-source/ - - Authors ======= @@ -135,12 +124,6 @@ but now has contributions from the following people: .. _`Tom Prince`: https://github.com/tomprince -IRC -=== - -There is a ``#python-effect`` IRC channel on irc.freenode.net. - - See Also ======== diff --git a/dev-requirements.txt b/dev-requirements.txt index 70023d4..6bc8db6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,5 @@ testtools flake8 pytest +sphinx +sphinx-rtd-theme diff --git a/docs/source/api/effect.async.rst b/docs/source/api/effect.async.rst deleted file mode 100644 index feaf833..0000000 --- a/docs/source/api/effect.async.rst +++ /dev/null @@ -1,7 +0,0 @@ -effect.async module -=================== - -.. automodule:: effect.async - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/effect.parallel_async.rst b/docs/source/api/effect.parallel_async.rst new file mode 100644 index 0000000..b2943df --- /dev/null +++ b/docs/source/api/effect.parallel_async.rst @@ -0,0 +1,7 @@ +effect.parallel\_async module +============================= + +.. automodule:: effect.parallel_async + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index 2ca3f5a..3cd32cf 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", ] # include __init__ docstrings in class docstrings -autoclass_content = 'both' +autoclass_content = "both" -source_suffix = '.rst' -master_doc = 'index' -project = u'Effect' -copyright = u'2015, Christopher Armstrong' -version = release = '0.10.1' +source_suffix = ".rst" +master_doc = "index" +project = u"Effect" +copyright = u"2015, Christopher Armstrong" +version = release = "1.1.0" -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" diff --git a/docs/source/index.rst b/docs/source/index.rst index a6c51f3..f3e656b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,7 +4,7 @@ Effect Effect is a library for helping you write purely functional code by isolating the effects (that is, IO or state manipulation) in your code. -It supports both Python 2.6 and up, and 3.4 and up, as well as PyPy. +It supports both Python 3.6 and up, as well as PyPy. It lives on PyPI at https://pypi.python.org/pypi/effect and GitHub at https://github.com/python-effect/effect. diff --git a/docs/source/intro.rst b/docs/source/intro.rst index 4663957..2690a4f 100644 --- a/docs/source/intro.rst +++ b/docs/source/intro.rst @@ -47,7 +47,7 @@ the ``on`` method: def greet(): return get_user_name().on( success=lambda r: Effect(Print("Hello,", r)), - error=lambda exc_info: Effect(Print("There was an error!", exc_info[1]))) + error=lambda exc: Effect(Print("There was an error!", exc))) (Here we assume another intent, ``Print``, which shows some text to the user.) diff --git a/effect/__init__.py b/effect/__init__.py index 772106d..b03abfd 100644 --- a/effect/__init__.py +++ b/effect/__init__.py @@ -7,30 +7,43 @@ See https://effect.readthedocs.org/ for documentation. """ -from __future__ import absolute_import - from ._base import Effect, perform, NoPerformerFoundError, catch, raise_ -from ._sync import ( - NotSynchronousError, - sync_perform, - sync_performer) +from ._sync import NotSynchronousError, sync_perform, sync_performer from ._intents import ( - Delay, perform_delay_with_sleep, - ParallelEffects, parallel, parallel_all_errors, FirstError, - Constant, Error, Func, - base_dispatcher) + Delay, + perform_delay_with_sleep, + ParallelEffects, + parallel, + parallel_all_errors, + FirstError, + Constant, + Error, + Func, + base_dispatcher, +) from ._dispatcher import ComposedDispatcher, TypeDispatcher __all__ = [ # Order here affects the order that these things show up in the API docs. - "Effect", "sync_perform", "sync_performer", + "Effect", + "sync_perform", + "sync_performer", "base_dispatcher", - "TypeDispatcher", "ComposedDispatcher", - "Delay", "perform_delay_with_sleep", - "ParallelEffects", "parallel", "parallel_all_errors", - "Constant", "Error", "Func", - "catch", "raise_", - "NoPerformerFoundError", "NotSynchronousError", "perform", + "TypeDispatcher", + "ComposedDispatcher", + "Delay", + "perform_delay_with_sleep", + "ParallelEffects", + "parallel", + "parallel_all_errors", + "Constant", + "Error", + "Func", + "catch", + "raise_", + "NoPerformerFoundError", + "NotSynchronousError", + "perform", "FirstError", ] diff --git a/effect/_base.py b/effect/_base.py index b428032..efaef5c 100644 --- a/effect/_base.py +++ b/effect/_base.py @@ -1,14 +1,8 @@ # -*- test-case-name: effect.test_base -*- -from __future__ import print_function, absolute_import - -import sys - from functools import partial import attr -import six - from ._continuation import trampoline @@ -34,20 +28,19 @@ def on(self, success=None, error=None): The result of the Effect will be passed to the first callback. Any callbacks added afterwards will receive the result of the previous callback. Normal return values are passed on to the next ``success`` - callback, and exceptions are passed to the next ``error`` callback - as a ``sys.exc_info()`` tuple. + callback, and exceptions are passed to the next ``error`` callback. If a callback returns an :obj:`Effect`, the result of that :obj:`Effect` will be passed to the next callback. """ - return Effect(self.intent, - callbacks=self.callbacks + [(success, error)]) + return Effect(self.intent, callbacks=self.callbacks + [(success, error)]) class _Box(object): """ An object into which an effect dispatcher can place a result. """ + def __init__(self, cont): """ :param callable cont: Called with (bool is_error, result) @@ -62,7 +55,7 @@ def succeed(self, result): def fail(self, result): """ - Indicate that the effect has failed. result must be an exc_info tuple. + Indicate that the effect has failed. result must be an exception. """ self._cont((True, result)) @@ -71,13 +64,13 @@ def guard(f, *args, **kwargs): """ Run a function. - Return (is_error, result), where is_error is a boolean indicating whether - it raised an exception. In that case result will be ``sys.exc_info()``. + Return (is_error, result), where ``is_error`` is a boolean indicating whether + it raised an exception. In that case, ``result`` will be an exception. """ try: return (False, f(*args, **kwargs)) - except: - return (True, sys.exc_info()) + except Exception as e: + return (True, e) class NoPerformerFoundError(Exception): @@ -110,30 +103,30 @@ def perform(dispatcher, effect): or return another Effect, which will be recursively performed, such that the result of the returned Effect becomes the result passed to the next callback. In the case of exceptions, the next error-callback will be called - with a ``sys.exc_info()``-style tuple. + with the exception instance. :returns: None .. [#dispatcher] The dispatcher is passed because some performers need to make recursive calls to :func:`perform`, because they need to perform - other effects (see :func:`parallel` and :func:`.perform_parallel_async` - for an example of this). + other effects (see :func:`parallel` and + :func:`.parallel_async.perform_parallel_async` for an example of this). .. [#box] Without using one of those decorators, the performer is actually passed three arguments, not two: the dispatcher, the intent, and a "box". The box is an object that lets the performer provide the result, optionally asynchronously. To provide the result, use - ``box.succeed(result)`` or ``box.fail(exc_info)``, where ``exc_info`` is - a ``sys.exc_info()``-style tuple. Decorators like :func:`sync_performer` - simply abstract this away. + ``box.succeed(result)`` or ``box.fail(exc)``, where ``exc`` is + an exception. Decorators like :func:`sync_performer` simply abstract this away. """ + def _run_callbacks(bouncer, chain, result): is_error, value = result if type(value) is Effect: bouncer.bounce( - _perform, - Effect(value.intent, callbacks=value.callbacks + chain)) + _perform, Effect(value.intent, callbacks=value.callbacks + chain) + ) return if not chain: @@ -154,10 +147,9 @@ def _perform(bouncer, effect): performer( dispatcher, effect.intent, - _Box(partial(bouncer.bounce, - _run_callbacks, effect.callbacks))) - except: - e = sys.exc_info() + _Box(partial(bouncer.bounce, _run_callbacks, effect.callbacks)), + ) + except Exception as e: _run_callbacks(bouncer, effect.callbacks, (True, e)) trampoline(_perform, effect) @@ -168,27 +160,26 @@ def catch(exc_type, callable): A helper for handling errors of a specific type:: eff.on(error=catch(SpecificException, - lambda exc_info: "got an error!")) + lambda exc: "got an error!")) If any exception other than a ``SpecificException`` is thrown, it will be - ignored by this handler and propogate further down the chain of callbacks. + ignored by this handler and propagate further down the chain of callbacks. """ - def catcher(exc_info): - if isinstance(exc_info[1], exc_type): - return callable(exc_info) - six.reraise(*exc_info) - return catcher + def catcher(error): + if isinstance(error, exc_type): + return callable(error) + raise error + + return catcher -def raise_(exception, tb=None): - """Simple convenience function to allow raising exceptions from lambdas. - This is slightly more convenient than ``six.reraise`` because it takes an - exception instance instead of needing the type separate from the instance. +def raise_(exception): + """Simple convenience function to allow raising exceptions as an expression, + useful in lambdas. :param exception: An exception *instance* (not an exception type). - - ``raise_(exc)`` is the same as ``raise exc``. - - ``raise_(exc, tb)`` is the same as ``raise type(exc), exc, tb``. + ``raise_(exc)`` is the same as ``raise exc``. """ - six.reraise(type(exception), exception, tb) + raise exception diff --git a/effect/_continuation.py b/effect/_continuation.py index b53f1e2..042303c 100644 --- a/effect/_continuation.py +++ b/effect/_continuation.py @@ -19,7 +19,8 @@ def bounce(self, func, *args, **kwargs): if self.work is not None: raise RuntimeError( "Already specified work %r, refusing to set to (%r %r %r)" - % (self.work, func, args, kwargs)) + % (self.work, func, args, kwargs) + ) self.work = (func, args, kwargs) if self._asynchronous: trampoline(func, *args, **kwargs) diff --git a/effect/_dispatcher.py b/effect/_dispatcher.py index 30b34d4..d80780b 100644 --- a/effect/_dispatcher.py +++ b/effect/_dispatcher.py @@ -4,8 +4,6 @@ import attr -from six.moves import filter - @attr.s class TypeDispatcher(object): @@ -14,6 +12,7 @@ class TypeDispatcher(object): :param mapping: mapping of intent type to performer """ + mapping = attr.ib() def __call__(self, intent): diff --git a/effect/_intents.py b/effect/_intents.py index c030b97..f8307e2 100644 --- a/effect/_intents.py +++ b/effect/_intents.py @@ -8,9 +8,6 @@ performed, sunch as :class:`Func`, :class:`Error`, and :class:`Constant`. """ - -from __future__ import print_function, absolute_import - import time import attr @@ -26,7 +23,7 @@ class ParallelEffects(object): An effect intent that asks for a number of effects to be run in parallel, and for their results to be gathered up into a sequence. - :func:`effect.async.perform_parallel_async` can perform this Intent + :func:`effect.parallel_async.perform_parallel_async` can perform this Intent assuming all child effects have asynchronous performers. :func:`effect.threads.perform_parallel_with_pool` can perform blocking performers in a thread pool. @@ -77,28 +74,33 @@ def parallel_all_errors(effects): :param effects: Effects which should be performed in parallel. :return: An Effect that results in a list of ``(is_error, result)`` tuples, where ``is_error`` is True if the child effect raised an exception, in - which case ``result`` will be an exc_info tuple. If ``is_error`` is + which case ``result`` will be the exception. If ``is_error`` is False, then ``result`` will just be the result as provided by the child effect. """ - effects = [effect.on(success=lambda r: (False, r), - error=lambda e: (True, e)) - for effect in effects] + effects = [ + effect.on(success=lambda r: (False, r), error=lambda e: (True, e)) + for effect in effects + ] return Effect(ParallelEffects(list(effects))) -@attr.s +@attr.s(hash=True) class FirstError(Exception): """ One of the effects in a :obj:`ParallelEffects` resulted in an error. This represents the first such error that occurred. """ - exc_info = attr.ib() + + exception = attr.ib() index = attr.ib() def __str__(self): - return '(index=%s) %s: %s' % ( - self.index, self.exc_info[0].__name__, self.exc_info[1]) + return "(index=%s) %s: %s" % ( + self.index, + type(self.exception).__name__, + self.exception, + ) @attr.s @@ -111,6 +113,7 @@ class Delay(object): :param float delay: The number of seconds to delay. """ + delay = attr.ib() @@ -127,6 +130,7 @@ class Constant(object): :param result: The object which the Effect will result in. """ + result = attr.ib() @@ -143,6 +147,7 @@ class Error(object): :param BaseException exception: Exception instance to raise. """ + exception = attr.ib() @@ -176,6 +181,7 @@ class Func(object): :param args: Positional arguments to pass to the function. :param kwargs: Keyword arguments to pass to the function. """ + func = attr.ib() args = attr.ib() kwargs = attr.ib() @@ -192,8 +198,6 @@ def perform_func(dispatcher, intent): return intent.func(*intent.args, **intent.kwargs) -base_dispatcher = TypeDispatcher({ - Constant: perform_constant, - Error: perform_error, - Func: perform_func, -}) +base_dispatcher = TypeDispatcher( + {Constant: perform_constant, Error: perform_error, Func: perform_func} +) diff --git a/effect/_sync.py b/effect/_sync.py index 2a93d87..f7a8cdd 100644 --- a/effect/_sync.py +++ b/effect/_sync.py @@ -4,9 +4,6 @@ Tools for dealing with Effects synchronously. """ -import six -import sys - from ._base import perform from ._utils import wraps @@ -31,10 +28,9 @@ def sync_perform(dispatcher, effect): if successes: return successes[0] elif errors: - six.reraise(*errors[0]) + raise errors[0] else: - raise NotSynchronousError("Performing %r was not synchronous!" - % (effect,)) + raise NotSynchronousError("Performing %r was not synchronous!" % (effect,)) def sync_performer(f): @@ -64,12 +60,14 @@ def sync_performer(f): def perform_foo(dispatcher, foo): return do_side_effect(foo) """ + @wraps(f) def sync_wrapper(*args, **kwargs): box = args[-1] pass_args = args[:-1] try: box.succeed(f(*pass_args, **kwargs)) - except: - box.fail(sys.exc_info()) + except Exception as e: + box.fail(e) + return sync_wrapper diff --git a/effect/_test_do_py3.py b/effect/_test_do_py3.py deleted file mode 100644 index 1ac1df6..0000000 --- a/effect/_test_do_py3.py +++ /dev/null @@ -1,11 +0,0 @@ -# This code only works in Python 3, so it's left out of test_do.py, to be -# optionally imported. - -from effect import Constant, Effect -from effect.do import do - - -@do -def py3_generator_with_return(): - yield Effect(Constant(1)) - return 2 # noqa diff --git a/effect/_test_utils.py b/effect/_test_utils.py index 51de1d6..46ea60f 100644 --- a/effect/_test_utils.py +++ b/effect/_test_utils.py @@ -1,11 +1,10 @@ """Another sad little utility module.""" -import sys import traceback import attr -from testtools.matchers import Equals +from testtools.matchers import Equals, Mismatch @attr.s @@ -14,11 +13,27 @@ class ReraisedTracebackMismatch(object): got_tb = attr.ib() def describe(self): - return ("The reference traceback:\n" - + ''.join(self.expected_tb) - + "\nshould match the tail end of the received traceback:\n" - + ''.join(self.got_tb) - + "\nbut it doesn't.") + return ( + "The reference traceback:\n" + + "".join(self.expected_tb) + + "\nshould match the tail end of the received traceback:\n" + + "".join(self.got_tb) + + "\nbut it doesn't." + ) + + +@attr.s +class MatchesException(object): + expected = attr.ib() + + def match(self, other): + expected_type = type(self.expected) + if type(other) is not expected_type: + return Mismatch("{} is not a {}".format(other, expected_type)) + if other.args != self.expected.args: + return Mismatch( + "{} has different arguments: {}.".format(other.args, self.expected.args) + ) @attr.s @@ -27,26 +42,19 @@ class MatchesReraisedExcInfo(object): expected = attr.ib() def match(self, actual): - valcheck = Equals(self.expected[1]).match(actual[1]) + valcheck = Equals(self.expected.args).match(actual.args) if valcheck is not None: return valcheck - typecheck = Equals(self.expected[0]).match(actual[0]) + typecheck = Equals(type(self.expected)).match(type(actual)) if typecheck is not None: return typecheck - expected = traceback.format_exception(*self.expected) - new = traceback.format_exception(*actual) - tail_equals = lambda a, b: a == b[-len(a):] + expected = list( + traceback.TracebackException.from_exception(self.expected).format() + ) + new = list(traceback.TracebackException.from_exception(actual).format()) + tail_equals = lambda a, b: a == b[-len(a) :] if not tail_equals(expected[1:], new[1:]): - return ReraisedTracebackMismatch(expected_tb=expected, - got_tb=new) - - -def get_exc_info(exception): - """Get an exc_info tuple based on an exception instance.""" - try: - raise exception - except: - return sys.exc_info() + return ReraisedTracebackMismatch(expected_tb=expected, got_tb=new) def raise_(e): diff --git a/effect/_utils.py b/effect/_utils.py index 90de164..574a91b 100644 --- a/effect/_utils.py +++ b/effect/_utils.py @@ -10,13 +10,15 @@ def wraps(original): This is like :func:`functools.wraps`, except you can wrap non-functions without blowing up. """ + def wraps_decorator(wrapper): try: wrapper.__name__ = original.__name__ wrapper.__doc__ = original.__doc__ wrapper.__dict__.update(original.__dict__) wrapper.__module__ = original.__module__ - except: + except Exception: pass return wrapper + return wraps_decorator diff --git a/effect/do.py b/effect/do.py index a11bd46..da7b995 100644 --- a/effect/do.py +++ b/effect/do.py @@ -4,11 +4,8 @@ See :func:`do`. """ - -from __future__ import print_function - -import sys import types +import warnings from . import Effect, Func from ._utils import wraps @@ -22,19 +19,15 @@ def do(f): @do def foo(): thing = yield Effect(Constant(1)) - yield do_return('the result was %r' % (thing,)) + return 'the result was %r' % (thing,) eff = foo() return eff.on(...) ``@do`` must decorate a generator function (not any other type of - iterator). Any yielded values must either be Effects or the result of a - :func:`do_return` call. The result of a yielded Effect will be passed back - into the generator as the result of the ``yield`` expression. Yielded - :func:`do_return` values will provide the ultimate result of the Effect - that is returned by the decorated function. Note that :func:`do_return` is - only necessary for Python 2 compatibility; return statements can be used - directly in Python 3-only code. + iterator). Any yielded values must be Effects. The result of a yielded Effect will be passed + back into the generator as the result of the ``yield`` expression. A returned value becomes the + ultimate result of the Effect that is returned by the decorated function. It's important to note that any generator function decorated by ``@do`` will no longer return a generator, but instead it will return an Effect, @@ -47,27 +40,28 @@ def foo(): try: thing = yield Effect(Error(RuntimeError('foo'))) except RuntimeError: - yield do_return('got a RuntimeError as expected') + return 'got a RuntimeError as expected' (This decorator is named for Haskell's ``do`` notation, which is similar in spirit). """ + @wraps(f) def do_wrapper(*args, **kwargs): - def doit(): gen = f(*args, **kwargs) if not isinstance(gen, types.GeneratorType): raise TypeError( - "%r is not a generator function. It returned %r." - % (f, gen)) + "%r is not a generator function. It returned %r." % (f, gen) + ) return _do(None, gen, False) - fname = getattr(f, '__name__', None) + fname = getattr(f, "__name__", None) if fname is not None: - doit.__name__ = 'do_' + fname + doit.__name__ = "do_" + fname return Effect(Func(doit)) + return do_wrapper @@ -80,22 +74,26 @@ def do_return(val): """ Specify a return value for a @do function. + This is deprecated. Just use `return`. + The result of this function must be yielded. e.g.:: @do def foo(): yield do_return('hello') - - If you're writing Python 3-only code, you don't need to use this function, - and can just use the `return` statement as normal. """ + warnings.warn( + "do_return is deprecated. Just return as normal.", + DeprecationWarning, + stacklevel=1, + ) return _ReturnSentinel(val) def _do(result, generator, is_error): try: if is_error: - val = generator.throw(*result) + val = generator.throw(result) else: val = generator.send(result) except StopIteration as stop: @@ -104,8 +102,7 @@ def _do(result, generator, is_error): # case where some other code is raising StopIteration up through this # generator, in which case we shouldn't really treat it like a function # return -- it could quite easily hide bugs. - tb = sys.exc_info()[2] - if tb.tb_next: + if stop.__traceback__.tb_next: raise else: # Python 3 allows you to use `return val` in a generator, which @@ -113,13 +110,16 @@ def _do(result, generator, is_error): # set to the return value. So we'll return that value as the # ultimate result of the effect. Python 2 doesn't have the 'value' # attribute of StopIteration, so we'll fall back to None. - return getattr(stop, 'value', None) + return getattr(stop, "value", None) if type(val) is _ReturnSentinel: return val.result elif type(val) is Effect: - return val.on(success=lambda r: _do(r, generator, False), - error=lambda e: _do(e, generator, True)) + return val.on( + success=lambda r: _do(r, generator, False), + error=lambda e: _do(e, generator, True), + ) else: raise TypeError( - "@do functions must only yield Effects or results of do_return. " - "Got %r from %r" % (val, generator)) + "@do functions must only yield Effects. " + "Got %r from %r" % (val, generator) + ) diff --git a/effect/fold.py b/effect/fold.py index df9dd05..ec92e53 100644 --- a/effect/fold.py +++ b/effect/fold.py @@ -9,20 +9,23 @@ class FoldError(Exception): Raised when one of the Effects passed to :func:`fold_effect` fails. :ivar accumulator: The data accumulated so far, before the failing Effect. - :ivar wrapped_exception: The exc_info tuple representing the original - exception raised by the failing Effect. + :ivar wrapped_exception: The original exception raised by the failing Effect. """ + def __init__(self, accumulator, wrapped_exception): self.accumulator = accumulator self.wrapped_exception = wrapped_exception def __str__(self): - tb_lines = traceback.format_exception(*self.wrapped_exception) - tb = ''.join(tb_lines) - st = ( - " Original traceback follows:\n%s" - % (self.accumulator, tb)) - return st.rstrip('\n') + tb_lines = traceback.TracebackException.from_exception( + self.wrapped_exception + ).format() + tb = "".join(tb_lines) + st = " Original traceback follows:\n%s" % ( + self.accumulator, + tb, + ) + return st.rstrip("\n") def fold_effect(f, initial, effects): @@ -55,8 +58,9 @@ def failed(acc, e): raise FoldError(acc, e) def folder(acc, element): - return acc.on(lambda r: element.on(lambda r2: f(r, r2), - error=lambda e: failed(r, e))) + return acc.on( + lambda r: element.on(lambda r2: f(r, r2), error=lambda e: failed(r, e)) + ) return reduce(folder, effects, Effect(Constant(initial))) @@ -69,10 +73,11 @@ def sequence(effects): fails. """ # Could be: folder = lambda acc, el: acc + [el] - # But, for peformance: - l = [] + # But, for performance: + result = [] def folder(acc, el): - l.append(el) - return l - return fold_effect(folder, l, effects) + result.append(el) + return result + + return fold_effect(folder, result, effects) diff --git a/effect/io.py b/effect/io.py index b40e733..50277f6 100644 --- a/effect/io.py +++ b/effect/io.py @@ -4,9 +4,7 @@ :obj:`Prompt` that uses built-in Python standard io facilities. """ -from __future__ import print_function import attr -from six.moves import input from . import sync_performer, TypeDispatcher @@ -14,12 +12,14 @@ @attr.s class Display(object): """Display some text to the user.""" + output = attr.ib() @attr.s class Prompt(object): """Get some input from the user, with a prompt.""" + prompt = attr.ib() @@ -32,13 +32,11 @@ def perform_display_print(dispatcher, intent): @sync_performer def perform_get_input_raw_input(dispatcher, intent): """ - Perform a :obj:`Prompt` intent by using ``raw_input`` (or ``input`` on - Python 3). + Perform a :obj:`Prompt` intent by using ``input``. """ return input(intent.prompt) -stdio_dispatcher = TypeDispatcher({ - Display: perform_display_print, - Prompt: perform_get_input_raw_input, -}) +stdio_dispatcher = TypeDispatcher( + {Display: perform_display_print, Prompt: perform_get_input_raw_input} +) diff --git a/effect/async.py b/effect/parallel_async.py similarity index 79% rename from effect/async.py rename to effect/parallel_async.py index 32eea71..c044ee7 100644 --- a/effect/async.py +++ b/effect/parallel_async.py @@ -28,13 +28,10 @@ def succeed(index, result): box.succeed(results) def fail(index, result): - box.fail((FirstError, - FirstError(exc_info=result, index=index), - result[2])) + box.fail(FirstError(exception=result, index=index)) for index, effect in enumerate(effects): perform( dispatcher, - effect.on( - success=partial(succeed, index), - error=partial(fail, index))) + effect.on(success=partial(succeed, index), error=partial(fail, index)), + ) diff --git a/effect/ref.py b/effect/ref.py index 2d0350a..f4df760 100644 --- a/effect/ref.py +++ b/effect/ref.py @@ -5,8 +5,13 @@ from ._sync import sync_performer __all__ = [ - 'Reference', 'ReadReference', 'ModifyReference', 'perform_read_reference', - 'perform_modify_reference', 'reference_dispatcher'] + "Reference", + "ReadReference", + "ModifyReference", + "perform_read_reference", + "perform_modify_reference", + "reference_dispatcher", +] class Reference(object): @@ -93,6 +98,6 @@ def perform_modify_reference(dispatcher, intent): return new_value -reference_dispatcher = TypeDispatcher({ - ReadReference: perform_read_reference, - ModifyReference: perform_modify_reference}) +reference_dispatcher = TypeDispatcher( + {ReadReference: perform_read_reference, ModifyReference: perform_modify_reference} +) diff --git a/effect/retry.py b/effect/retry.py index dd87cb7..f3c1984 100644 --- a/effect/retry.py +++ b/effect/retry.py @@ -1,7 +1,5 @@ """Retrying effects.""" -import six - from functools import partial @@ -14,7 +12,7 @@ def retry(effect, should_retry): will fail with the most recent error from func. :param effect.Effect effect: Any effect. - :param should_retry: A function which should take an exc_info tuple as an + :param should_retry: A function which should take an exception as an argument and return an effect of bool. """ @@ -22,11 +20,11 @@ def maybe_retry(error, retry_allowed): if retry_allowed: return try_() else: - six.reraise(*error) + raise error def try_(): return effect.on( - error=lambda e: should_retry(e).on( - success=partial(maybe_retry, e))) + error=lambda e: should_retry(e).on(success=partial(maybe_retry, e)) + ) return try_() diff --git a/effect/test_async.py b/effect/test_async.py index 2d04595..d57dda7 100644 --- a/effect/test_async.py +++ b/effect/test_async.py @@ -1,11 +1,9 @@ -from __future__ import print_function - from testtools.testcase import TestCase from ._base import Effect, perform from ._dispatcher import ComposedDispatcher, TypeDispatcher from ._intents import ParallelEffects, base_dispatcher, parallel -from .async import perform_parallel_async +from .parallel_async import perform_parallel_async from .test_base import func_dispatcher from .test_parallel_performers import ParallelPerformerTestsMixin @@ -15,9 +13,9 @@ class PerformParallelAsyncTests(TestCase, ParallelPerformerTestsMixin): def setUp(self): super(PerformParallelAsyncTests, self).setUp() - self.dispatcher = ComposedDispatcher([ - base_dispatcher, - TypeDispatcher({ParallelEffects: perform_parallel_async})]) + self.dispatcher = ComposedDispatcher( + [base_dispatcher, TypeDispatcher({ParallelEffects: perform_parallel_async})] + ) def test_out_of_order(self): """ @@ -27,17 +25,22 @@ def test_out_of_order(self): """ result = [] boxes = [None] * 2 - eff = parallel([ - Effect(lambda box: boxes.__setitem__(0, box)), - Effect(lambda box: boxes.__setitem__(1, box)), - ]) + eff = parallel( + [ + Effect(lambda box: boxes.__setitem__(0, box)), + Effect(lambda box: boxes.__setitem__(1, box)), + ] + ) perform( - ComposedDispatcher([ - TypeDispatcher({ParallelEffects: perform_parallel_async}), - func_dispatcher, - ]), - eff.on(success=result.append, error=print)) - boxes[1].succeed('a') + ComposedDispatcher( + [ + TypeDispatcher({ParallelEffects: perform_parallel_async}), + func_dispatcher, + ] + ), + eff.on(success=result.append, error=print), + ) + boxes[1].succeed("a") self.assertEqual(result, []) - boxes[0].succeed('b') - self.assertEqual(result[0], ['b', 'a']) + boxes[0].succeed("b") + self.assertEqual(result[0], ["b", "a"]) diff --git a/effect/test_base.py b/effect/test_base.py index 0a5b5c8..3b1d9c4 100644 --- a/effect/test_base.py +++ b/effect/test_base.py @@ -1,13 +1,10 @@ -from __future__ import print_function, absolute_import - -import sys import traceback from testtools import TestCase -from testtools.matchers import MatchesException, MatchesListwise +from testtools.matchers import MatchesListwise from ._base import Effect, NoPerformerFoundError, catch, perform -from ._test_utils import raise_ +from ._test_utils import MatchesException, raise_ def func_dispatcher(intent): @@ -15,8 +12,10 @@ def func_dispatcher(intent): Simple effect dispatcher that takes callables taking a box, and calls them with the given box. """ + def performer(dispatcher, intent, box): intent(box) + return performer @@ -34,10 +33,8 @@ def test_no_performer(self): eff = Effect(intent).on(error=calls.append) perform(dispatcher, eff) self.assertThat( - calls, - MatchesListwise([ - MatchesException(NoPerformerFoundError(intent)) - ])) + calls, MatchesListwise([MatchesException(NoPerformerFoundError(intent))]) + ) def test_dispatcher(self): """ @@ -65,9 +62,9 @@ def test_success_with_callback(self): effect's callback """ calls = [] - intent = lambda box: box.succeed('dispatched') + intent = lambda box: box.succeed("dispatched") perform(func_dispatcher, Effect(intent).on(calls.append)) - self.assertEqual(calls, ['dispatched']) + self.assertEqual(calls, ["dispatched"]) def test_error_with_callback(self): """ @@ -75,92 +72,87 @@ def test_error_with_callback(self): the error callback. """ calls = [] - intent = lambda box: box.fail( - (ValueError, ValueError('dispatched'), None)) + intent = lambda box: box.fail(ValueError("dispatched")) perform(func_dispatcher, Effect(intent).on(error=calls.append)) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('dispatched'))])) + calls, MatchesListwise([MatchesException(ValueError("dispatched"))]) + ) def test_dispatcher_raises(self): """ - When a dispatcher raises an exception, the exc_info is passed to the + When a dispatcher raises an exception, the exception is passed to the error handler. """ calls = [] - eff = Effect('meaningless').on(error=calls.append) - dispatcher = lambda i: raise_(ValueError('oh dear')) + eff = Effect("meaningless").on(error=calls.append) + dispatcher = lambda i: raise_(ValueError("oh dear")) perform(dispatcher, eff) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('oh dear')) - ]) + calls, MatchesListwise([MatchesException(ValueError("oh dear"))]) ) def test_performer_raises(self): """ - When a performer raises an exception, the exc_info is passed to the + When a performer raises an exception, it is passed to the error handler. """ calls = [] - eff = Effect('meaningless').on(error=calls.append) - performer = lambda d, i, box: raise_(ValueError('oh dear')) + eff = Effect("meaningless").on(error=calls.append) + performer = lambda d, i, box: raise_(ValueError("oh dear")) dispatcher = lambda i: performer perform(dispatcher, eff) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('oh dear')) - ]) + calls, MatchesListwise([MatchesException(ValueError("oh dear"))]) ) def test_success_propagates_effect_exception(self): """ - If an succes callback is specified, but a exception result occurs, + If an success callback is specified, but a exception result occurs, the exception is passed to the next callback. """ calls = [] - intent = lambda box: box.fail( - (ValueError, ValueError('dispatched'), None)) - perform(func_dispatcher, - Effect(intent) - .on(success=lambda box: calls.append("foo")) - .on(error=calls.append)) + intent = lambda box: box.fail(ValueError("dispatched")) + perform( + func_dispatcher, + Effect(intent) + .on(success=lambda box: calls.append("foo")) + .on(error=calls.append), + ) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('dispatched'))])) + calls, MatchesListwise([MatchesException(ValueError("dispatched"))]) + ) def test_error_propagates_effect_result(self): """ - If an error callback is specified, but a succesful result occurs, + If an error callback is specified, but a successful result occurs, the success is passed to the next callback. """ calls = [] intent = lambda box: box.succeed("dispatched") - perform(func_dispatcher, - Effect(intent) - .on(error=lambda box: calls.append("foo")) - .on(success=calls.append)) + perform( + func_dispatcher, + Effect(intent) + .on(error=lambda box: calls.append("foo")) + .on(success=calls.append), + ) self.assertEqual(calls, ["dispatched"]) - def test_callback_sucecss_exception(self): + def test_callback_success_exception(self): """ If a success callback raises an error, the exception is passed to the error callback. """ calls = [] - perform(func_dispatcher, - Effect(lambda box: box.succeed("foo")) - .on(success=lambda _: raise_(ValueError("oh dear"))) - .on(error=calls.append)) + perform( + func_dispatcher, + Effect(lambda box: box.succeed("foo")) + .on(success=lambda _: raise_(ValueError("oh dear"))) + .on(error=calls.append), + ) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('oh dear'))])) + calls, MatchesListwise([MatchesException(ValueError("oh dear"))]) + ) def test_callback_error_exception(self): """ @@ -169,17 +161,17 @@ def test_callback_error_exception(self): """ calls = [] - intent = lambda box: box.fail( - (ValueError, ValueError('dispatched'), None)) + intent = lambda box: box.fail(ValueError("dispatched")) - perform(func_dispatcher, - Effect(intent) - .on(error=lambda _: raise_(ValueError("oh dear"))) - .on(error=calls.append)) + perform( + func_dispatcher, + Effect(intent) + .on(error=lambda _: raise_(ValueError("oh dear"))) + .on(error=calls.append), + ) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('oh dear'))])) + calls, MatchesListwise([MatchesException(ValueError("oh dear"))]) + ) def test_effects_returning_effects(self): """ @@ -188,10 +180,11 @@ def test_effects_returning_effects(self): - the result of that is returned. """ calls = [] - perform(func_dispatcher, - Effect(lambda box: box.succeed( - Effect(lambda box: calls.append("foo"))))) - self.assertEqual(calls, ['foo']) + perform( + func_dispatcher, + Effect(lambda box: box.succeed(Effect(lambda box: calls.append("foo")))), + ) + self.assertEqual(calls, ["foo"]) def test_effects_returning_effects_returning_effects(self): """ @@ -200,27 +193,32 @@ def test_effects_returning_effects_returning_effects(self): returned from the outermost effect's perform. """ calls = [] - perform(func_dispatcher, - Effect(lambda box: box.succeed( - Effect(lambda box: box.succeed( - Effect(lambda box: calls.append("foo"))))))) - self.assertEqual(calls, ['foo']) + perform( + func_dispatcher, + Effect( + lambda box: box.succeed( + Effect( + lambda box: box.succeed(Effect(lambda box: calls.append("foo"))) + ) + ) + ), + ) + self.assertEqual(calls, ["foo"]) def test_nested_effect_exception_passes_to_outer_error_handler(self): """ If an inner effect raises an exception, it bubbles up and the - exc_info is passed to the outer effect's error handlers. + exception is passed to the outer effect's error handlers. """ calls = [] - intent = lambda box: box.fail( - (ValueError, ValueError('oh dear'), None)) - perform(func_dispatcher, - Effect(lambda box: box.succeed(Effect(intent))) - .on(error=calls.append)) + intent = lambda box: box.fail(ValueError("oh dear")) + perform( + func_dispatcher, + Effect(lambda box: box.succeed(Effect(intent))).on(error=calls.append), + ) self.assertThat( - calls, - MatchesListwise([ - MatchesException(ValueError('oh dear'))])) + calls, MatchesListwise([MatchesException(ValueError("oh dear"))]) + ) def test_bounced(self): """ @@ -231,6 +229,7 @@ def test_bounced(self): def out_of_order(box): box.succeed("foo") calls.append("bar") + perform(func_dispatcher, Effect(out_of_order).on(success=calls.append)) self.assertEqual(calls, ["bar", "foo"]) @@ -242,9 +241,13 @@ def test_callbacks_bounced(self): def get_stack(_): calls.append(traceback.extract_stack()) - perform(func_dispatcher, - Effect(lambda box: box.succeed(None)) - .on(success=get_stack).on(success=get_stack)) + + perform( + func_dispatcher, + Effect(lambda box: box.succeed(None)) + .on(success=get_stack) + .on(success=get_stack), + ) self.assertEqual(calls[0], calls[1]) def test_effect_bounced(self): @@ -258,8 +261,9 @@ def get_stack(box): calls.append(traceback.extract_stack()) box.succeed(None) - perform(func_dispatcher, - Effect(get_stack).on(success=lambda _: Effect(get_stack))) + perform( + func_dispatcher, Effect(get_stack).on(success=lambda _: Effect(get_stack)) + ) self.assertEqual(calls[0], calls[1]) def test_asynchronous_callback_invocation(self): @@ -271,8 +275,8 @@ def test_asynchronous_callback_invocation(self): boxes = [] eff = Effect(boxes.append).on(success=results.append) perform(func_dispatcher, eff) - boxes[0].succeed('foo') - self.assertEqual(results, ['foo']) + boxes[0].succeed("foo") + self.assertEqual(results, ["foo"]) def test_asynchronous_callback_bounced(self): """ @@ -287,7 +291,7 @@ def get_stack(_): boxes = [] eff = Effect(boxes.append).on(success=get_stack).on(success=get_stack) perform(func_dispatcher, eff) - boxes[0].succeed('foo') + boxes[0].succeed("foo") self.assertEqual(calls[0], calls[1]) @@ -296,27 +300,27 @@ class CatchTests(TestCase): def test_caught(self): """ - When the exception type matches the type in the ``exc_info`` tuple, the + When the exception type matches the type of the raised exception, the callable is invoked and its result is returned. """ try: - raise RuntimeError('foo') - except: - exc_info = sys.exc_info() - result = catch(RuntimeError, lambda e: ('caught', e))(exc_info) - self.assertEqual(result, ('caught', exc_info)) + raise RuntimeError("foo") + except Exception as e: + error = e + result = catch(RuntimeError, lambda e: ("caught", e))(error) + self.assertEqual(result, ("caught", error)) def test_missed(self): """ - When the exception type does not match the type in the ``exc_info`` - tuple, the callable is not invoked and the original exception is - reraised. + When the exception type does not match the type of the raised exception, + the callable is not invoked and the original exception is reraised. """ try: - raise ZeroDivisionError('foo') - except: - exc_info = sys.exc_info() + raise ZeroDivisionError("foo") + except Exception as e: + error = e e = self.assertRaises( ZeroDivisionError, - lambda: catch(RuntimeError, lambda e: ('caught', e))(exc_info)) - self.assertEqual(str(e), 'foo') + lambda: catch(RuntimeError, lambda e: ("caught", e))(error), + ) + self.assertEqual(str(e), "foo") diff --git a/effect/test_do.py b/effect/test_do.py index 789ace6..60f5464 100644 --- a/effect/test_do.py +++ b/effect/test_do.py @@ -1,17 +1,18 @@ import sys from functools import partial -from py.test import raises as raises -from py.test import mark - -import six - -from testtools import TestCase -from testtools.matchers import raises as match_raises, MatchesException +from py.test import raises, warns from . import ( - ComposedDispatcher, Constant, Effect, Error, TypeDispatcher, - base_dispatcher, sync_perform, sync_performer) + ComposedDispatcher, + Constant, + Effect, + Error, + TypeDispatcher, + base_dispatcher, + sync_perform, + sync_performer, +) from .do import do, do_return @@ -19,125 +20,162 @@ def perf(e): return sync_perform(base_dispatcher, e) -class DoTests(TestCase): - - def test_do_non_gf(self): - """When do is passed a non-generator function, it raises an error.""" - f = lambda: None - self.assertThat( - lambda: perf(do(f)()), - match_raises(TypeError( - "%r is not a generator function. It returned None." % (f,) - ))) - - def test_do_return(self): - """ - When a @do function yields a do_return, the given value becomes the - eventual result. - """ - @do - def f(): - yield do_return("hello") - self.assertEqual(perf(f()), "hello") - - def test_yield_effect(self): - """Yielding an effect in @do results in the Effect's result.""" - @do - def f(): - x = yield Effect(Constant(3)) - yield do_return(x) - self.assertEqual(perf(f()), 3) - - def test_fall_off_the_end(self): - """Falling off the end results in None.""" - @do - def f(): - yield Effect(Constant(3)) - self.assertEqual(perf(f()), None) - - def test_yield_non_effect(self): - """Yielding a non-Effect results in a TypeError.""" - @do - def f(): - yield 1 - result = f() - e = self.assertRaises(TypeError, lambda: perf(result)) - self.assertTrue( - str(e).startswith( - '@do functions must only yield Effects or results of ' - 'do_return. Got 1 from (3, 7): + # In Python 3.7, generators straight up aren't allowed to raise StopIteration any more + with raises(RuntimeError): + perf(eff) + else: + with raises(StopIteration): + perf(eff) -@mark.skipif(not six.PY3, reason="Testing a Py3-specific feature") def test_py3_return(): """The `return x` syntax in Py3 sets the result of the Effect to `x`.""" - from effect._test_do_py3 import py3_generator_with_return + + @do + def py3_generator_with_return(): + yield Effect(Constant(1)) + return 2 # noqa + eff = py3_generator_with_return() assert perf(eff) == 2 diff --git a/effect/test_fold.py b/effect/test_fold.py index f75d7d0..a7e1d9c 100644 --- a/effect/test_fold.py +++ b/effect/test_fold.py @@ -1,11 +1,12 @@ - import operator from pytest import raises +from testtools.assertions import assert_that from effect import Effect, Error, base_dispatcher, sync_perform from effect.fold import FoldError, fold_effect, sequence from effect.testing import perform_sequence +from ._test_utils import MatchesException def test_fold_effect(): @@ -13,16 +14,16 @@ def test_fold_effect(): :func:`fold_effect` folds the given function over the results of the effects. """ - effs = [Effect('a'), Effect('b'), Effect('c')] + effs = [Effect("a"), Effect("b"), Effect("c")] dispatcher = [ - ('a', lambda i: 'Ei'), - ('b', lambda i: 'Bee'), - ('c', lambda i: 'Cee'), + ("a", lambda i: "Ei"), + ("b", lambda i: "Bee"), + ("c", lambda i: "Cee"), ] - eff = fold_effect(operator.add, 'Nil', effs) + eff = fold_effect(operator.add, "Nil", effs) result = perform_sequence(dispatcher, eff) - assert result == 'NilEiBeeCee' + assert result == "NilEiBeeCee" def test_fold_effect_empty(): @@ -39,44 +40,46 @@ def test_fold_effect_errors(): When one of the effects in the folding list fails, a FoldError is raised with the accumulator so far. """ - effs = [Effect('a'), Effect(Error(ZeroDivisionError('foo'))), Effect('c')] + effs = [Effect("a"), Effect(Error(ZeroDivisionError("foo"))), Effect("c")] - dispatcher = [('a', lambda i: 'Ei')] + dispatcher = [("a", lambda i: "Ei")] - eff = fold_effect(operator.add, 'Nil', effs) + eff = fold_effect(operator.add, "Nil", effs) with raises(FoldError) as excinfo: perform_sequence(dispatcher, eff) - assert excinfo.value.accumulator == 'NilEi' - assert excinfo.value.wrapped_exception[0] is ZeroDivisionError - assert str(excinfo.value.wrapped_exception[1]) == 'foo' + assert excinfo.value.accumulator == "NilEi" + assert_that( + excinfo.value.wrapped_exception, MatchesException(ZeroDivisionError("foo")) + ) def test_fold_effect_str(): """str()ing a FoldError returns useful traceback/exception info.""" - effs = [Effect('a'), Effect(Error(ZeroDivisionError('foo'))), Effect('c')] - dispatcher = [('a', lambda i: 'Ei')] + effs = [Effect("a"), Effect(Error(ZeroDivisionError("foo"))), Effect("c")] + dispatcher = [("a", lambda i: "Ei")] - eff = fold_effect(operator.add, 'Nil', effs) + eff = fold_effect(operator.add, "Nil", effs) with raises(FoldError) as excinfo: perform_sequence(dispatcher, eff) assert str(excinfo.value).startswith( - " Original traceback follows:\n") - assert str(excinfo.value).endswith('ZeroDivisionError: foo') + " Original traceback follows:\n" + ) + assert str(excinfo.value).endswith("ZeroDivisionError: foo") def test_sequence(): """Collects each Effectful result into a list.""" - effs = [Effect('a'), Effect('b'), Effect('c')] + effs = [Effect("a"), Effect("b"), Effect("c")] dispatcher = [ - ('a', lambda i: 'Ei'), - ('b', lambda i: 'Bee'), - ('c', lambda i: 'Cee'), + ("a", lambda i: "Ei"), + ("b", lambda i: "Bee"), + ("c", lambda i: "Cee"), ] eff = sequence(effs) result = perform_sequence(dispatcher, eff) - assert result == ['Ei', 'Bee', 'Cee'] + assert result == ["Ei", "Bee", "Cee"] def test_sequence_empty(): @@ -89,14 +92,15 @@ def test_sequence_error(): Allows :obj:`FoldError` to be raised when an Effect fails. The list accumulated so far is the `accumulator` value in the :obj:`FoldError`. """ - effs = [Effect('a'), Effect(Error(ZeroDivisionError('foo'))), Effect('c')] + effs = [Effect("a"), Effect(Error(ZeroDivisionError("foo"))), Effect("c")] - dispatcher = [('a', lambda i: 'Ei')] + dispatcher = [("a", lambda i: "Ei")] eff = sequence(effs) with raises(FoldError) as excinfo: perform_sequence(dispatcher, eff) - assert excinfo.value.accumulator == ['Ei'] - assert excinfo.value.wrapped_exception[0] is ZeroDivisionError - assert str(excinfo.value.wrapped_exception[1]) == 'foo' + assert excinfo.value.accumulator == ["Ei"] + assert_that( + excinfo.value.wrapped_exception, MatchesException(ZeroDivisionError("foo")) + ) diff --git a/effect/test_intents.py b/effect/test_intents.py index 2fb2273..85987b4 100644 --- a/effect/test_intents.py +++ b/effect/test_intents.py @@ -1,36 +1,36 @@ -from __future__ import print_function, absolute_import - from functools import partial -import six - from testtools import TestCase from testtools.matchers import Equals, MatchesListwise from pytest import raises -from ._base import Effect +from ._base import Effect, raise_ from ._dispatcher import ComposedDispatcher, TypeDispatcher from ._intents import ( base_dispatcher, - Constant, perform_constant, - Delay, perform_delay_with_sleep, - Error, perform_error, - Func, perform_func, + Constant, + perform_constant, + Delay, + perform_delay_with_sleep, + Error, + perform_error, + Func, + perform_func, FirstError, - ParallelEffects, parallel_all_errors) + ParallelEffects, + parallel_all_errors, +) from ._sync import sync_perform -from ._test_utils import MatchesReraisedExcInfo, get_exc_info -from .async import perform_parallel_async +from ._test_utils import MatchesReraisedExcInfo +from .parallel_async import perform_parallel_async from .test_parallel_performers import EquitableException def test_perform_constant(): """perform_constant returns the result of a Constant.""" intent = Constant("foo") - result = sync_perform( - TypeDispatcher({Constant: perform_constant}), - Effect(intent)) + result = sync_perform(TypeDispatcher({Constant: perform_constant}), Effect(intent)) assert result == "foo" @@ -44,30 +44,28 @@ def test_perform_error(): def test_perform_func(): """perform_func calls the function given in a Func.""" intent = Func(lambda: "foo") - result = sync_perform( - TypeDispatcher({Func: perform_func}), - Effect(intent)) + result = sync_perform(TypeDispatcher({Func: perform_func}), Effect(intent)) assert result == "foo" + def test_perform_func_args_kwargs(): """arbitrary positional and keyword arguments can be passed to Func.""" f = lambda *a, **kw: (a, kw) intent = Func(f, 1, 2, key=3) result = sync_perform(TypeDispatcher({Func: perform_func}), Effect(intent)) - assert result == ((1, 2), {'key': 3}) + assert result == ((1, 2), {"key": 3}) def test_first_error_str(): """FirstErrors have a pleasing format.""" - fe = FirstError(exc_info=(ValueError, ValueError('foo'), None), - index=150) - assert str(fe) == '(index=150) ValueError: foo' + fe = FirstError(ValueError("foo"), index=150) + assert str(fe) == "(index=150) ValueError: foo" def test_perform_delay_with_sleep(monkeypatch): """:func:`perform_delay_with_sleep` calls time.sleep.""" calls = [] - monkeypatch.setattr('time.sleep', calls.append) + monkeypatch.setattr("time.sleep", calls.append) disp = TypeDispatcher({Delay: perform_delay_with_sleep}) sync_perform(disp, Effect(Delay(3.7))) assert calls == [3.7] @@ -78,29 +76,29 @@ class ParallelAllErrorsTests(TestCase): def test_parallel_all_errors(self): """ - Exceptions raised from child effects get turned into (True, exc_info) + Exceptions raised from child effects get turned into (True, exc) results. """ - exc_info1 = get_exc_info(EquitableException(message='foo')) - reraise1 = partial(six.reraise, *exc_info1) - exc_info2 = get_exc_info(EquitableException(message='bar')) - reraise2 = partial(six.reraise, *exc_info2) - - dispatcher = ComposedDispatcher([ - TypeDispatcher({ - ParallelEffects: perform_parallel_async, - }), - base_dispatcher]) - es = [Effect(Func(reraise1)), - Effect(Constant(1)), - Effect(Func(reraise2))] + exc1 = EquitableException(message="foo") + reraise1 = partial(raise_, exc1) + exc2 = EquitableException(message="bar") + reraise2 = partial(raise_, exc2) + + dispatcher = ComposedDispatcher( + [ + TypeDispatcher({ParallelEffects: perform_parallel_async}), + base_dispatcher, + ] + ) + es = [Effect(Func(reraise1)), Effect(Constant(1)), Effect(Func(reraise2))] eff = parallel_all_errors(es) self.assertThat( sync_perform(dispatcher, eff), - MatchesListwise([ - MatchesListwise([Equals(True), - MatchesReraisedExcInfo(exc_info1)]), - Equals((False, 1)), - MatchesListwise([Equals(True), - MatchesReraisedExcInfo(exc_info2)]), - ])) + MatchesListwise( + [ + MatchesListwise([Equals(True), MatchesReraisedExcInfo(exc1)]), + Equals((False, 1)), + MatchesListwise([Equals(True), MatchesReraisedExcInfo(exc2)]), + ] + ), + ) diff --git a/effect/test_io.py b/effect/test_io.py index 4312717..8652f7c 100644 --- a/effect/test_io.py +++ b/effect/test_io.py @@ -6,13 +6,11 @@ def test_perform_display_print(capsys): """The stdio dispatcher has a performer that prints output.""" assert sync_perform(stdio_dispatcher, Effect(Display("foo"))) is None out, err = capsys.readouterr() - assert err == '' - assert out == 'foo\n' + assert err == "" + assert out == "foo\n" def test_perform_get_input_raw_input(monkeypatch): """The stdio dispatcher has a performer that reads input.""" - monkeypatch.setattr( - 'effect.io.input', - lambda p: 'my name' if p == '> ' else 'boo') - assert sync_perform(stdio_dispatcher, Effect(Prompt('> '))) == 'my name' + monkeypatch.setattr("builtins.input", lambda p: "my name" if p == "> " else "boo") + assert sync_perform(stdio_dispatcher, Effect(Prompt("> "))) == "my name" diff --git a/effect/test_parallel_performers.py b/effect/test_parallel_performers.py index 4c5fe39..05b2dbf 100644 --- a/effect/test_parallel_performers.py +++ b/effect/test_parallel_performers.py @@ -2,17 +2,15 @@ import attr -import six - from testtools.matchers import MatchesStructure, Equals -from . import Effect +from . import Effect, raise_ from ._intents import Constant, Func, FirstError, parallel from ._sync import sync_perform -from ._test_utils import MatchesReraisedExcInfo, get_exc_info +from ._test_utils import MatchesReraisedExcInfo -@attr.s +@attr.s(hash=True) class EquitableException(Exception): message = attr.ib() @@ -23,11 +21,9 @@ class ParallelPerformerTestsMixin(object): def test_empty(self): """ When given an empty list of effects, ``perform_parallel_async`` returns - an empty list synchronusly. + an empty list synchronously. """ - result = sync_perform( - self.dispatcher, - parallel([])) + result = sync_perform(self.dispatcher, parallel([])) self.assertEqual(result, []) def test_parallel(self): @@ -36,28 +32,26 @@ def test_parallel(self): same order that they were passed to parallel. """ result = sync_perform( - self.dispatcher, - parallel([Effect(Constant('a')), - Effect(Constant('b'))])) - self.assertEqual(result, ['a', 'b']) + self.dispatcher, parallel([Effect(Constant("a")), Effect(Constant("b"))]) + ) + self.assertEqual(result, ["a", "b"]) def test_error(self): """ When given an effect that results in a Error, ``perform_parallel_async`` result in ``FirstError``. """ - expected_exc_info = get_exc_info(EquitableException(message='foo')) - reraise = partial(six.reraise, *expected_exc_info) + expected_exc = EquitableException(message="foo") + reraise = partial(raise_, expected_exc) try: - sync_perform( - self.dispatcher, - parallel([Effect(Func(reraise))])) + sync_perform(self.dispatcher, parallel([Effect(Func(reraise))])) except FirstError as fe: self.assertThat( fe, MatchesStructure( - index=Equals(0), - exc_info=MatchesReraisedExcInfo(expected_exc_info))) + index=Equals(0), exception=MatchesReraisedExcInfo(expected_exc) + ), + ) else: self.fail("sync_perform should have raised FirstError.") @@ -66,18 +60,19 @@ def test_error_index(self): The ``index`` of a :obj:`FirstError` is the index of the effect that failed in the list. """ - expected_exc_info = get_exc_info(EquitableException(message='foo')) - reraise = partial(six.reraise, *expected_exc_info) + expected_exc = EquitableException(message="foo") + reraise = partial(raise_, expected_exc) try: sync_perform( self.dispatcher, - parallel([ - Effect(Constant(1)), - Effect(Func(reraise)), - Effect(Constant(2))])) + parallel( + [Effect(Constant(1)), Effect(Func(reraise)), Effect(Constant(2))] + ), + ) except FirstError as fe: self.assertThat( fe, MatchesStructure( - index=Equals(1), - exc_info=MatchesReraisedExcInfo(expected_exc_info))) + index=Equals(1), exception=MatchesReraisedExcInfo(expected_exc) + ), + ) diff --git a/effect/test_ref.py b/effect/test_ref.py index f105ebe..af581e7 100644 --- a/effect/test_ref.py +++ b/effect/test_ref.py @@ -2,9 +2,7 @@ from ._base import Effect from ._sync import sync_perform -from .ref import ( - Reference, ModifyReference, ReadReference, - reference_dispatcher) +from .ref import Reference, ModifyReference, ReadReference, reference_dispatcher class ReferenceTests(TestCase): @@ -12,7 +10,7 @@ class ReferenceTests(TestCase): def test_read(self): """``read`` returns an Effect that represents the current value.""" - ref = Reference('initial') + ref = Reference("initial") self.assertEqual(ref.read(), Effect(ReadReference(ref=ref))) def test_modify(self): @@ -20,15 +18,13 @@ def test_modify(self): ref = Reference(0) transformer = lambda x: x + 1 eff = ref.modify(transformer) - self.assertEqual(eff, - Effect(ModifyReference(ref=ref, - transformer=transformer))) + self.assertEqual(eff, Effect(ModifyReference(ref=ref, transformer=transformer))) def test_perform_read(self): """Performing the reading results in the current value.""" - ref = Reference('initial') + ref = Reference("initial") result = sync_perform(reference_dispatcher, ref.read()) - self.assertEqual(result, 'initial') + self.assertEqual(result, "initial") def test_perform_modify(self): """ diff --git a/effect/test_retry.py b/effect/test_retry.py index c1cd8bc..acfd221 100644 --- a/effect/test_retry.py +++ b/effect/test_retry.py @@ -7,13 +7,13 @@ class RetryTests(TestCase): - def test_should_not_retry(self): """retry raises the last error if should_retry returns False.""" - result = retry(ESError(RuntimeError("oh no!")), - lambda e: ESConstant(False)) - self.assertThat(lambda: resolve_stubs(base_dispatcher, result), - raises(RuntimeError("oh no!"))) + result = retry(ESError(RuntimeError("oh no!")), lambda e: ESConstant(False)) + self.assertThat( + lambda: resolve_stubs(base_dispatcher, result), + raises(RuntimeError("oh no!")), + ) def _repeated_effect_func(self, *funcs): """ @@ -35,10 +35,9 @@ def test_retry(self): again. """ func = self._repeated_effect_func( - lambda: raise_(RuntimeError("foo")), - lambda: "final") - result = retry(ESFunc(func), - lambda e: ESConstant(True)) + lambda: raise_(RuntimeError("foo")), lambda: "final" + ) + result = retry(ESFunc(func), lambda e: ESConstant(True)) self.assertEqual(resolve_stubs(base_dispatcher, result), "final") def test_continue_retrying(self): @@ -50,14 +49,16 @@ def test_continue_retrying(self): func = self._repeated_effect_func( lambda: raise_(RuntimeError("1")), lambda: raise_(RuntimeError("2")), - lambda: raise_(RuntimeError("3"))) + lambda: raise_(RuntimeError("3")), + ) def should_retry(e): - return ESConstant(str(e[1]) != "3") + return ESConstant(str(e) != "3") result = retry(ESFunc(func), should_retry) - self.assertThat(lambda: resolve_stubs(base_dispatcher, result), - raises(RuntimeError("3"))) + self.assertThat( + lambda: resolve_stubs(base_dispatcher, result), raises(RuntimeError("3")) + ) def raise_(exc): diff --git a/effect/test_sync.py b/effect/test_sync.py index b868dd0..e473472 100644 --- a/effect/test_sync.py +++ b/effect/test_sync.py @@ -1,15 +1,17 @@ from functools import partial from testtools import TestCase -from testtools.matchers import MatchesException, raises +from testtools.matchers import raises from ._base import Effect from ._sync import NotSynchronousError, sync_perform, sync_performer +from ._test_utils import MatchesException def func_dispatcher(intent): def performer(dispatcher, intent, box): intent(box) + return performer @@ -21,27 +23,28 @@ def test_sync_perform_effect_function_dispatch(self): sync_perform returns the result of the effect. """ intent = lambda box: box.succeed("foo") - self.assertEqual( - sync_perform(func_dispatcher, Effect(intent)), - 'foo') + self.assertEqual(sync_perform(func_dispatcher, Effect(intent)), "foo") def test_sync_perform_async_effect(self): """If an effect is asynchronous, sync_effect raises an error.""" intent = lambda box: None self.assertRaises( - NotSynchronousError, - lambda: sync_perform(func_dispatcher, Effect(intent))) + NotSynchronousError, lambda: sync_perform(func_dispatcher, Effect(intent)) + ) def test_error_bubbles_up(self): """ When effect performance fails, the exception is raised up through sync_perform. """ + def fail(box): - box.fail((ValueError, ValueError("oh dear"), None)) + box.fail(ValueError("oh dear")) + self.assertThat( lambda: sync_perform(func_dispatcher, Effect(fail)), - raises(ValueError('oh dear'))) + raises(ValueError("oh dear")), + ) class SyncPerformerTests(TestCase): @@ -51,6 +54,7 @@ class SyncPerformerTests(TestCase): def test_success(self): """Return value of the performer becomes the result of the Effect.""" + @sync_performer def succeed(dispatcher, intent): return intent @@ -63,19 +67,22 @@ def test_failure(self): """ Errors are caught and cause the effect to fail with the exception info. """ + @sync_performer def fail(dispatcher, intent): raise intent dispatcher = lambda _: fail self.assertThat( - sync_perform(dispatcher, - Effect(ValueError('oh dear')).on(error=lambda e: e)), - MatchesException(ValueError('oh dear'))) + sync_perform( + dispatcher, Effect(ValueError("oh dear")).on(error=lambda e: e) + ), + MatchesException(ValueError("oh dear")), + ) def test_instance_method_performer(self): """The decorator works on instance methods.""" - eff = Effect('meaningless') + eff = Effect("meaningless") class PerformerContainer(object): @sync_performer @@ -86,41 +93,46 @@ def performer(self, dispatcher, intent): dispatcher = lambda i: container.performer result = sync_perform(dispatcher, eff) - self.assertEqual(result, (container, dispatcher, 'meaningless')) + self.assertEqual(result, (container, dispatcher, "meaningless")) def test_promote_metadata(self): """ The decorator copies metadata from the wrapped function onto the wrapper. """ + def original(dispatcher, intent): """Original!""" pass + original.attr = 1 wrapped = sync_performer(original) - self.assertEqual(wrapped.__name__, 'original') + self.assertEqual(wrapped.__name__, "original") self.assertEqual(wrapped.attr, 1) - self.assertEqual(wrapped.__doc__, 'Original!') + self.assertEqual(wrapped.__doc__, "Original!") def test_ignore_lack_of_metadata(self): """ When the original callable is not a function, a new function is still returned. """ + def original(something, dispatcher, intent): """Original!""" pass - new_func = partial(original, 'something') + + new_func = partial(original, "something") original.attr = 1 wrapped = sync_performer(new_func) - self.assertEqual(wrapped.__name__, 'sync_wrapper') + self.assertEqual(wrapped.__name__, "sync_wrapper") def test_kwargs(self): """Additional kwargs are passed through.""" + @sync_performer def p(dispatcher, intent, extra): return extra - dispatcher = lambda _: partial(p, extra='extra val') - result = sync_perform(dispatcher, Effect('foo')) - self.assertEqual(result, 'extra val') + dispatcher = lambda _: partial(p, extra="extra val") + result = sync_perform(dispatcher, Effect("foo")) + self.assertEqual(result, "extra val") diff --git a/effect/test_testing.py b/effect/test_testing.py index 3871f35..42c8db3 100644 --- a/effect/test_testing.py +++ b/effect/test_testing.py @@ -7,8 +7,7 @@ import pytest from testtools import TestCase -from testtools.matchers import (MatchesListwise, Equals, MatchesException, - raises) +from testtools.matchers import MatchesListwise, Equals, raises from . import ( ComposedDispatcher, @@ -17,25 +16,32 @@ base_dispatcher, parallel, sync_perform, - sync_performer) -from .do import do, do_return + sync_performer, +) +from .do import do from .fold import FoldError, sequence from .testing import ( + _ANY, ESConstant, ESError, ESFunc, EQDispatcher, EQFDispatcher, SequenceDispatcher, + const, + conste, fail_effect, + intent_func, + nested_sequence, parallel_sequence, perform_sequence, resolve_effect, - resolve_stubs) + resolve_stubs, +) +from ._test_utils import MatchesException class ResolveEffectTests(TestCase): - def test_basic_resolution(self): """ When no callbacks are attached to the effect, the result argument is @@ -51,6 +57,7 @@ def test_invoke_callbacks(self): def add1(n): return n + 1 + eff = Effect(None).on(success=add1).on(success=add1) self.assertEqual(resolve_effect(eff, 0), 2) @@ -58,12 +65,11 @@ def test_callback_returning_effect(self): """ When a callback returns an effect, that effect is returned. """ - stub_effect = Effect('inner') + stub_effect = Effect("inner") eff = Effect(None).on(success=lambda r: stub_effect) - result = resolve_effect(eff, 'foo') - self.assertEqual(result.intent, 'inner') - self.assertEqual(resolve_effect(result, 'next-result'), - 'next-result') + result = resolve_effect(eff, "foo") + self.assertEqual(result.intent, "inner") + self.assertEqual(resolve_effect(result, "next-result"), "next-result") def test_intermediate_callback_returning_effect(self): """ @@ -78,11 +84,12 @@ def a(r): def b(r): return ("b-result", r) + eff = Effect("orig").on(success=a).on(success=b) result = resolve_effect(eff, "foo") self.assertEqual( - resolve_effect(result, "next-result"), - ('b-result', 'next-result')) + resolve_effect(result, "next-result"), ("b-result", "next-result") + ) def test_maintain_intermediate_effect_callbacks(self): """ @@ -100,10 +107,13 @@ def nested_b(r): def c(r): return ("c-result", r) + eff = Effect("orig").on(success=a).on(success=c) result = resolve_effect(eff, "foo") - self.assertEqual(resolve_effect(result, 'next-result'), - ('c-result', ('nested-b-result', 'next-result'))) + self.assertEqual( + resolve_effect(result, "next-result"), + ("c-result", ("nested-b-result", "next-result")), + ) def test_resolve_effect_cb_exception(self): """ @@ -113,12 +123,17 @@ def test_resolve_effect_cb_exception(self): self.assertThat( resolve_effect( Effect("orig") - .on(success=lambda r: 1 / 0) - .on(error=lambda exc: ('handled', exc)), - 'result'), - MatchesListwise([ - Equals('handled'), - MatchesException(ZeroDivisionError)])) + .on(success=lambda r: 1 / 0) + .on(error=lambda exc: ("handled", exc)), + "result", + ), + MatchesListwise( + [ + Equals("handled"), + MatchesException(ZeroDivisionError("division by zero")), + ] + ), + ) def test_raise_if_final_result_is_error(self): """ @@ -126,12 +141,12 @@ def test_raise_if_final_result_is_error(self): resolve_effect. """ self.assertThat( - lambda: - resolve_effect( - Effect('orig').on( - success=lambda r: _raise(ValueError('oh goodness'))), - 'result'), - raises(ValueError('oh goodness'))) + lambda: resolve_effect( + Effect("orig").on(success=lambda r: _raise(ValueError("oh goodness"))), + "result", + ), + raises(ValueError("oh goodness")), + ) def test_fail_effect(self): """ @@ -139,20 +154,16 @@ def test_fail_effect(self): handler is invoked. """ self.assertThat( - lambda: - fail_effect( - Effect('orig'), - ValueError('oh deary me')), - raises(ValueError('oh deary me'))) + lambda: fail_effect(Effect("orig"), ValueError("oh deary me")), + raises(ValueError("oh deary me")), + ) def test_skip_callbacks(self): """ Intermediate callbacks of the wrong type are skipped. """ - eff = (Effect('foo') - .on(error=lambda f: 1) - .on(success=lambda x: ('succeeded', x))) - self.assertEqual(resolve_effect(eff, 'foo'), ('succeeded', 'foo')) + eff = Effect("foo").on(error=lambda f: 1).on(success=lambda x: ("succeeded", x)) + self.assertEqual(resolve_effect(eff, "foo"), ("succeeded", "foo")) class ResolveStubsTests(TestCase): @@ -165,7 +176,9 @@ def test_resolve_stubs(self): """ eff = ESConstant("foo").on( success=lambda r: ESError(RuntimeError("foo")).on( - error=lambda e: ESFunc(lambda: "heyo"))) + error=lambda e: ESFunc(lambda: "heyo") + ) + ) self.assertEqual(resolve_stubs(base_dispatcher, eff), "heyo") def test_non_test_intent(self): @@ -196,8 +209,7 @@ def test_resolve_stubs_callbacks_only_invoked_once(self): This is a regression test for a really dumb bug. """ eff = ESConstant("foo").on(success=lambda r: ("got it", r)) - self.assertEqual(resolve_stubs(base_dispatcher, eff), - ("got it", "foo")) + self.assertEqual(resolve_stubs(base_dispatcher, eff), ("got it", "foo")) def test_outer_callbacks_after_intermediate_effect(self): """ @@ -205,10 +217,11 @@ def test_outer_callbacks_after_intermediate_effect(self): callbacks, the remaining callbacks will be wrapped around the returned effect. """ - eff = ESConstant("foo").on( - success=lambda r: Effect("something") - ).on( - lambda r: ("callbacked", r)) + eff = ( + ESConstant("foo") + .on(success=lambda r: Effect("something")) + .on(lambda r: ("callbacked", r)) + ) result = resolve_stubs(base_dispatcher, eff) self.assertIs(type(result), Effect) self.assertEqual(result.intent, "something") @@ -225,9 +238,7 @@ def test_parallel_non_stubs(self): If a parallel effect contains a non-stub, the parallel effect is returned as-is. """ - p_eff = parallel( - [ESConstant(1), Effect(Constant(2))] - ).on(lambda x: 0) + p_eff = parallel([ESConstant(1), Effect(Constant(2))]).on(lambda x: 0) self.assertEqual(resolve_stubs(base_dispatcher, p_eff), p_eff) def test_parallel_stubs_with_callbacks(self): @@ -244,7 +255,8 @@ def test_parallel_stubs_with_callbacks_returning_effects(self): callbacks of parallel effects. """ p_eff = parallel([ESConstant(1), ESConstant(2)]).on( - lambda r: ESConstant(r[0] + 1)) + lambda r: ESConstant(r[0] + 1) + ) self.assertEqual(resolve_stubs(base_dispatcher, p_eff), 2) def test_parallel_stubs_with_element_callbacks_returning_non_stubs(self): @@ -253,8 +265,7 @@ def test_parallel_stubs_with_element_callbacks_returning_non_stubs(self): NOT be performed. """ p_eff = parallel([ESConstant(1).on(lambda r: Effect(Constant(2)))]) - self.assertEqual(resolve_stubs(base_dispatcher, p_eff), - [Effect(Constant(2))]) + self.assertEqual(resolve_stubs(base_dispatcher, p_eff), [Effect(Constant(2))]) def _raise(e): @@ -267,12 +278,12 @@ class EQDispatcherTests(TestCase): def test_no_intent(self): """When the dispatcher can't match the intent, it returns None.""" d = EQDispatcher([]) - self.assertIs(d('foo'), None) + self.assertIs(d("foo"), None) def test_perform(self): """When an intent matches, performing it returns the canned result.""" - d = EQDispatcher([('hello', 'there')]) - self.assertEqual(sync_perform(d, Effect('hello')), 'there') + d = EQDispatcher([("hello", "there")]) + self.assertEqual(sync_perform(d, Effect("hello")), "there") class EQFDispatcherTests(TestCase): @@ -281,12 +292,12 @@ class EQFDispatcherTests(TestCase): def test_no_intent(self): """When the dispatcher can't match the intent, it returns None.""" d = EQFDispatcher([]) - self.assertIs(d('foo'), None) + self.assertIs(d("foo"), None) def test_perform(self): """When an intent matches, performing it returns the canned result.""" - d = EQFDispatcher([('hello', lambda i: (i, 'there'))]) - self.assertEqual(sync_perform(d, Effect('hello')), ('hello', 'there')) + d = EQFDispatcher([("hello", lambda i: (i, "there"))]) + self.assertEqual(sync_perform(d, Effect("hello")), ("hello", "there")) class SequenceDispatcherTests(TestCase): @@ -296,35 +307,39 @@ def test_mismatch(self): """ When an intent isn't expected, a None is returned. """ - d = SequenceDispatcher([('foo', lambda i: 1 / 0)]) - self.assertEqual(d('hello'), None) + d = SequenceDispatcher([("foo", lambda i: 1 / 0)]) + self.assertEqual(d("hello"), None) def test_success(self): """ Each intent is performed in sequence with the provided functions, as long as the intents match. """ - d = SequenceDispatcher([ - ('foo', lambda i: ('performfoo', i)), - ('bar', lambda i: ('performbar', i)), - ]) - eff = Effect('foo').on(lambda r: Effect('bar').on(lambda r2: (r, r2))) + d = SequenceDispatcher( + [ + ("foo", lambda i: ("performfoo", i)), + ("bar", lambda i: ("performbar", i)), + ] + ) + eff = Effect("foo").on(lambda r: Effect("bar").on(lambda r2: (r, r2))) self.assertEqual( - sync_perform(d, eff), - (('performfoo', 'foo'), ('performbar', 'bar'))) + sync_perform(d, eff), (("performfoo", "foo"), ("performbar", "bar")) + ) def test_ran_out(self): """When there are no more items left, None is returned.""" d = SequenceDispatcher([]) - self.assertEqual(d('foo'), None) + self.assertEqual(d("foo"), None) def test_out_of_order(self): """Order of items in the sequence matters.""" - d = SequenceDispatcher([ - ('bar', lambda i: ('performbar', i)), - ('foo', lambda i: ('performfoo', i)), - ]) - self.assertEqual(d('foo'), None) + d = SequenceDispatcher( + [ + ("bar", lambda i: ("performbar", i)), + ("foo", lambda i: ("performfoo", i)), + ] + ) + self.assertEqual(d("foo"), None) def test_consumed(self): """`consumed` returns True if there are no more elements.""" @@ -336,30 +351,31 @@ def test_consumed_honors_changes(self): `consumed` returns True if there are no more elements after performing some. """ - d = SequenceDispatcher([('foo', lambda i: 'bar')]) - sync_perform(d, Effect('foo')) + d = SequenceDispatcher([("foo", lambda i: "bar")]) + sync_perform(d, Effect("foo")) self.assertTrue(d.consumed()) def test_not_consumed(self): """ `consumed` returns False if there are more elements. """ - d = SequenceDispatcher([('foo', lambda i: 'bar')]) + d = SequenceDispatcher([("foo", lambda i: "bar")]) self.assertFalse(d.consumed()) def test_consume_good(self): """``consume`` doesn't raise an error if all elements are consumed.""" - d = SequenceDispatcher([('foo', lambda i: 'bar')]) + d = SequenceDispatcher([("foo", lambda i: "bar")]) with d.consume(): - sync_perform(d, Effect('foo')) + sync_perform(d, Effect("foo")) def test_consume_raises(self): """``consume`` raises an error if not all elements are consumed.""" - d = SequenceDispatcher([('foo', None)]) + d = SequenceDispatcher([("foo", None)]) def failer(): with d.consume(): pass + e = self.assertRaises(AssertionError, failer) self.assertEqual(str(e), "Not all intents were performed: ['foo']") @@ -379,14 +395,16 @@ def test_perform_sequence(): @do def code_under_test(): - r = yield Effect(MyIntent('a')) - r2 = yield Effect(OtherIntent('b')) - yield do_return((r, r2)) + r = yield Effect(MyIntent("a")) + r2 = yield Effect(OtherIntent("b")) + return (r, r2) - seq = [(MyIntent('a'), lambda i: 'result1'), - (OtherIntent('b'), lambda i: 'result2')] + seq = [ + (MyIntent("a"), lambda i: "result1"), + (OtherIntent("b"), lambda i: "result2"), + ] eff = code_under_test() - assert perform_sequence(seq, eff) == ('result1', 'result2') + assert perform_sequence(seq, eff) == ("result1", "result2") def test_perform_sequence_log(): @@ -394,18 +412,18 @@ def test_perform_sequence_log(): When an intent isn't found, a useful log of intents is included in the exception message. """ + @do def code_under_test(): - r = yield Effect(MyIntent('a')) - r2 = yield Effect(OtherIntent('b')) - yield do_return((r, r2)) + r = yield Effect(MyIntent("a")) + r2 = yield Effect(OtherIntent("b")) + return (r, r2) - seq = [(MyIntent('a'), lambda i: 'result1')] + seq = [(MyIntent("a"), lambda i: "result1")] with pytest.raises(AssertionError) as exc: perform_sequence(seq, code_under_test()) - expected = ("sequence: MyIntent(val='a')\n" - "NOT FOUND: OtherIntent(val='b')") + expected = "sequence: MyIntent(val='a')\n" "NOT FOUND: OtherIntent(val='b')" assert expected in str(exc.value) @@ -415,14 +433,16 @@ def test_parallel_sequence(): order, and returns the results associated with those intents. """ seq = [ - parallel_sequence([ - [(1, lambda i: "one!")], - [(2, lambda i: "two!")], - [(3, lambda i: "three!")], - ]) + parallel_sequence( + [ + [(1, lambda i: "one!")], + [(2, lambda i: "two!")], + [(3, lambda i: "three!")], + ] + ) ] p = parallel([Effect(1), Effect(2), Effect(3)]) - assert perform_sequence(seq, p) == ['one!', 'two!', 'three!'] + assert perform_sequence(seq, p) == ["one!", "two!", "three!"] def test_parallel_sequence_fallback(): @@ -430,20 +450,24 @@ def test_parallel_sequence_fallback(): Accepts a ``fallback`` dispatcher that will be used when the sequence doesn't contain an intent. """ + def dispatch_2(intent): if intent == 2: return sync_performer(lambda d, i: "two!") + fallback = ComposedDispatcher([dispatch_2, base_dispatcher]) seq = [ - parallel_sequence([ - [(1, lambda i: 'one!')], - [], # only implicit effects in this slot - [(3, lambda i: 'three!')], - ], - fallback_dispatcher=fallback), + parallel_sequence( + [ + [(1, lambda i: "one!")], + [], # only implicit effects in this slot + [(3, lambda i: "three!")], + ], + fallback_dispatcher=fallback, + ), ] p = parallel([Effect(1), Effect(2), Effect(3)]) - assert perform_sequence(seq, p) == ['one!', 'two!', 'three!'] + assert perform_sequence(seq, p) == ["one!", "two!", "three!"] def test_parallel_sequence_must_be_parallel(): @@ -452,13 +476,76 @@ def test_parallel_sequence_must_be_parallel(): match and a FoldError of NoPerformerFoundError will be raised. """ seq = [ - parallel_sequence([ - [(1, lambda i: "one!")], - [(2, lambda i: "two!")], - [(3, lambda i: "three!")], - ]) + parallel_sequence( + [ + [(1, lambda i: "one!")], + [(2, lambda i: "two!")], + [(3, lambda i: "three!")], + ] + ) ] p = sequence([Effect(1), Effect(2), Effect(3)]) with pytest.raises(FoldError) as excinfo: perform_sequence(seq, p) - assert excinfo.value.wrapped_exception[0] is AssertionError + assert type(excinfo.value.wrapped_exception) is AssertionError + + +def test_nested_sequence(): + """ + :func:`nested_sequence` returns sequence performer function for an intent + that wraps an effect. + """ + + @attr.s + class WrappedIntent(object): + effect = attr.ib() + value = attr.ib() + + @do + def internal(): + yield Effect(1) + yield Effect(2) + return "wrap" + + @do + def code_under_test(): + r = yield Effect(WrappedIntent(internal(), "field")) + r2 = yield Effect(MyIntent("a")) + return (r, r2) + + seq = [ + ( + WrappedIntent(_ANY, "field"), + nested_sequence([(1, const("r1")), (2, const("r2"))]), + ), + (MyIntent("a"), const("result2")), + ] + eff = code_under_test() + assert perform_sequence(seq, eff) == ("wrap", "result2") + + +def test_const(): + """ + :func:`const` takes an argument but returns fixed value + """ + assert const(2)(MyIntent("whatever")) == 2 + assert const("text")(OtherIntent("else")) == "text" + + +def test_conste(): + """ + :func:`conste` takes an argument but always raises given exception + """ + func = conste(ValueError("boo")) + with pytest.raises(ValueError): + func(MyIntent("yo")) + + +def test_intent_func(): + """ + :func:`intent_func` returns function that returns Effect of tuple of passed arg + and its args. + """ + func = intent_func("myfunc") + assert func(2, 3) == Effect(("myfunc", 2, 3)) + assert func("text", 3, None) == Effect(("myfunc", "text", 3, None)) diff --git a/effect/test_threads.py b/effect/test_threads.py index f02b231..ad5c9b7 100644 --- a/effect/test_threads.py +++ b/effect/test_threads.py @@ -1,5 +1,3 @@ -from __future__ import print_function - from functools import partial from multiprocessing.pool import ThreadPool @@ -19,6 +17,6 @@ def setUp(self): super(ParallelPoolPerformerTests, self).setUp() self.pool = ThreadPool() self.p_performer = partial(perform_parallel_with_pool, self.pool) - self.dispatcher = ComposedDispatcher([ - base_dispatcher, - TypeDispatcher({ParallelEffects: self.p_performer})]) + self.dispatcher = ComposedDispatcher( + [base_dispatcher, TypeDispatcher({ParallelEffects: self.p_performer})] + ) diff --git a/effect/testing.py b/effect/testing.py index 62c7674..7fa8e3c 100644 --- a/effect/testing.py +++ b/effect/testing.py @@ -4,32 +4,36 @@ Usually the best way to test effects is by using :func:`perform_sequence`. """ -from __future__ import print_function - from contextlib import contextmanager from functools import partial -import sys +from operator import attrgetter import attr -from ._base import Effect, guard, _Box, NoPerformerFoundError +from ._base import Effect, guard, _Box, NoPerformerFoundError, raise_ from ._sync import NotSynchronousError, sync_perform, sync_performer from ._intents import Constant, Error, Func, ParallelEffects, base_dispatcher -import six __all__ = [ - 'perform_sequence', - 'SequenceDispatcher', - 'noop', - 'resolve_effect', - 'fail_effect', - 'EQDispatcher', - 'EQFDispatcher', - 'Stub', - 'ESConstant', 'ESError', 'ESFunc', - 'resolve_stubs', - 'resolve_stub', + "perform_sequence", + "parallel_sequence", + "nested_sequence", + "SequenceDispatcher", + "noop", + "const", + "conste", + "intent_func", + "resolve_effect", + "fail_effect", + "EQDispatcher", + "EQFDispatcher", + "Stub", + "ESConstant", + "ESError", + "ESFunc", + "resolve_stubs", + "resolve_stub", ] @@ -44,7 +48,7 @@ def perform_sequence(seq, eff, fallback_dispatcher=None): def code_under_test(): r = yield Effect(MyIntent('a')) r2 = yield Effect(OtherIntent('b')) - yield do_return((r, r2)) + return (r, r2) def test_code(): seq = [ @@ -83,14 +87,14 @@ def test_code(): :param fallback_dispatcher: A dispatcher to use for intents that aren't found in the sequence. if None is provided, ``base_dispatcher`` is used. + :return: Result of performed sequence """ + def fmt_log(): - next_item = '' + next_item = "" if len(sequence.sequence) > 0: - next_item = '\nNEXT EXPECTED: %s' % (sequence.sequence[0][0],) - return '{{{\n%s%s\n}}}' % ( - '\n'.join('%s: %s' % x for x in log), - next_item) + next_item = "\nNEXT EXPECTED: %s" % (sequence.sequence[0][0],) + return "{{{\n%s%s\n}}}" % ("\n".join("%s: %s" % x for x in log), next_item) def dispatcher(intent): p = sequence(intent) @@ -104,8 +108,8 @@ def dispatcher(intent): else: log.append(("NOT FOUND", intent)) raise AssertionError( - "Performer not found: %s! Log follows:\n%s" % ( - intent, fmt_log())) + "Performer not found: %s! Log follows:\n%s" % (intent, fmt_log()) + ) if fallback_dispatcher is None: fallback_dispatcher = base_dispatcher @@ -117,8 +121,11 @@ def dispatcher(intent): @object.__new__ class _ANY(object): - def __eq__(self, o): return True - def __ne__(self, o): return False + def __eq__(self, o): + return True + + def __ne__(self, o): + return False def parallel_sequence(parallel_seqs, fallback_dispatcher=None): @@ -135,7 +142,7 @@ def code_under_test(): r = yield Effect(SerialIntent('serial')) r2 = yield parallel([Effect(MyIntent('a')), Effect(OtherIntent('b'))]) - yield do_return((r, r2)) + return (r, r2) def test_code(): seq = [ @@ -159,17 +166,27 @@ def test_code(): what :func:`perform_sequence` accepts. :param fallback_dispatcher: an optional dispatcher to compose onto the sequence dispatcher. + :return: (intent, performer) tuple as expected by :func:`perform_sequence` + where intent is ParallelEffects object """ perf = partial(perform_sequence, fallback_dispatcher=fallback_dispatcher) + def performer(intent): if len(intent.effects) != len(parallel_seqs): raise AssertionError( "Need one list in parallel_seqs per parallel effect. " "Got %s effects and %s seqs.\n" "Effects: %s\n" - "parallel_seqs: %s" % (len(intent.effects), len(parallel_seqs), - intent.effects, parallel_seqs)) + "parallel_seqs: %s" + % ( + len(intent.effects), + len(parallel_seqs), + intent.effects, + parallel_seqs, + ) + ) return list(map(perf, parallel_seqs, intent.effects)) + return (ParallelEffects(effects=_ANY), performer) @@ -185,6 +202,7 @@ class Stub(object): :class:`Stub` is intentionally not performable by any default mechanism. """ + intent = attr.ib() @@ -241,7 +259,7 @@ def resolve_effect(effect, result, is_error=False): :param result: If ``is_error`` is False, this can be any object and will be treated as the result of the effect. If ``is_error`` is True, this must - be a three-tuple in the style of ``sys.exc_info``. + be an exception. """ for i, (callback, errback) in enumerate(effect.callbacks): cb = errback if is_error else callback @@ -250,10 +268,10 @@ def resolve_effect(effect, result, is_error=False): is_error, result = guard(cb, result) if type(result) is Effect: return Effect( - result.intent, - callbacks=result.callbacks + effect.callbacks[i + 1:]) + result.intent, callbacks=result.callbacks + effect.callbacks[i + 1 :] + ) if is_error: - six.reraise(*result) + raise result return result @@ -263,8 +281,8 @@ def fail_effect(effect, exception): """ try: raise exception - except: - return resolve_effect(effect, sys.exc_info(), is_error=True) + except Exception as e: + return resolve_effect(effect, e, is_error=True) def resolve_stub(dispatcher, effect): @@ -286,18 +304,16 @@ def resolve_stub(dispatcher, effect): if len(result_slot) == 0: raise NotSynchronousError( "Performer %r was not synchronous during stub resolution for " - "effect %r" - % (performer, effect)) + "effect %r" % (performer, effect) + ) if len(result_slot) > 1: raise RuntimeError( "Pathological error (too many box results) while running " - "performer %r for effect %r" - % (performer, effect)) - return resolve_effect(effect, result_slot[0][1], - is_error=result_slot[0][0]) + "performer %r for effect %r" % (performer, effect) + ) + return resolve_effect(effect, result_slot[0][1], is_error=result_slot[0][0]) else: - raise TypeError("resolve_stub can only resolve stubs, not %r" - % (effect,)) + raise TypeError("resolve_stub can only resolve stubs, not %r" % (effect,)) def resolve_stubs(dispatcher, effect): @@ -317,14 +333,15 @@ def resolve_stubs(dispatcher, effect): if type(effect.intent) is Stub: effect = resolve_stub(dispatcher, effect) elif type(effect.intent) is ParallelEffects: - if not all(isinstance(x.intent, Stub) - for x in effect.intent.effects): + if not all(isinstance(x.intent, Stub) for x in effect.intent.effects): break else: effect = resolve_effect( effect, - list(map(partial(resolve_stubs, dispatcher), - effect.intent.effects))) + list( + map(partial(resolve_stubs, dispatcher), effect.intent.effects) + ), + ) else: break @@ -363,6 +380,7 @@ class EQDispatcher(object): :param list mapping: A sequence of tuples of (intent, result). """ + mapping = attr.ib() def __call__(self, intent): @@ -407,6 +425,7 @@ class EQFDispatcher(object): :param list mapping: A sequence of two-tuples of (intent, function). """ + mapping = attr.ib() def __call__(self, intent): @@ -436,6 +455,7 @@ class SequenceDispatcher(object): :param list sequence: Sequence of (intent, fn). """ + sequence = attr.ib() def __call__(self, intent): @@ -460,12 +480,52 @@ def consume(self): if not self.consumed(): raise AssertionError( "Not all intents were performed: {0}".format( - [x[0] for x in self.sequence])) + [x[0] for x in self.sequence] + ) + ) -def noop(intent): +def nested_sequence( + seq, get_effect=attrgetter("effect"), fallback_dispatcher=base_dispatcher +): """ + Return a function of Intent -> a that performs an effect retrieved from the + intent (by accessing its `effect` attribute, by default) with the given + intent-sequence. + + A demonstration is best:: + + SequenceDispatcher([ + (BoundFields(effect=mock.ANY, fields={...}), + nested_sequence([(SomeIntent(), perform_some_intent)])) + ]) + + The point is that sometimes you have an intent that wraps another effect, + and you want to ensure that the nested effects follow some sequence in the + context of that wrapper intent. + ``get_effect`` defaults to ``attrgetter('effect')``, so you can override it if + your intent stores its nested effect in a different attribute. Or, more + interestingly, if it's something other than a single effect, e.g. for + ParallelEffects see the :func:`parallel_sequence` function. + + :param list seq: sequence of intents like :obj:`SequenceDispatcher` takes + :param get_effect: callable to get the inner effect from the wrapper + intent. + :param fallback_dispatcher: an optional dispatcher to compose onto the + sequence dispatcher. + :return: ``callable`` that can be used as performer of a wrapped intent + """ + + def performer(intent): + effect = get_effect(intent) + return perform_sequence(seq, effect, fallback_dispatcher=fallback_dispatcher) + + return performer + + +def noop(intent): + """ Return None. This is just a handy way to make your intent sequences (as used by :func:`perform_sequence`) more concise when the effects you're expecting in a test don't return a result (and are instead only performed @@ -478,3 +538,62 @@ def noop(intent): """ return None + + +def const(value): + """ + Return function that takes an argument but always return given `value`. + Useful when creating sequence used by :func:`perform_sequence`. For example, + + >>> dt = datetime(1970, 1, 1) + >>> seq = [(Func(datetime.now), const(dt))] + + :param value: This will be returned when called by returned function + :return: ``callable`` that takes an arg and always returns ``value`` + """ + return lambda intent: value + + +def conste(excp): + """ + Like :func:`const` but takes and exception and returns function that raises + the exception + + :param excp: Exception that will be raised + :type: :obj:`Exception` + :return: ``callable`` that will raise given exception + """ + return lambda intent: raise_(excp) + + +def intent_func(fname): + """ + Return function that returns Effect of tuple of fname and its args. Useful + in writing tests that expect intent based on args. For example, if you are + testing following function:: + + @do + def code_under_test(arg1, arg2, eff_returning_func=eff_returning_func): + r = yield Effect(MyIntent('a')) + r2 = yield eff_returning_func(arg1, arg2) + return (r, r2) + + you will need to know the intents which ``eff_returning_func`` generates + to test this using :func:`perform_sequence`. You can avoid that by doing:: + + def test_code(): + test_eff_func = intent_func("erf") + seq = [ + (MyIntent('a'), const('result1')), + (("erf", 'a1', 'a2'), const('result2')) + ] + eff = code_under_test('a1', 'a2', eff_returning_func=test_eff_func) + assert perform_sequence(seq, eff) == ('result1', 'result2') + + Here, the ``seq`` ensures that ``eff_returning_func`` is called with arguments + ``a1`` and ``a2``. + + :param str fname: First member of intent tuple returned + :return: ``callable`` with multiple positional arguments + """ + return lambda *a: Effect((fname,) + a) diff --git a/effect/threads.py b/effect/threads.py index 1840cb7..7e915c4 100644 --- a/effect/threads.py +++ b/effect/threads.py @@ -1,7 +1,3 @@ -import sys - -import six - from ._intents import FirstError from ._sync import sync_perform, sync_performer @@ -36,9 +32,7 @@ def perform_child(index_and_effect): index, effect = index_and_effect try: return sync_perform(dispatcher, effect) - except: - exc_info = sys.exc_info() - six.reraise(FirstError, - FirstError(exc_info=exc_info, index=index), - exc_info[2]) + except Exception as e: + raise FirstError(exception=e, index=index) + return pool.map(perform_child, enumerate(parallel_effects.effects)) diff --git a/setup.cfg b/setup.cfg index 79bc678..2a28575 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,7 @@ # 3. If at all possible, it is good practice to do this. If you cannot, you # will need to generate wheels for each Python version that you support. universal=1 + +[flake8] +max-line-length = 100 +ignore = E131,E301,E302,E731,W503,E701,E704,E722,E203 diff --git a/setup.py b/setup.py index 1b7d602..b513d66 100644 --- a/setup.py +++ b/setup.py @@ -3,17 +3,16 @@ setuptools.setup( name="effect", - version="0.10.1", + version="1.1.0", description="pure effects for Python", - long_description=open('README.rst').read(), + long_description=open("README.rst").read(), url="http://github.com/python-effect/effect/", author="Christopher Armstrong", license="MIT", classifiers=[ - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - ], - packages=['effect'], - install_requires=['six', 'attrs'], - ) + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + ], + packages=["effect"], + install_requires=["attrs"], +)