Skip to content

Commit c5e180e

Browse files
committed
Add LazyValue
1 parent 26b1446 commit c5e180e

File tree

2 files changed

+201
-2
lines changed

2 files changed

+201
-2
lines changed

orangecanvas/scheme/signalmanager.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import logging
1212
import warnings
1313
import enum
14+
import functools
1415

1516
from collections import defaultdict
1617
from operator import attrgetter
@@ -19,7 +20,7 @@
1920

2021
import typing
2122
from typing import (
22-
Any, Optional, List, NamedTuple, Set, Dict,
23+
Any, Optional, List, NamedTuple, Set, Dict, Callable,
2324
Sequence, Union, DefaultDict, Type
2425
)
2526

@@ -95,6 +96,138 @@ class Close(Signal): ...
9596
is_enabled = attrgetter("enabled")
9697

9798

99+
class _LazyValueType:
100+
"""
101+
LazyValue is an abstract type for wrapper for lazy evaluation of signals.
102+
103+
LazyValue is intended for situations in which computation of outputs is
104+
reasonably fast, but we won't to compute it only if the output is connected
105+
to some input, in order to save memory.
106+
107+
Assume the widget has a method `commit` that outputs the sum of two objects,
108+
`self.a` and `self.b`. The output signal is names `signal_name` and its
109+
type is `SomeType`.
110+
111+
```
112+
def commit(self):
113+
self.send(self.Outputs.signal_name, self.a + self.b)
114+
```
115+
116+
To use lazy values, we modify the method as follows.
117+
118+
```
119+
def commit(self):
120+
def f():
121+
return self.a + self.b
122+
123+
self.send(self.Outputs.signal_name, LazySignal[SomeType](f))
124+
```
125+
126+
The lazy function receives no arguments, so `commit` will often prepare
127+
some data accessible through closure or default arguments. After calling
128+
the function, LazyValue will release the reference to function, which in
129+
turn releases references to any data from closure or arguments.
130+
131+
LazyValue is a singleton, used in similar way as generic classes from
132+
typing. "Indexing" returns an instance of (internal) class `LazyValue_`.
133+
Indexing is cached; `LazyValue[SomeType]` always returns the same object.
134+
135+
LazySignal[SomeType] (that is: LazyValue_) has a constructor that expects
136+
the following arguments.
137+
138+
- A function that computes the actual value. This function must expect
139+
no arguments, but will usually get data (for instance `self`, in the
140+
above example) from closure.
141+
- An optional function that can be called to interrupt the computation.
142+
This function is called when the signal is deleted.
143+
- Optional extra arguments that are stored as LazyValue's attributes.
144+
These are not accessible by the above function and are primarily
145+
intended to be used in output summaries.
146+
147+
Properties:
148+
149+
- `is_cached()`, which returns `True` if the value is already computed.
150+
Functions for output summaries can use this to show more information
151+
if the value is available, and avoid computing it when not.
152+
153+
Methods:
154+
155+
- `get_value()` returns the actual value by calling the function, if
156+
the value has not been computed yet, or providing the cached value.
157+
- `type()` returns the type of the lazy signal (e.g. `SomeType`, in
158+
above case.
159+
"""
160+
161+
class LazyValueMeta(type):
162+
def __repr__(cls):
163+
"""
164+
Pretty-prints the LazyValue[SomeType] as "LazyValue[SomeType]"
165+
instead of generic `LazyValue_`.
166+
"""
167+
return f"LazyValue[{cls.type().__name__}]"
168+
169+
@classmethod
170+
def is_lazy(cls, value):
171+
"""
172+
Tells whether the given value is lazy.
173+
174+
```
175+
>>> def f():
176+
... return 12
177+
...
178+
>>> lazy = LazyValue[int](f)
179+
>>> eager = f()
180+
>>> LazyValue.is_lazy(lazy)
181+
True
182+
>>> LazyValue.is_lazy(eager)
183+
False
184+
```
185+
"""
186+
return isinstance(type(value), cls.LazyValueMeta)
187+
188+
@classmethod
189+
@functools.lru_cache(maxsize=None)
190+
def __getitem__(cls, type_):
191+
# This is cached, so that it always returns the same class for the
192+
# same type.
193+
# >>> t1 = LazyValue[int]
194+
# >>> t2 = LazyValue[int]
195+
# >>> t1 is t2
196+
# True
197+
class LazyValue_(metaclass=cls.LazyValueMeta):
198+
__type = type_
199+
200+
def __init__(self, func: Callable, interrupt=None, **extra_attrs):
201+
self.__func = func
202+
self.__cached = None
203+
self.interrupt = interrupt
204+
self.__dict__.update(extra_attrs)
205+
206+
def __del__(self):
207+
if self.interrupt is not None:
208+
self.interrupt()
209+
210+
@property
211+
def is_cached(self):
212+
return self.__func is None
213+
214+
@classmethod
215+
def type(cls):
216+
return cls.__type
217+
218+
def get_value(self):
219+
if self.__func is not None:
220+
self.__cached = self.__func()
221+
# This frees any references to closure and arguments
222+
self.__func = None
223+
return self.__cached
224+
225+
return LazyValue_
226+
227+
228+
LazyValue = _LazyValueType()
229+
230+
98231
class _OutputState:
99232
"""Output state for a single node/channel"""
100233
__slots__ = ('flags', 'outputs')

