asimpy 0.5.0__py3-none-any.whl → 0.5.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.
asimpy/allof.py CHANGED
@@ -45,5 +45,5 @@ class _AllOfWatcher:
45
45
  self.parent = parent
46
46
  self.key = key
47
47
 
48
- def _resume(self, value):
48
+ def resume(self, value):
49
49
  self.parent._child_done(self.key, value)
asimpy/environment.py CHANGED
@@ -12,15 +12,17 @@ from .timeout import Timeout
12
12
  class Environment:
13
13
  """Simulation environment."""
14
14
 
15
- def __init__(self, logging=False):
15
+ def __init__(self):
16
16
  self._now = 0
17
- self._logging = logging
18
17
  self._pending = []
19
18
 
20
19
  @property
21
20
  def now(self):
22
21
  return self._now
23
22
 
23
+ def immediate(self, callback):
24
+ self.schedule(self._now, callback)
25
+
24
26
  def schedule(self, time, callback):
25
27
  heapq.heappush(self._pending, _Pending(time, callback))
26
28
 
@@ -36,9 +38,6 @@ class Environment:
36
38
  if (result is not NO_TIME) and (pending.time > self._now):
37
39
  self._now = pending.time
38
40
 
39
- def _immediate(self, callback):
40
- self.schedule(self._now, callback)
41
-
42
41
  def __str__(self):
43
42
  return f"Env(t={self._now})"
44
43
 
asimpy/event.py CHANGED
@@ -39,7 +39,7 @@ class Event:
39
39
  self._triggered = True
40
40
  self._value = value
41
41
  for proc in self._waiters:
42
- proc._resume(value)
42
+ proc.resume(value)
43
43
  self._waiters.clear()
44
44
 
45
45
  def cancel(self):
@@ -53,7 +53,7 @@ class Event:
53
53
 
54
54
  def _add_waiter(self, proc):
55
55
  if self._triggered:
56
- proc._resume(self._value)
56
+ proc.resume(self._value)
57
57
  elif not self._cancelled:
58
58
  self._waiters.append(proc)
59
59
 
asimpy/firstof.py CHANGED
@@ -37,6 +37,7 @@ class FirstOf(Event):
37
37
  def _child_done(self, key, value, winner):
38
38
  if self._done:
39
39
  return
40
+
40
41
  self._done = True
41
42
 
42
43
  for evt in self._events.values():
@@ -52,5 +53,5 @@ class _FirstOfWatcher:
52
53
  self.key = key
53
54
  self.evt = evt
54
55
 
55
- def _resume(self, value):
56
+ def resume(self, value):
56
57
  self.parent._child_done(self.key, value, self.evt)
asimpy/interrupt.py CHANGED
@@ -17,4 +17,5 @@ class Interrupt(Exception):
17
17
  self.cause = cause
18
18
 
19
19
  def __str__(self):
20
+ """Represent interrupt as string."""
20
21
  return f"Interrupt({self.cause})"
asimpy/process.py CHANGED
@@ -23,9 +23,11 @@ class Process(ABC):
23
23
  self._env = env
24
24
  self._done = False
25
25
  self._interrupt = None
26
+
26
27
  self.init(*args, **kwargs)
28
+
27
29
  self._coro = self.run()
28
- self._env._immediate(self._loop)
30
+ self._env.immediate(self._loop)
29
31
 
30
32
  def init(self, *args: Any, **kwargs: Any):
31
33
  """
@@ -65,7 +67,7 @@ class Process(ABC):
65
67
  """
66
68
  if not self._done:
67
69
  self._interrupt = Interrupt(cause)
68
- self._env._immediate(self._loop)
70
+ self._env.immediate(self._loop)
69
71
 
70
72
  def _loop(self, value=None):
71
73
  if self._done:
@@ -78,6 +80,7 @@ class Process(ABC):
78
80
  exc = self._interrupt
79
81
  self._interrupt = None
80
82
  yielded = self._coro.throw(exc)
83
+
81
84
  yielded._add_waiter(self)
82
85
 
83
86
  except StopIteration:
@@ -87,6 +90,6 @@ class Process(ABC):
87
90
  self._done = True
88
91
  raise exc
89
92
 
90
- def _resume(self, value=None):
93
+ def resume(self, value=None):
91
94
  if not self._done:
92
- self._env._immediate(lambda: self._loop(value))
95
+ self._env.immediate(lambda: self._loop(value))
asimpy/resource.py CHANGED
@@ -39,27 +39,25 @@ class Resource:
39
39
  evt.succeed()
40
40
 
41
41
  async def _acquire_available(self):
42
- self._count += 1
43
- evt = Event(self._env)
44
-
45
42
  def cancel():
46
43
  self._count -= 1
47
44
 
45
+ self._count += 1
46
+ evt = Event(self._env)
48
47
  evt._on_cancel = cancel
49
- self._env._immediate(evt.succeed)
48
+ self._env.immediate(evt.succeed)
50
49
  await evt
51
50
 
52
51
  async def _acquire_unavailable(self):
53
52
  evt = Event(self._env)
54
- self._waiters.append(evt)
55
53
 
56
54
  def cancel():
57
55
  if evt in self._waiters:
58
56
  self._waiters.remove(evt)
59
57
 
58
+ self._waiters.append(evt)
60
59
  evt._on_cancel = cancel
