Skip to content

Commit 3ffa686

Browse files
committed
Implement ObservableCollection.map, map_to_float, map_to_int, other helpful methods, supporting classes and tests
1 parent 7e3f6bc commit 3ffa686

File tree

8 files changed

+994
-24
lines changed

8 files changed

+994
-24
lines changed

src/spellbind/float_collections.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import operator
4+
from abc import ABC, abstractmethod
5+
from functools import cached_property
6+
from typing import Iterable, Callable, Any, TypeVar
7+
8+
from typing_extensions import TypeIs, override
9+
10+
from spellbind.float_values import FloatValue, FloatConstant
11+
from spellbind.observable_collections import ObservableCollection, ReducedValue, CombinedValue, ValueCollection, \
12+
MappedObservableBag
13+
from spellbind.observable_sequences import ObservableList, TypedValueList, ValueSequence, UnboxedValueSequence, \
14+
ObservableSequence
15+
from spellbind.values import Value
16+
17+
18+
_S = TypeVar("_S")
19+
20+
21+
class ObservableFloatCollection(ObservableCollection[float], ABC):
22+
@property
23+
def summed(self) -> FloatValue:
24+
return self.reduce_to_float(add_reducer=operator.add, remove_reducer=operator.sub, initial=0.0)
25+
26+
@property
27+
def multiplied(self) -> FloatValue:
28+
return self.reduce_to_float(add_reducer=operator.mul, remove_reducer=operator.truediv, initial=1.0)
29+
30+
31+
class MappedToFloatBag(MappedObservableBag[float], ObservableFloatCollection):
32+
pass
33+
34+
35+
class ObservableFloatSequence(ObservableSequence[float], ObservableFloatCollection, ABC):
36+
pass
37+
38+
39+
class ObservableFloatList(ObservableList[float], ObservableFloatSequence):
40+
pass
41+
42+
43+
class FloatValueCollection(ValueCollection[float], ABC):
44+
@property
45+
def summed(self) -> FloatValue:
46+
return self.unboxed.reduce_to_float(add_reducer=operator.add, remove_reducer=operator.sub, initial=0.0)
47+
48+
@property
49+
@abstractmethod
50+
def unboxed(self) -> ObservableFloatCollection: ...
51+
52+
53+
class CombinedFloatValue(CombinedValue[float], FloatValue):
54+
def __init__(self, collection: ObservableCollection[_S], combiner: Callable[[Iterable[_S]], float]) -> None:
55+
super().__init__(collection=collection, combiner=combiner)
56+
57+
58+
class ReducedFloatValue(ReducedValue[float], FloatValue):
59+
def __init__(self,
60+
collection: ObservableCollection[_S],
61+
add_reducer: Callable[[float, _S], float],
62+
remove_reducer: Callable[[float, _S], float],
63+
initial: float):
64+
super().__init__(collection=collection,
65+
add_reducer=add_reducer,
66+
remove_reducer=remove_reducer,
67+
initial=initial)
68+
69+
70+
class UnboxedFloatValueSequence(UnboxedValueSequence[float], ObservableFloatSequence):
71+
def __init__(self, sequence: FloatValueSequence) -> None:
72+
super().__init__(sequence)
73+
74+
75+
class FloatValueSequence(ValueSequence[float], FloatValueCollection, ABC):
76+
@cached_property
77+
@override
78+
def unboxed(self) -> ObservableFloatSequence:
79+
return UnboxedFloatValueSequence(self)
80+
81+
82+
class FloatValueList(TypedValueList[float], FloatValueSequence):
83+
def __init__(self, values: Iterable[float | Value[float]] | None = None):
84+
def is_float(value: Any) -> TypeIs[float]:
85+
return isinstance(value, float)
86+
super().__init__(values, checker=is_float, constant_factory=FloatConstant.of)

src/spellbind/int_collections.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from typing_extensions import TypeIs, override
99

