Skip to content

Commit

Permalink
Merge pull request #27 from ddmee/poll_decorator
Browse files Browse the repository at this point in the history
poll_decorator add poll_decorator() per use request
  • Loading branch information
ddmee authored Jul 19, 2021
2 parents 4ef54fa + d6a4099 commit 379fcf9
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 4 deletions.
9 changes: 6 additions & 3 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ The API

.. module:: polling2

This part of the documentation covers all the interfaces of Requests. For
parts where Requests depends on external libraries, we document the most
important right here and provide links to the canonical documentation.
This part of the documentation covers all the interfaces of polling2. polling2
depends on no external libraries and should run any version of python 2 or 3.


Poll Method
Expand All @@ -15,6 +14,10 @@ The main method is the poll method.

.. autofunction:: poll

There is also an equivalent decorator method. It's interface is essentially the same as poll().

.. autofunction:: poll_decorator


Helper Methods
--------------
Expand Down
18 changes: 18 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,21 @@ Note, that setting the timeout parameter to None or 0 has the equivalent effect
# Setting timeout to zero is equivalent to setting poll_forever=True.
# This call will also poll the target forever.
poll(target=lambda: False, step=1, timeout=0)


Wrap a target function in a polling decorator
---------------------------------------------

Perhaps you'd like to use the decorator syntax to implement the polling functional. No problem!

::

from polling2 import poll_decorator
import requests
@poll_decorator(step=1, timeout=15)
def wait_until_exists(uri):
return requests.get(uri).status_code != 404
# Call when you please
wait_until_exists(uri='http://www.google.com')

Inspiration taken from https://github.com/benjamin-hodgson/poll per request from https://github.com/lucasmelin.
9 changes: 9 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ Examples
>> # Wait until the number of seconds (ignoring milliseconds) is divisible by 5.
>> polling2.poll(target=time.time, check_success=lambda x: int(x) % 5 == 0, step=0.5, timeout=6)
1599737060.4507122
>> # Lets use the decorator version to create a function that waits until the next even second.
>> @polling2.poll_decorator(check_success=lambda x: int(x) % 2 == 0, step=0.5, timeout=6)
... def even_time():
... return time.time()
>> even_time()
1599737080.016323
>> even_time()
1599737082.035758


View all the examples:

Expand Down
5 changes: 5 additions & 0 deletions docs/release-notes.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Release notes
=============

0.5.0
-----
- NEW API addition: poll_decorator(). Per user-request, you can now use @poll_decorator() as a way to wrap a function with poll().
- The api otherwise remains the same. See the new function poll_decorator(). All options and arguments remain equivalent in poll()/poll_decorator(). Fully backward compatible change.

0.4.7
-----