61
60
  await evt
62
-
63
61
  self._count += 1
64
62
 
65
63
  async def __aenter__(self):
asimpy/simqueue.py CHANGED
@@ -25,15 +25,15 @@ class Queue:
25
25
  async def get(self):
26
26
  """Get one item from the queue."""
27
27
  if self._items:
28
- item = self._items.pop(0)
28
+ item = self._get_item()
29
29
  evt = Event(self._env)
30
- self._env._immediate(lambda: evt.succeed(item))
31
30
  evt._on_cancel = lambda: self._items.insert(0, item)
31
+ self._env.immediate(lambda: evt.succeed(item))
32
+ return await evt
33
+ else:
34
+ evt = Event(self._env)
35
+ self._getters.append(evt)
32
36
  return await evt
33
-
34
- evt = Event(self._env)
35
- self._getters.append(evt)
36
- return await evt
37
37
 
38
38
  async def put(self, item: Any):
39
39
  """
@@ -46,33 +46,20 @@ class Queue:
46
46
  evt = self._getters.pop(0)
47
47
  evt.succeed(item)
48
48
  else:
49
- self._items.append(item)
49
+ self._put_item(item)
50
50
 
51
+ def _get_item(self):
52
+ return self._items.pop(0)
51
53
 
52
- class PriorityQueue(Queue):
53
- """Ordered queue."""
54
+ def _put_item(self, item):
55
+ self._items.append(item)
54
56
 
55
- async def get(self):
56
- """Get highest priority item from the queue."""
57
- if self._items:
58
- item = heapq.heappop(self._items)
59
- evt = Event(self._env)
60
- self._env._immediate(lambda: evt.succeed(item))
61
- evt._on_cancel = lambda: heapq.heappush(self._items, item)
62
- return await evt
63
57
 
64
- evt = Event(self._env)
65
- self._getters.append(evt)
66
- return await evt
58
+ class PriorityQueue(Queue):
59
+ """Ordered queue."""
67
60
 
68
- async def put(self, item: Any):
69
- """
70
- Add one item to the queue.
61
+ def _get_item(self):
62
+ return heapq.heappop(self._items)
71
63
 
