Mesa 3.1.0.dev0__py3-none-any.whl → 3.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Mesa might be problematic. Click here for more details.
- mesa/__init__.py +3 -3
- mesa/agent.py +48 -0
- mesa/batchrunner.py +14 -1
- mesa/datacollection.py +1 -6
- mesa/examples/__init__.py +2 -2
- mesa/examples/advanced/epstein_civil_violence/app.py +5 -0
- mesa/examples/advanced/pd_grid/agents.py +2 -1
- mesa/examples/advanced/pd_grid/app.py +5 -0
- mesa/examples/advanced/pd_grid/model.py +3 -5
- mesa/examples/advanced/sugarscape_g1mt/agents.py +12 -65
- mesa/examples/advanced/sugarscape_g1mt/app.py +24 -19
- mesa/examples/advanced/sugarscape_g1mt/model.py +45 -52
- mesa/examples/advanced/wolf_sheep/agents.py +3 -1
- mesa/examples/advanced/wolf_sheep/model.py +17 -16
- mesa/examples/basic/boid_flockers/app.py +5 -0
- mesa/examples/basic/boltzmann_wealth_model/app.py +8 -5
- mesa/examples/basic/boltzmann_wealth_model/st_app.py +1 -1
- mesa/examples/basic/conways_game_of_life/app.py +5 -0
- mesa/examples/basic/conways_game_of_life/st_app.py +2 -2
- mesa/examples/basic/schelling/agents.py +11 -5
- mesa/examples/basic/schelling/app.py +6 -1
- mesa/examples/basic/virus_on_network/app.py +5 -0
- mesa/experimental/__init__.py +17 -10
- mesa/experimental/cell_space/__init__.py +19 -7
- mesa/experimental/cell_space/cell.py +22 -37
- mesa/experimental/cell_space/cell_agent.py +12 -1
- mesa/experimental/cell_space/cell_collection.py +18 -3
- mesa/experimental/cell_space/discrete_space.py +15 -64
- mesa/experimental/cell_space/grid.py +74 -4
- mesa/experimental/cell_space/network.py +13 -1
- mesa/experimental/cell_space/property_layer.py +444 -0
- mesa/experimental/cell_space/voronoi.py +13 -1
- mesa/experimental/devs/__init__.py +20 -2
- mesa/experimental/devs/eventlist.py +19 -1
- mesa/experimental/devs/simulator.py +24 -8
- mesa/experimental/mesa_signals/__init__.py +23 -0
- mesa/experimental/mesa_signals/mesa_signal.py +485 -0
- mesa/experimental/mesa_signals/observable_collections.py +133 -0
- mesa/experimental/mesa_signals/signals_util.py +52 -0
- mesa/mesa_logging.py +190 -0
- mesa/model.py +17 -23
- mesa/visualization/__init__.py +2 -2
- mesa/visualization/mpl_space_drawing.py +8 -5
- mesa/visualization/solara_viz.py +49 -11
- {mesa-3.1.0.dev0.dist-info → mesa-3.1.1.dist-info}/METADATA +1 -1
- mesa-3.1.1.dist-info/RECORD +94 -0
- {mesa-3.1.0.dev0.dist-info → mesa-3.1.1.dist-info}/WHEEL +1 -1
- mesa/experimental/UserParam.py +0 -67
- mesa/experimental/components/altair.py +0 -81
- mesa/experimental/components/matplotlib.py +0 -242
- mesa/experimental/devs/examples/epstein_civil_violence.py +0 -305
- mesa/experimental/devs/examples/wolf_sheep.py +0 -250
- mesa/experimental/solara_viz.py +0 -453
- mesa-3.1.0.dev0.dist-info/RECORD +0 -94
- {mesa-3.1.0.dev0.dist-info → mesa-3.1.1.dist-info}/entry_points.txt +0 -0
- {mesa-3.1.0.dev0.dist-info → mesa-3.1.1.dist-info}/licenses/LICENSE +0 -0
- {mesa-3.1.0.dev0.dist-info → mesa-3.1.1.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Core implementation of Mesa's reactive programming system.
|
|
2
|
+
|
|
3
|
+
This module provides the foundational classes for Mesa's observable/reactive programming
|
|
4
|
+
functionality:
|
|
5
|
+
|
|
6
|
+
- BaseObservable: Abstract base class defining the interface for all observables
|
|
7
|
+
- Observable: Main class for creating observable properties that emit change signals
|
|
8
|
+
- Computable: Class for properties that automatically update based on other observables
|
|
9
|
+
- HasObservables: Mixin class that enables an object to contain and manage observables
|
|
10
|
+
- All: Helper class for subscribing to all signals from an observable
|
|
11
|
+
|
|
12
|
+
The module implements a robust reactive system where changes to observable properties
|
|
13
|
+
automatically trigger updates to dependent computed values and notify subscribed
|
|
14
|
+
observers. This enables building models with complex interdependencies while maintaining
|
|
15
|
+
clean separation of concerns.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import functools
|
|
22
|
+
import weakref
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from collections import defaultdict, namedtuple
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from mesa.experimental.mesa_signals.signals_util import AttributeDict, create_weakref
|
|
29
|
+
|
|
30
|
+
__all__ = ["All", "Computable", "HasObservables", "Observable"]
|
|
31
|
+
|
|
32
|
+
_hashable_signal = namedtuple("_HashableSignal", "instance name")
|
|
33
|
+
|
|
34
|
+
CURRENT_COMPUTED: Computed | None = None # the current Computed that is evaluating
|
|
35
|
+
PROCESSING_SIGNALS: set[tuple[str,]] = set()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BaseObservable(ABC):
|
|
39
|
+
"""Abstract base class for all Observables."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def __init__(self, fallback_value=None):
|
|
43
|
+
"""Initialize a BaseObservable."""
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.public_name: str
|
|
46
|
+
self.private_name: str
|
|
47
|
+
|
|
48
|
+
# fixme can we make this an inner class enum?
|
|
49
|
+
# or some SignalTypes helper class?
|
|
50
|
+
# its even more complicated. Ideally you can define
|
|
51
|
+
# signal_types throughout the class hierarchy and they are just
|
|
52
|
+
# combined together.
|
|
53
|
+
# while we also want to make sure that any signal being emitted is valid for that class
|
|
54
|
+
self.signal_types: set = set()
|
|
55
|
+
self.fallback_value = fallback_value
|
|
56
|
+
|
|
57
|
+
def __get__(self, instance: HasObservables, owner):
|
|
58
|
+
value = getattr(instance, self.private_name)
|
|
59
|
+
|
|
60
|
+
if CURRENT_COMPUTED is not None:
|
|
61
|
+
# there is a computed dependent on this Observable, so let's add
|
|
62
|
+
# this Observable as a parent
|
|
63
|
+
CURRENT_COMPUTED._add_parent(instance, self.public_name, value)
|
|
64
|
+
|
|
65
|
+
# fixme, this can be done more cleanly
|
|
66
|
+
# problem here is that we cannot use self (i.e., the observable), we need to add the instance as well
|
|
67
|
+
PROCESSING_SIGNALS.add(_hashable_signal(instance, self.public_name))
|
|
68
|
+
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
def __set_name__(self, owner: HasObservables, name: str):
|
|
72
|
+
self.public_name = name
|
|
73
|
+
self.private_name = f"_{name}"
|
|
74
|
+
# owner.register_observable(self)
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def __set__(self, instance: HasObservables, value):
|
|
78
|
+
# this only emits an on change signal, subclasses need to specify
|
|
79
|
+
# this in more detail
|
|
80
|
+
instance.notify(
|
|
81
|
+
self.public_name,
|
|
82
|
+
getattr(instance, self.private_name, self.fallback_value),
|
|
83
|
+
value,
|
|
84
|
+
"change",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __str__(self):
|
|
88
|
+
return f"{self.__class__.__name__}: {self.public_name}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Observable(BaseObservable):
|
|
92
|
+
"""Observable class."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, fallback_value=None):
|
|
95
|
+
"""Initialize an Observable."""
|
|
96
|
+
super().__init__(fallback_value=fallback_value)
|
|
97
|
+
|
|
98
|
+
self.signal_types: set = {
|
|
99
|
+
"change",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def __set__(self, instance: HasObservables, value): # noqa D103
|
|
103
|
+
if (
|
|
104
|
+
CURRENT_COMPUTED is not None
|
|
105
|
+
and _hashable_signal(instance, self.public_name) in PROCESSING_SIGNALS
|
|
106
|
+
):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"cyclical dependency detected: Computed({CURRENT_COMPUTED.name}) tries to change "
|
|
109
|
+
f"{instance.__class__.__name__}.{self.public_name} while also being dependent it"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
super().__set__(instance, value) # send the notify
|
|
113
|
+
setattr(instance, self.private_name, value)
|
|
114
|
+
|
|
115
|
+
PROCESSING_SIGNALS.clear() # we have notified our children, so we can clear this out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Computable(BaseObservable):
|
|
119
|
+
"""A Computable that is depended on one or more Observables.
|
|
120
|
+
|
|
121
|
+
.. code-block:: python
|
|
122
|
+
|
|
123
|
+
class MyAgent(Agent):
|
|
124
|
+
wealth = Computable()
|
|
125
|
+
|
|
126
|
+
def __init__(self, model):
|
|
127
|
+
super().__init__(model)
|
|
128
|
+
wealth = Computed(func, args, kwargs)
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
# fixme, with new _register_observable thing
|
|
133
|
+
# we can do computed without a descriptor, but then you
|
|
134
|
+
# don't have attribute like access, you would need to do a call operation to get the value
|
|
135
|
+
|
|
136
|
+
def __init__(self):
|
|
137
|
+
"""Initialize a Computable."""
|
|
138
|
+
super().__init__()
|
|
139
|
+
|
|
140
|
+
# fixme have 2 signal: change and is_dirty?
|
|
141
|
+
self.signal_types: set = {
|
|
142
|
+
"change",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def __get__(self, instance, owner): # noqa: D105
|
|
146
|
+
computed = getattr(instance, self.private_name)
|
|
147
|
+
old_value = computed._value
|
|
148
|
+
|
|
149
|
+
if CURRENT_COMPUTED is not None:
|
|
150
|
+
CURRENT_COMPUTED._add_parent(instance, self.public_name, old_value)
|
|
151
|
+
|
|
152
|
+
new_value = computed()
|
|
153
|
+
|
|
154
|
+
if new_value != old_value:
|
|
155
|
+
instance.notify(
|
|
156
|
+
self.public_name,
|
|
157
|
+
old_value,
|
|
158
|
+
new_value,
|
|
159
|
+
"change",
|
|
160
|
+
)
|
|
161
|
+
return new_value
|
|
162
|
+
else:
|
|
163
|
+
return old_value
|
|
164
|
+
|
|
165
|
+
def __set__(self, instance: HasObservables, value: Computed): # noqa D103
|
|
166
|
+
if not isinstance(value, Computed):
|
|
167
|
+
raise ValueError("value has to be a Computable instance")
|
|
168
|
+
|
|
169
|
+
setattr(instance, self.private_name, value)
|
|
170
|
+
value.name = self.public_name
|
|
171
|
+
value.owner = instance
|
|
172
|
+
getattr(
|
|
173
|
+
instance, self.public_name
|
|
174
|
+
) # force evaluation of the computed to build the dependency graph
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Computed:
|
|
178
|
+
def __init__(self, func: Callable, *args, **kwargs):
|
|
179
|
+
self.func = func
|
|
180
|
+
self.args = args
|
|
181
|
+
self.kwargs = kwargs
|
|
182
|
+
self._is_dirty = True
|
|
183
|
+
self._first = True
|
|
184
|
+
self._value = None
|
|
185
|
+
self.name: str = "" # set by Computable
|
|
186
|
+
self.owner: HasObservables # set by Computable
|
|
187
|
+
|
|
188
|
+
self.parents: weakref.WeakKeyDictionary[HasObservables, dict[str, Any]] = (
|
|
189
|
+
weakref.WeakKeyDictionary()
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def __str__(self):
|
|
193
|
+
return f"COMPUTED: {self.name}"
|
|
194
|
+
|
|
195
|
+
def _set_dirty(self, signal):
|
|
196
|
+
if not self._is_dirty:
|
|
197
|
+
self._is_dirty = True
|
|
198
|
+
self.owner.notify(self.name, self._value, None, "change")
|
|
199
|
+
|
|
200
|
+
def _add_parent(
|
|
201
|
+
self, parent: HasObservables, name: str, current_value: Any
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Add a parent Observable.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
parent: the HasObservable instance to which the Observable belongs
|
|
207
|
+
name: the public name of the Observable
|
|
208
|
+
current_value: the current value of the Observable
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
parent.observe(name, All(), self._set_dirty)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
self.parents[parent][name] = current_value
|
|
215
|
+
except KeyError:
|
|
216
|
+
self.parents[parent] = {name: current_value}
|
|
217
|
+
|
|
218
|
+
def _remove_parents(self):
|
|
219
|
+
"""Remove all parent Observables."""
|
|
220
|
+
# we can unsubscribe from everything on each parent
|
|
221
|
+
for parent in self.parents:
|
|
222
|
+
parent.unobserve(All(), All(), self._set_dirty)
|
|
223
|
+
|
|
224
|
+
def __call__(self):
|
|
225
|
+
global CURRENT_COMPUTED # noqa: PLW0603
|
|
226
|
+
|
|
227
|
+
if self._is_dirty:
|
|
228
|
+
changed = False
|
|
229
|
+
|
|
230
|
+
if self._first:
|
|
231
|
+
# fixme might be a cleaner solution for this
|
|
232
|
+
# basically, we have no parents.
|
|
233
|
+
changed = True
|
|
234
|
+
self._first = False
|
|
235
|
+
|
|
236
|
+
# we might be dirty but values might have changed
|
|
237
|
+
# back and forth in our parents so let's check to make sure we
|
|
238
|
+
# really need to recalculate
|
|
239
|
+
if not changed:
|
|
240
|
+
for parent in self.parents.keyrefs():
|
|
241
|
+
# does parent still exist?
|
|
242
|
+
if parent := parent():
|
|
243
|
+
# if yes, compare old and new values for all
|
|
244
|
+
# tracked observables on this parent
|
|
245
|
+
for name, old_value in self.parents[parent].items():
|
|
246
|
+
new_value = getattr(parent, name)
|
|
247
|
+
if new_value != old_value:
|
|
248
|
+
changed = True
|
|
249
|
+
break # we need to recalculate
|
|
250
|
+
else:
|
|
251
|
+
# trick for breaking cleanly out of nested for loops
|
|
252
|
+
# see https://stackoverflow.com/questions/653509/breaking-out-of-nested-loops
|
|
253
|
+
continue
|
|
254
|
+
break
|
|
255
|
+
else:
|
|
256
|
+
# one of our parents no longer exists
|
|
257
|
+
changed = True
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
if changed:
|
|
261
|
+
# the dependencies of the computable function might have changed
|
|
262
|
+
# so, we rebuilt
|
|
263
|
+
self._remove_parents()
|
|
264
|
+
|
|
265
|
+
old = CURRENT_COMPUTED
|
|
266
|
+
CURRENT_COMPUTED = self
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
self._value = self.func(*self.args, **self.kwargs)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise e
|
|
272
|
+
finally:
|
|
273
|
+
CURRENT_COMPUTED = old
|
|
274
|
+
|
|
275
|
+
self._is_dirty = False
|
|
276
|
+
|
|
277
|
+
return self._value
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class All:
|
|
281
|
+
"""Helper constant to subscribe to all Observables."""
|
|
282
|
+
|
|
283
|
+
def __init__(self): # noqa: D107
|
|
284
|
+
self.name = "all"
|
|
285
|
+
|
|
286
|
+
def __copy__(self): # noqa: D105
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
def __deepcopy__(self, memo): # noqa: D105
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class HasObservables:
|
|
294
|
+
"""HasObservables class."""
|
|
295
|
+
|
|
296
|
+
# we can't use a weakset here because it does not handle bound methods correctly
|
|
297
|
+
# also, a list is faster for our use case
|
|
298
|
+
subscribers: dict[str, dict[str, list]]
|
|
299
|
+
observables: dict[str, set[str]]
|
|
300
|
+
|
|
301
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
302
|
+
"""Initialize a HasObservables."""
|
|
303
|
+
super().__init__(*args, **kwargs)
|
|
304
|
+
self.subscribers = defaultdict(functools.partial(defaultdict, list))
|
|
305
|
+
self.observables = dict(descriptor_generator(self))
|
|
306
|
+
|
|
307
|
+
def _register_signal_emitter(self, name: str, signal_types: set[str]):
|
|
308
|
+
"""Helper function to register an Observable.
|
|
309
|
+
|
|
310
|
+
This method can be used to register custom signals that are emitted by
|
|
311
|
+
the class for a given attribute, but which cannot be covered by the Observable descriptor
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
name: the name of the signal emitter
|
|
315
|
+
signal_types: the set of signals that might be emitted
|
|
316
|
+
|
|
317
|
+
"""
|
|
318
|
+
self.observables[name] = signal_types
|
|
319
|
+
|
|
320
|
+
def observe(
|
|
321
|
+
self,
|
|
322
|
+
name: str | All,
|
|
323
|
+
signal_type: str | All,
|
|
324
|
+
handler: Callable,
|
|
325
|
+
):
|
|
326
|
+
"""Subscribe to the Observable <name> for signal_type.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
name: name of the Observable to subscribe to
|
|
330
|
+
signal_type: the type of signal on the Observable to subscribe to
|
|
331
|
+
handler: the handler to call
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
ValueError: if the Observable <name> is not registered or if the Observable
|
|
335
|
+
does not emit the given signal_type
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
# fixme should name/signal_type also take a list of str?
|
|
339
|
+
if not isinstance(name, All):
|
|
340
|
+
if name not in self.observables:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"you are trying to subscribe to {name}, but this Observable is not known"
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
names = [
|
|
346
|
+
name,
|
|
347
|
+
]
|
|
348
|
+
else:
|
|
349
|
+
names = self.observables.keys()
|
|
350
|
+
|
|
351
|
+
for name in names:
|
|
352
|
+
if not isinstance(signal_type, All):
|
|
353
|
+
if signal_type not in self.observables[name]:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"you are trying to subscribe to a signal of {signal_type} "
|
|
356
|
+
f"on Observable {name}, which does not emit this signal_type"
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
signal_types = [
|
|
360
|
+
signal_type,
|
|
361
|
+
]
|
|
362
|
+
else:
|
|
363
|
+
signal_types = self.observables[name]
|
|
364
|
+
|
|
365
|
+
ref = create_weakref(handler)
|
|
366
|
+
for signal_type in signal_types:
|
|
367
|
+
self.subscribers[name][signal_type].append(ref)
|
|
368
|
+
|
|
369
|
+
def unobserve(self, name: str | All, signal_type: str | All, handler: Callable):
|
|
370
|
+
"""Unsubscribe to the Observable <name> for signal_type.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
name: name of the Observable to unsubscribe from
|
|
374
|
+
signal_type: the type of signal on the Observable to unsubscribe to
|
|
375
|
+
handler: the handler that is unsubscribing
|
|
376
|
+
|
|
377
|
+
"""
|
|
378
|
+
names = (
|
|
379
|
+
[
|
|
380
|
+
name,
|
|
381
|
+
]
|
|
382
|
+
if not isinstance(name, All)
|
|
383
|
+
else self.observables.keys()
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
for name in names:
|
|
387
|
+
# we need to do this here because signal types might
|
|
388
|
+
# differ for name so for each name we need to check
|
|
389
|
+
if isinstance(signal_type, All):
|
|
390
|
+
signal_types = self.observables[name]
|
|
391
|
+
else:
|
|
392
|
+
signal_types = [
|
|
393
|
+
signal_type,
|
|
394
|
+
]
|
|
395
|
+
for signal_type in signal_types:
|
|
396
|
+
with contextlib.suppress(KeyError):
|
|
397
|
+
remaining = []
|
|
398
|
+
for ref in self.subscribers[name][signal_type]:
|
|
399
|
+
if subscriber := ref(): # noqa: SIM102
|
|
400
|
+
if subscriber != handler:
|
|
401
|
+
remaining.append(ref)
|
|
402
|
+
self.subscribers[name][signal_type] = remaining
|
|
403
|
+
|
|
404
|
+
def clear_all_subscriptions(self, name: str | All):
|
|
405
|
+
"""Clears all subscriptions for the observable <name>.
|
|
406
|
+
|
|
407
|
+
if name is All, all subscriptions are removed
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
name: name of the Observable to unsubscribe for all signal types
|
|
411
|
+
|
|
412
|
+
"""
|
|
413
|
+
if not isinstance(name, All):
|
|
414
|
+
with contextlib.suppress(KeyError):
|
|
415
|
+
del self.subscribers[name]
|
|
416
|
+
# ignore when unsubscribing to Observables that have no subscription
|
|
417
|
+
else:
|
|
418
|
+
self.subscribers = defaultdict(functools.partial(defaultdict, list))
|
|
419
|
+
|
|
420
|
+
def notify(
|
|
421
|
+
self,
|
|
422
|
+
observable: str,
|
|
423
|
+
old_value: Any,
|
|
424
|
+
new_value: Any,
|
|
425
|
+
signal_type: str,
|
|
426
|
+
**kwargs,
|
|
427
|
+
):
|
|
428
|
+
"""Emit a signal.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
observable: the public name of the observable emitting the signal
|
|
432
|
+
old_value: the old value of the observable
|
|
433
|
+
new_value: the new value of the observable
|
|
434
|
+
signal_type: the type of signal to emit
|
|
435
|
+
kwargs: additional keyword arguments to include in the signal
|
|
436
|
+
|
|
437
|
+
"""
|
|
438
|
+
signal = AttributeDict(
|
|
439
|
+
name=observable,
|
|
440
|
+
old=old_value,
|
|
441
|
+
new=new_value,
|
|
442
|
+
owner=self,
|
|
443
|
+
type=signal_type,
|
|
444
|
+
**kwargs,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
self._mesa_notify(signal)
|
|
448
|
+
|
|
449
|
+
def _mesa_notify(self, signal: AttributeDict):
|
|
450
|
+
"""Send out the signal.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
signal: the signal
|
|
454
|
+
|
|
455
|
+
Notes:
|
|
456
|
+
signal must contain name and type attributes because this is how observers are stored.
|
|
457
|
+
|
|
458
|
+
"""
|
|
459
|
+
# we put this into a helper method, so we can emit signals with other fields
|
|
460
|
+
# then the default ones in notify.
|
|
461
|
+
observable = signal.name
|
|
462
|
+
signal_type = signal.type
|
|
463
|
+
|
|
464
|
+
# because we are using a list of subscribers
|
|
465
|
+
# we should update this list to subscribers that are still alive
|
|
466
|
+
observers = self.subscribers[observable][signal_type]
|
|
467
|
+
active_observers = []
|
|
468
|
+
for observer in observers:
|
|
469
|
+
if active_observer := observer():
|
|
470
|
+
active_observer(signal)
|
|
471
|
+
active_observers.append(observer)
|
|
472
|
+
# use iteration to also remove inactive observers
|
|
473
|
+
self.subscribers[observable][signal_type] = active_observers
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def descriptor_generator(obj) -> [str, BaseObservable]:
|
|
477
|
+
"""Yield the name and signal_types for each Observable defined on obj."""
|
|
478
|
+
# we need to traverse the entire class hierarchy to properly get
|
|
479
|
+
# also observables defined in super classes
|
|
480
|
+
for base in type(obj).__mro__:
|
|
481
|
+
base_dict = vars(base)
|
|
482
|
+
|
|
483
|
+
for entry in base_dict.values():
|
|
484
|
+
if isinstance(entry, BaseObservable):
|
|
485
|
+
yield entry.public_name, entry.signal_types
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Observable collection types that emit signals when modified.
|
|
2
|
+
|
|
3
|
+
This module extends Mesa's reactive programming capabilities to collection types like
|
|
4
|
+
lists. Observable collections emit signals when items are added, removed, or modified,
|
|
5
|
+
allowing other components to react to changes in the collection's contents.
|
|
6
|
+
|
|
7
|
+
The module provides:
|
|
8
|
+
- ObservableList: A list descriptor that emits signals on modifications
|
|
9
|
+
- SignalingList: The underlying list implementation that manages signal emission
|
|
10
|
+
|
|
11
|
+
These classes enable building models where components need to track and react to
|
|
12
|
+
changes in collections of agents, resources, or other model elements.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterable, MutableSequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .mesa_signal import BaseObservable, HasObservables
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ObservableList",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ObservableList(BaseObservable):
|
|
26
|
+
"""An ObservableList that emits signals on changes to the underlying list."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
"""Initialize the ObservableList."""
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.signal_types: set = {"remove", "replace", "change", "insert", "append"}
|
|
32
|
+
self.fallback_value = []
|
|
33
|
+
|
|
34
|
+
def __set__(self, instance: "HasObservables", value: Iterable):
|
|
35
|
+
"""Set the value of the descriptor attribute.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
instance: The instance on which to set the attribute.
|
|
39
|
+
value: The value to set the attribute to.
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
super().__set__(instance, value)
|
|
43
|
+
setattr(
|
|
44
|
+
instance,
|
|
45
|
+
self.private_name,
|
|
46
|
+
SignalingList(value, instance, self.public_name),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SignalingList(MutableSequence[Any]):
|
|
51
|
+
"""A basic lists that emits signals on changes."""
|
|
52
|
+
|
|
53
|
+
__slots__ = ["data", "name", "owner"]
|
|
54
|
+
|
|
55
|
+
def __init__(self, iterable: Iterable, owner: HasObservables, name: str):
|
|
56
|
+
"""Initialize a SignalingList.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
iterable: initial values in the list
|
|
60
|
+
owner: the HasObservables instance on which this list is defined
|
|
61
|
+
name: the attribute name to which this list is assigned
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
self.owner: HasObservables = owner
|
|
65
|
+
self.name: str = name
|
|
66
|
+
self.data = list(iterable)
|
|
67
|
+
|
|
68
|
+
def __setitem__(self, index: int, value: Any) -> None:
|
|
69
|
+
"""Set item to index.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
index: the index to set item to
|
|
73
|
+
value: the item to set
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
old_value = self.data[index]
|
|
77
|
+
self.data[index] = value
|
|
78
|
+
self.owner.notify(self.name, old_value, value, "replace", index=index)
|
|
79
|
+
|
|
80
|
+
def __delitem__(self, index: int) -> None:
|
|
81
|
+
"""Delete item at index.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
index: The index of the item to remove
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
old_value = self.data
|
|
88
|
+
del self.data[index]
|
|
89
|
+
self.owner.notify(self.name, old_value, None, "remove", index=index)
|
|
90
|
+
|
|
91
|
+
def __getitem__(self, index) -> Any:
|
|
92
|
+
"""Get item at index.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
index: The index of the item to retrieve
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
the item at index
|
|
99
|
+
"""
|
|
100
|
+
return self.data[index]
|
|
101
|
+
|
|
102
|
+
def __len__(self) -> int:
|
|
103
|
+
"""Return the length of the list."""
|
|
104
|
+
return len(self.data)
|
|
105
|
+
|
|
106
|
+
def insert(self, index, value):
|
|
107
|
+
"""Insert value at index.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
index: the index to insert value into
|
|
111
|
+
value: the value to insert
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
self.data.insert(index, value)
|
|
115
|
+
self.owner.notify(self.name, None, value, "insert", index=index)
|
|
116
|
+
|
|
117
|
+
def append(self, value):
|
|
118
|
+
"""Insert value at index.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
index: the index to insert value into
|
|
122
|
+
value: the value to insert
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
index = len(self.data)
|
|
126
|
+
self.data.append(value)
|
|
127
|
+
self.owner.notify(self.name, None, value, "append", index=index)
|
|
128
|
+
|
|
129
|
+
def __str__(self):
|
|
130
|
+
return self.data.__str__()
|
|
131
|
+
|
|
132
|
+
def __repr__(self):
|
|
133
|
+
return self.data.__repr__()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Utility functions and classes for Mesa's signals implementation.
|
|
2
|
+
|
|
3
|
+
This module provides helper functionality used by Mesa's reactive programming system:
|
|
4
|
+
|
|
5
|
+
- AttributeDict: A dictionary subclass that allows attribute-style access to its contents
|
|
6
|
+
- create_weakref: Helper function to properly create weak references to different types
|
|
7
|
+
|
|
8
|
+
These utilities support the core signals implementation by providing reference
|
|
9
|
+
management and convenient data structures used throughout the reactive system.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import weakref
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AttributeDict",
|
|
16
|
+
"create_weakref",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AttributeDict(dict):
|
|
21
|
+
"""A dict with attribute like access.
|
|
22
|
+
|
|
23
|
+
Each value can be accessed as if it were an attribute with its key as attribute name
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# I want our signals to act like traitlet signals, so this is inspired by trailets Bunch
|
|
28
|
+
# and some stack overflow posts.
|
|
29
|
+
__setattr__ = dict.__setitem__
|
|
30
|
+
__delattr__ = dict.__delitem__
|
|
31
|
+
|
|
32
|
+
def __getattr__(self, key): # noqa: D105
|
|
33
|
+
try:
|
|
34
|
+
return self.__getitem__(key)
|
|
35
|
+
except KeyError as e:
|
|
36
|
+
# we need to go from key error to attribute error
|
|
37
|
+
raise AttributeError(key) from e
|
|
38
|
+
|
|
39
|
+
def __dir__(self): # noqa: D105
|
|
40
|
+
# allows us to easily access all defined attributes
|
|
41
|
+
names = dir({})
|
|
42
|
+
names.extend(self.keys())
|
|
43
|
+
return names
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_weakref(item, callback=None):
|
|
47
|
+
"""Helper function to create a correct weakref for any item."""
|
|
48
|
+
if hasattr(item, "__self__"):
|
|
49
|
+
ref = weakref.WeakMethod(item, callback)
|
|
50
|
+
else:
|
|
51
|
+
ref = weakref.ref(item, callback)
|
|
52
|
+
return ref
|