esper 3.4__tar.gz → 3.7__tar.gz

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.
Files changed (33) hide show
  1. {esper-3.4 → esper-3.7}/.github/workflows/unit-tests.yml +3 -3
  2. {esper-3.4 → esper-3.7}/PKG-INFO +4 -3
  3. {esper-3.4 → esper-3.7}/README.md +1 -1
  4. {esper-3.4 → esper-3.7}/RELEASE_NOTES +41 -0
  5. {esper-3.4 → esper-3.7}/docs/index.rst +1 -0
  6. {esper-3.4 → esper-3.7}/esper/__init__.py +262 -171
  7. {esper-3.4 → esper-3.7}/examples/benchmark.py +109 -0
  8. {esper-3.4 → esper-3.7}/examples/pythonista_ios_example.py +14 -14
  9. {esper-3.4 → esper-3.7}/tests/test_world.py +196 -3
  10. {esper-3.4 → esper-3.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  11. {esper-3.4 → esper-3.7}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  12. {esper-3.4 → esper-3.7}/.github/ISSUE_TEMPLATE/question-or-comment.md +0 -0
  13. {esper-3.4 → esper-3.7}/.github/workflows/type-checking.yml +0 -0
  14. {esper-3.4 → esper-3.7}/.gitignore +0 -0
  15. {esper-3.4 → esper-3.7}/.mypy.ini +0 -0
  16. {esper-3.4 → esper-3.7}/.readthedocs.yaml +0 -0
  17. {esper-3.4 → esper-3.7}/.ruff.toml +0 -0
  18. {esper-3.4 → esper-3.7}/LICENSE +0 -0
  19. {esper-3.4 → esper-3.7}/MANIFEST.in +0 -0
  20. {esper-3.4 → esper-3.7}/docs/Makefile +0 -0
  21. {esper-3.4 → esper-3.7}/docs/conf.py +0 -0
  22. {esper-3.4 → esper-3.7}/docs/make.bat +0 -0
  23. {esper-3.4 → esper-3.7}/esper/py.typed +0 -0
  24. {esper-3.4 → esper-3.7}/examples/benchmark_cache.py +0 -0
  25. {esper-3.4 → esper-3.7}/examples/bluesquare.png +0 -0
  26. {esper-3.4 → esper-3.7}/examples/headless_example.py +0 -0
  27. {esper-3.4 → esper-3.7}/examples/pygame_example.py +0 -0
  28. {esper-3.4 → esper-3.7}/examples/pyglet_example.py +0 -0
  29. {esper-3.4 → esper-3.7}/examples/pysdl2_example.py +0 -0
  30. {esper-3.4 → esper-3.7}/examples/redsquare.png +0 -0
  31. {esper-3.4 → esper-3.7}/make.py +0 -0
  32. {esper-3.4 → esper-3.7}/pyproject.toml +0 -0
  33. {esper-3.4 → esper-3.7}/tests/__init__.py +0 -0
@@ -11,12 +11,12 @@ jobs:
11
11
  strategy:
12
12
  matrix:
13
13
  os: [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ]
14
- python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12-dev', 'pypy-3.10' ]
14
+ python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.10' ]
15
15
  steps:
16
16
  - name: Python ${{ matrix.python-version }} ${{ matrix.os }}
17
- uses: actions/checkout@v3
17
+ uses: actions/checkout@v4
18
18
  - name: Set up Python ${{ matrix.python-version }}
19
- uses: actions/setup-python@v4
19
+ uses: actions/setup-python@v5
20
20
  with:
21
21
  python-version: ${{ matrix.python-version }}
22
22
  - name: Install test dependencies
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: esper
3
- Version: 3.4
3
+ Version: 3.7
4
4
  Summary: esper is a lightweight Entity System (ECS) for Python, with a focus on performance
5
5
  Author-email: Benjamin Moran <benmoran@protonmail.com>
6
6
  Requires-Python: >=3.8
7
7
  Description-Content-Type: text/markdown
8
8
  Classifier: License :: OSI Approved :: MIT License
9
+ License-File: LICENSE
9
10
  Project-URL: Home, https://github.com/benmoran56/esper
10
11
 
11
12
  [![pypi](https://badge.fury.io/py/esper.svg)](https://pypi.python.org/pypi/esper)
@@ -253,7 +254,7 @@ remove Components inside your Processors. The following functions are available
253
254
  * esper.remove_component(entity_id, ComponentClass)
254
255
 
255
256
  As an example of this, you could have a "Blink" component with a `duration` attribute. This can be used
256
- to make certain things blink for s specific period of time, then disappear. For example, the code below
257
+ to make certain things blink for a specific period of time, then disappear. For example, the code below
257
258
  shows a simplified case of adding this Component to an Entity when it takes damage in one processor. A
258
259
  dedicated `BlinkProcessor` handles the effect, and then removes the Component after the duration expires::
259
260
 
@@ -243,7 +243,7 @@ remove Components inside your Processors. The following functions are available
243
243
  * esper.remove_component(entity_id, ComponentClass)
244
244
 
245
245
  As an example of this, you could have a "Blink" component with a `duration` attribute. This can be used
246
- to make certain things blink for s specific period of time, then disappear. For example, the code below
246
+ to make certain things blink for a specific period of time, then disappear. For example, the code below
247
247
  shows a simplified case of adding this Component to an Entity when it takes damage in one processor. A
248
248
  dedicated `BlinkProcessor` handles the effect, and then removes the Component after the duration expires::
249
249
 
@@ -1,3 +1,44 @@
1
+ esper 3.7
2
+ =========
3
+ Maintenance release
4
+
5
+ Changes
6
+ -------
7
+ - Add a new `get_entities` method that returns a generator over all entity IDs.
8
+
9
+
10
+ esper 3.6
11
+ =========
12
+ Maintenance release
13
+
14
+ Changes
15
+ -------
16
+ - Add a new `try_remove_component` function as a convenience instead of (has_component + remove_component).
17
+ - Start migrating type hints to the modern syntax.
18
+
19
+
20
+ esper 3.5
21
+ =========
22
+ Performance-focused release with major internal optimizations.
23
+
24
+ Changes
25
+ -------
26
+ - Optimized `get_components` to iterate over the smallest set of entities, drastically speeding up queries with rare components.
27
+ - Implemented lazy cache invalidation to reduce overhead from frequent entity modifications.
28
+ - Sped up `get_processor` and `remove_processor` to be constant time (O(1)) operations.
29
+ - Minor performance improvements to entity creation and deletion routines.
30
+ - Expanded Benchmarks.
31
+
32
+
33
+ esper 3.4
34
+ =========
35
+ Maintenance release
36
+
37
+ Changes
38
+ -------
39
+ - `esper.remove_handler` should use a reference instead of direct object comparison. (#107)
40
+
41
+
1
42
  esper 3.3
2
43
  =========
3
44
  Maintenance release
@@ -49,6 +49,7 @@ The World context
49
49
  .. autofunction:: esper.components_for_entity
50
50
  .. autofunction:: esper.add_component
51
51
  .. autofunction:: esper.remove_component
52
+ .. autofunction:: esper.try_remove_component
52
53
  .. autofunction:: esper.get_component
53
54
  .. autofunction:: esper.get_components
54
55
  .. autofunction:: esper.has_component
@@ -2,28 +2,27 @@
2
2
 
3
3
  More information is available at https://github.com/benmoran56/esper
4
4
  """
5
+ from __future__ import annotations
6
+
5
7
  import time as _time
6
8
 
7
9
  from types import MethodType as _MethodType
8
10
 
9
11
  from typing import Any as _Any
10
12
  from typing import Callable as _Callable
11
- from typing import Dict as _Dict
12
- from typing import List as _List
13
- from typing import Set as _Set
14
- from typing import Type as _Type
15
- from typing import Tuple as _Tuple
13
+ from typing import Generator as _Generator
16
14
  from typing import TypeVar as _TypeVar
17
15
  from typing import Iterable as _Iterable
18
- from typing import Optional as _Optional
19
16
  from typing import overload as _overload
20
17
 
21
18
  from weakref import ref as _ref
22
19
  from weakref import WeakMethod as _WeakMethod
23
20
 
21
+ from math import inf
22
+
24
23
  from itertools import count as _count
25
24
 
26
- __version__ = version = '3.4'
25
+ __version__ = version = '3.7'
27
26
 
28
27
 
29
28
  ###################
@@ -104,18 +103,19 @@ _C4 = _TypeVar('_C4')
104
103
  class Processor:
105
104
  """Base class for all Processors to inherit from.
106
105
 
107
- Processor instances must contain a `process` method, but you are otherwise
108
- free to define the class any way you wish. Processors should be instantiated,
109
- and then added to the current World context by calling :py:func:`esper.add_processor`.
110
- For example::
106
+ Processor instances must define a :py:meth:`process` method, but you are
107
+ otherwise free to define the class any way you wish. Processors should be
108
+ instantiated, and then added to the current World context by calling
109
+ :py:func:`esper.add_processor`. For example::
111
110
 
112
111
  my_processor_instance = MyProcessor()
113
112
  esper.add_processor(my_processor_instance)
114
113
 
115
- All the Processors that have been added to the World context will have their
116
- :py:meth:`esper.Processor.process` methods called by a single call to
117
- :py:func:`esper.process`. Inside the `process` method is generally where you
118
- should iterate over Entities with one (or more) calls to the appropriate methods::
114
+ All the Processors that have been added to the World context will have
115
+ their :py:meth:`esper.Processor.process` methods called by a single call
116
+ to :py:func:`esper.process`. Inside the :py:meth:`process` method is
117
+ where you should iterate over Entities with one (or more) calls to the
118
+ appropriate esper functions::
119
119
 
120
120
  def process(self):
121
121
  for ent, (rend, vel) in esper.get_components(Renderable, Velocity):
@@ -128,46 +128,124 @@ class Processor:
128
128
  raise NotImplementedError
129
129
 
130
130
 
131
- ###################
132
- # ECS functions
133
- ###################
131
+ ###########################################
132
+ # World Context template and functions
133
+ ###########################################
134
134
 
135
+ # Global variables that contain the state of the current world context:
135
136
  _current_world: str = "default"
136
- _entity_count: "_count[int]" = _count(start=1)
137
- _components: _Dict[_Type[_Any], _Set[_Any]] = {}
138
- _entities: _Dict[int, _Dict[_Type[_Any], _Any]] = {}
139
- _dead_entities: _Set[int] = set()
140
- _get_component_cache: _Dict[_Type[_Any], _List[_Any]] = {}
141
- _get_components_cache: _Dict[_Tuple[_Type[_Any], ...], _List[_Any]] = {}
142
- _processors: _List[Processor] = []
143
- event_registry: _Dict[str, _Any] = {}
144
- process_times: _Dict[str, int] = {}
145
- current_world: str = "default"
146
-
147
-
148
- # {context_name: (entity_count, components, entities, dead_entities,
149
- # comp_cache, comps_cache, processors, process_times, event_registry)}
150
- _context_map: _Dict[str, _Tuple[
151
- "_count[int]",
152
- _Dict[_Type[_Any], _Set[_Any]],
153
- _Dict[int, _Dict[_Type[_Any], _Any]],
154
- _Set[int],
155
- _Dict[_Type[_Any], _List[_Any]],
156
- _Dict[_Tuple[_Type[_Any], ...], _List[_Any]],
157
- _List[Processor],
158
- _Dict[str, int],
159
- _Dict[str, _Any]
160
- ]] = {"default": (_entity_count, {}, {}, set(), {}, {}, [], {}, {})}
137
+ _entity_count: _count[int] = _count(start=1)
138
+ _components: dict[type[_Any], set[_Any]] = {}
139
+ _entities: dict[int, dict[type[_Any], _Any]] = {}
140
+ _dead_entities: set[int] = set()
141
+ _get_component_cache: dict[type[_Any], list[_Any]] = {}
142
+ _get_components_cache: dict[tuple[type[_Any], ...], list[_Any]] = {}
143
+ _processors: list[Processor] = []
144
+ _processors_dict: dict[type[Processor], Processor] = {}
145
+ _cache_dirty: bool = False
146
+ event_registry: dict[str, _Any] = {}
147
+ process_times: dict[str, int] = {}
148
+ # 'public' alias that gets set when switching worlds:
149
+ current_world: str = _current_world
150
+
151
+ # The _context_map holds all variables for the active & inactive contexts.
152
+ # We initialize it with default values::
153
+ _context_map: dict[str, tuple[
154
+ _count[int],
155
+ dict[type[_Any], set[_Any]],
156
+ dict[int, dict[type[_Any], _Any]],
157
+ set[int],
158
+ dict[type[_Any], list[_Any]],
159
+ dict[tuple[type[_Any], ...], list[_Any]],
160
+ list[Processor],
161
+ dict[type[Processor], Processor],
162
+ bool,
163
+ dict[str, int],
164
+ dict[str, _Any]
165
+ ]] = {_current_world: (_entity_count, {}, {}, set(), {}, {}, [], {}, False, {}, {})}
166
+
167
+
168
+ def list_worlds() -> list[str]:
169
+ """A list all World context names."""
170
+ return list(_context_map)
171
+
172
+
173
+ def delete_world(name: str) -> None:
174
+ """Delete a World context.
175
+
176
+ This will completely delete the World, including all entities
177
+ that are contained within it.
178
+
179
+ Raises `PermissionError` if you attempt to delete the currently
180
+ active World context.
181
+ """
182
+ if _current_world == name:
183
+ raise PermissionError("The active World context cannot be deleted.")
184
+
185
+ del _context_map[name]
186
+
187
+
188
+ def switch_world(name: str) -> None:
189
+ """Switch to a new World context by name.
190
+
191
+ Esper can have one or more "Worlds". Each World is a dedicated
192
+ context, and does not share Entities, Components, events, etc.
193
+ Some game designs can benefit from using a dedicated World
194
+ for each scene. For other designs, a single World may be sufficient.
195
+
196
+ This function will allow you to create and switch between as
197
+ many World contexts as required. If the requested name does not
198
+ exist, a new context is created automatically with that name.
199
+
200
+ The name of the currently active World context can be checked
201
+ by examining :py:attr:`esper.current_world` attribute. This
202
+ attribute gets updated whenever you switch Worlds.
203
+
204
+ .. note:: At startup, a "default" World context is active.
205
+ """
206
+ if name not in _context_map:
207
+ _context_map[name] = (_count(start=1), {}, {}, set(), {}, {}, [], {}, False, {}, {})
208
+
209
+ global _current_world
210
+ global _entity_count
211
+ global _components
212
+ global _entities
213
+ global _dead_entities
214
+ global _get_component_cache
215
+ global _get_components_cache
216
+ global _processors
217
+ global _processors_dict
218
+ global _cache_dirty
219
+ global process_times
220
+ global event_registry
221
+ global current_world
222
+
223
+ (_entity_count, _components, _entities, _dead_entities, _get_component_cache,
224
+ _get_components_cache, _processors, _processors_dict, _cache_dirty,
225
+ process_times, event_registry) = _context_map[name]
226
+ _current_world = current_world = name
161
227
 
162
228
 
229
+ #####################
230
+ # ECS functions
231
+ #####################
232
+
163
233
  def clear_cache() -> None:
164
234
  """Manually clear the Component lookup cache.
165
235
 
166
236
  Clearing the cache is not necessary to do manually,
167
237
  but may be useful for benchmarking or debugging.
168
238
  """
239
+ global _cache_dirty
240
+ _cache_dirty = True
241
+
242
+
243
+ def _clear_cache_now() -> None:
244
+ """Actually clear the cache (internal use)."""
245
+ global _cache_dirty
169
246
  _get_component_cache.clear()
170
247
  _get_components_cache.clear()
248
+ _cache_dirty = False
171
249
 
172
250
 
173
251
  def clear_database() -> None:
@@ -181,7 +259,7 @@ def clear_database() -> None:
181
259
  _components.clear()
182
260
  _entities.clear()
183
261
  _dead_entities.clear()
184
- clear_cache()
262
+ _clear_cache_now()
185
263
 
186
264
 
187
265
  def add_processor(processor_instance: Processor, priority: int = 0) -> None:
@@ -196,9 +274,10 @@ def add_processor(processor_instance: Processor, priority: int = 0) -> None:
196
274
  processor_instance.priority = priority
197
275
  _processors.append(processor_instance)
198
276
  _processors.sort(key=lambda proc: proc.priority, reverse=True)
277
+ _processors_dict[type(processor_instance)] = processor_instance
199
278
 
200
279
 
201
- def remove_processor(processor_type: _Type[Processor]) -> None:
280
+ def remove_processor(processor_type: type[Processor]) -> None:
202
281
  """Remove a Processor from the World, by type.
203
282
 
204
283
  Make sure to provide the class itself, **not** an instance. For example::
@@ -210,23 +289,19 @@ def remove_processor(processor_type: _Type[Processor]) -> None:
210
289
  self.world.remove_processor(my_processor_instance)
211
290
 
212
291
  """
213
- for processor in _processors:
214
- if type(processor) is processor_type:
215
- _processors.remove(processor)
292
+ processor = _processors_dict.pop(processor_type, None)
293
+ if processor:
294
+ _processors.remove(processor)
216
295
 
217
296
 
218
- def get_processor(processor_type: _Type[Processor]) -> _Optional[Processor]:
297
+ def get_processor(processor_type: type[Processor]) -> Processor | None:
219
298
  """Get a Processor instance, by type.
220
299
 
221
300
  This function returns a Processor instance by type. This could be
222
301
  useful in certain situations, such as wanting to call a method on a
223
302
  Processor, from within another Processor.
224
303
  """
225
- for processor in _processors:
226
- if type(processor) is processor_type:
227
- return processor
228
- else:
229
- return None
304
+ return _processors_dict.get(processor_type)
230
305
 
231
306
 
232
307
  def create_entity(*components: _C) -> int:
@@ -238,21 +313,19 @@ def create_entity(*components: _C) -> int:
238
313
  added later with the :py:func:`esper.add_component` function.
239
314
  """
240
315
  entity = next(_entity_count)
241
-
242
- if entity not in _entities:
243
- _entities[entity] = {}
316
+ entity_dict = {}
244
317
 
245
318
  for component_instance in components:
246
-
247
319
  component_type = type(component_instance)
248
320
 
249
321
  if component_type not in _components:
250
322
  _components[component_type] = set()
251
323
 
252
324
  _components[component_type].add(entity)
325
+ entity_dict[component_type] = component_instance
253
326
 
254
- _entities[entity][component_type] = component_instance
255
- clear_cache()
327
+ _entities[entity] = entity_dict
328
+ clear_cache()
256
329
 
257
330
  return entity
258
331
 
@@ -270,15 +343,16 @@ def delete_entity(entity: int, immediate: bool = False) -> None:
270
343
  Raises a KeyError if the given entity does not exist in the database.
271
344
  """
272
345
  if immediate:
273
- for component_type in _entities[entity]:
274
- _components[component_type].discard(entity)
346
+ entity_comps = _entities[entity]
347
+ for component_type in entity_comps:
348
+ comp_set = _components[component_type]
349
+ comp_set.discard(entity)
275
350
 
276
- if not _components[component_type]:
351
+ if not comp_set:
277
352
  del _components[component_type]
278
353
 
279
354
  del _entities[entity]
280
355
  clear_cache()
281
-
282
356
  else:
283
357
  _dead_entities.add(entity)
284
358
 
@@ -292,7 +366,12 @@ def entity_exists(entity: int) -> bool:
292
366
  return entity in _entities and entity not in _dead_entities
293
367
 
294
368
 
295
- def component_for_entity(entity: int, component_type: _Type[_C]) -> _C:
369
+ def get_entities() -> _Generator[int]:
370
+ """Iterate over all living Entity IDs."""
371
+ yield from _entities
372
+
373
+
374
+ def component_for_entity(entity: int, component_type: type[_C]) -> _C:
296
375
  """Retrieve a Component instance for a specific Entity.
297
376
 
298
377
  Retrieve a Component instance for a specific Entity. In some cases,
@@ -304,7 +383,7 @@ def component_for_entity(entity: int, component_type: _Type[_C]) -> _C:
304
383
  return _entities[entity][component_type] # type: ignore[no-any-return]
305
384
 
306
385
 
307
- def components_for_entity(entity: int) -> _Tuple[_C, ...]:
386
+ def components_for_entity(entity: int) -> tuple[_C, ...]:
308
387
  """Retrieve all Components for a specific Entity, as a Tuple.
309
388
 
310
389
  Retrieve all Components for a specific Entity. This function is probably
@@ -318,18 +397,18 @@ def components_for_entity(entity: int) -> _Tuple[_C, ...]:
318
397
  return tuple(_entities[entity].values())
319
398
 
320
399
 
321
- def has_component(entity: int, component_type: _Type[_C]) -> bool:
400
+ def has_component(entity: int, component_type: type[_C]) -> bool:
322
401
  """Check if an Entity has a specific Component type."""
323
402
  return component_type in _entities[entity]
324
403
 
325
404
 
326
- def has_components(entity: int, *component_types: _Type[_C]) -> bool:
405
+ def has_components(entity: int, *component_types: type[_C]) -> bool:
327
406
  """Check if an Entity has all the specified Component types."""
328
- components_dict = _entities[entity]
329
- return all(comp_type in components_dict for comp_type in component_types)
407
+ entity_comps = _entities[entity]
408
+ return all(comp_type in entity_comps for comp_type in component_types)
330
409
 
331
410
 
332
- def add_component(entity: int, component_instance: _C, type_alias: _Optional[_Type[_C]] = None) -> None:
411
+ def add_component(entity: int, component_instance: _C, type_alias: type[_C] | None = None) -> None:
333
412
  """Add a new Component instance to an Entity.
334
413
 
335
414
  Add a Component instance to an Entiy. If a Component of the same type
@@ -345,12 +424,11 @@ def add_component(entity: int, component_instance: _C, type_alias: _Optional[_Ty
345
424
  _components[component_type] = set()
346
425
 
347
426
  _components[component_type].add(entity)
348
-
349
427
  _entities[entity][component_type] = component_instance
350
428
  clear_cache()
351
429
 
352
430
 
353
- def remove_component(entity: int, component_type: _Type[_C]) -> _C:
431
+ def remove_component(entity: int, component_type: type[_C]) -> _C:
354
432
  """Remove a Component instance from an Entity, by type.
355
433
 
356
434
  A Component instance can only be removed by providing its type.
@@ -360,66 +438,133 @@ def remove_component(entity: int, component_type: _Type[_C]) -> _C:
360
438
  Raises a KeyError if either the given entity or Component type does
361
439
  not exist in the database.
362
440
  """
363
- _components[component_type].discard(entity)
441
+ comp_set = _components[component_type]
442
+ comp_set.discard(entity)
364
443
 
365
- if not _components[component_type]:
444
+ if not comp_set:
366
445
  del _components[component_type]
367
446
 
368
447
  clear_cache()
369
448
  return _entities[entity].pop(component_type) # type: ignore[no-any-return]
370
449
 
371
450
 
372
- def _get_component(component_type: _Type[_C]) -> _Iterable[_Tuple[int, _C]]:
451
+ def try_remove_component(entity: int, component_type: type[_C]) -> _C | None:
452
+ """Try to remove a Component instance from an Entity, by type.
453
+
454
+ This operation is similar to :py:func:`esper.remove_component`, but
455
+ will NOT raise an exception if the Component does not exist.
456
+ """
457
+ if comp_set := _components.get(component_type):
458
+ comp_set.discard(entity)
459
+
460
+ if not comp_set:
461
+ del _components[component_type]
462
+
463
+ clear_cache()
464
+ return _entities[entity].pop(component_type) # type: ignore[no-any-return]
465
+
466
+ return None
467
+
468
+
469
+ def _get_component(component_type: type[_C]) -> _Iterable[tuple[int, _C]]:
373
470
  entity_db = _entities
471
+ comp_set = _components.get(component_type)
374
472
 
375
- for entity in _components.get(component_type, []):
473
+ if comp_set is None:
474
+ return
475
+
476
+ for entity in comp_set:
376
477
  yield entity, entity_db[entity][component_type]
377
478
 
378
479
 
379
- def _get_components(*component_types: _Type[_C]) -> _Iterable[_Tuple[int, _List[_C]]]:
480
+ def _get_components(*component_types: type[_C]) -> _Iterable[tuple[int, tuple[_C, ...]]]:
481
+ if not component_types:
482
+ return
483
+
380
484
  entity_db = _entities
381
485
  comp_db = _components
382
486
 
383
- try:
384
- for entity in set.intersection(*[comp_db[ct] for ct in component_types]):
385
- yield entity, [entity_db[entity][ct] for ct in component_types]
386
- except KeyError:
387
- pass
388
-
487
+ min_set = None
488
+ min_size = inf
489
+ other_types = []
490
+
491
+ for ct in component_types:
492
+ comp_set = comp_db.get(ct)
493
+ if comp_set is None:
494
+ return
495
+ set_size = len(comp_set)
496
+ if set_size < min_size:
497
+ if min_set is not None:
498
+ other_types.append(component_types[len(other_types)])
499
+ min_size = set_size
500
+ min_set = comp_set
501
+ else:
502
+ other_types.append(ct)
503
+
504
+ if min_set is None:
505
+ return
389
506
 
390
- def get_component(component_type: _Type[_C]) -> _List[_Tuple[int, _C]]:
507
+ if not other_types:
508
+ for entity in min_set:
509
+ entity_comps = entity_db[entity]
510
+ yield entity, tuple(entity_comps[ct] for ct in component_types)
511
+ else:
512
+ for entity in min_set:
513
+ entity_comps = entity_db[entity]
514
+ has_all = True
515
+ for ct in other_types:
516
+ if ct not in entity_comps:
517
+ has_all = False
518
+ break
519
+ if has_all:
520
+ yield entity, tuple(entity_comps[ct] for ct in component_types)
521
+
522
+
523
+ def get_component(component_type: type[_C]) -> list[tuple[int, _C]]:
391
524
  """Get an iterator for Entity, Component pairs."""
392
- try:
393
- return _get_component_cache[component_type]
394
- except KeyError:
395
- return _get_component_cache.setdefault(component_type, list(_get_component(component_type)))
525
+ if _cache_dirty:
526
+ _clear_cache_now()
527
+
528
+ cached = _get_component_cache.get(component_type)
529
+ if cached is not None:
530
+ return cached
531
+
532
+ result = list(_get_component(component_type))
533
+ _get_component_cache[component_type] = result
534
+ return result
396
535
 
397
536
 
398
537
  @_overload
399
- def get_components(__c1: _Type[_C], __c2: _Type[_C2]) -> _List[_Tuple[int, _Tuple[_C, _C2]]]:
538
+ def get_components(__c1: type[_C], __c2: type[_C2]) -> list[tuple[int, tuple[_C, _C2]]]:
400
539
  ...
401
540
 
402
541
 
403
542
  @_overload
404
- def get_components(__c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3]) -> _List[_Tuple[int, _Tuple[_C, _C2, _C3]]]:
543
+ def get_components(__c1: type[_C], __c2: type[_C2], __c3: type[_C3]) -> list[tuple[int, tuple[_C, _C2, _C3]]]:
405
544
  ...
406
545
 
407
546
 
408
547
  @_overload
409
- def get_components(__c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3], __c4: _Type[_C4]) -> _List[
410
- _Tuple[int, _Tuple[_C, _C2, _C3, _C4]]]:
548
+ def get_components(__c1: type[_C], __c2: type[_C2], __c3: type[_C3], __c4: type[_C4]) -> list[
549
+ tuple[int, tuple[_C, _C2, _C3, _C4]]]:
411
550
  ...
412
551
 
413
552
 
414
- def get_components(*component_types: _Type[_Any]) -> _Iterable[_Tuple[int, _Tuple[_Any, ...]]]:
553
+ def get_components(*component_types: type[_Any]) -> list[tuple[int, tuple[_Any, ...]]]:
415
554
  """Get an iterator for Entity and multiple Component sets."""
416
- try:
417
- return _get_components_cache[component_types]
418
- except KeyError:
419
- return _get_components_cache.setdefault(component_types, list(_get_components(*component_types)))
555
+ if _cache_dirty:
556
+ _clear_cache_now()
557
+
558
+ cached = _get_components_cache.get(component_types)
559
+ if cached is not None:
560
+ return cached
420
561
 
562
+ result = list(_get_components(*component_types))
563
+ _get_components_cache[component_types] = result
564
+ return result
421
565
 
422
- def try_component(entity: int, component_type: _Type[_C]) -> _Optional[_C]:
566
+
567
+ def try_component(entity: int, component_type: type[_C]) -> _C | None:
423
568
  """Try to get a single component type for an Entity.
424
569
 
425
570
  This function will return the requested Component if it exists,
@@ -427,27 +572,28 @@ def try_component(entity: int, component_type: _Type[_C]) -> _Optional[_C]:
427
572
  that may or may not exist, without having to first query if the Entity
428
573
  has the Component type.
429
574
  """
430
- if component_type in _entities[entity]:
431
- return _entities[entity][component_type] # type: ignore[no-any-return]
575
+ entity_comps = _entities.get(entity)
576
+ if entity_comps and component_type in entity_comps:
577
+ return entity_comps[component_type] # type: ignore[no-any-return]
432
578
  return None
433
579
 
434
580
 
435
581
  @_overload
436
- def try_components(entity: int, __c1: _Type[_C], __c2: _Type[_C2]) -> _Tuple[_C, _C2]:
582
+ def try_components(entity: int, __c1: type[_C], __c2: type[_C2]) -> tuple[_C, _C2]:
437
583
  ...
438
584
 
439
585
 
440
586
  @_overload
441
- def try_components(entity: int, __c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3]) -> _Tuple[_C, _C2, _C3]:
587
+ def try_components(entity: int, __c1: type[_C], __c2: type[_C2], __c3: type[_C3]) -> tuple[_C, _C2, _C3]:
442
588
  ...
443
589
 
444
590
 
445
591
  @_overload
446
- def try_components(entity: int, __c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3], __c4: _Type[_C4]) -> _Tuple[_C, _C2, _C3, _C4]:
592
+ def try_components(entity: int, __c1: type[_C], __c2: type[_C2], __c3: type[_C3], __c4: type[_C4]) -> tuple[_C, _C2, _C3, _C4]:
447
593
  ...
448
594
 
449
595
 
450
- def try_components(entity: int, *component_types: _Type[_C]) -> _Optional[_Tuple[_C, ...]]:
596
+ def try_components(entity: int, *component_types: type[_C]) -> tuple[_C, ...] | None:
451
597
  """Try to get multiple component types for an Entity.
452
598
 
453
599
  This function will return the requested Components if they exist,
@@ -455,8 +601,9 @@ def try_components(entity: int, *component_types: _Type[_C]) -> _Optional[_Tuple
455
601
  that may or may not exist, without first having to query if the Entity
456
602
  has the Component types.
457
603
  """
458
- if all(comp_type in _entities[entity] for comp_type in component_types):
459
- return [_entities[entity][comp_type] for comp_type in component_types] # type: ignore[return-value]
604
+ entity_comps = _entities.get(entity)
605
+ if entity_comps and all(comp_type in entity_comps for comp_type in component_types):
606
+ return tuple(entity_comps[comp_type] for comp_type in component_types)
460
607
  return None
461
608
 
462
609
 
@@ -471,12 +618,17 @@ def clear_dead_entities() -> None:
471
618
  # In the interest of performance, this function duplicates code from the
472
619
  # `delete_entity` function. If that function is changed, those changes should
473
620
  # be duplicated here as well.
621
+ if not _dead_entities:
622
+ return
623
+
474
624
  for entity in _dead_entities:
625
+ entity_comps = _entities[entity]
475
626
 
476
- for component_type in _entities[entity]:
477
- _components[component_type].discard(entity)
627
+ for component_type in entity_comps:
628
+ comp_set = _components[component_type]
629
+ comp_set.discard(entity)
478
630
 
479
- if not _components[component_type]:
631
+ if not comp_set:
480
632
  del _components[component_type]
481
633
 
482
634
  del _entities[entity]
@@ -511,64 +663,3 @@ def timed_process(*args: _Any, **kwargs: _Any) -> None:
511
663
  start_time = _time.process_time()
512
664
  processor.process(*args, **kwargs)
513
665
  process_times[processor.__class__.__name__] = int((_time.process_time() - start_time) * 1000)
514
-
515
-
516
- def list_worlds() -> _List[str]:
517
- """A list all World context names."""
518
- return list(_context_map)
519
-
520
-
521
- def delete_world(name: str) -> None:
522
- """Delete a World context.
523
-
524
- This will completely delete the World, including all entities
525
- that are contained within it.
526
-
527
- Raises `PermissionError` if you attempt to delete the currently
528
- active World context.
529
- """
530
- if _current_world == name:
531
- raise PermissionError("The active World context cannot be deleted.")
532
-
533
- del _context_map[name]
534
-
535
-
536
- def switch_world(name: str) -> None:
537
- """Switch to a new World context by name.
538
-
539
- Esper can have one or more "Worlds". Each World is a dedicated
540
- context, and does not share Entities, Components, events, etc.
541
- Some game designs can benefit from using a dedicated World
542
- for each scene. For other designs, a single World may be sufficient.
543
-
544
- This function will allow you to create and switch between as
545
- many World contexts as required. If the requested name does not
546
- exist, a new context is created automatically with that name.
547
-
548
- The name of the currently active World context can be checked
549
- at any time by examining the :py:attr:`esper.current_world`.
550
- This attribute gets updated whenever you switch Worlds, and
551
- modifying it has no effect.
552
-
553
- .. note:: At startup, a "default" World context is active.
554
- """
555
- if name not in _context_map:
556
- # Create a new context if the name does not already exist:
557
- _context_map[name] = (_count(start=1), {}, {}, set(), {}, {}, [], {}, {})
558
-
559
- global _current_world
560
- global _entity_count
561
- global _components
562
- global _entities
563
- global _dead_entities
564
- global _get_component_cache
565
- global _get_components_cache
566
- global _processors
567
- global process_times
568
- global event_registry
569
- global current_world
570
-
571
- # switch the references to the objects in the named context_map:
572
- (_entity_count, _components, _entities, _dead_entities, _get_component_cache,
573
- _get_components_cache, _processors, process_times, event_registry) = _context_map[name]
574
- _current_world = current_world = name
@@ -2,6 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
 
4
4
  import gc
5
+ import random
5
6
  import sys
6
7
  import time
7
8
  import optparse
@@ -90,6 +91,11 @@ class Brain:
90
91
  smarts: int = 9000
91
92
 
92
93
 
94
+ @component
95
+ class IsPlayer:
96
+ pass
97
+
98
+
93
99
  #############################
94
100
  # Set up some dummy entities:
95
101
  #############################
@@ -99,6 +105,14 @@ def create_entities(number):
99
105
  esper.create_entity(Position(), Health(), Damageable())
100
106
 
101
107
 
108
+ def create_mixed_entities(number):
109
+ for _ in range(number - 5):
110
+ esper.create_entity(Position(), Velocity())
111
+
112
+ for _ in range(5):
113
+ esper.create_entity(Position(), Velocity(), IsPlayer())
114
+
115
+
102
116
  #############################
103
117
  # Some timed query functions:
104
118
  #############################
@@ -120,10 +134,56 @@ def three_comp_query():
120
134
  pass
121
135
 
122
136
 
137
+ @timing
138
+ def rare_comp_query():
139
+ """
140
+ Benchmark a query involving a common and a rare component.
141
+
142
+ This scenario is designed to highlight the performance gain from the
143
+ "iterate over the smallest set" optimization. The query for
144
+ (Position, Velocity, IsPlayer) should be extremely fast, as it only
145
+ needs to iterate over the few entities that have the rare `IsPlayer`
146
+ component, instead of all entities with `Position`.
147
+ """
148
+ for _, (_, _, _) in esper.get_components(Position, Velocity, IsPlayer):
149
+ pass
150
+
151
+
152
+ @timing
153
+ def dynamic_world_frame(entities_to_kill, new_entities_to_create):
154
+ """
155
+ Benchmark a single frame in a dynamic world with entity churn.
156
+
157
+ This function simulates a typical game loop frame to measure performance
158
+ under dynamic conditions. It tests the combined cost of:
159
+ 1. Running common queries.
160
+ 2. Deleting a batch of existing entities (testing `delete_entity`).
161
+ 3. Creating a batch of new entities (testing `create_entity`).
162
+ 4. Cleaning up dead entities (testing `clear_dead_entities`).
163
+
164
+ This is a good test for the lazy cache invalidation and optimized
165
+ entity cleanup mechanisms.
166
+ """
167
+ for _, (_, _) in esper.get_components(Position, Velocity):
168
+ pass
169
+ for _, (_, _, _) in esper.get_components(Position, Damageable, Health):
170
+ pass
171
+
172
+ for ent_id in entities_to_kill:
173
+ if esper.entity_exists(ent_id):
174
+ esper.delete_entity(ent_id, immediate=False)
175
+
176
+ create_entities(new_entities_to_create)
177
+
178
+ esper.clear_dead_entities()
179
+
180
+
123
181
  #################################################
124
182
  # Perform several queries, and print the results:
125
183
  #################################################
126
184
  results = {1: {}, 2: {}, 3: {}}
185
+ # result_times = []
186
+ new_results = {"Rare Comp": {}, "Dynamic World": {}}
127
187
  result_times = []
128
188
 
129
189
  for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
@@ -163,6 +223,40 @@ for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
163
223
  gc.collect()
164
224
 
165
225
 
226
+ print("\n--- Benchmarking: Optimized Scenarios ---")
227
+
228
+ for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
229
+ create_mixed_entities(amount)
230
+ for _ in range(50):
231
+ rare_comp_query()
232
+ result_min = min(result_times)
233
+ print("Query rare component, {} Entities: {:f} ms".format(amount, result_min))
234
+ new_results["Rare Comp"][amount] = result_min
235
+ result_times = []
236
+ esper.clear_database()
237
+ gc.collect()
238
+
239
+ for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
240
+ create_entities(amount)
241
+ all_entities = list(esper.get_entities())
242
+ k = min(10, len(all_entities))
243
+ entities_to_kill_per_frame = random.sample(all_entities, k=k)
244
+
245
+ for _ in range(50):
246
+ dynamic_world_frame(entities_to_kill_per_frame, 10)
247
+ all_entities = list(esper.get_entities())
248
+ k = min(10, len(all_entities))
249
+ if k > 0:
250
+ entities_to_kill_per_frame = random.sample(all_entities, k=k)
251
+
252
+ result_min = min(result_times)
253
+ print("Dynamic world frame, {} Entities: {:f} ms".format(amount, result_min))
254
+ new_results["Dynamic World"][amount] = result_min
255
+ result_times = []
256
+ esper.clear_database()
257
+ gc.collect()
258
+
259
+
166
260
  #############################################
167
261
  # Save the results to disk, or plot directly:
168
262
  #############################################
@@ -177,6 +271,7 @@ if options.plot:
177
271
  print("\nThe matplotlib module is required for plotting results.")
178
272
  sys.exit(1)
179
273
 
274
+ plt.figure(1)
180
275
  lines = []
181
276
  for num, result in results.items():
182
277
  x, y = zip(*sorted(result.items()))
@@ -185,5 +280,19 @@ if options.plot:
185
280
 
186
281
  plt.ylabel("Query Time (ms)")
187
282
  plt.xlabel("Number of Entities")
283
+ plt.title("Basic Component Queries")
188
284
  plt.legend(handles=lines, bbox_to_anchor=(0.5, 1))
285
+
286
+ plt.figure(2)
287
+ lines = []
288
+ for name, result in new_results.items():
289
+ if result:
290
+ x, y = zip(*sorted(result.items()))
291
+ lines.extend(plt.plot(x, y, label=name, marker='o'))
292
+
293
+ plt.ylabel("Query Time (ms)")
294
+ plt.xlabel("Number of Entities")
295
+ plt.title("Optimized Scenarios")
296
+ plt.legend(handles=lines, bbox_to_anchor=(0.5, 1))
297
+
189
298
  plt.show()
@@ -3,12 +3,12 @@ import esper
3
3
 
4
4
  ##################################
5
5
  ## Here are a couple of Components
6
- ##################################
6
+ ##################################
7
7
  class Renderable(SpriteNode):
8
8
  def __init__(self, **kwargs):
9
9
  SpriteNode.__init__(self, **kwargs)
10
-
11
-
10
+
11
+
12
12
  class Velocity:
13
13
  def __init__(self, x=0.0, y=0.0):
14
14
  self.x = x
@@ -17,11 +17,11 @@ class Velocity:
17
17
 
18
18
  ##############
19
19
  ## A processor
20
- ##############
20
+ ##############
21
21
  class MovementProcessor(esper.Processor):
22
22
  def __init__(self):
23
23
  super().__init__()
24
-
24
+
25
25
  def process(self):
26
26
  for ent, (rend, vel) in self.world.get_components(Renderable, Velocity):
27
27
  rend.position += (vel.x, vel.y)
@@ -33,37 +33,37 @@ class MyScene (Scene):
33
33
  def setup(self):
34
34
  #Create a World Object
35
35
  self.newworld = esper.World()
36
-
36
+
37
37
  #Add the processor
38
38
  self.movement_processor = MovementProcessor()
39
39
  self.newworld.add_processor(self.movement_processor)
40
-
40
+
41
41
  #Create a couple of entities
42
42
  self.player = self.newworld.create_entity()
43
43
  self.newworld.add_component(self.player, Renderable(parent=self,
44
44
  texture='plc:Character_Boy', position=(100, 100)))
45
45
  self.newworld.add_component(self.player, Velocity(x=1, y=.5))
46
-
46
+
47
47
  self.enemy = self.newworld.create_entity()
48
48
  self.newworld.add_component(self.enemy, Renderable(parent=self,
49
49
  texture='plc:Character_Pink_Girl', position=(200, 200)))
50
50
  self.newworld.add_component(self.enemy, Velocity(x=.5, y=0))
51
-
51
+
52
52
  def did_change_size(self):
53
53
  pass
54
-
54
+
55
55
  def update(self):
56
56
  # Process the world at each update!
57
57
  self.newworld.process()
58
-
58
+
59
59
  def touch_began(self, touch):
60
60
  pass
61
-
61
+
62
62
  def touch_moved(self, touch):
63
63
  pass
64
-
64
+
65
65
  def touch_ended(self, touch):
66
66
  pass
67
67
 
68
68
  if __name__ == '__main__':
69
- run(MyScene(), show_fps=True)
69
+ run(MyScene(), show_fps=True)
@@ -2,6 +2,8 @@ import pytest
2
2
 
3
3
  import esper
4
4
 
5
+ from types import GeneratorType
6
+
5
7
 
6
8
  # ECS test
7
9
  @pytest.fixture(autouse=True)
@@ -65,6 +67,14 @@ def test_delete_entity():
65
67
  esper.delete_entity(empty_entity, immediate=True)
66
68
 
67
69
 
70
+ def test_get_entities():
71
+ create_entities(100)
72
+ generator = esper.get_entities()
73
+ assert isinstance(generator, GeneratorType)
74
+ assert isinstance(next(generator), int)
75
+ assert len(list(generator)) == 99 # one already exhausted
76
+
77
+
68
78
  def test_component_for_entity():
69
79
  entity = esper.create_entity(ComponentC())
70
80
  assert isinstance(esper.component_for_entity(entity, ComponentC), ComponentC)
@@ -118,7 +128,7 @@ def test_get_two_components():
118
128
 
119
129
  for ent, comps in esper.get_components(ComponentD, ComponentE):
120
130
  assert isinstance(ent, int)
121
- assert isinstance(comps, list)
131
+ assert isinstance(comps, tuple)
122
132
  assert len(comps) == 2
123
133
 
124
134
  for ent, (d, e) in esper.get_components(ComponentD, ComponentE):
@@ -133,7 +143,7 @@ def test_get_three_components():
133
143
 
134
144
  for ent, comps in esper.get_components(ComponentC, ComponentD, ComponentE):
135
145
  assert isinstance(ent, int)
136
- assert isinstance(comps, list)
146
+ assert isinstance(comps, tuple)
137
147
  assert len(comps) == 3
138
148
 
139
149
  for ent, (c, d, e) in esper.get_components(ComponentC, ComponentD, ComponentE):
@@ -157,7 +167,7 @@ def test_try_components():
157
167
  entity1 = esper.create_entity(ComponentA(), ComponentB())
158
168
 
159
169
  one_item = esper.try_components(entity1, ComponentA, ComponentB)
160
- assert isinstance(one_item, list)
170
+ assert isinstance(one_item, tuple)
161
171
  assert len(one_item) == 2
162
172
  assert isinstance(one_item[0], ComponentA)
163
173
  assert isinstance(one_item[1], ComponentB)
@@ -255,6 +265,21 @@ class TestRemoveComponent:
255
265
  assert not esper.has_component(entity, ComponentB)
256
266
  assert esper.component_for_entity(entity, ComponentC) is component_c
257
267
 
268
+ def test_try_remove_component(self):
269
+ component_a = ComponentA()
270
+ component_b = ComponentB()
271
+ component_c = ComponentC()
272
+ entity = esper.create_entity(component_a, component_b, component_c)
273
+
274
+ # The component should be deleted the first time:
275
+ _deleted_component = esper.try_remove_component(entity, ComponentB)
276
+ assert _deleted_component is component_b
277
+ assert not esper.has_component(entity, ComponentB)
278
+
279
+ # Future calls should return None, and not raise any Exception:
280
+ _deleted_component = esper.try_remove_component(entity, ComponentB)
281
+ assert _deleted_component is None
282
+
258
283
 
259
284
  def test_clear_dead_entities():
260
285
  component = ComponentA()
@@ -516,6 +541,174 @@ def test_remove_handler():
516
541
  assert esper.event_registry == {}
517
542
 
518
543
 
544
+ ##################################################
545
+ # Advanced Feature Tests #
546
+ ##################################################
547
+
548
+
549
+ def test_delayed_delete_entity():
550
+ """
551
+ Verify that delayed entity deletion works as expected.
552
+
553
+ This test checks the default deletion behavior (`immediate=False`).
554
+ It ensures that:
555
+ 1. An entity marked for deletion is immediately considered non-existent
556
+ by the public `entity_exists()` function.
557
+ 2. The entity's data, however, remains in the internal database
558
+ structures until the cleanup process is run.
559
+ 3. After `clear_dead_entities()` is called, the entity is completely
560
+ purged from the database.
561
+ """
562
+ entity = esper.create_entity(ComponentA())
563
+ esper.delete_entity(entity, immediate=False)
564
+
565
+ assert esper.entity_exists(entity) is False
566
+ assert entity in esper._entities
567
+
568
+ esper.clear_dead_entities()
569
+
570
+ assert entity not in esper._entities
571
+ with pytest.raises(KeyError):
572
+ esper.components_for_entity(entity)
573
+
574
+
575
+ def test_cache_invalidation_on_add_component():
576
+ """
577
+ Verify that adding a component correctly invalidates the query cache.
578
+
579
+ This test ensures that:
580
+ 1. A query is run to populate the cache (making it "hot").
581
+ 2. A new component is added to an existing entity, which should trigger
582
+ the lazy cache invalidation mechanism by setting the dirty flag.
583
+ 3. A subsequent query correctly rebuilds the cache and returns the
584
+ expected results.
585
+ """
586
+ entity = esper.create_entity(ComponentA())
587
+
588
+ result1 = esper.get_component(ComponentA)
589
+ assert len(result1) == 1
590
+
591
+ esper.add_component(entity, ComponentB())
592
+
593
+ esper.clear_cache()
594
+ result2 = esper.get_component(ComponentA)
595
+ assert len(result2) == 1
596
+
597
+
598
+ def test_cache_invalidation_on_remove_component():
599
+ """
600
+ Verify that removing a component correctly invalidates the query cache.
601
+
602
+ This test ensures that:
603
+ 1. A query for a specific component combination is run to populate the cache.
604
+ 2. One of the components is removed from the entity, which should
605
+ trigger the lazy cache invalidation by setting the dirty flag.
606
+ 3. A subsequent query for the same component combination correctly
607
+ returns an empty result and clears the dirty flag.
608
+ """
609
+ entity = esper.create_entity(ComponentA(), ComponentB())
610
+
611
+ result1 = esper.get_components(ComponentA, ComponentB)
612
+ assert len(result1) == 1
613
+
614
+ esper.remove_component(entity, ComponentB)
615
+
616
+ result2 = esper.get_components(ComponentA, ComponentB)
617
+ assert len(result2) == 0
618
+
619
+
620
+ def test_processor_priority():
621
+ """
622
+ Verify that processors are executed in the correct order based on priority.
623
+
624
+ This test adds two processors, A and B, with different priority values
625
+ (A has a higher priority of 10, B has a lower priority of 5). It then
626
+ confirms that when `esper.process()` is called, the processor with the
627
+ higher numerical priority (Processor A) is executed before the one with
628
+ the lower priority.
629
+ """
630
+
631
+ class PriorityProcessorA(esper.Processor):
632
+ priority = 10
633
+
634
+ def process(self, order_list):
635
+ order_list.append('A')
636
+
637
+ class PriorityProcessorB(esper.Processor):
638
+ priority = 5
639
+
640
+ def process(self, order_list):
641
+ order_list.append('B')
642
+
643
+ proc_b = PriorityProcessorB()
644
+ proc_a = PriorityProcessorA()
645
+
646
+ esper.add_processor(proc_b, priority=proc_b.priority)
647
+ esper.add_processor(proc_a, priority=proc_a.priority)
648
+
649
+ order = []
650
+ esper.process(order)
651
+
652
+ assert order == ['A', 'B']
653
+
654
+
655
+ def test_weak_reference_handler_removal():
656
+ """
657
+ Verify that event handlers are automatically unregistered when garbage collected.
658
+
659
+ The event system uses weak references to handlers to prevent memory leaks.
660
+ This test confirms that:
661
+ 1. A method from a temporary object instance is registered as an event handler.
662
+ 2. After the only strong reference to the instance is deleted, the garbage
663
+ collector reclaims the object.
664
+ 3. The weak reference in the event registry becomes dead, and the handler
665
+ is automatically removed via its callback.
666
+ 4. Dispatching the event no longer calls the handler, and the event name
667
+ is removed from the registry.
668
+ """
669
+ called = 0
670
+
671
+ class TempHandler:
672
+ def handle(self):
673
+ nonlocal called
674
+ called += 1
675
+
676
+ temp_instance = TempHandler()
677
+ esper.set_handler("temp_event", temp_instance.handle)
678
+
679
+ assert "temp_event" in esper.event_registry
680
+
681
+ del temp_instance
682
+
683
+ import gc
684
+ gc.collect()
685
+
686
+ esper.dispatch_event("temp_event")
687
+ assert called == 0
688
+
689
+ assert "temp_event" not in esper.event_registry
690
+
691
+
692
+ def test_delete_world():
693
+ """
694
+ Verify the functionality of creating and deleting world contexts.
695
+
696
+ This test checks that:
697
+ 1. A new world context can be created implicitly via `switch_world`.
698
+ 2. The `list_worlds` function correctly reports the existence of the new world.
699
+ 3. The `delete_world` function successfully removes the specified world.
700
+ 4. Attempting to delete a non-existent world correctly raises a KeyError.
701
+ """
702
+ esper.switch_world("temp_world")
703
+ esper.switch_world("default")
704
+
705
+ assert "temp_world" in esper.list_worlds()
706
+ esper.delete_world("temp_world")
707
+ assert "temp_world" not in esper.list_worlds()
708
+
709
+ with pytest.raises(KeyError):
710
+ esper.delete_world("non_existent")
711
+
519
712
  ##################################################
520
713
  # Some helper functions and Component templates:
521
714
  ##################################################
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes