import itertools
import time
import copy
import random
from unittest import mock

import pytest

from jaraco.functools import Throttler, method_cache, retry_call


class TestThrottler(object):
	def test_function_throttled(self):
		"""
		Ensure the throttler actually throttles calls.
		"""
		# set up a function to be called
		counter = itertools.count()
		# set up a version of `next` that is only called 30 times per second
		limited_next = Throttler(next, 30)
		# for one second, call next as fast as possible
		deadline = time.time() + 1
		while time.time() < deadline:
			limited_next(counter)
		# ensure the counter was advanced about 30 times
		assert 28 <= next(counter) <= 32

		# ensure that another burst of calls after some idle period will also
		# get throttled
		time.sleep(1)
		deadline = time.time() + 1
		counter = itertools.count()
		while time.time() < deadline:
			limited_next(counter)
		assert 28 <= next(counter) <= 32

	def test_reconstruct_unwraps(self):
		"""
		The throttler should be re-usable - if one wants to throttle a
		function that's aready throttled, the original function should be
		used.
		"""
		wrapped = Throttler(next, 30)
		wrapped_again = Throttler(wrapped, 60)
		assert wrapped_again.func is next
		assert wrapped_again.max_rate == 60

	def test_throttled_method(self):
		class ThrottledMethodClass(object):
			@Throttler
			def echo(self, arg):
				return arg

		tmc = ThrottledMethodClass()
		assert tmc.echo('foo') == 'foo'


class TestMethodCache:
	bad_vers = '(3, 5, 0) <= sys.version_info < (3, 5, 2)'
	@pytest.mark.skipif(bad_vers, reason="https://bugs.python.org/issue25447")
	def test_deepcopy(self):
		"""
		A deepcopy of an object with a method cache should still
		succeed.
		"""
		class ClassUnderTest:
			calls = 0

			@method_cache
			def method(self, value):
				self.calls += 1
				return value

		ob = ClassUnderTest()
		copy.deepcopy(ob)
		ob.method(1)
		copy.deepcopy(ob)

	def test_special_methods(self):
		"""
		Test method_cache with __getitem__ and __getattr__.
		"""
		class ClassUnderTest(object):
			getitem_calls = 0
			getattr_calls = 0

			@method_cache
			def __getitem__(self, item):
				self.getitem_calls += 1
				return item

			@method_cache
			def __getattr__(self, name):
				self.getattr_calls += 1
				return name

		ob = ClassUnderTest()

		# __getitem__
		_ = ob[1] + ob[1]
		assert ob.getitem_calls == 1

		# __getattr__
		_ = ob.one + ob.one
		assert ob.getattr_calls == 1


class TestRetry:
	def attempt(self):
		if next(self.fails_left):
			raise ValueError("Failed!")
		return "Success"

	def set_to_fail(self, times):
		self.fails_left = itertools.count(times, -1)

	def test_set_to_fail(self):
		"""
		Test this test's internal failure mechanism.
		"""
		self.set_to_fail(times=2)
		with pytest.raises(ValueError):
			self.attempt()
		with pytest.raises(ValueError):
			self.attempt()
		assert self.attempt() == 'Success'

	def test_retry_call_succeeds(self):
		self.set_to_fail(times=2)
		res = retry_call(self.attempt, retries=2, trap=ValueError)
		assert res == "Success"

	def test_retry_call_fails(self):
		"""
		Failing more than the number of retries should
		raise the underlying error.
		"""
		self.set_to_fail(times=3)
		with pytest.raises(ValueError) as res:
			retry_call(self.attempt, retries=2, trap=ValueError)
		assert str(res.value) == 'Failed!'

	def test_retry_multiple_exceptions(self):
		self.set_to_fail(times=2)
		errors = ValueError, NameError
		res = retry_call(self.attempt, retries=2, trap=errors)
		assert res == "Success"

	def test_retry_exception_superclass(self):
		self.set_to_fail(times=2)
		res = retry_call(self.attempt, retries=2, trap=Exception)
		assert res == "Success"

	def test_default_traps_nothing(self):
		self.set_to_fail(times=1)
		with pytest.raises(ValueError):
			retry_call(self.attempt, retries=1)

	def test_default_does_not_retry(self):
		self.set_to_fail(times=1)
		with pytest.raises(ValueError):
			retry_call(self.attempt, trap=Exception)

	def test_cleanup_called_on_exception(self):
		calls = random.randint(1, 10)
		cleanup = mock.Mock()
		self.set_to_fail(times=calls)
		retry_call(self.attempt, retries=calls, cleanup=cleanup, trap=Exception)
		assert cleanup.call_count == calls
		assert cleanup.called_with()
