Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Dropped support for Python 3.8 & 3.9.

### Fixed

- Component key type-hints now use `TypeForm`.
Complex component types which already worked at runtime are now recognized by type linters.

## [5.4.1] - 2025-07-20

Maintenance release
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dependencies = [
"attrs >=23.1.0",
"cattrs >=23.1.2",
"sentinel-value >=1.0.0",
"typing-extensions >=4.9.0",
"typing-extensions >=4.13.1",
]

[tool.setuptools_scm]
Expand Down Expand Up @@ -50,7 +50,8 @@ Source = "https://github.com/HexDecimal/python-tcod-ecs"
files = "."
exclude = ['^build/', '^\.']
explicit_package_bases = true
python_version = "3.10" # Type check Python version with EllipsisType
python_version = "3.10"
enable_incomplete_feature = ["TypeForm"]
warn_unused_configs = true
disallow_any_generics = true
disallow_subclassing_any = true
Expand Down
19 changes: 9 additions & 10 deletions tcod/ecs/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import attrs
from sentinel_value import sentinel
from typing_extensions import Self, deprecated
from typing_extensions import Self, TypeForm, deprecated

import tcod.ecs.callbacks
import tcod.ecs.query
Expand Down Expand Up @@ -382,7 +382,7 @@ def _traverse_entities(start: Entity, traverse_parents: tuple[object, ...]) -> I


@attrs.define(eq=False, frozen=True, weakref_slot=False)
class EntityComponents(MutableMapping[type[Any] | tuple[object, type[Any]], object]):
class EntityComponents(MutableMapping[TypeForm[Any] | tuple[object, TypeForm[Any]], object]):
"""A proxy attribute to access an entities components like a dictionary.

See :any:`Entity.components`.
Expand Down Expand Up @@ -440,7 +440,7 @@ def __setitem__(self, key: ComponentKey[T], value: T) -> None:

tcod.ecs.callbacks._on_component_changed(key, self.entity, old_value, value)

def __delitem__(self, key: type[object] | tuple[object, type[object]]) -> None:
def __delitem__(self, key: TypeForm[object] | tuple[object, TypeForm[object]]) -> None:
"""Delete a directly held component from an entity."""
assert self.__assert_key(key)

Expand All @@ -467,8 +467,8 @@ def keys(self) -> AbstractSet[ComponentKey[object]]: # type: ignore[override]
*(_components_by_entity.get(entity, ()) for entity in _traverse_entities(self.entity, self.traverse))
)

def __contains__(self, key: ComponentKey[object]) -> bool: # type: ignore[override]
"""Return True if this entity has the provided component."""
def __contains__(self, key: object) -> bool:
"""Return True if this entity has the provided component key."""
_components_by_entity = self.entity.registry._components_by_entity
return any(
key in _components_by_entity.get(entity, ()) for entity in _traverse_entities(self.entity, self.traverse)
Expand All @@ -493,21 +493,20 @@ def update_values(self, values: Iterable[object]) -> None:
self.set(value)

@deprecated("This method has been deprecated. Iterate over items instead.", category=FutureWarning)
def by_name_type(self, name_type: type[_T1], component_type: type[_T2]) -> Iterator[tuple[_T1, type[_T2]]]:
def by_name_type(self, name_type: type[_T1], component_type: TypeForm[_T2]) -> Iterator[tuple[_T1, TypeForm[_T2]]]:
"""Iterate over all of an entities component keys with a specific (name_type, component_type) combination.

.. versionadded:: 3.0

.. deprecated:: 3.1
This method has been deprecated. Iterate over items instead.
"""
# Naive implementation until I feel like optimizing it
for key in self:
if not isinstance(key, tuple):
continue
key_name, key_component = key
if key_component is component_type and isinstance(key_name, name_type):
yield key_name, key_component
yield key_name, key_component # type: ignore[unused-ignore] # Too complex for PyLance, deprecated anyways

@overload
def __ior__(self, value: SupportsKeysAndGetItem[ComponentKey[Any], Any]) -> Self: ...
Expand Down Expand Up @@ -1044,14 +1043,14 @@ def __getitem__(self, key: ComponentKey[T]) -> EntityComponentRelationMapping[T]
"""Access relations for this component key as a `{target: component}` dict-like object."""
return EntityComponentRelationMapping(self.entity, key, self.traverse)

def __setitem__(self, __key: ComponentKey[T], __values: Mapping[Entity, object], /) -> None:
def __setitem__(self, __key: ComponentKey[T], __values: Mapping[Entity, T], /) -> None:
"""Redefine the component relations for this entity.

..versionadded:: 4.2.0
"""
if isinstance(__values, EntityComponentRelationMapping) and __values.entity is self.entity:
return
mapping: EntityComponentRelationMapping[object] = self[__key]
mapping: EntityComponentRelationMapping[T] = self[__key]
mapping.clear()
for target, component in __values.items():
mapping[target] = component
Expand Down
6 changes: 3 additions & 3 deletions tcod/ecs/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ def _defaultdict_of_dict() -> defaultdict[_T1, dict[_T2, _T3]]:


def _components_by_entity_from(
by_type: defaultdict[ComponentKey[object], dict[Entity, Any]],
) -> defaultdict[Entity, dict[ComponentKey[object], Any]]:
by_type: defaultdict[ComponentKey[_T1], dict[Entity, _T1]],
) -> defaultdict[Entity, dict[ComponentKey[_T1], _T1]]:
"""Return the component lookup table from the components sparse-set."""
by_entity: defaultdict[Entity, dict[ComponentKey[object], Any]] = defaultdict(dict)
by_entity: defaultdict[Entity, dict[ComponentKey[_T1], _T1]] = defaultdict(dict)
for component_key, components in by_type.items():
for entity, component in components.items():
by_entity[entity][component_key] = component
Expand Down
4 changes: 3 additions & 1 deletion tcod/ecs/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from types import EllipsisType
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar

from typing_extensions import TypeForm

if TYPE_CHECKING:
from tcod.ecs.entity import Entity
from tcod.ecs.query import BoundQuery
Expand All @@ -15,7 +17,7 @@

_T = TypeVar("_T")

ComponentKey: TypeAlias = type[_T] | tuple[object, type[_T]]
ComponentKey: TypeAlias = TypeForm[_T] | tuple[object, TypeForm[_T]]
"""ComponentKey is plain `type` or tuple `(tag, type)`."""

_RelationTargetLookup: TypeAlias = Entity | EllipsisType
Expand Down
11 changes: 11 additions & 0 deletions tests/test_ecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pickletools
import sys
from collections.abc import Callable, Iterator # noqa: TC003
from typing import Final

import pytest

Expand Down Expand Up @@ -290,3 +291,13 @@ def test_any_of() -> None:
assert world.Q.any_of(tags=["foo"])
assert world.Q.any_of(tags=["foo", "bar"])
assert not world.Q.any_of(tags=["bar"])


def test_type_form() -> None:
world = tcod.ecs.Registry()
TupleKey: Final = ("TupleKey", tuple[int, int]) # noqa: N806

# tuple layout is forgotten when TypeForm support is missing
world[None].components[TupleKey] = (1, 2)
x, y = world[None].components[TupleKey]
assert (x, y) == (1, 2)
Loading