Expand Down
19 changes: 18 additions & 1 deletion polling2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"""

__version__ = '0.4.7'
__version__ = '0.5.0'

from functools import wraps
import logging
import time
try:
Expand Down Expand Up @@ -215,3 +216,19 @@ def poll(target, step, args=(), kwargs=None, timeout=None, max_tries=None, check

time.sleep(step)
step = step_function(step)


def poll_decorator(step, timeout=None, max_tries=None, check_success=is_truthy,
step_function=step_constant, ignore_exceptions=(), poll_forever=False,
collect_values=None, log=logging.NOTSET, log_error=logging.NOTSET):
"""Use poll() as a decorator.
:return: decorator using poll()"""
def decorator(target):
@wraps(target)
def wrapper(*args, **kwargs):
return poll(target=target, step=step, args=args, kwargs=kwargs, timeout=timeout, max_tries=max_tries,
check_success=check_success, step_function=step_function, ignore_exceptions=ignore_exceptions,
poll_forever=poll_forever, collect_values=collect_values, log=log, log_error=log_error)
return wrapper
return decorator
165 changes: 165 additions & 0 deletions tests/test_polling2.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,95 @@ def test_import(self):
"""Test that you can import via correct usage"""
import polling2
from polling2 import poll
from polling2 import poll_decorator

assert poll
assert polling2
assert poll_decorator

def test_arg_no_arg(self):
"""Tests various permutations of calling with invalid args"""
with pytest.raises(TypeError):
polling2.poll()

def test_decorator_arg_no_arg(self):
with pytest.raises(TypeError):
@polling2.poll_decorator
def throwaway():
pass
throwaway()

def test_arg_no_step(self):
with pytest.raises(TypeError):
polling2.poll(lambda: True)

def test_decorator_arg_no_step(self):
with pytest.raises(TypeError):
@polling2.poll_decorator
def throwaway():
pass
throwaway()

def test_no_poll_forever_or_maxtries(self):
"""No error raised without specifying poll_forever or a timeout/max_tries"""
with pytest.raises(AssertionError):
polling2.poll(lambda: True, step=1)

def test_decorator_no_poll_forever_or_maxtries(self):
"""No error raised without specifying poll_forever or a timeout/max_tries"""
with pytest.raises(AssertionError):
@polling2.poll_decorator(step=1)
def throwaway():
return True
throwaway()

def test_poll_forever_with_timeout_max_tries(self):
with pytest.raises(AssertionError):
polling2.poll(lambda: True, step=1, timeout=1, max_tries=1, poll_forever=True)

def test_decorator_poll_forever_with_timeout_max_tries(self):
with pytest.raises(AssertionError):
@polling2.poll_decorator(step=1, timeout=1, max_tries=1, poll_forever=True)
def throwaway():
return True
throwaway()

def test_type_error_when_misspelt_argnames(self):
with pytest.raises(TypeError):
polling2.poll(target=lambda: None, step=2, timeout=10, check_sucess=lambda rv: rv is None)

def test_decorator_type_error_when_misspelt_argnames(self):
with pytest.raises(TypeError):
@polling2.poll_decorator(step=2, timeout=10, check_sucess=lambda rv: rv is None)
def throwaway():
return None
throwaway()

def test_valid_arg_options(self):
# Valid options
polling2.poll(lambda: True, step=1, poll_forever=True)
@polling2.poll_decorator(step=1, poll_forever=True)
def throwaway():
return True
throwaway()

polling2.poll(lambda: True, step=1, timeout=1)
@polling2.poll_decorator(step=1, timeout=1)
def throwaway():
return True
throwaway()

polling2.poll(lambda: True, step=1, max_tries=1)
@polling2.poll_decorator(step=1, max_tries=1)
def throwaway():
return True
throwaway()

polling2.poll(lambda: True, step=1, timeout=1, max_tries=1)
@polling2.poll_decorator(step=1, timeout=1, max_tries=1)
def throwaway():
return True
throwaway()

@patch('time.sleep', return_value=None)
@patch('time.time', return_value=0)
Expand All @@ -71,6 +128,29 @@ def test_timeout_exception(self, patch_sleep, patch_time):
val = polling2.poll(lambda: True, step=0, timeout=0)
assert val is True, 'Val was: {} != {}'.format(val, True)

@patch('time.sleep', return_value=None)
@patch('time.time', return_value=0)
def test_decorator_timeout_exception(self, patch_sleep, patch_time):

# Since the timeout is < 0, the first iteration of polling should raise the error if max timeout < 0
try:
@polling2.poll_decorator(step=10, timeout=-1)
def throwaway():
return False
throwaway()
except polling2.TimeoutException as e:
assert e.values.qsize() == 1, 'There should have been 1 value pushed to the queue of values'
assert e.last is False, 'The last value was incorrect'
else:
assert False, 'No timeout exception raised'

# Test happy path timeout
@polling2.poll_decorator(step=0, timeout=0)
def throwaway():
return True
val = throwaway()
assert val is True, 'Val was: {} != {}'.format(val, True)

def test_max_call_exception(self):
"""
Test that a MaxCallException will be raised
Expand All @@ -84,6 +164,22 @@ def test_max_call_exception(self):
else:
assert False, 'No MaxCallException raised'