72
- Args:
73
- item: comparable item to add to queue.
74
- """
64
+ def _put_item(self, item):
75
65
  heapq.heappush(self._items, item)
76
- if self._getters:
77
- evt = self._getters.pop(0)
78
- evt.succeed(heapq.heappop(self._items))
@@ -0,0 +1,484 @@
1
+ Metadata-Version: 2.4
2
+ Name: asimpy
3
+ Version: 0.5.1
4
+ Summary: A simple discrete event simulator using async/await
5
+ Author-email: Greg Wilson <gvwilson@third-bit.com>
6
+ Maintainer-email: Greg Wilson <gvwilson@third-bit.com>
7
+ License-File: LICENSE.md
8
+ Keywords: discrete event simulation,open source
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # asimpy
16
+
17
+ A simple discrete event simulation framework in Python using `async`/`await`.
18
+
19
+ - [Documentation][asimpy]
20
+ - [Package][package]
21
+ - [Repository][repo]
22
+
23
+ *Thanks to the creators of [SimPy][simpy] for inspiration.*
24
+
25
+ ## Core Concepts
26
+
27
+ Discrete event simulation (DES) simulates systems in which events occur at discrete points in time.
28
+ The simulation maintains a virtual clock and executes events in chronological order.
29
+ Unlike real-time systems,
30
+ the simulation jumps directly from one event time to the next,
31
+ skipping empty intervals.
32
+ (Time steps are often referred to as "ticks".)
33
+
34
+ ## Async/Await
35
+
36
+ Python's `async`/`await` syntax enables cooperative multitasking without threads.
37
+ Functions defined as `async def` return coroutine objects when called.
38
+ These coroutines can be paused at `await` points and later resumed.
39
+ More specifically,
40
+ when a coroutine executes `value = await expr`, it:
41
+
42
+ 1. yields the awaited object `expr` to its caller;
43
+ 2. suspends execution at that point;
44
+ 3. resumes later when `send(value)` is called on it; an thend
45
+ 4. returns the value passed to `send()` as the result of the `await` expression
46
+ inside the resumed coroutine.
47
+
48
+ [asimpy][asimpy] uses this mechanism to pause and resume coroutines to simulate simultaneously execution.
49
+ This is similar to the `yield`-based mechanism used in [SimPy][simpy].
50
+
51
+ ## `Environment`: Process and Event Management
52
+
53
+ The `Environment` class maintains the simulation state:
54
+
55
+ - `_now` is the current simulated time.
56
+ - `_pending` is a priority queue of callbacks waiting to be run in order of increasing time
57
+ (so that the next one to run is at the front of the queue).
58
+
59
+ `Environment.schedule(time, callback)` adds a callback to the queue.
60
+ The `_Pending` dataclass used to store it includes a serial number
61
+ to ensure deterministic ordering when multiple events occur at the same time.
62
+
63
+ `Environment.run()` implements the main simulation loop:
64
+
65
+ 1. Extract the next pending event from the priority queue.
66
+ 2. If an `until` parameter is specified and the event time exceeds it, stop.
67
+ 3. Execute the callback.
68
+ 4. If the callback doesn't return `NO_TIME` and the event time is greater than the current simulated time,
69
+ advance the clock.
70
+
71
+ The `NO_TIME` sentinel prevents time from advancing mistakenly when events are canceled.
72
+ This is explained in detail later.
73
+
74
+ ## `Event`: the Synchronization Primitive
75
+
76
+ The `Event` class represents an action that will complete in the future.
77
+ It has four members:
78
+
79
+ - `_triggered` indicates whether the event has completed.
80
+ - `_cancelled` indicaets whether the event was cancelled.
81
+ - `_value` is the event's result value.
82
+ - `_waiters` is a list of processes waiting for this event to occur.
83
+
84
+ When `Event.succeed(value)` is called, it:
85
+
86
+ 1. sets `_triggered` to `True` to show that the event has completed;
87
+ 2. stores the value for later retrieval;
88
+ 3. calls `resume(value)` on all waiting processes; and
89
+ 3. clears the list of waiting processes.
90
+
91
+ The internal `Event._add_waiter(proc)` method handles three cases:
92
+
93
+ 1. If the event has already completed (i.e., if `_triggered` is `True`),
94
+ it immediately calls `proc.resume(value)`.
95
+ 2. If the event has been canceled,
96
+ it does nothing.
97
+ 2. Otherwise, it adds `proc` to the list of waiting processes.
98
+
99
+ Finally,
100
+ `Event` implements `__await__()`,
101
+ which Python calls automatically when it executes `await evt`.
102
+ `Event.__await__` yields `self` so that the awaiting process gets the event back.
103
+
104
+ ## `Process`: Active Entities
105
+
106
+ `Process` is the base class for simulation processes.
107
+ (Unlike [SimPy][simpy], [asimpy][asimpy] uses a class rather than bare coroutines.)
108
+ When a `Process` is constructed, it:
109
+
110
+ 1. store a reference to the simulation environment;
111
+ 2. calls `init()` for subclass-specific setup
112
+ (the default implementation of this method does nothing);
113
+ 3. create a coroutine by calling `run()`; and
114
+ 4. schedules immediate execution of `Process._loop()`.
115
+
116
+ The `_loop()` method drives coroutine execution:
117
+
118
+ 1. If an interrupt is pending, throw it into the coroutine via `throw()`.
119
+ 2. Otherwise, send the value into the coroutine via `send()`.
120
+ 3. Receive the yielded event.
121
+ 4. Register this process as a waiter on that event
122
+
123
+ When `StopIteration` is raised by the coroutine,
124
+ the process is marked as done.
125
+ If any other exception occurs,
126
+ the process is marked as done and the exception is re-raised.
127
+
128
+ **Note:** The word "process" can be confusing.
129
+ These are *not* operating system processes with their own memory and permissions.
130
+
131
+ ### A Note on Scheduling
132
+
133
+ When an event completes it calls `proc.resume(value)` to schedules another iteration of `_loop()`
134
+ with the provided value.
135
+ This continues the coroutine past its `await` point.
136
+
137
+ ### A Note on Interrupts
138
+
139
+ The interrupt mechanism sets `_interrupt` and schedules immediate execution of the process.
140
+ The next `_loop()` iteration throws the interrupt into the coroutine,
141
+ where it can be caught with `try`/`except`.
142
+ This is a bit clumsy,
143
+ but is the only way to inject exceptions into running coroutines.
144
+
145
+ **Note:**
146
+ A process can *only* be interrupted at an `await` point.
147
+ Exceptions *cannot* be raised from the outside at arbitrary points.
148
+
149
+ ## `Timeout`: Waiting Until
150
+
151
+ A `Timeout` object schedules a callback at a future time.
152
+ `Timeout._fire()` method returns `NO_TIME` if the timeout has ben canceled,
153
+ which prevents canceled timeouts from accidentally advancing the simulation time.
154
+ Otherwise,
155
+ `Timeout._fire()` calls `succeed()` to trigger the event.
156
+
157
+ ## `Queue` and `PriorityQueue`: Exchanging Data
158
+
159
+ `Queue` enables processes to exchange data.
160
+ It has two members:
161
+
162
+ - `_items` is a list of items being passed between processes.
163
+ - `_getters` is a list of processes waiting for items.
164
+
165
+ The invariant for `Queue` is that one or the other list must be empty,
166
+ i.e.,
167
+ if there are processes waiting then there aren't any items to take,
168
+ while if there are items waiting to be taken there aren't any waiting processes.
169
+
170
+ `Queue.put(item)` immediately calls `evt.succeed(item)` if a process is waiting
171
+ to pass that item to the waiting process
172
+ (which is stored in the event).
173
+ Otherwise,
174
+ the item is appended to `queue._items`.
175
+
176
+ `Queue.get()` is a bit more complicated.
177
+ If the queue has items,
178
+ `queue.get()` creates an event that immediately succeeds with the first item.
179
+ If the queue is empty,
180
+ the call creates an event and adds the caller to the list of processes waiting to get items.
181
+
182
+ The complication is that if there *is* an item to get,
183
+ `queue.get()` sets the `_on_cancel` callback of the event to handles cancellation
184
+ by returning the item taken to the front of the queue.
185
+
186
+ `PriorityQueue` uses `heapq` operations to maintain ordering,
187
+ which means items must be comparable (i.e., must implement `__lt__`).
188
+ `get()` pops the minimum element;
189
+ `put()` pushes onto the heap and potentially satisfies a waiting getter.
190
+
191
+ ## `Resource`: Capacity-Limited Sharing
192
+
193
+ The `Resource` class simulates a shared resource with limited capacity.
194
+ It has three members:
195
+
196
+ - `capacity` is the maximum number of concurrent users.
197
+ - `_count` is the current number of users.
198
+ - `_waiters` is a list of processes waiting for the resource to be available.
199
+
200
+ If the resource is below capacity when `res.acquire()` is called,
201
+ it calls increments the internal count and immediately succeeds.
202
+ Otherwise,
203
+ it adds the caller to the list of waiting processes.
204
+ Similarly,
205
+ `res.release()` decrements the count and then checks the list of waiting processes.
206
+ If there are any,
207
+ it calls `evt.succeed()` for the event representing the first waiting process.
208
+
209
+ `Resource.acquire` depends on internal methods
210
+ `Resource._acquire_available` and `Resource._acquire_unavailable`,
211
+ both of which set the `_on_cancel` callback of the event they create
212
+ to restore the counter to its original state
213
+ or remove the event marking a waiting process.
214
+
215
+ Finally,
216
+ the context manager protocol methods `__aenter__` and `__aexit__`
217
+ allows processes to use `async with res`
218
+ to acquire and release a resource in a block.
219
+
220
+ ## `Barrier`: Synchronizing Multiple Processes
221
+
222
+ A `Barrier` holds multiple processes until they are explicitly released,
223
+ i.e.,
224
+ it allows the simulation to synchronize multiple processes.
225
+
226
+ - `wait()` creates an event and adds it to the list of waiters.
227
+ - `release()` calls `succeed()` on all waiting events and clears the list.
228
+
229
+ ## AllOf: Waiting for Multiple Events
230
+
231
+ `AllOf` and `FirstOf` are the most complicated parts of [asimpy][asimpy],
232
+ and the reason that parts such as cancellation management exist.
233
+ `AllOf` succeeds when all provided events complete.
234
+ It:
235
+
236
+ 1. converts each input to an event (discussed later);
237
+ 2. registers an `_AllOfWatcher` on each of those events;
238
+ 3. accumulates results in `_results` dictionary; and
239
+ 4. succeeds when all results collected.
240
+
241
+ Each watcher calls `_child_done(key, value)` when its event completes.
242
+ This stores the result and checks if all events are done.
243
+
244
+ ### A Note on Interface
245
+
246
+ A process calls `AllOf` like this:
247
+
248
+ ```python
249
+ await AllOf(self._env, a=self.timeout(5), b=self.timeout(10))
250
+ ```
251
+
252
+ The eventual result is a dictionary in which
253
+ the name of the events are keys and the results of the events are values;
254
+ in this case,
255
+ the keys will be `"a"` and `"b"`.
256
+ This gives callers an easy way to keep track of events,
257
+ though it *doesn't* support waiting on all events in a list.
258
+
259
+ `AllOf`'s interface would be tidier
260
+ if it didn't require the simulation environment as its first argument.
261
+ However,
262
+ removing it made the implementation significantly more complicated.
263
+
264
+ ## FirstOf: Racing Multiple Events
265
+
266
+ `FirstOf` succeeds as soon as *any* of the provided events succeeds,
267
+ and then cancels all of the other events.
268
+ To do this, it:
269
+
270
+ 1. converts each input to an event;
271
+ 2. registers a `_FirstOfWatcher` on each;
272
+ 3. on first completion, cancels all other events; and
273
+ 4. succeeds with a `(key, value)` to identify the winning event.
274
+
275
+ `FirstOf`'s `_done` flag prevents multiple completions.
276
+ When `_child_done()` is called,
277
+ it checks this flag,
278
+ cancels other waiters,
279
+ and succeeds.
280
+
281
+ ## Control Flow Example
282
+
283
+ Consider a process that waits 5 ticks:
284
+
285
+ ```python
286
+ class Waiter(Process):
287
+ async def run(self):
288
+ await self.timeout(5)
289
+ print("done")
290
+ ```
291
+
292
+ When it executes:
293
+
294
+ 1. Construction calls `__init__()`,
295
+ which creates a coroutine by calling `run()`
296
+ and immediately schedules `_loop()`.
297
+ 1. The first `_loop()` calls `send(None)` to the coroutine,
298
+ which executes to the `await`
299
+ and yields a `Timeout` event.
300
+ 1. `_loop()` registers this process as a waiter on the timeout event.
301
+ 1. The timeout schedules a callback to run at time 5.
302
+ 1. The environment takes the event from its `_pending` queue and updates the simulated time to 5.
303
+ 1. The environment runs the callback, which calls `succeed()` on the timeout.
304
+ 1. The timeout calls `resume()` on the process.
305
+ 1. `resume()` schedules an immediate call to `_loop()` with the value `None`.
306
+ 1. `_loop()` calls `send(None)` on the coroutine,
307
+ causing it to advance past the `await`.
308
+ 1. The process prints `"done"` and raises a `StopIteration` exception.
309
+ 1. The process is marked as done.
310
+ 1. Since there are no other events in the pending queue, the environment ends the simulation.
311
+
312
+ ## A Note on Coroutine Adaptation
313
+
314
+ The `ensure_event()` function handles both `Event` objects and bare coroutines.
315
+ For coroutines, it creates a `_Runner` process that `await`s the coroutine
316
+ and then calls `succeed()` on an event with the result.
317
+ This allows `AllOf` and `FirstOf` to accept both events and coroutines.
318
+
319
+ `AllOf` and `FirstOf` must accept coroutines in addition to events
320
+ because of the way Python's `async`/`await` syntax works
321
+ and what users naturally write.
322
+ In the statement:
323
+
324
+ ```python
325
+ await AllOf(env, a=queue.get(), b=resource.acquire())
326
+ ```
327
+
328
+ the expressions `queue.get()` and `resource.acquire()` are calls to `async def` functions.
329
+ In Python,
330
+ calling an async function *does not execute it.
331
+ Instead, it returns a coroutine object.
332
+ If `AllOf` couldn't accept coroutines directly,
333
+ this code would fail because it expects `Event`s.
334
+
335
+ If `AllOf` only accepted events, users would need to write:
336
+
337
+ ```python
338
+ # Manually create events
339
+ evt_a = Event(env)
340
+ evt_b = Event(env)
341
+
342
+ # Manually create runners
343
+ _Runner(env, evt_a, queue.get())
344
+ _Runner(env, evt_b, resource.acquire())
345
+
346
+ # Now use the events
347
+ await AllOf(env, a=evt_a, b=evt_b)
348
+ ```
349
+
350
+ This is verbose and exposes internal implementation details.
351
+
352
+ ## Things I Learned the Hard Way
353
+
354
+ ### Requirements for Correctness
355
+
356
+ `Event` waiter notification must occur before clearing the list.
357
+ : If the list were cleared first, waiters couldn't be resumed.
358
+
359
+ The `_Pending` serial number is necessary.
360
+ : Heap operations require total ordering.
361
+ Without this value,
362
+ events occurring at the same time wouldn't be deterministically ordered,
363
+ which would make simulations irreproducible.
364
+
365
+ Cancelled events must not advance time.
366
+ : The `NO_TIME` sentinel prevents this.
367
+ Without it,
368
+ cancelled timeouts create gaps in the simulation timeline.
369
+
370
+ Process interrupt checking must occur before coroutine sends.
371
+ : This ensures interrupts are handled immediately
372
+ rather than being delayed until the next event.
373
+
374
+ Queue cancellation handlers must remove items or waiters.
375
+ : Without this,
376
+ cancelled `get`s leave processes in the waiters list indefinitely,
377
+ and cancelled items disappear from the queue.
378
+
379
+ Resource cancellation handlers must adjust state.
380
+ : Without them,
381
+ cancelled `acquire`s permanently reduce available capacity or leave ghost waiters.
382
+
383
+ `AllOf` must track completion.
384
+ : Without checking if all events are done, it succeeds prematurely.
385
+
386
+ `FirstOf` must cancel losing events.
387
+ : Otherwise,
388
+ those events remain active and can run later.
389
+
390
+ ### Why Not Just Use Coroutines?
391
+
392
+ [SimPy][simpy] uses bare coroutines.
393
+ [asimpy][asimpy] uses `Event` as the internal primitive for several reasons.
394
+
395
+ Events can be triggered externally.
396
+ : A `Timeout` schedules a callback that later calls `succeed()`.
397
+ A coroutine cannot be "succeeded" from outside: it must run to completion.
398
+
399
+ Events support multiple waiters.
400
+ : Multiple processes can `await` the same event.
401
+ A coroutine can only be awaited once.
402
+
403
+ Events decouple triggering from waiting.
404
+ : The thing that creates an event (like `Timeout.__init__()`)
405
+ is separate from the thing that waits for it.
406
+ With coroutines, creation and execution are more tightly coupled.
407
+
408
+ ### `Event.__await__`
409
+
410
+ `Event.__await__` is defined as:
411
+
412
+ ```python
413
+ def __await__(self):
414
+ value = yield self
415
+ return value
416
+ ```
417
+
418
+ This appears redundant but each part serves a specific purpose in the coroutine protocol.
419
+
420
+ When a coroutine executes `await event`,
421
+ Python calls `event.__await__()`,
422
+ which must return an iterator.
423
+ The `yield self` statement:
424
+
425
+ 1. makes `__await__()` a generator function,
426
+ so it returns a generator (which is a kind of iterator).
427
+ 2. Yields the `Event` object itself up to the `Process`'s `_loop()` method.
428
+
429
+ The `Process` needs the `Event` object so it can call `_add_waiter()` on it:
430
+
431
+ ```python
432
+ def _loop(self, value=None):
433
+ # ...
434
+ yielded = self._coro.send(value) # This receives the Event
435
+ yielded._add_waiter(self) # Register as waiter
436
+ ```
437
+
438
+ Without `yield self`, the `Process` wouldn't know which event to register on.
439
+
440
+ The `value = yield self` statement captures what gets sent back into the generator.
441
+ When the event completes:
442
+
443
+ 1. `Event` calls `proc.resume(value)` .
444
+ 2. `Process` calls `self._loop(value)`.
445
+ 3. `_loop` calls `self._coro.send(value)`.
446
+ 4. This resumes the generator, making `yield self` return `value`.
447
+
448
+ The assignment therefore captures the event's result value.
449
+
450
+ ### Why Return Value
451
+
452
+ The `return value` statement makes that result available to the code that wrote `await event`.
453
+ When a generator returns (via `return` or falling off the end)
454
+ Python raises `StopIteration` with the return value as an attribute.
455
+ The `async`/`await` machinery extracts this and provides it as the result of the `await` expression,
456
+ So when a user writes:
457
+
458
+ ```python
459
+ result = await queue.get()
460
+ ```
461
+
462
+ the flow is:
463
+
464
+ 1. `queue.get()` creates and returns an `Event`.
465
+ 1. `await` calls `Event.__await__()` which yields the `Event` object.
466
+ 1. `Process._loop()` receives the `Event` and registers itself as a waiter.
467
+ 1. Later, the queue calls `event.succeed(item)`.
468
+ 1. `Event` calls `process.resume(item)`.
469
+ 1. `Process` calls `coro.send(item)`.
470
+ 1. The generator resumes, and `yield self` evaluates to `item`.
471
+ 1. The generator executes `return item`.
472
+ 1. `StopIteration(item)` is raised.
473
+ 1. The `async` machinery catches this and makes `await` evaluate to `item`.
474
+
475
+ None of the simpler alternatives would work:
476
+
477
+ - `yield self` alone (no return): the await expression would evaluate to `None`.
478
+ - `return self` (no yield): not a generator, so it violates the iterator protocol.
479
+ - `yield value` then `return value`: the first yield wouldn't provide the `Event` object to the `Process`.
480
+
481
+ [asimpy]: https://asimpy.readthedocs.io/
482
+ [package]: https://pypi.org/project/asimpy/
483
+ [repo]: https://github.com/gvwilson/asimpy
484
+ [simpy]: https://simpy.readthedocs.io/
@@ -0,0 +1,16 @@
1
+ asimpy/__init__.py,sha256=c01u2z29W688jAccd6OKnON132IueBKn31JrcA5wNSk,490
2
+ asimpy/_adapt.py,sha256=-rUIvJMneEDdxdiMImwXYfMChsqdQwWoyHVsDCsxRKI,521
3
+ asimpy/allof.py,sha256=HMn-w1tNM4ej7IO5oj0icOFPxDzWL6qF8gIsxpxUHck,1210
4
+ asimpy/barrier.py,sha256=evubm6H_93qi9vBogqrNLg1dZosY7YdnCnJc1jNGYW4,770
5
+ asimpy/environment.py,sha256=PhzY4OILYSNnm0lMSZPZKaGA8BcQwe3m6y2Wl4Z8egs,1303
6
+ asimpy/event.py,sha256=2itFcYmgxiV2raGhoRf76mbNROTYSAPpUX33ofOPjpU,1435
7
+ asimpy/firstof.py,sha256=H7W1rsWjRLmosFAlabMunkXjqpsEfmlbTbdk5zwRa_Q,1356
8
+ asimpy/interrupt.py,sha256=TmirtrO9jikyQBAO8791kpP4Y8tLd3EpQ5Z9ZDadFzE,451
9
+ asimpy/process.py,sha256=IVxLMTr7EWVWZg8RtAOkW4fAL9f30dIfq7ba20zwO1c,2348
10
+ asimpy/resource.py,sha256=a6Z3pWwGZ-JOIvBaJmpYJvgCNXtMuGce3uIKhEQDnsQ,1677
11
+ asimpy/simqueue.py,sha256=W3AV8YGuK8FrDQBI7EpuH49Be_GseM8A9xVMZFAzYsw,1479
12
+ asimpy/timeout.py,sha256=75OqbpLeepJ5UdSN6F6JZ0ctlr53jpuilaI7Wxi9XP8,686
13
+ asimpy-0.5.1.dist-info/METADATA,sha256=sHuInjnJLTEedEjnrlEbPhKsAIY-aaeBjxX1qYIAbko,17637
14
+ asimpy-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
15
+ asimpy-0.5.1.dist-info/licenses/LICENSE.md,sha256=IjTDUvBk8xdl_n50CG1Vtk4FYdrS-C3uEYrRWAoOQqQ,1066
16
+ asimpy-0.5.1.dist-info/RECORD,,
@@ -1,228 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: asimpy
3
- Version: 0.5.0
4
- Summary: A simple discrete event simulator using async/await
5
- Author-email: Greg Wilson <gvwilson@third-bit.com>
6
- Maintainer-email: Greg Wilson <gvwilson@third-bit.com>
7
- License-File: LICENSE.md
8
- Keywords: discrete event simulation,open source
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Classifier: Programming Language :: Python :: 3
12
- Requires-Python: >=3.12
13
- Description-Content-Type: text/markdown
14
-
15
- # asimpy
16
-
17
- A simple discrete event simulation framework in Python using `async`/`await`.
18
-
19
- - [Documentation][docs]
20
- - [Package][package]
21
- - [Repository][repo]
22
-
23
- *Thanks to the creators of [SimPy][simpy] for inspiration.*
24
-
25
- ## What This Is
26
-
27
- This discrete-event simulation framework uses Python's `async`/`await` without `asyncio`.
28
- Key concepts include:
29
-
30
- - Simulation time is virtual, not wall-clock time.
31
- - Processes are active entities (customers, producers, actors).
32
- - Events are things that happen at specific simulation times.
33
- - `await` is used to pause a process until an event occurs.
34
- - A single-threaded event loop (the `Environment` class) advances simulated time and resumes processes.
35
-
36
- The result feels like writing synchronous code, but executes deterministically.
37
-
38
- ## The Environment
39
-
40
- The `Environment` class is the core of `asimpy`.
41
- It store upcoming events in a heap that keeps entries ordered by (simulated) time.
42
- `env.run()` repeatedly pops the earliest scheduled callback,
43
- advances `env._now` to that time,
44
- and runs the callback.
45
-
46
- > The key idea is that *nothing runs until the environment schedules it*.
47
-
48
- ## Events
49
-
50
- An `Event` represents something that may happen later (similar to an `asyncio` `Future`).
51
- When a process runs:
52
-
53
- ```python
54
- value = await some_event
55
- ```
56
-
57
- the following happens:
58
-
59
- 1. `Event.__await__()` yields the event object.
60
- 1. The process is suspended.
61
- 1. The process is registered as a waiter on that event.
62
-
63
- When `event.succeed(value)` is called:
64
-
65
- 1. The event is marked as triggered.
66
- 1. All waiting processes are resumed.
67
- 1. Each of those processes receives `value` as the result of `await`.
68
-
69
- One way to understand `Event` is to look at the purpose of its key attributes.
70
-
71
- ### `_triggered`
72
-
73
- The Boolean `_triggered` indicates whether this event has already successfully occurred.
74
- It is initially `False`,
75
- and is set to `True` when `evt.succeed(value)` is called.
76
- If the event has been triggered before,
77
- and some process wants to wait on it later,
78
- that process is immediately resumed with the store `_value`.
79
- This prevents lost or duplicated notifications.
80
-
81
- ```python
82
- def succeed(self, value=None):
83
- if self._triggered or self._cancelled:
84
- return
85
- self._triggered = True
86
- self._value = value
87
- for proc in self._waiters:
88
- proc._resume(value)
89
- self._waiters.clear()
90
- ```
91
-
92
- ### `_cancelled` and `_on_cancel`
93
-
94
- The Boolean `_cancelled` indicates whether the event was aborted before it could succeed.
95
- It is also initially `False`,
96
- and is used in both `succeed()` (shown above) and in `cancel()`:
97
-
98
- ```python
99
- def cancel(self):
100
- if self._triggered or self._cancelled:
101
- return
102
- self._cancelled = True
103
- self._waiters.clear()
104
- if self._on_cancel:
105
- self._on_cancel()
106
- ```
107
-
108
- If a cancellation callback has been registered with the event,
109
- the callback is executed to do resource cleanup.
110
- For example,
111
- suppose a program is using `FirstOf` to wait until an item is available
112
- on one or the other of two queues.
113
- If both queues become ready at the same simulated time,
114
- the program might take one item from both queues
115
- when it should instead take an item from one or the other queue.
116
- To clean this up,
117
- `Queue.get()` registers a callback that puts an item back at the front of the queue
118
- to prevent incorrect over-consumption of items:
119
-
120
- ```python
121
- evt._on_cancel = lambda: self._items.insert(0, item)
122
- ```
123
-
124
- ### `_value`
125
-
126
- When `evt.succeed(value)` is called,
127
- the value passed in is saved as `evt._value`.
128
- Any process resuming from `await event` receives this value:
129
-
130
- ```python
131
- class Event:
132
- def __await__(self):
133
- value = yield self
134
- return value
135
- ```
136
-
137
- This is how the framework passes results between processes:
138
- for example,
139
- the value of a `Queue.get()` event is the item retrieved from the queue.
140
-
141
- ### `_waiters`
142
-
143
- `_waiters` is a list of processes waiting for the event to complete.
144
- When a process `await`s an event,
145
- it is added to the list by the internal method `_add_waiter()`.
146
- If the event has already been triggered,
147
- `_add_waiter` immediately resumes the process:
148
-
149
- ```python
150
- def _add_waiter(self, proc):
151
- if self._triggered:
152
- proc._resume(self._value)
153
- elif not self._cancelled:
154
- self._waiters.append(proc)
155
- ```
156
-
157
- When `succeed()` is called,
158
- every process in `_waiters` is resumed and `_waiters` is cleared.
159
- When `cancel()` is called, `_waiters` is cleared without resuming any process.
160
-
161
- ## Processes
162
-
163
- The `Process` class wraps an `async def run()` coroutine.
164
- When a `Process` is created,
165
- its constructor called `self.run()` to create a coroutine
166
- and then gives the `self._loop` callback to the `Enviroment`,
167
- which schedules it to run.
168
-
169
- `self._loop` drives the coroutine by executing:
170
-
171
- ```python
172
- yielded = self._coro.send(value)
173
- yielded._add_waiter(self)
174
- ```
175
-
176
- 1. The coroutine runs until it hits `await`.
177
- 1. The `await` yields an `Event`.
178
- 1. The process registers itself as waiting on that event.
179
- 1. Control returns to the environment.
180
-
181
- When the event fires, it calls:
182
-
183
- ```python
184
- proc._resume(value)
185
- ```
186
-
187
- which schedules `_loop(value)` again.
188
-
189
- ## Example: `Timeout`
190
-
191
- The entire `Timeout` class is:
192
-
193
- ```python
194
- class Timeout(Event):
195
- def __init__(self, env, delay):
196
- super().__init__(env)
197
- env.schedule(env.now + delay, lambda: self.succeed())
198
- ```
199
-
200
- which simply asks the `Environment` to schedule `Event.succeed()` in the simulated future.
201
-
202
- ## Example: `FirstOf`
203
-
204
- Suppose the queue `q1` contains `"A"` and the `q2` contains `"B"`,
205
- and a process then waits for a value from either queue with:
206
-
207
- ```python
208
- item = await FirstOf(env, a=q1.get(), b=q2.get())
209
- ```
210
-
211
- The sequence of events is:
212
-
213
- | Action / Event | Queue State | Event State | Process / Waiters | Notes |
214
- | ----------------------------------------- | ------------------ | --------------------------------------- | ------------------------------------------------ | --------------------------------------------------------- |
215
- | `Tester` starts | q1=["A"], q2=["B"] | evt_a & evt_b created | `_waiters` in evt_a and evt_b = [Tester] | `FirstOf` yields to environment |
216
- | `q1.get()` removes "A" | q1=[], q2=["B"] | evt_a._value=None, _triggered=False | evt_a._waiters=[FirstOfWatcher] | _on_cancel set to re-insert "A" if cancelled |
217
- | `q2.get()` removes "B" | q1=[], q2=[] | evt_b._value=None, _triggered=False | evt_b._waiters=[FirstOfWatcher] | _on_cancel set to re-insert "B" if cancelled |
218
- | Environment immediately triggers evt_a | q1=[], q2=[] | evt_a._value="A", _triggered=True | _waiters processed: FirstOf._child_done() called | This is the winner |
219
- | FirstOf `_child_done()` sets `_done=True` | q1=[], q2=[] | _done=True | _events = {a: evt_a, b: evt_b} | Notifies all losing events to cancel |
220
- | evt_b.cancel() called (loser) | q1=[], q2=[] | evt_b._cancelled=True | _waiters cleared | _on_cancel triggers: inserts "B" back at front of q2 |
221
- | `_on_cancel` restores item | q1=[], q2=["B"] | evt_b._triggered=False, _cancelled=True | _waiters cleared | Queue order preserved |
222
- | FirstOf succeeds with winner | q1=[], q2=["B"] | _value=("a","A"), _triggered=True | Processes waiting on FirstOf resumed | `Tester` receives ("a","A") |
223
- | `Tester` continues execution | q1=[], q2=["B"] | - | - | Remaining queue items untouched; correct order guaranteed |
224
-
225
- [docs]: https://asimpy.readthedocs.io/
226
- [package]: https://pypi.org/project/asimpy/
227
- [repo]: https://github.com/gvwilson/asimpy
228
- [simpy]: https://simpy.readthedocs.io/
@@ -1,16 +0,0 @@
1
- asimpy/__init__.py,sha256=c01u2z29W688jAccd6OKnON132IueBKn31JrcA5wNSk,490
2
- asimpy/_adapt.py,sha256=-rUIvJMneEDdxdiMImwXYfMChsqdQwWoyHVsDCsxRKI,521
3
- asimpy/allof.py,sha256=artqJqBHUaHh8oOk3a3tpqIlt5sJzGW9VEJ2-YkdECU,1211
4
- asimpy/barrier.py,sha256=evubm6H_93qi9vBogqrNLg1dZosY7YdnCnJc1jNGYW4,770
5
- asimpy/environment.py,sha256=EEHkW6vuQJF9koY_DrNVXQ9bUj4GQDKRWkGCqvDx9U0,1351
6
- asimpy/event.py,sha256=gS4_PHydP6MUNaEG2o0L8QK67Y1R_9dybJq4BEQXsu8,1437
7
- asimpy/firstof.py,sha256=htwskm_5lwBVD6yb7hD8jmAypeaJJk5iUqB7cKPYWyk,1356
8
- asimpy/interrupt.py,sha256=tybPzsCeX7cpVL6psOUQf6egcAujV0vnJe1zDwkZWxo,406
9
- asimpy/process.py,sha256=IQldzjqF5CuBUccdkM3ZV5mEtEIivZx8whGeNEr2jIc,2349
10
- asimpy/resource.py,sha256=Ml8E71FdJ61Sy6dcoXyQkb0SXLv6xRBkI7Rkriyu438,1680
11
- asimpy/simqueue.py,sha256=B826YjNKHXkdkRRYPijQETkF9w84hoWNT4an_YQcalw,1953
12
- asimpy/timeout.py,sha256=75OqbpLeepJ5UdSN6F6JZ0ctlr53jpuilaI7Wxi9XP8,686
13
- asimpy-0.5.0.dist-info/METADATA,sha256=gsgdx4ouMqpNgHBGLaKfC5ygBWEjNgYlv8IwmCRohKc,8709
14
- asimpy-0.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
15
- asimpy-0.5.0.dist-info/licenses/LICENSE.md,sha256=IjTDUvBk8xdl_n50CG1Vtk4FYdrS-C3uEYrRWAoOQqQ,1066
16
- asimpy-0.5.0.dist-info/RECORD,,
File without changes