diff --git a/CHANGELOG.md b/CHANGELOG.md index 194f317..315869e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 2b0b4c9..0b84935 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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 diff --git a/tcod/ecs/entity.py b/tcod/ecs/entity.py index cc89c6f..6013bee 100644 --- a/tcod/ecs/entity.py +++ b/tcod/ecs/entity.py @@ -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 @@ -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`. @@ -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) @@ -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) @@ -493,7 +493,7 @@ 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 @@ -501,13 +501,12 @@ def by_name_type(self, name_type: type[_T1], component_type: type[_T2]) -> Itera .. 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: ... @@ -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 diff --git a/tcod/ecs/registry.py b/tcod/ecs/registry.py index 2d3db74..a2927f1 100644 --- a/tcod/ecs/registry.py +++ b/tcod/ecs/registry.py @@ -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 diff --git a/tcod/ecs/typing.py b/tcod/ecs/typing.py index 4949a2f..d262677 100644 --- a/tcod/ecs/typing.py +++ b/tcod/ecs/typing.py @@ -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 @@ -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 diff --git a/tests/test_ecs.py b/tests/test_ecs.py index c7e4412..a245f24 100644 --- a/tests/test_ecs.py +++ b/tests/test_ecs.py @@ -7,6 +7,7 @@ import pickletools import sys from collections.abc import Callable, Iterator # noqa: TC003 +from typing import Final import pytest @@ -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)