10+
from spellbind.float_values import FloatValue
1011
from spellbind.int_values import IntValue, IntConstant
11-
from spellbind.observable_collections import ObservableCollection, ReducedValue, CombinedValue, ValueCollection
12+
from spellbind.observable_collections import ObservableCollection, ReducedValue, CombinedValue, ValueCollection, \
13+
MappedObservableBag
1214
from spellbind.observable_sequences import ObservableList, TypedValueList, ValueSequence, UnboxedValueSequence, \
1315
ObservableSequence
1416
from spellbind.values import Value
@@ -27,6 +29,10 @@ def multiplied(self) -> IntValue:
2729
return self.reduce_to_int(add_reducer=operator.mul, remove_reducer=operator.floordiv, initial=1)
2830

2931

32+
class MappedToIntBag(MappedObservableBag[int], ObservableIntCollection):
33+
pass
34+
35+
3036
class ObservableIntSequence(ObservableSequence[int], ObservableIntCollection, ABC):
3137
pass
3238

@@ -50,6 +56,11 @@ def __init__(self, collection: ObservableCollection[_S], combiner: Callable[[Ite
5056
super().__init__(collection=collection, combiner=combiner)
5157

5258

59+
class CombinedFloatValue(CombinedValue[float], FloatValue):
60+
def __init__(self, collection: ObservableCollection[_S], combiner: Callable[[Iterable[_S]], float]) -> None:
61+
super().__init__(collection=collection, combiner=combiner)
62+
63+
5364
class ReducedIntValue(ReducedValue[int], IntValue):
5465
def __init__(self,
5566
collection: ObservableCollection[_S],

src/spellbind/observable_collections.py

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
import functools
4+
import logging
45
from abc import ABC, abstractmethod
5-
from typing import TypeVar, Generic, Collection, Callable, Iterable, Iterator, Any
6+
from typing import TypeVar, Generic, Collection, Callable, Iterable, Iterator, Any, TYPE_CHECKING
67

78
from typing_extensions import override
89

@@ -11,16 +12,24 @@
1112
from spellbind.bool_values import BoolValue
1213
from spellbind.deriveds import Derived
1314
from spellbind.event import BiEvent, ValueEvent
15+
from spellbind.float_values import FloatValue
1416
from spellbind.int_values import IntValue, IntVariable
1517
from spellbind.observables import ValuesObservable, ValueObservable, Observer, ValueObserver, BiObserver, \
1618
Subscription
1719
from spellbind.str_values import StrValue
1820
from spellbind.values import Value, EMPTY_FROZEN_SET
1921

22+
if TYPE_CHECKING:
23+
from spellbind.float_collections import ObservableFloatCollection
24+
from spellbind.int_collections import ObservableIntCollection
25+
26+
2027
_S = TypeVar("_S")
2128
_S_co = TypeVar("_S_co", covariant=True)
2229
_T = TypeVar("_T")
2330

31+
_logger = logging.getLogger(__name__)
32+
2433

2534
class ObservableCollection(Collection[_S_co], Generic[_S_co], ABC):
2635
@property
@@ -56,6 +65,11 @@ def combine_to_int(self, combiner: Callable[[Iterable[_S_co]], int]) -> IntValue
5665

5766
return CombinedIntValue(self, combiner=combiner)
5867

68+
def combine_to_float(self, combiner: Callable[[Iterable[_S_co]], float]) -> 'FloatValue':
69+
from spellbind.float_collections import CombinedFloatValue
70+
71+
return CombinedFloatValue(self, combiner=combiner)
72+
5973
def reduce(self,
6074
add_reducer: Callable[[_T, _S_co], _T],
6175
remove_reducer: Callable[[_T, _S_co], _T],
@@ -79,17 +93,41 @@ def reduce_to_str(self,
7993
def reduce_to_int(self,
8094
add_reducer: Callable[[int, _S_co], int],
8195
remove_reducer: Callable[[int, _S_co], int],
82-
initial: int) -> IntValue:
96+
initial: int = 0) -> IntValue:
8397
from spellbind.int_collections import ReducedIntValue
8498

8599
return ReducedIntValue(self,
86100
add_reducer=add_reducer,
87101
remove_reducer=remove_reducer,
88102
initial=initial)
89103

90-
def filter_to_bag(self, predicate: Callable[[_S_co], bool]) -> FilteredObservableBag[_S_co]:
104+
def reduce_to_float(self,
105+
add_reducer: Callable[[float, _S_co], float],
106+
remove_reducer: Callable[[float, _S_co], float],
107+
initial: float = 0.) -> FloatValue:
108+
from spellbind.float_collections import ReducedFloatValue
109+
110+
return ReducedFloatValue(self,
111+
add_reducer=add_reducer,
112+
remove_reducer=remove_reducer,
113+
initial=initial)
114+
115+
def filter_to_bag(self, predicate: Callable[[_S_co], bool]) -> ObservableCollection[_S_co]:
91116
return FilteredObservableBag(self, predicate)
92117

118+
def map(self, transform: Callable[[_S_co], _T]) -> ObservableCollection[_T]:
119+
return MappedObservableBag(self, transform)
120+
121+
def map_to_float(self, transform: Callable[[_S_co], float]) -> ObservableFloatCollection:
122+
from spellbind.float_collections import MappedToFloatBag
123+
124+
return MappedToFloatBag(self, transform)
125+
126+
def map_to_int(self, transform: Callable[[_S_co], int]) -> ObservableIntCollection:
127+
from spellbind.int_collections import MappedToIntBag
128+
129+
return MappedToIntBag(self, transform)
130+
93131

94132
class ReducedValue(Value[_S], Generic[_S]):
95133
def __init__(self,
@@ -156,18 +194,6 @@ def is_observed(self, by: Callable[..., Any] | None = None) -> bool:
156194

157195

158196
class ValueCollection(ObservableCollection[Value[_S]], Generic[_S], ABC):
159-
@override
160-
def reduce_to_int(self,
161-
add_reducer: Callable[[int, Value[_S]], int],
162-
remove_reducer: Callable[[int, Value[_S]], int],
163-
initial: int) -> IntValue:
164-
from spellbind.int_collections import ReducedIntValue
165-
166-
return ReducedIntValue(self,
167-
add_reducer=add_reducer,
168-
remove_reducer=remove_reducer,
169-
initial=initial)
170-
171197
def value_iterable(self) -> Iterable[_S]:
172198
return (value.value for value in self)
173199

@@ -281,6 +307,52 @@ def _clear(self) -> None:
281307
self._action_event(clear_action())
282308

283309

310+
class MappedObservableBag(_ObservableBagBase[_S], Generic[_S]):
311+
def __init__(self, source: ObservableCollection[_T], transform: Callable[[_T], _S]) -> None:
312+
super().__init__(tuple(transform(item) for item in source))
313+
self._source = source
314+
self._transform = transform
315+
316+
self._source.on_change.observe(self._on_source_action)
317+
318+
def _on_source_action(self, action: CollectionAction[Any]) -> None:
319+
if isinstance(action, ClearAction):
320+
self._clear()
321+
elif isinstance(action, ReverseAction):
322+
pass
323+
elif isinstance(action, DeltasAction):
324+
mapped_action = action.map(self._transform)
325+
total_count = self._len_value.value
326+
for delta in mapped_action.delta_actions:
327+
if delta.is_add:
328+
self._item_counts[delta.value] = self._item_counts.get(delta.value, 0) + 1
329+
total_count += 1
330+
else:
331+
count = self._item_counts.get(delta.value, 0)
332+
if count > 0:
333+
if count == 1:
334+
del self._item_counts[delta.value]
335+
else:
336+
self._item_counts[delta.value] = count - 1
337+
total_count -= 1
338+
else:
339+
_logger.warning(
340+
f"Attempted to remove {delta.value!r} from {self.__class__.__name__}, "
341+
f"but item not present. Source collection may be inconsistent with the mapped collection."
342+
)
343+
344+
if self._is_observed():
345+
with self._len_value.set_delay_notify(total_count):
346+
self._action_event(mapped_action)
347+
self._deltas_event(mapped_action)
348+
else:
349+
self._len_value.value = total_count
350+
351+
@override
352+
def __repr__(self) -> str:
353+
return f"{self.__class__.__name__}({list(self)!r})"
354+
355+
284356
class FilteredObservableBag(_ObservableBagBase[_S], Generic[_S]):
285357
def __init__(self, source: ObservableCollection[_S], predicate: Callable[[_S], bool]) -> None:
286358
super().__init__(tuple(item for item in source if predicate(item)))
@@ -310,6 +382,11 @@ def _on_source_action(self, action: CollectionAction[_S]) -> None:
310382
else:
311383
self._item_counts[delta.value] = count - 1
312384
total_count -= 1
385+
else:
386+
_logger.warning(
387+
f"Attempted to remove {delta.value!r} from {self.__class__.__name__}, "
388+
f"but item not present. Source collection may be inconsistent with the filtered collection."
389+
)
313390

314391
if self._is_observed():
315392
with self._len_value.set_delay_notify(total_count):

src/spellbind/observable_sequences.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class ObservableSequence(Sequence[_S_co], ObservableCollection[_S_co], Generic[_
3535
def on_change(self) -> ValueObservable[AtIndicesDeltasAction[_S_co] | ClearAction[_S_co] | ReverseAction[_S_co] | ElementsChangedAction[_S_co]]: ...
3636

3737
@abstractmethod
38+
@override
3839
def map(self, transformer: Callable[[_S_co], _T]) -> ObservableSequence[_T]: ...
3940

4041
@override
@@ -848,25 +849,25 @@ def __eq__(self, other: object) -> bool:
848849

849850

850851
class MappedIndexObservableSequence(IndexObservableSequenceBase[_S], Generic[_S]):
851-
def __init__(self, mapped_from: IndexObservableSequence[_T], map_func: Callable[[_T], _S]) -> None:
852-
super().__init__(map_func(item) for item in mapped_from)
853-
self._mapped_from = mapped_from
854-
self._map_func = map_func
852+
def __init__(self, source: IndexObservableSequence[_T], transform: Callable[[_T], _S]) -> None:
853+
super().__init__(transform(item) for item in source)
854+
self._source = source
855+
self._transform = transform
855856

856857
def on_action(other_action: AtIndicesDeltasAction[_T] | ClearAction[_T] | ReverseAction[_T]) -> None:
857858
if isinstance(other_action, AtIndicesDeltasAction):
858859
if isinstance(other_action, ExtendAction):
859-
self._extend((self._map_func(item) for item in other_action.items))
860+
self._extend((self._transform(item) for item in other_action.items))
860861
else:
861862
for delta in other_action.delta_actions:
862863
if delta.is_add:
863-
value: _S = self._map_func(delta.value)
864+
value: _S = self._transform(delta.value)
864865
self._values.insert(delta.index, value)
865866
else:
866867
del self._values[delta.index]
867868
if self._is_observed():
868869
with self._len_value.set_delay_notify(len(self._values)):
869-
action = other_action.map(self._map_func)
870+
action = other_action.map(self._transform)
870871
self._action_event(action)
871872
self._deltas_event(action)
872873
else:
@@ -876,7 +877,7 @@ def on_action(other_action: AtIndicesDeltasAction[_T] | ClearAction[_T] | Revers
876877
elif isinstance(other_action, ReverseAction):
877878
self._reverse()
878879

879-
mapped_from.on_change.observe(on_action)
880+
source.on_change.observe(on_action)
880881

881882
def _is_observed(self) -> bool:
882883
return self._action_event.is_observed() or self._deltas_event.is_observed()

0 commit comments

Comments
 (0)