orangecanvas/scheme/tests/test_signalmanager.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,79 @@
1+
import sys
2+
import unittest
3+
from unittest.mock import Mock
4+
15
from AnyQt.QtTest import QSignalSpy
26

37
from orangecanvas.scheme import Scheme, SchemeNode, SchemeLink
48
from orangecanvas.scheme.signalmanager import (
5-
SignalManager, Signal, compress_signals, compress_single
9+
SignalManager, Signal, compress_signals, compress_single, LazyValue
610
)
711
from orangecanvas.registry import tests as registry_tests
812
from orangecanvas.gui.test import QCoreAppTestCase
913

1014

15+
class TestLazyValue(unittest.TestCase):
16+
def test_singletonnes(self):
17+
i1 = LazyValue[int]
18+
i2 = LazyValue[int]
19+
f1 = LazyValue[float]
20+
self.assertIs(i1, i2)
21+
self.assertIsNot(i1, f1)
22+
self.assertIsNot(i2, f1)
23+
24+
def test_repr(self):
25+
self.assertEqual(repr(LazyValue[int]), "LazyValue[int]")
26+
27+
def test_get_value_and_cached(self):
28+
f = Mock(return_value=42)
29+
30+
lazy = LazyValue[int](f)
31+
f.assert_not_called()
32+
self.assertFalse(lazy.is_cached)
33+
34+
self.assertEqual(lazy.get_value(), 42)
35+
f.assert_called_once()
36+
self.assertTrue(lazy.is_cached)
37+
38+
self.assertEqual(lazy.get_value(), 42)
39+
f.assert_called_once()
40+
self.assertTrue(lazy.is_cached)
41+
42+
def test_type(self):
43+
self.assertIs(LazyValue[int].type(), int)
44+
self.assertIs(LazyValue[float].type(), float)
45+
46+
def test_release_closure(self):
47+
deleted = Mock()
48+
49+
def commit():
50+
class S:
51+
def __del__(self):
52+
deleted()
53+
54+
s = S()
55+
56+
def f():
57+
return 42 + bool(s)
58+
return LazyValue[int](f)
59+
60+
lazy = commit()
61+
deleted.assert_not_called()
62+
lazy.get_value()
63+
deleted.assert_called_once()
64+
65+
def test_interrupt(self):
66+
interrupt = Mock()
67+
lazy = LazyValue[int](Mock(), interrupt)
68+
del lazy
69+
interrupt.assert_called_once()
70+
71+
def test_extra_args(self):
72+
lazy = LazyValue[int](Mock(), a=1, b=2)
73+
self.assertEqual(lazy.a, 1)
74+
self.assertEqual(lazy.b, 2)
75+
76+
1177
class TestingSignalManager(SignalManager):
1278
def is_blocking(self, node):
1379
return bool(node.property("-blocking"))

0 commit comments

Comments
 (0)