Skip to content

Commit

Permalink
Merge pull request #6612 from janezd/util-allot
Browse files Browse the repository at this point in the history
Orange.utils: Add decorator 'allot'
  • Loading branch information
markotoplak committed Nov 13, 2023
2 parents 5f02359 + 3bd09ee commit 0957652
Show file tree
Hide file tree
Showing 3 changed files with 301 additions and 3 deletions.
161 changes: 159 additions & 2 deletions Orange/tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from itertools import count
import time
import os
import unittest
from unittest.mock import Mock, patch
import warnings

import numpy as np
Expand All @@ -11,7 +13,7 @@
from Orange.data.util import vstack, hstack, array_equal
from Orange.statistics.util import stats
from Orange.tests.test_statistics import dense_sparse
from Orange.util import wrap_callback, get_entry_point
from Orange.util import wrap_callback, get_entry_point, allot

SOMETHING = 0xf00babe

Expand All @@ -25,7 +27,7 @@ def test_get_entry_point(self):

def test_export_globals(self):
self.assertEqual(sorted(export_globals(globals(), __name__)),
['SOMETHING', 'TestUtil'])
['SOMETHING', 'TestAllot', 'TestUtil'])

def test_flatten(self):
self.assertEqual(list(flatten([[1, 2], [3]])), [1, 2, 3])
Expand Down Expand Up @@ -241,5 +243,160 @@ def test_namegen(self):
["foo 2", "foo 3", "foo 4"])


class TestAllot(unittest.TestCase):
# names of functions within tests don't matter, pylint: disable=invalid-name

def setUp(self):
# patch the object to user perf_counter, which will include the time
# when tests `sleep`
patcher = patch.object(allot, "_allot__timer", new=time.perf_counter)
patcher.start()
self.addCleanup(patcher.stop)

def test_duration(self):
@allot
def f(x, y):
self.assertEqual(x, 5)
self.assertEqual(y, 6)
time.sleep(0.2)

f(5, y=6)
self.assertGreaterEqual(f.last_call_duration, 0.2)

class A:
def __init__(self):
self.x = self.y = 0

@allot
def f(self, x, y):
self.x = x
self.y = y
time.sleep(0.2)

a = A()
a.f(5, y=6)
self.assertEqual(a.x, 5)
self.assertEqual(a.y, 6)
self.assertGreaterEqual(f.last_call_duration, 0.2)

def test_skipping(self):
uf = Mock()

@allot(0.5)
def f(x, y):
uf()
self.assertEqual(x, 5)
self.assertEqual(y, 6)
time.sleep(0.2)

f(5, y=6)
uf.assert_called_once()
uf.reset_mock()
self.assertGreaterEqual(f.last_call_duration, 0.2)
f(5, y=6)
uf.assert_not_called()
self.assertGreaterEqual(f.last_call_duration, 0.2)
time.sleep(0.35)
f(5, y=6)
uf.assert_called_once()
self.assertGreaterEqual(f.last_call_duration, 0.2)

class A:
def __init__(self):
self.x = self.y = 0

@allot(0.5)
def f(self, x, y):
self.x = x
self.y = y
time.sleep(0.2)

a = A()
a2 = A()
a.f(5, y=6)
self.assertEqual(a.x, 5)
self.assertEqual(a.y, 6)
self.assertGreaterEqual(f.last_call_duration, 0.2)

a.f(7, y=8)
self.assertEqual(a.x, 5) # no call, `a` is unchanged
self.assertGreaterEqual(f.last_call_duration, 0.2)

time.sleep(0.35)
a.f(9, y=10)
a2.f(11, y=12)
self.assertEqual(a.x, 9)
self.assertGreaterEqual(f.last_call_duration, 0.2)
# a2.f is not being skipped because of a.f - bound methods for different
# instance are separate
self.assertEqual(a2.x, 11)

# forced calls work
a.f.call(11, 12)
self.assertEqual(a.x, 11)

# forced calls block later non-forced calls
a = A()
a.f.call(13, y=14)
self.assertEqual(a.x, 13)
a.f(15, y=16)
self.assertEqual(a.x, 13)

def test_overflow(self):
uf = Mock()
of = Mock(return_value=13)

@allot(0.1, overflow=of)
def f(*_, **__):
uf()
time.sleep(0.1)
return 42

self.assertEqual(f(5, y=6), 42)
uf.assert_called()
uf.reset_mock()
of.assert_not_called()

self.assertEqual(f(7, y=8), 13)
uf.assert_not_called()
of.assert_called_with(7, y=8)

def test_assert_no_result(self):
# pylint: disable=function-redefined
@allot(0.1)
def f():
return 42

self.assertRaises(AssertionError, f)

@allot(0.05, overflow=lambda x: 3 * x)
def f(x):
time.sleep(0.6)
return 2 * x

self.assertEqual(f(3), 6)
self.assertEqual(f(3), 9)

@allot
def f():
return 42

self.assertEqual(f(), 42)

def test_assertion(self):
self.assertRaises(AssertionError, allot, "foo")
self.assertRaises(AssertionError, allot, 0.0)
self.assertRaises(AssertionError, allot, -1.0)