def test_decorator_max_call_exception(self):
"""
Test that a MaxCallException will be raised
"""
tries = 100
try:
@polling2.poll_decorator(step=0, max_tries=tries)
def throwaway():
return False
throwaway()
except polling2.MaxCallException as e:
assert e.values.qsize() == tries, 'Poll function called the incorrect number of times'
assert e.last is False, 'The last value was incorrect'
else:
assert False, 'No MaxCallException raised'

def test_max_call_no_sleep(self):
"""
Test that a MaxCallException is raised without sleeping after the last call
Expand All @@ -96,6 +192,21 @@ def test_max_call_no_sleep(self):
polling2.poll(lambda: False, step=sleep, max_tries=tries)
assert time.time() - start_time < tries * sleep, 'Poll function slept before MaxCallException'

def test_decorator_max_call_no_sleep(self):
"""
Test that a MaxCallException is raised without sleeping after the last call
"""
tries = 2
sleep = 0.1
start_time = time.time()

with pytest.raises(polling2.MaxCallException):
@polling2.poll_decorator(step=sleep, max_tries=tries)
def throwaway():
return False
throwaway()
assert time.time() - start_time < tries * sleep, 'Poll function slept before MaxCallException'

def test_ignore_specified_exceptions(self):
"""
Test that ignore_exceptions tuple will ignore exceptions specified.
Expand All @@ -110,6 +221,23 @@ def test_ignore_specified_exceptions(self):
ignore_exceptions=(ValueError, EOFError))
assert raises_errors.call_count == 3

def test_decorator_ignore_specified_exceptions(self):
"""
Test that ignore_exceptions tuple will ignore exceptions specified.
Should throw any errors not in the tuple.
"""
# raises_errors is a function that returns 3 different things, each time it is called.
# First it raises a ValueError, then EOFError, then a TypeError.
raises_errors = Mock(return_value=True, side_effect=[ValueError, EOFError, RuntimeError])
# Seems to be an issue on python 2 with functools.wraps and Mocks(). See https://stackoverflow.com/a/22204742/4498470
# Just going to ignore this until someone complains.
raises_errors.__name__ = 'raises_errors'
with pytest.raises(RuntimeError):
# We are ignoring the exceptions other than a TypeError.
# Note, instead of using @, calling poll_decorator like a traditional function.
polling2.poll_decorator(step=0.1, max_tries=3, ignore_exceptions=(ValueError, EOFError))(target=raises_errors)()
assert raises_errors.call_count == 3

def test_check_is_value(self):
"""
Test that is_value() function can be used to create custom checkers.
Expand All @@ -127,6 +255,43 @@ def test_check_is_value(self):
polling2.poll(target=lambda: 123, step=0.1, max_tries=1,
check_success=polling2.is_value(444))

def test_decorator_check_is_value(self):
"""
Test that is_value() function can be used to create custom checkers.
"""
@polling2.poll_decorator(step=0.1, max_tries=1, check_success=polling2.is_value(None))
def throwaway():
return None
assert throwaway() is None

@polling2.poll_decorator(step=0.1, max_tries=1, check_success=polling2.is_value(False))
def throwaway():
return False
assert throwaway() is False

@polling2.poll_decorator(step=0.1, max_tries=1, check_success=polling2.is_value(123))
def throwaway():
return 123
assert throwaway() is 123

with pytest.raises(polling2.MaxCallException):
@polling2.poll_decorator(step=0.1, max_tries=1, check_success=polling2.is_value(444))
def throwaway():
return 123
throwaway()

def test_decorator_uses_wraps(self):
"""
Test that the function name is not replaced when poll_decorator() is used.
Thus we should be using functools.wraps() correctly.
"""
@polling2.poll_decorator(step=0.1, max_tries=1)
def throwaway():
"""Is the doc retained?"""
return True
assert throwaway.__name__ == 'throwaway', 'decorated function name has changed'
assert throwaway.__doc__ == 'Is the doc retained?', 'decorated function doc has changed'


@pytest.mark.skipif(is_py_34(), reason="pytest logcap fixture isn't available on 3.4")
class TestPollLogging(object):
Expand Down

0 comments on commit 379fcf9

Please sign in to comment.