def test_getter(self):
class A:
@allot
def f(self):
pass

# getter for class must not do anything with the method
self.assertIs(A.f, A.__dict__["f"])


if __name__ == "__main__":
unittest.main()
140 changes: 139 additions & 1 deletion Orange/util.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Various small utilities that might be useful everywhere"""
import logging
import os
import time
import inspect
import datetime
import math
import functools
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Callable, Union, Optional
from weakref import WeakKeyDictionary

import pkg_resources
from enum import Enum as _Enum
Expand Down Expand Up @@ -180,6 +183,141 @@ def wrapper(*args, **kwargs):
return decorator if alternative else decorator(obj)


# This should look like decorator, not a class, pylint: disable=invalid-name
class allot:
"""
Decorator that allows a function only a specified portion of time per call.
Usage:
```
@allot(0.2, overflow=of)
def f(x):
...
```
The above function is allotted 0.2 second per second. If it runs for 0.2 s,
all subsequent calls in the next second (after the start of the call) are
ignored. If it runs for 0.1 s, subsequent calls in the next 0.5 s are
ignored. If it runs for a second, subsequent calls are ignored for 5 s.
An optional overflow function can be given as a keyword argument
`overflow`. This function must have the same signature as the wrapped
function and is called instead of the original when the call is blocked.
If the overflow function is not given, the wrapped function must not return
result. This is because without the overflow function, the wrapper has no
value to return when the call is skipped.
The decorator adds a method `call` to force the call, e.g. by calling
f.call(5), in the above case. The used up time still counts for the
following (non-forced) calls.
The decorator also adds two attributes:
- f.last_call_duration is the duration of the last call (in seconds)
- f.no_call_before contains the time stamp when the next call will be made.
The decorator can be used for functions and for methods.
A non-parametrized decorator doesn't block any calls and only adds
last_call_duration, so that it can be used for timing.
"""

try:
__timer = time.thread_time
except AttributeError:
# thread_time is not available on macOS
__timer = time.process_time

def __new__(cls: type, arg: Union[None, float, Callable], *,
overflow: Optional[Callable] = None,
_bound_methods: Optional[WeakKeyDictionary] = None):
self = super().__new__(cls)

if arg is None or isinstance(arg, float):
# Parametrized decorator
if arg is not None:
assert arg > 0

def set_func(func):
self.__init__(func,
overflow=overflow,
_bound_methods=_bound_methods)
self.allotted_time = arg
return self

return set_func

else:
# Non-parametrized decorator
self.allotted_time = None
return self

def __init__(self,
func: Callable, *,
overflow: Optional[Callable] = None,
_bound_methods: Optional[WeakKeyDictionary] = None):
assert callable(func)
self.func = func
self.overflow = overflow
functools.update_wrapper(self, func)

self.no_call_before = 0
self.last_call_duration = None

# Used by __get__; see a comment there
if _bound_methods is None:
self.__bound_methods = WeakKeyDictionary()
else:
self.__bound_methods = _bound_methods

# If we are wrapping a method, __get__ is called to bind it.
# Create a wrapper for each instance and store it, so that each instance's
# method gets its share of time.
def __get__(self, inst, cls):
if inst is None:
return self

if inst not in self.__bound_methods:
# __bound_methods caches bound methods per instance. This is not
# done for perfoamnce. Bound methods can be rebound, even to
# different instances or even classes, e.g.
# >>> x = f.__get__(a, A)
# >>> y = x.__get__(b, B)
# >>> z = x.__get__(a, A)
# After this, we want `x is z`, there shared caching. This looks
# bizarre, but let's keep it safe. At least binding to the same
# instance, f.__get__(a, A),__get__(a, A), sounds reasonably
# possible.
cls = type(self)
bound_overflow = self.overflow and self.overflow.__get__(inst, cls)
decorator = cls(
self.allotted_time,
overflow=bound_overflow,
_bound_methods=self.__bound_methods)
self.__bound_methods[inst] = decorator(self.func.__get__(inst, cls))

return self.__bound_methods[inst]

def __call__(self, *args, **kwargs):
if self.__timer() < self.no_call_before:
if self.overflow is None:
return None
return self.overflow(*args, **kwargs)
return self.call(*args, **kwargs)

def call(self, *args, **kwargs):
start = self.__timer()
result = self.func(*args, **kwargs)
self.last_call_duration = self.__timer() - start
if self.allotted_time is not None:
if self.overflow is None:
assert result is None, "skippable function cannot return a result"
self.no_call_before = start + self.last_call_duration / self.allotted_time
return result


def literal_eval(literal):
import ast # pylint: disable=import-outside-toplevel
# ast.literal_eval does not parse empty set ¯\_(ツ)_/¯
Expand Down
3 changes: 3 additions & 0 deletions i18n/si.jaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ util.py:
__self__: false
{func.__self__.__class__}.{name}: false
Call to deprecated {name}{alternative}: false
class `allot`:
def `call`:
skippable function cannot return a result: false
def `literal_eval`:
set(): false
==: false
Expand Down

0 comments on commit 0957652

Please sign in to comment.