diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2b2eb8c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 6d20db5..a91c4d8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,6 +5,10 @@ name: Package on: push: + branches: + - "*" + tags: + - "*.*.*" pull_request: types: [opened, reopened] @@ -12,101 +16,103 @@ defaults: run: shell: bash -jobs: - black: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Install Black - run: pip install black - - name: Run Black - run: black --check --diff tcod/ +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true - isort: +jobs: + pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Install isort - run: pip install isort - - name: isort - run: isort tcod/ --check --diff + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: pre-commit/action@v3.0.1 - ruff: + mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Install Ruff - run: pip install ruff - - name: Ruff - run: ruff . + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + - name: Install package + run: pip install -e ".[test]" + - name: Mypy + run: mypy --show-column-numbers test: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15-dev"] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 name: Setup Python ${{ matrix.python-version }} with: python-version: ${{ matrix.python-version }} - name: Install package run: pip install -e ".[test]" - - name: Mypy - uses: liskin/gh-problem-matcher-wrap@v2 - with: - linters: mypy - run: mypy --show-column-numbers --python-version ${{ matrix.python-version }} - name: Run tests run: pytest --cov-report=xml - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} build-dist: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install build run: pip install build - name: Build package run: python -m build - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v7 with: name: python-dist path: dist/* if-no-files-found: error retention-days: 1 + compression-level: 0 deploy: needs: [build-dist] - if: startsWith(github.ref, 'refs/tags/') + if: github.ref_type == 'tag' runs-on: ubuntu-latest environment: - name: release - url: https://pypi.org/p/tcod-ecs + name: pypi + url: https://pypi.org/project/tcod-ecs/${{ github.ref_name }}/ permissions: - id-token: write + id-token: write # Attestation steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v8 with: name: python-dist path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 release: - if: startsWith(github.ref, 'refs/tags/') + if: github.ref_type == 'tag' name: Create Release runs-on: ubuntu-latest permissions: - contents: write + contents: write # Publish GitHub Releases steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 + with: + persist-credentials: false - name: Generate body run: scripts/get_release_description.py | tee release_body.md - name: Create Release id: create_release - uses: ncipollo/release-action@v1 - with: - bodyFile: release_body.md + # https://cli.github.com/manual/gh_release_create + run: gh release create "${GITHUB_REF_NAME}" --verify-tag --notes-file release_body.md + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/zizmor.yaml b/.github/zizmor.yaml new file mode 100644 index 0000000..d9e822c --- /dev/null +++ b/.github/zizmor.yaml @@ -0,0 +1,9 @@ +rules: + anonymous-definition: + disable: true + cache-poisoning: + disable: true + excessive-permissions: + disable: true + unpinned-uses: + disable: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e2de62..524851c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,10 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +ci: + autoupdate_schedule: quarterly repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -14,18 +16,13 @@ repos: - id: debug-statements - id: fix-byte-order-marker - id: detect-private-key - - repo: https://github.com/psf/black - rev: 23.9.1 - hooks: - - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.15.14 hooks: - - id: ruff + - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - - repo: https://github.com/pycqa/isort - rev: 5.12.0 + - id: ruff-format + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.25.2 hooks: - - id: isort -default_language_version: - python: python3.11 + - id: zizmor diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..6333cdc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "ms-python.mypy-type-checker", + "ms-python.vscode-pylance", + "ms-python.python", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index ce2253e..8be899d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,14 +6,14 @@ "configurations": [ { "name": "Python: Current File", - "type": "python", + "type": "debugpy", "request": "launch", "program": "${file}", "console": "integratedTerminal", }, { "name": "Python: Run tests", - "type": "python", + "type": "debugpy", "request": "launch", "module": "pytest", "preLaunchTask": "develop tcod-ecs", diff --git a/.vscode/settings.json b/.vscode/settings.json index 73adb4a..c0fdc09 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,22 +1,20 @@ { - "python.linting.enabled": true, - "python.linting.pylintEnabled": false, - "python.linting.flake8Enabled": false, - "python.linting.mypyEnabled": true, - "python.linting.mypyArgs": [ - "--follow-imports=silent", - "--show-column-numbers" - ], "editor.formatOnSave": true, "files.trimFinalNewlines": true, "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, "cSpell.words": [ + "addopts", "automodule", + "autoupdate", "autouse", "Benesch", + "buildapi", "cattrs", "codecov", + "cooldown", + "docstrings", + "doctest", "doctests", "dtype", "furo", @@ -28,19 +26,30 @@ "libtcod", "liskin", "maxdepth", + "Mertens", + "minversion", "modindex", "Mypy", + "ncipollo", "Numpad", "PAGEDOWN", "PAGEUP", "pickleable", + "pydocstyle", + "pypa", + "pypi", + "pyright", "pytest", "quickstart", "RMASK", "rtype", "scancode", "setdefault", + "setuptools", + "stdlib", + "subclassing", "tcod", + "testpaths", "toctree", "Traceback", "typehints", @@ -52,11 +61,11 @@ "unstructure", "WASD", "WAXD", - "WINDOWLEAVE" + "WINDOWLEAVE", + "zizmor" ], - "python.formatting.provider": "none", "editor.codeActionsOnSave": { - "source.fixAll": true, + "source.fixAll": "always" }, "editor.rulers": [ 120 @@ -64,6 +73,7 @@ "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "charliermarsh.ruff" }, + "mypy-type-checker.importStrategy": "fromEnvironment" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d683175..4f14484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,130 +6,268 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [5.5.0] - 2026-01-14 + +### Changed + +- Removed dependency on `sentinel-value`. + +## [5.4.2] - 2025-11-28 + +### Removed + +- 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 + +## [5.4.0] - 2025-04-10 + ### Added + +- Allow setting the `traverse` state of an `entity.component_tags[type][entity](traverse=...)` attribute. + +### Changed + +- Updated deprecations to use [PEP 702](https://peps.python.org/pep-0702/). + +### Fixed + +- Fixed `.clear` methods for `entity.components` and `entity.component_relations`. + +## [5.3.0] - 2025-03-08 + +### Added + +- New query `.any_of` method. This was possible before but it is easier with this method. + +## [5.2.4] - 2025-03-07 + +### Fixed + +- Clearing an entity with inherited components no longer leaves the entity with missed components. + +## [5.2.3] - 2024-08-20 + +### Fixed + +- Clearing an entity with inherited tags no longer hangs. + +## [5.2.2] - 2024-08-03 + +### Fixed + +- `EntityComponents.pop` now correctly returns defaults when the components are inherited instead of local. + +## [5.2.1] - 2024-07-30 + +### Fixed + +- Fixed type of default parameter for `EntityComponents.get`. + +## [5.2.0] - 2024-07-22 + +### Changed + +- Queries are now truthy if they match any entity. + +## [5.1.0] - 2024-02-13 + +### Changed + +- Renamed `World` to the more standard name `Registry` in multiple places. + +### Deprecated + +- `World` is now `Registry` +- `WorldQuery` is now `BoundQuery` +- `.world` attributes of `Entity` and `BoundQuery` are now `.registry` + +### Fixed + +- Updated `EntityComponents.__ior__` type hints which were causing false positives. + +## [5.0.0] - 2023-10-20 + +### Added + - Added the `tcod.ecs.IsA` sentinel value. - Entities will automatically inherit components/tags/relations from entities they have an `IsA` relationship with. https://github.com/HexDecimal/python-tcod-ecs/pull/15 - Entities can be used as prefabs, use `Entity.instantiate()` to make a new entities inheriting the base entities components/tags/relations. ### Removed + - `tcod.ecs.query.Query` removed due to a refactor. - `abstract_component` decorator removed. ### Fixed + - Fix for `x in Entity.relation_tags_many` not checking the correct values. ## [4.4.0] - 2023-08-11 + ### Added + - Added `WorldQuery.get_entities` for returning query results as a set. ### Fixed + - Removed an optimization which would check the equality of component values, since this would fail when comparing some types such as NumPy arrays. - Removed unintentional iteration behavior from `World`. https://github.com/HexDecimal/python-tcod-ecs/issues/8 ## [4.3.1] - 2023-08-02 + ### Fixed + - Relation component lookup tables were replacing previous entries instead of adding to them. - Relation ellipsis lookup tables were discarding entities which still had a relevant relation. ## [4.3.0] - 2023-08-01 + ### Added + - `tcod.ecs.typing.ComponentKey` is now stable. - Can now register a callback to be called on component changes. ### Fixed + - Fixed stale caches for relation components. ## [4.2.1] - 2023-07-28 + ### Fixed + - Unpickled worlds had reversed relations from what were saved. ## [4.2.0] - 2023-07-28 + ### Added + - `Entity.relation_components` now has `MutableMapping` functionality. - You can now set the value of `Entity.relation_components[component_key] = {target: component}`. - Added the `Entity.clear` method which effectively deletes an entity by removing its components/tags/relations. This does not delete relations targeting the cleared entity. ## [4.1.0] - 2023-07-28 + ### Added + - Now supports giving a query to another relation query, allowing conditional and chained relation queries. https://github.com/HexDecimal/python-tcod-ecs/issues/1 ## [4.0.0] - 2023-07-26 + ### Changed + - The type returned by `World.Q` has been renamed from `tcod.ecs.Query` to `tcod.ecs.query.WorldQuery`. - Serialization format updated. ### Performance + - Added a simple query cache. A query is automatically cached until any of the components/tags/relations it depends on have changed. ## [3.5.0] - 2023-07-23 + ### Changed + - Serialization format updated, older versions will not be able to unpickle this version. - Reduced the size of the pickled World. ### Fixed + - Missing components in `Entity.components` now returns the missing key in the KeyError exception instead of the entity. - Backwards relations for querying were not cleared on relation deletions. ## [3.4.0] - 2023-07-12 + ### Added + - `Entity.components` now supports the `|=` assignment operator. ## [3.3.0] - 2023-07-12 + ### Added + - `Entity.tags` now supports the `|=` and `-=` assignment operators. ## [3.2.0] - 2023-07-02 + ### Changed + - Warn if a string is passed directly as a tags parameter, which might cause unexpected behavior. - `Entity.relation_tags` has been renamed to `Entity.relation_tag`. ### Deprecated + - Deprecated the renamed attribute `Entity.relation_tag`. ## [3.1.0] - 2023-06-10 + ### Changed + - `World.new_entity` can now take a `Mapping` as the `components` parameter. ### Deprecated + - Implicit keys for components have been deprecated in all places. - The names feature has been deprecated. - `Entity.components.by_name_type` has been deprecated. ## [3.0.1] - 2023-06-04 + ### Deprecated + - `World.global_` has been deprecated since `world[None]` is simpler and less redundant. ## [3.0.0] - 2023-05-29 + ### Added + - `Entity.components.by_name_type(name_type, component_type)` to iterate over named components with names of a specific type. ### Changed + - Remap `World.global_` to `uid=None`. ## [2.0.0] - 2023-05-26 + ### Added + - You can now use custom identifiers for entity objects. You can access these from World instances with `entity = world[uid]`. ### Removed + - Dropped support for unpickling v1.0 World objects. ## [1.2.0] - 2023-04-26 + ### Added + - Allow `Entity` instances to be referenced weakly. ### Fixed + - Added missing typing marker. - Corrected the type-hinting of `Entity.component.get` and `Entity.component.setdefault`. ## [1.1.0] - 2023-04-21 + ### Added + - World's now have a globally accessible entity accessed with `World.global_`. ### Changed + - You can now quickly lookup the relation tags and relation component keys of an entity. ## [1.0.0] - 2023-04-11 + First stable release. diff --git a/LICENSE b/LICENSE index 79dc0f7..a65ed3f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 Kyle Benesch +Copyright (c) 2023-2025 Kyle Benesch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3d5e6d2..451bcf5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The following features are currently implemented: - Entities can have one instance of a type, or multiple instances of a type using a hashable tag to differentiate them. - Entity relationships are supported, either as many-to-many or many-to-one relationships. - ECS Queries can be made to fetch entities having a combination of components/tags/relations or excluding such. -- The ECS World object can be serialized with Python's pickle module for easy storage. +- The ECS Registry object can be serialized with Python's pickle module for easy storage. A lightweight version which implements only the entity-component framework exists called [tcod-ec](https://pypi.org/project/tcod-ec/). `tcod-ec` was geared towards a dynamic-typed-dict style of syntax and is missing a lot of important features such as queries and named components. @@ -24,6 +24,7 @@ A lightweight version which implements only the entity-component framework exist # Installation Use pip to install this library: + ``` pip install tcod-ecs ``` @@ -33,57 +34,59 @@ Remove or update `tcod` to fix this issue. # Examples -## World +## Registry + +The ECS Registry is used to create and store entities and their components. ```py >>> import tcod.ecs ->>> world = tcod.ecs.World() # New empty world +>>> registry = tcod.ecs.Registry() # New empty registry ``` ## Entity -Each Entity is identified by its unique id (`uid`) which can be any hashable object combined with the `world` it belongs. -New unique entities can be created with `World.new_entity` which uses a new `object()` as the `uid`, this guarantees uniqueness which is not always desireable. -An entity always knows about its assigned world, which can be access with the `Entity.world` property from any Entity instance. -Worlds only know about their entities once the entity is assigned a name, component, tag, or relation. +Each Entity is identified by its unique id (`uid`) which can be any hashable object combined with the `registry` it belongs. +New unique entities can be created with `Registry.new_entity` which uses a new `object()` as the `uid`, this guarantees uniqueness which is not always desirable. +An entity always knows about its assigned registry, which can be accessed with the `Entity.registry` property from any Entity instance. +Registries only know about their entities once the entity is assigned a name, component, tag, or relation. ```py ->>> entity = world.new_entity() # Creates a unique entity using `object()` as the uid +>>> entity = registry.new_entity() # Creates a unique entity using `object()` as the uid >>> entity ->>> entity.world is world # Worlds can always be accessed from their entity +>>> entity.registry is registry # Registry can always be accessed from their entity True ->>> world[entity.uid] is entity # Entities with the same world/uid are compared using `is` +>>> registry[entity.uid] is entity # Entities with the same registry/uid are compared using `is` True # Reference an entity with the given uid, can be any hashable object: ->>> entity = world["MyEntity"] +>>> entity = registry["MyEntity"] >>> entity ->>> world["MyEntity"] is entity # Matching entities ALWAYS share a single identity +>>> registry["MyEntity"] is entity # Matching entities ALWAYS share a single identity True ``` -Use `World.new_entity` to create unique entities and use `World[x]` to reference a global entity or relation with an id. -`World[None]` is recommend for use as a global entity when you want to store components in the world itself. +Use `Registry.new_entity` to create unique entities and use `Registry[x]` to reference a global entity or relation with an id. +`registry[None]` is recommend for use as a global entity when you want to store components in the registry itself. -Do not save the `uid`'s of entities to be used later with `World[uid]`, this process is slower than holding onto the Entity instance. +Do not save the `uid`'s of entities to be used later with `registry[uid]`, this process is slower than holding onto the Entity instance. ## Serialization -Worlds are normal Python objects and can be pickled as long as all stored components are pickleable. +Registries are normal Python objects and can be pickled as long as all stored components are pickleable. ```py >>> import pickle ->>> pickled_data: bytes = pickle.dumps(world) ->>> world = pickle.loads(pickled_data) +>>> pickled_data: bytes = pickle.dumps(registry) +>>> registry = pickle.loads(pickled_data) ``` Stability is a priority but changes may still break older saves. -Backwards compatibility is not a priority, pickled worlds should not be unpickled with an older version of the library. +Backwards compatibility is not a priority, pickled registries should not be unpickled with an older version of the library. This project follows [Semantic Versioning](https://semver.org/), major version increments will break the API, the save format or both, minor version increments may break backwards compatibility. Check the [changelog](https://github.com/HexDecimal/python-tcod-ecs/blob/main/CHANGELOG.md) to be aware of format changes and breaks. There should always be a transition period before a format break, so keeping up with the latest version is a good idea. @@ -97,7 +100,7 @@ The types used can be custom classes or standard Python types. ```py >>> import attrs ->>> entity = world.new_entity() +>>> entity = registry.new_entity() >>> entity.components[int] = 42 >>> entity.components[int] 42 @@ -123,20 +126,20 @@ Vector2(x=1, y=2) >>> entity.components[Vector2] Vector2(x=0, y=0) -# Queries can be made on all entities of a world with matching components ->>> for e in world.Q.all_of(components=[Vector2]): +# Queries can be made on all entities of a registry with matching components +>>> for e in registry.Q.all_of(components=[Vector2]): ... e.components[Vector2].x += 10 >>> entity.components[Vector2] Vector2(x=10, y=0) # You can match components and iterate over them at the same time. This can be combined with the above ->>> for pos, i in world.Q[Vector2, int]: +>>> for pos, i in registry.Q[Vector2, int]: ... print((pos, i)) (Vector2(x=10, y=0), 11) # You can include `Entity` to iterate over entities with their components # This always iterates over the entity itself instead of an Entity component ->>> for e, pos, i in world.Q[tcod.ecs.Entity, Vector2, int]: +>>> for e, pos, i in registry.Q[tcod.ecs.Entity, Vector2, int]: ... print((e, pos, i)) (, Vector2(x=10, y=0), 11) @@ -151,7 +154,7 @@ The syntax `[type]` and `[(name, type)]` can be used interchangeably in all plac Queries on components access named components with the same syntax and must use names explicitly. ```py ->>> entity = world.new_entity() +>>> entity = registry.new_entity() >>> entity.components[Vector2] = Vector2(0, 0) >>> entity.components[("velocity", Vector2)] = Vector2(1, 1) >>> entity.components[("velocity", Vector2)] @@ -174,11 +177,11 @@ Vector2(x=1, y=1) 'empty' # Queries can be made on all named components with the same syntax as normal ones ->>> for e in world.Q.all_of(components=[("hp", int), ("max_hp", int)]): +>>> for e in registry.Q.all_of(components=[("hp", int), ("max_hp", int)]): ... e.components[("hp", int)] = e.components[("max_hp", int)] >>> entity.components[("hp", int)] 12 ->>> for e, pos, delta in world.Q[tcod.ecs.Entity, Vector2, ("velocity", Vector2)]: +>>> for e, pos, delta in registry.Q[tcod.ecs.Entity, Vector2, ("velocity", Vector2)]: ... e.components[Vector2] = Vector2(pos.x + delta.x, pos.y + delta.y) >>> entity.components[Vector2] Vector2(x=1, y=1) @@ -191,66 +194,84 @@ Tags are hashable objects stored in the set-like `Entity.tags`. These are useful as flags or to group entities together. ```py ->>> entity = world.new_entity() +>>> entity = registry.new_entity() >>> entity.tags.add("player") # Works well for groups >>> "player" in entity.tags True >>> entity.tags.add(("eats", "fruit")) >>> entity.tags.add(("eats", "meat")) ->>> set(world.Q.all_of(tags=["player"])) == {entity} +>>> set(registry.Q.all_of(tags=["player"])) == {entity} True ``` ## Relations -Use `Entity.relation_components[component_key][target] = component` to associate a target entity with a component. -Use `Entity.relation_tag[tag] = target` to associate a tag exclusively with a target entity. -Use `Entity.relation_tags_many[tag].add(target)` to associate a tag with multiple targets. +Entity relations are unidirectional from an origin entity to possibly multiple target entities. + +- Use `origin.relation_tag[tag] = target` to associate an origins tag exclusively with the target entity. + This uses standard assignment and is useful for tags which would not make sense with multiple targets. + Reading `origin.relation_tag[tag]` returns a single target while enforcing the invariant of only having one target. +- Use `origin.relation_tags_many[tag].add(target)` to associate a tag with multiple targets. + This supports `set`-like syntax such as adding or removing multiple targets at once. + This allows for many-to-many relations. +- Use `origin.relation_components[component_key][target] = component` to associate a target entity with a component. + This allows storing data along with a relation. + This supports `dict`-like syntax. + The `component_key` can be queried like a normal tag. + +### Relation queries + +Relations are queried with `registry.Q.all_of(relations=[...])`. +This expects 2-item or 3-item tuples following these rules: -Relation queries are a little more complex than other queries. -Relation tags and relation components share the same space then queried, so 'normal' tags should not be in the format of a component key. -Relations are unidirectional, but you can query either end of a relation. +- Use `(tag, target)` to match the origin entities with the relation `tag` to `target`. +- If `tag` is a component key then component relations are also matched. + This means you should be careful with tags which look like component keys. +- `target` can be a specific entity. This means only entities relating to that specific entity will be matched. +- `target` can be query itself. This means only entities relating to a match from the sub-query are matched. +- `target` can be `...` which means an entity with a relation to any entity is matched. +- To reverse the direction use a 3-item tuple `(origin, tag, None)`. `origin` can be anything a `target` could be. + +Relations using sub-queries may be chained together. +See [Sander Mertens - Why it is time to start thinking of games as databases](https://ajmmertens.medium.com/why-it-is-time-to-start-thinking-of-games-as-databases-e7971da33ac3) to understand the repercussion of this. + +You can use the following table to help with constructing relation queries: + +| Matches | Syntax | +| ------------------------------------------------------------------- | :--------------------------------------: | +| Origins with a relation `tag` to `target_entity` | `(tag, target_entity)` | +| Origins with a relation `tag` to any target entity | `(tag, ...)` (Literal dot-dot-dot) | +| Origins with a relation `tag` to any targets matching a sub-query | `(tag, registry.Q.all_of(...))` | +| Targets of the relation `tag` from `origin_entity` | `(origin_entity, tag, None)` | +| Targets of the relation `tag` from any origin entity | `(..., tag, None)` (Literal dot-dot-dot) | +| Targets of the relation `tag` from any origins matching a sub-query | `(registry.Q.all_of(...), tag, None)` | ```py >>> @attrs.define ... class OrbitOf: # OrbitOf component ... dist: int >>> LandedOn = "LandedOn" # LandedOn tag ->>> star = world.new_entity() ->>> planet = world.new_entity() ->>> moon = world.new_entity() ->>> ship = world.new_entity() ->>> player = world.new_entity() ->>> moon_rock = world.new_entity() +>>> star = registry.new_entity() +>>> planet = registry.new_entity() +>>> moon = registry.new_entity() +>>> ship = registry.new_entity() +>>> player = registry.new_entity() +>>> moon_rock = registry.new_entity() >>> planet.relation_components[OrbitOf][star] = OrbitOf(dist=1000) >>> moon.relation_components[OrbitOf][planet] = OrbitOf(dist=10) >>> ship.relation_tag[LandedOn] = moon >>> moon_rock.relation_tag[LandedOn] = moon >>> player.relation_tag[LandedOn] = moon_rock ->>> set(world.Q.all_of(relations=[(OrbitOf, planet)])) == {moon} +>>> set(registry.Q.all_of(relations=[(OrbitOf, planet)])) == {moon} True ->>> set(world.Q.all_of(relations=[(OrbitOf, ...)])) == {planet, moon} # Get objects in an orbit +>>> set(registry.Q.all_of(relations=[(OrbitOf, ...)])) == {planet, moon} # Get objects in an orbit True ->>> set(world.Q.all_of(relations=[(..., OrbitOf, None)])) == {star, planet} # Get objects being orbited +>>> set(registry.Q.all_of(relations=[(..., OrbitOf, None)])) == {star, planet} # Get objects being orbited True ->>> set(world.Q.all_of(relations=[(LandedOn, ...)])) == {ship, moon_rock, player} +>>> set(registry.Q.all_of(relations=[(LandedOn, ...)])) == {ship, moon_rock, player} True ->>> set(world.Q.all_of(relations=[(LandedOn, ...)]).none_of(relations=[(LandedOn, moon)])) == {player} +>>> set(registry.Q.all_of(relations=[(LandedOn, ...)]).none_of(relations=[(LandedOn, moon)])) == {player} True ``` - -### Relation queries - -You can use the following table to help with constructing relation queries. -`tag` is a component key if you are querying for a component relation. - -| Includes | Syntax | -| --------------------------------------------------------------------- | :----: | -| Entities with a relation tag to the given target | `(tag, target_entity)` | -| Entities with a relation tag to any target | `(tag, ...)` (Literal dot-dot-dot) | -| Entities with a relation tag to the targets in the given query | `(tag, world.Q.all_of(...))` | -| The target entities of a relation of a given entity | `(origin_entity, tag, None)` | -| The target entities of any entity with the given relation tag | `(..., tag, None)` (Literal dot-dot-dot) | -| The target entities of the queried entities with the given relation | `(tag, world.Q.all_of(...))` | diff --git a/conftest.py b/conftest.py index fe2d585..2ff6934 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,7 @@ -# ruff: noqa: D100 D103 ANN401 -from typing import Any, Dict +# ruff: noqa: D100 +from __future__ import annotations + +from typing import Any import pytest @@ -7,15 +9,15 @@ @pytest.fixture(autouse=True) -def _add_world_entity(doctest_namespace: Dict[str, Any]) -> None: - """Add world and entity objects to all doctests.""" - world = tcod.ecs.World() - entity = world["entity"] - other_entity = world["other"] +def _add_registry_entity(doctest_namespace: dict[str, Any]) -> None: + """Add registry and entity objects to all doctests.""" + registry = tcod.ecs.Registry() + entity = registry["entity"] + other_entity = registry["other"] doctest_namespace.update( { "tcod": tcod, - "world": world, + "registry": registry, "entity": entity, "other_entity": other_entity, } diff --git a/docs/api.rst b/docs/api.rst index f0c6c83..d1e60e9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,9 +5,9 @@ API reference :members: :undoc-members: :show-inheritance: - :exclude-members: World, Entity + :exclude-members: Registry, World, Entity, IsA -.. automodule:: tcod.ecs.world +.. automodule:: tcod.ecs.registry :members: :undoc-members: :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index 74e1656..bc08cb5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,10 +6,12 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +from __future__ import annotations + import importlib.metadata project = "tcod-ecs" -copyright = "2023, Kyle Benesch" +copyright = "2023-2024, Kyle Benesch" # noqa: A001 author = "Kyle Benesch" release = importlib.metadata.version(project) version = ".".join(release.split(".")[:2]) @@ -32,7 +34,7 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "furo" -# html_static_path = ["_static"] +# html_static_path = ["_static"] # noqa: ERA001 intersphinx_mapping = { diff --git a/pyproject.toml b/pyproject.toml index a61887c..84cea06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,12 +17,11 @@ classifiers = [ "Typing :: Typed", ] dynamic = ["version", "description"] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ "attrs >=23.1.0", "cattrs >=23.1.2", - "sentinel-value >=1.0.0", - "typing-extensions >=4.4.0", + "typing-extensions >=4.14.0", ] [tool.setuptools_scm] @@ -34,7 +33,7 @@ test = [ "pytest >=7.2.0", "pytest-cov >=4.0.0", "pytest-benchmark >=4.0.0", - "mypy >=1.1.1", + "mypy >=1.17.0", ] [tool.flit.module] @@ -46,21 +45,12 @@ Changelog = "https://github.com/HexDecimal/python-tcod-ecs/blob/main/CHANGELOG.m Documentation = "https://python-tcod-ecs.readthedocs.io" Source = "https://github.com/HexDecimal/python-tcod-ecs" -[tool.black] # https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-via-a-file -target-version = ["py38"] -line-length = 120 - -[tool.isort] # https://pycqa.github.io/isort/docs/configuration/options.html -py_version = "38" -line_length = 120 -profile = "black" -skip_gitignore = true - [tool.mypy] # https://mypy.readthedocs.io/en/stable/config_file.html 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 @@ -75,6 +65,16 @@ warn_unused_ignores = true warn_return_any = true no_implicit_reexport = true strict_equality = true +strict_bytes = true +extra_checks = true + +[tool.pyright] +reportInconsistentOverload = false +reportIncompatibleMethodOverride = false +reportAssignmentType = false +reportCallIssue = false +reportInvalidTypeVarUse = false +reportArgumentType = false [tool.pytest.ini_options] minversion = "6.0" @@ -86,42 +86,22 @@ testpaths = ["."] exclude_lines = ['^\s*\.\.\.', "if TYPE_CHECKING:", "# pragma: no cover"] [tool.ruff] -# https://beta.ruff.rs/docs/rules/ -select = [ - "C90", # mccabe - "E", # pycodestyle - "W", # pycodestyle - "F", # Pyflakes - "I", # isort - "UP", # pyupgrade - "YTT", # flake8-2020 - "ANN", # flake8-annotations - "S", # flake8-bandit - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "DTZ", # flake8-datetimez - "EM", # flake8-errmsg - "EXE", # flake8-executable - "RET", # flake8-return - "ICN", # flake8-import-conventions - "PIE", # flake8-pie - "PT", # flake8-pytest-style - "SIM", # flake8-simplify - "PTH", # flake8-use-pathlib - "PL", # Pylint - "TRY", # tryceratops - "RUF", # NumPy-specific rules - "G", # flake8-logging-format - "D", # pydocstyle -] +line-length = 120 + +[tool.ruff.lint] # https://docs.astral.sh/ruff/rules/ +select = ["ALL"] ignore = [ + "A005", # stdlib-module-shadowing, workaround VSCode treating all modules as local + "COM", # flake8-commas, handled by formatter "E501", # line-too-long "S101", # assert - "ANN101", # missing-type-self - "ANN102", # missing-type-cls + "SLF001", # private-member-access + "T10", # flake8-debugger + "T20", # flake8-print ] -line-length = 120 -[tool.ruff.pydocstyle] -# Use Google-style docstrings. -convention = "google" +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] + +[tool.ruff.lint.pydocstyle] +convention = "google" # Use Google-style docstrings. diff --git a/scripts/get_release_description.py b/scripts/get_release_description.py index 7d47d9f..c106885 100755 --- a/scripts/get_release_description.py +++ b/scripts/get_release_description.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Print the description used for GitHub Releases.""" + from __future__ import annotations import re diff --git a/tcod/ecs/__init__.py b/tcod/ecs/__init__.py index 946df61..5661153 100644 --- a/tcod/ecs/__init__.py +++ b/tcod/ecs/__init__.py @@ -1,18 +1,29 @@ """A type-hinted Entity Component System based on Python dictionaries and sets.""" + from __future__ import annotations import importlib.metadata from collections import defaultdict -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from tcod.ecs.constants import IsA from tcod.ecs.entity import Entity -from tcod.ecs.world import World +from tcod.ecs.registry import Registry + +if TYPE_CHECKING: + from typing_extensions import deprecated + + @deprecated("Renamed to Registry.") + class World(Registry): # noqa: D101 + pass + +else: + from tcod.ecs.registry import Registry as World __all__ = ( - "__version__", "Entity", "IsA", + "Registry", "World", ) diff --git a/tcod/ecs/_converter.py b/tcod/ecs/_converter.py index 8e7a2ff..55fcf11 100644 --- a/tcod/ecs/_converter.py +++ b/tcod/ecs/_converter.py @@ -2,17 +2,20 @@ import functools from collections import defaultdict -from typing import Any, Callable, get_args, get_origin +from typing import TYPE_CHECKING, Any, get_args, get_origin import cattrs +if TYPE_CHECKING: + from collections.abc import Callable + def _is_defaultdict_type(type_hint: object) -> bool: """Return True if `type_hint` is a defaultdict type-hint.""" return get_origin(type_hint) is defaultdict -def _setup_defaultdict_factory(type_hint: type[defaultdict[Any, Any]] | type[object]) -> Callable[[], Any]: +def _setup_defaultdict_factory(type_hint: type[defaultdict[Any, Any] | object]) -> Callable[[], Any]: """Return the factory value for a defaultdict given its value type-hint.""" assert type_hint is not Any if get_origin(type_hint) is not defaultdict: diff --git a/tcod/ecs/callbacks.py b/tcod/ecs/callbacks.py index e051216..34e51e3 100644 --- a/tcod/ecs/callbacks.py +++ b/tcod/ecs/callbacks.py @@ -1,25 +1,25 @@ """ECS callback management.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union -from typing_extensions import TypeAlias +from __future__ import annotations -from tcod.ecs.typing import ComponentKey +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar if TYPE_CHECKING: from tcod.ecs.entity import Entity + from tcod.ecs.typing import ComponentKey _T = TypeVar("_T") -_OnComponentChangedFunc: TypeAlias = Callable[["Entity", Union[_T, None], Union[_T, None]], None] +_OnComponentChangedFunc: TypeAlias = Callable[["Entity", _T | None, _T | None], None] _OnComponentChangedFuncT = TypeVar("_OnComponentChangedFuncT", bound=_OnComponentChangedFunc[Any]) _on_component_changed_callbacks: dict[ComponentKey[Any], list[_OnComponentChangedFunc[Any]]] = {} def register_component_changed( - *, component: ComponentKey[Any] + *, + component: ComponentKey[Any], ) -> Callable[[_OnComponentChangedFuncT], _OnComponentChangedFuncT]: """Return a decorator to register on-component-changed callback functions. diff --git a/tcod/ecs/constants.py b/tcod/ecs/constants.py index 75ca669..a7a122a 100644 --- a/tcod/ecs/constants.py +++ b/tcod/ecs/constants.py @@ -1,7 +1,21 @@ """Special constants and sentinel values.""" -from typing import Final -from sentinel_value import sentinel +from __future__ import annotations -IsA: Final = sentinel("IsA") -"""The default is-a relationship tag used for entity inheritance.""" +from typing_extensions import Never + + +class _IgnoreSetState(type): + def __setstate__(cls, _state: object) -> None: + """Ignore setstate on outdated sentinel-value pickle data.""" + + +class IsA(metaclass=_IgnoreSetState): + """The default is-a relationship tag used for entity inheritance.""" + + def __new__(cls: type[IsA], *_: object) -> Never: # noqa: D102 + # Return own type instead of instance, for outdated sentinel-value pickle data. + return cls # type: ignore[misc] + + +_sentinel_IsA = IsA # Compatibility with sentinel-value, deprecated since 5.4 # noqa: N816 diff --git a/tcod/ecs/entity.py b/tcod/ecs/entity.py index 018960b..3f5d1dc 100644 --- a/tcod/ecs/entity.py +++ b/tcod/ecs/entity.py @@ -1,27 +1,20 @@ """Entity management and interface tools.""" + from __future__ import annotations -import warnings -from collections.abc import Set +from collections.abc import Iterable, Iterator, Mapping, MutableMapping, MutableSet from typing import ( TYPE_CHECKING, Any, Final, Generic, - Iterable, - Iterator, - Mapping, - MutableMapping, - MutableSet, - Tuple, - Type, TypeVar, - Union, + overload, ) from weakref import WeakKeyDictionary, WeakValueDictionary import attrs -from typing_extensions import Self +from typing_extensions import Self, Sentinel, TypeForm, deprecated import tcod.ecs.callbacks import tcod.ecs.query @@ -29,77 +22,91 @@ from tcod.ecs.typing import ComponentKey if TYPE_CHECKING: + from collections.abc import Set as AbstractSet + from _typeshed import SupportsKeysAndGetItem - from tcod.ecs.world import World + from tcod.ecs.registry import Registry T = TypeVar("T") _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") -_entity_table: WeakKeyDictionary[World, WeakValueDictionary[object, Entity]] = WeakKeyDictionary() -"""A weak table of worlds and unique identifiers to entity objects. +_raise: Final = Sentinel("_raise") + +_entity_table: WeakKeyDictionary[Registry, WeakValueDictionary[object, Entity]] = WeakKeyDictionary() +"""A weak table of registries and unique identifiers to entity objects. This table is used to that non-unique Entity's won't create a new object and thus will always share identities. -_entity_table[world][uid] = entity +_entity_table[registry][uid] = entity """ class Entity: - """A unique entity in a world. + """A unique entity in a registry. Example:: >>> import tcod.ecs - >>> world = tcod.ecs.World() # Create a new world - >>> world.new_entity() # Create a new entity + >>> registry = tcod.ecs.Registry() # Create a new registry + >>> registry.new_entity() # Create a new entity - >>> entity = world["entity"] # Get an entity from a specific identifier - >>> other_entity = world["other"] + >>> entity = registry["entity"] # Get an entity from a specific identifier + >>> other_entity = registry["other"] """ # Changes here should be reflected in conftest.py - __slots__ = ("world", "uid", "__weakref__") + __slots__ = ("__weakref__", "registry", "uid") - world: Final[World] # type:ignore[misc] # https://github.com/python/mypy/issues/5774 - """The :any:`World` this entity belongs to.""" + registry: Final[Registry] # type:ignore[misc] # https://github.com/python/mypy/issues/5774 + """The :any:`Registry` this entity belongs to.""" uid: Final[object] # type:ignore[misc] """This entities unique identifier.""" - def __new__(cls, world: World, uid: object = object) -> Entity: - """Return a unique entity for the given `world` and `uid`. + @property + @deprecated("Use '.registry' instead of '.world'") + def world(self) -> Registry: + """Deprecated alias for registry. + + .. deprecated:: 5.1 + Use :any:`registry` instead. + """ + return self.registry + + def __new__(cls, registry: Registry, uid: object = object) -> Self | Entity: + """Return a unique entity for the given `registry` and `uid`. - If an entity already exists with a matching `world` and `uid` then that entity is returned. + If an entity already exists with a matching `registry` and `uid` then that entity is returned. The `uid` default of `object` will create an instance of :any:`object` as the `uid`. An entity created this way will never match or collide with an existing entity. Example:: - >>> world = tcod.ecs.World() - >>> Entity(world, "foo") + >>> registry = tcod.ecs.Registry() + >>> Entity(registry, "foo") - >>> Entity(world, "foo") is Entity(world, "foo") + >>> Entity(registry, "foo") is Entity(registry, "foo") True - >>> Entity(world) is Entity(world) + >>> Entity(registry) is Entity(registry) False """ if uid is object: uid = object() try: - table = _entity_table[world] + table = _entity_table[registry] except KeyError: table = WeakValueDictionary() - _entity_table[world] = table + _entity_table[registry] = table try: return table[uid] except KeyError: pass self = super().__new__(cls) - self.world = world # type:ignore[misc] # https://github.com/python/mypy/issues/5774 + self.registry = registry # type:ignore[misc] # https://github.com/python/mypy/issues/5774 self.uid = uid # type:ignore[misc] - _entity_table[world][uid] = self + _entity_table[registry][uid] = self return self def clear(self) -> None: @@ -120,29 +127,37 @@ def instantiate(self) -> Self: This creates a new unique entity and assigns an :any:`IsA` relationship with `self` to the new entity. The :any:`IsA` relation is the only data this new entity directly holds. + Immutable components inherited from the parent are always copy-on-write for its child instances. + + Keep in mind that components/tags/relations inherited from the parent are not removable from the child instance. + Example:: # 'child = entity.instantiate()' is equivalent to the following: >>> from tcod.ecs import IsA - >>> child = world[object()] # New unique entity + >>> child = registry[object()] # New unique entity >>> child.relation_tag[IsA] = entity # Configure IsA relation Example:: - >>> parent = world.new_entity() + >>> parent = registry.new_entity() >>> parent.components[str] = "baz" >>> child = parent.instantiate() >>> child.components[str] # Inherits components from parent 'baz' >>> parent.components[str] = "foo" - >>> child.components[str] # Changes in parent and reflected in children + >>> child.components[str] # Changes in parent are reflected in children 'foo' >>> child.components[str] += "bar" # In-place assignment operators will copy-on-write immutable objects >>> child.components[str] 'foobar' >>> parent.components[str] 'foo' - >>> del child.components[str] + >>> child.components.pop(str, None) # Revert the component to the inherited value + 'foobar' + >>> child.components[str] + 'foo' + >>> child.components.pop(str, None) # Safe to call .pop with default when the value isn't set on the child >>> child.components[str] 'foo' @@ -163,9 +178,9 @@ def instantiate(self) -> Self: >>> parent.components[Tuple[str, ...]] ('foo',) - .. versionadded:: Unreleased + .. versionadded:: 5.0 """ - new_entity = self.__class__(self.world, object()) + new_entity = self.__class__(self.registry, object()) new_entity.relation_tag[IsA] = self return new_entity @@ -186,9 +201,9 @@ def components(self) -> EntityComponents: True >>> {str, ("name", str)}.issubset(entity.components.keys()) True - >>> list(world.Q.all_of(components=[str])) # Query components + >>> list(registry.Q.all_of(components=[str])) # Query components [] - >>> list(world.Q[tcod.ecs.Entity, str, ("name", str)]) # Query zip components + >>> list(registry.Q[tcod.ecs.Entity, str, ("name", str)]) # Query zip components [(, 'foo', 'my_name')] """ return EntityComponents(self, (IsA,)) @@ -206,7 +221,7 @@ def tags(self) -> EntityTags: >>> entity.tags.add("tag") # Add tag >>> "tag" in entity.tags # Check tag True - >>> list(world.Q.all_of(tags=["tag"])) # Query tags + >>> list(registry.Q.all_of(tags=["tag"])) # Query tags [] >>> entity.tags.discard("tag") >>> entity.tags |= {"IsPortable", "CanBurn", "OnFire"} # Supports in-place syntax @@ -232,13 +247,13 @@ def relation_components(self) -> EntityComponentRelations: >>> entity.relation_components[("distance", int)][other_entity] = 42 # Also works for named components >>> other_entity in entity.relation_components[str] True - >>> list(world.Q.all_of(relations=[(str, other_entity)])) + >>> list(registry.Q.all_of(relations=[(str, other_entity)])) [] - >>> list(world.Q.all_of(relations=[(str, ...)])) + >>> list(registry.Q.all_of(relations=[(str, ...)])) [] - >>> list(world.Q.all_of(relations=[(entity, str, None)])) + >>> list(registry.Q.all_of(relations=[(entity, str, None)])) [] - >>> list(world.Q.all_of(relations=[(..., str, None)])) + >>> list(registry.Q.all_of(relations=[(..., str, None)])) [] """ return EntityComponentRelations(self, (IsA,)) @@ -250,22 +265,22 @@ def relation_tag(self) -> EntityRelationsExclusive: Example:: >>> entity.relation_tag["ChildOf"] = other_entity # Assign relation - >>> list(world.Q.all_of(relations=[("ChildOf", other_entity)])) # Get children of other_entity + >>> list(registry.Q.all_of(relations=[("ChildOf", other_entity)])) # Get children of other_entity [] - >>> list(world.Q.all_of(relations=[(entity, "ChildOf", None)])) # Get parents of entity + >>> list(registry.Q.all_of(relations=[(entity, "ChildOf", None)])) # Get parents of entity [] >>> del entity.relation_tag["ChildOf"] """ return EntityRelationsExclusive(self, (IsA,)) @property + @deprecated("The '.relation_tags' attribute has been renamed to '.relation_tag'", category=FutureWarning) def relation_tags(self) -> EntityRelationsExclusive: """Access an entities exclusive relations. .. deprecated:: 3.2 This attribute was renamed to :any:`relation_tag`. """ - warnings.warn("The '.relation_tags' attribute has been renamed to '.relation_tag'", FutureWarning, stacklevel=2) return EntityRelationsExclusive(self, (IsA,)) @property @@ -278,66 +293,63 @@ def relation_tags_many(self) -> EntityRelations: """ return EntityRelations(self, (IsA,)) - def _set_name(self, value: object, stacklevel: int = 1) -> None: - warnings.warn( - "The name feature has been deprecated and will be removed.", - FutureWarning, - stacklevel=stacklevel + 1, - ) + @deprecated("The name feature has been deprecated and will be removed.", category=FutureWarning) + def _set_name(self, value: object) -> None: old_name = self.name if old_name is not None: # Remove self from names - del self.world._names_by_name[old_name] - del self.world._names_by_entity[self] + del self.registry._names_by_name[old_name] + del self.registry._names_by_entity[self] if value is not None: # Add self to names - old_entity = self.world._names_by_name.get(value) + old_entity = self.registry._names_by_name.get(value) if old_entity is not None: # Remove entity with old name, name will be overwritten - del self.world._names_by_entity[old_entity] - self.world._names_by_name[value] = self - self.world._names_by_entity[self] = value + del self.registry._names_by_entity[old_entity] + self.registry._names_by_name[value] = self + self.registry._names_by_entity[self] = value @property def name(self) -> object: """The unique name of this entity or None. - You may assign a new name, but if an entity of the world already has that name then it will lose it. + You may assign a new name, but if an entity of the registry already has that name then it will lose it. .. deprecated:: 3.1 This feature has been deprecated. """ - return self.world._names_by_entity.get(self) + return self.registry._names_by_entity.get(self) @name.setter + @deprecated("The name feature has been deprecated and will be removed.", category=FutureWarning) def name(self, value: object) -> None: - self._set_name(value, stacklevel=2) + self._set_name(value) def __repr__(self) -> str: """Return a representation of this entity. Example:: - >>> world.new_entity() + >>> registry.new_entity() - >>> world["foo"] + >>> registry["foo"] """ - uid_str = f"object at 0x{id(self.uid):X}" if self.uid.__class__ == object else repr(self.uid) + uid_str = f"object at 0x{id(self.uid):X}" if self.uid.__class__ is object else repr(self.uid) items = [f"{self.__class__.__name__}(uid={uid_str})"] name = self.name if name is not None: # Switch to older style items = [self.__class__.__name__, f"name={name!r}"] return f"<{' '.join(items)}>" - def __reduce__(self) -> tuple[type[Entity], tuple[World, object]]: + def __reduce__(self) -> tuple[type[Entity], tuple[Registry, object]]: """Pickle this Entity. - Note that any pickled entity will include the world it belongs to and all the entities of that world. + Note that any pickled entity will include the registry it belongs to and all the entities of that registry. """ - return self.__class__, (self.world, self.uid) + return self.__class__, (self.registry, self.uid) def _force_remap(self, new_uid: object) -> None: """Remap this Entity to a new uid, both and old and new uid's will use this entity.""" - _entity_table[self.world][new_uid] = self + _entity_table[self.registry][new_uid] = self self.uid = new_uid # type: ignore[misc] @@ -349,7 +361,7 @@ def _traverse_entities(start: Entity, traverse_parents: tuple[object, ...]) -> I traverse_parents = traverse_parents[::-1] visited = {start} stack = [start] - _relation_tags_by_entity = start.world._relation_tags_by_entity + _relation_tags_by_entity = start.registry._relation_tags_by_entity while stack: entity = stack.pop() yield entity @@ -369,7 +381,7 @@ def _traverse_entities(start: Entity, traverse_parents: tuple[object, ...]) -> I @attrs.define(eq=False, frozen=True, weakref_slot=False) -class EntityComponents(MutableMapping[Union[Type[Any], Tuple[object, Type[Any]]], Any]): +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`. @@ -381,21 +393,17 @@ class EntityComponents(MutableMapping[Union[Type[Any], Tuple[object, Type[Any]]] def __call__(self, *, traverse: Iterable[object]) -> Self: """Update this view with alternative parameters, such as a specific traversal relation. - .. versionadded:: Unreleased + .. versionadded:: 5.0 """ return self.__class__(self.entity, tuple(traverse)) - def set(self, value: object, *, _stacklevel: int = 1) -> None: + @deprecated("Setting values without an explicit key has been deprecated.", category=FutureWarning) + def set(self, value: object) -> None: """Assign or overwrite a component, automatically deriving the key. .. deprecated:: 3.1 Setting values without an explicit key has been deprecated. """ - warnings.warn( - "Setting values without an explicit key has been deprecated.", - FutureWarning, - stacklevel=_stacklevel + 1, - ) key = value.__class__ self[key] = value @@ -409,11 +417,11 @@ def __assert_key(key: ComponentKey[Any]) -> bool: def __getitem__(self, key: ComponentKey[T]) -> T: """Return a component belonging to this entity, or an indirect parent.""" assert self.__assert_key(key) - _components_by_entity = self.entity.world._components_by_entity + _components_by_entity = self.entity.registry._components_by_entity for entity in _traverse_entities(self.entity, self.traverse): try: return _components_by_entity[entity][key] # type: ignore[no-any-return] - except KeyError: + except KeyError: # noqa: PERF203 pass raise KeyError(key) @@ -421,36 +429,36 @@ def __setitem__(self, key: ComponentKey[T], value: T) -> None: """Assign a component directly to an entity.""" assert self.__assert_key(key) - old_value = self.entity.world._components_by_entity[self.entity].get(key) + old_value = self.entity.registry._components_by_entity[self.entity].get(key) if old_value is None: - tcod.ecs.query._touch_component(self.entity.world, key) # Component added + tcod.ecs.query._touch_component(self.entity.registry, key) # Component added - self.entity.world._components_by_entity[self.entity][key] = value - self.entity.world._components_by_type[key][self.entity] = value + self.entity.registry._components_by_entity[self.entity][key] = value + self.entity.registry._components_by_type[key][self.entity] = value 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) - old_value = self.entity.world._components_by_entity[self.entity].get(key) + old_value = self.entity.registry._components_by_entity[self.entity].get(key) - del self.entity.world._components_by_entity[self.entity][key] - if not self.entity.world._components_by_entity[self.entity]: - del self.entity.world._components_by_entity[self.entity] + del self.entity.registry._components_by_entity[self.entity][key] + if not self.entity.registry._components_by_entity[self.entity]: + del self.entity.registry._components_by_entity[self.entity] - del self.entity.world._components_by_type[key][self.entity] - if not self.entity.world._components_by_type[key]: - del self.entity.world._components_by_type[key] + del self.entity.registry._components_by_type[key][self.entity] + if not self.entity.registry._components_by_type[key]: + del self.entity.registry._components_by_type[key] - tcod.ecs.query._touch_component(self.entity.world, key) # Component removed + tcod.ecs.query._touch_component(self.entity.registry, key) # Component removed tcod.ecs.callbacks._on_component_changed(key, self.entity, old_value, None) - def keys(self) -> Set[ComponentKey[object]]: # type: ignore[override] + def keys(self) -> AbstractSet[ComponentKey[object]]: # type: ignore[override] """Return the components held by this entity, including inherited components.""" - _components_by_entity = self.entity.world._components_by_entity + _components_by_entity = self.entity.registry._components_by_entity if not self.traverse: return _components_by_entity.get(self.entity, {}).keys() set_: set[ComponentKey[object]] = set() @@ -458,9 +466,9 @@ def keys(self) -> Set[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.""" - _components_by_entity = self.entity.world._components_by_entity + 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) ) @@ -473,16 +481,18 @@ def __len__(self) -> int: """Return the number of components belonging to this entity.""" return len(self.keys()) - def update_values(self, values: Iterable[object], *, _stacklevel: int = 1) -> None: + @deprecated("Setting values without an explicit key has been deprecated.", category=FutureWarning) + def update_values(self, values: Iterable[object]) -> None: """Add or overwrite multiple components inplace, deriving the keys from the values. .. deprecated:: 3.1 Setting values without an explicit key has been deprecated. """ for value in values: - self.set(value, _stacklevel=_stacklevel + 1) + self.set(value) - def by_name_type(self, name_type: type[_T1], component_type: type[_T2]) -> Iterator[tuple[_T1, type[_T2]]]: + @deprecated("This method has been deprecated. Iterate over items instead.", category=FutureWarning) + 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 @@ -490,14 +500,18 @@ 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. """ - warnings.warn("This method has been deprecated. Iterate over items instead.", FutureWarning, stacklevel=2) - # 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: ... + + @overload + def __ior__(self, value: Iterable[tuple[ComponentKey[Any], Any]]) -> Self: ... def __ior__( self, value: SupportsKeysAndGetItem[ComponentKey[Any], Any] | Iterable[tuple[ComponentKey[Any], Any]] @@ -509,14 +523,19 @@ def __ior__( self.update(value) return self - def get(self, __key: ComponentKey[T], __default: T | None = None) -> T | None: + @overload + def get(self, __key: ComponentKey[T], /) -> T | None: ... + @overload + def get(self, __key: ComponentKey[T], /, default: _T1) -> T | _T1: ... + + def get(self, __key: ComponentKey[T], /, default: _T1 | None = None) -> T | _T1: """Return a component, returns None or a default value when the component is missing.""" try: return self[__key] except KeyError: - return __default + return default # type: ignore[return-value] # https://github.com/python/mypy/issues/3737 - def setdefault(self, __key: ComponentKey[T], __default: T) -> T: # type: ignore[override] + def setdefault(self, __key: ComponentKey[T], __default: T, /) -> T: # type: ignore[override] # Disallows default None """Assign a default value if a component is missing, then returns the current value.""" try: return self[__key] @@ -524,6 +543,54 @@ def setdefault(self, __key: ComponentKey[T], __default: T) -> T: # type: ignore self[__key] = __default return __default + @overload + def pop(self, __key: ComponentKey[T], /) -> T: ... + @overload + def pop(self, __key: ComponentKey[T], /, default: _T1) -> T | _T1: ... + + def pop( + self, + __key: ComponentKey[T], + /, + default: _T1 = _raise, # type: ignore[assignment] # https://github.com/python/mypy/issues/3737 + ) -> T | _T1: + """Remove a component directly from this entity. + + Returns the removed value. + If the value is missing returns `default`. + If `default` is unset then raises :any:`KeyError` instead. + + Operates directly on the entity without traversal. + + >>> parent = registry[object()] + >>> parent.components[str] = "foo" + >>> child = parent.instantiate() + >>> child.components[str] = "bar" + >>> child.components.pop(str, None) + 'bar' + >>> child.components.pop(str, None) + >>> child.components[str] + 'foo' + >>> child.components.pop(str) + Traceback (most recent call last): + ... + KeyError: + """ + _components = self.entity.registry._components_by_entity.get(self.entity, {}) + if __key not in _components: + if default is _raise: + raise KeyError(__key) + return default + value: T | _T1 = _components[__key] + del self[__key] + return value + + def clear(self) -> None: + """Remove any components stored directly in this entity.""" + if self.traverse: + return self(traverse=()).clear() + return super().clear() + @attrs.define(eq=False, frozen=True, weakref_slot=False) class EntityTags(MutableSet[Any]): @@ -538,48 +605,54 @@ class EntityTags(MutableSet[Any]): def __call__(self, *, traverse: Iterable[object]) -> Self: """Update this view with alternative parameters, such as a specific traversal relation. - .. versionadded:: Unreleased + .. versionadded:: 5.0 """ return self.__class__(self.entity, tuple(traverse)) def add(self, tag: object) -> None: """Add a tag to the entity.""" - if tag in self.entity.world._tags_by_entity[self.entity]: + if tag in self.entity.registry._tags_by_entity[self.entity]: return # Already has tag - tcod.ecs.query._touch_tag(self.entity.world, tag) # Tag added + tcod.ecs.query._touch_tag(self.entity.registry, tag) # Tag added - self.entity.world._tags_by_entity[self.entity].add(tag) - self.entity.world._tags_by_key[tag].add(self.entity) + self.entity.registry._tags_by_entity[self.entity].add(tag) + self.entity.registry._tags_by_key[tag].add(self.entity) def discard(self, tag: object) -> None: """Discard a tag directly held by an entity.""" - if tag not in self.entity.world._tags_by_entity[self.entity]: + if tag not in self.entity.registry._tags_by_entity[self.entity]: return # Already doesn't have tag - tcod.ecs.query._touch_tag(self.entity.world, tag) # Tag removed + tcod.ecs.query._touch_tag(self.entity.registry, tag) # Tag removed - self.entity.world._tags_by_entity[self.entity].discard(tag) - if not self.entity.world._tags_by_entity[self.entity]: - del self.entity.world._tags_by_entity[self.entity] + self.entity.registry._tags_by_entity[self.entity].discard(tag) + if not self.entity.registry._tags_by_entity[self.entity]: + del self.entity.registry._tags_by_entity[self.entity] - self.entity.world._tags_by_key[tag].discard(self.entity) - if not self.entity.world._tags_by_key[tag]: - del self.entity.world._tags_by_key[tag] + self.entity.registry._tags_by_key[tag].discard(self.entity) + if not self.entity.registry._tags_by_key[tag]: + del self.entity.registry._tags_by_key[tag] def remove(self, tag: object) -> None: """Remove a tag directly held by an entity.""" - tags = self.entity.world._tags_by_entity.get(self.entity) + tags = self.entity.registry._tags_by_entity.get(self.entity) if tags is None or tag not in tags: raise KeyError(tag) self.discard(tag) + def clear(self) -> None: + """Remove all tags directly in this entity.""" + _tags_by_entity = self.entity.registry._tags_by_entity + while _tags_by_entity[self.entity]: + self.discard(next(iter(_tags_by_entity[self.entity]))) + def __contains__(self, x: object) -> bool: """Return True if this entity has the given tag.""" - _tags_by_entity = self.entity.world._tags_by_entity + _tags_by_entity = self.entity.registry._tags_by_entity return any(x in _tags_by_entity.get(entity, ()) for entity in _traverse_entities(self.entity, self.traverse)) def _as_set(self) -> set[object]: """Return all tags inherited by traversal rules into a single set with no duplicates.""" - _tags_by_entity = self.entity.world._tags_by_entity + _tags_by_entity = self.entity.registry._tags_by_entity return set().union( *(_tags_by_entity.get(entity, ()) for entity in _traverse_entities(self.entity, self.traverse)) ) @@ -592,7 +665,7 @@ def __len__(self) -> int: """Return the number of tags this entity has.""" return len(self._as_set()) - def __ior__(self, other: Set[object]) -> Self: + def __ior__(self, other: AbstractSet[object]) -> Self: """Add tags in-place. .. versionadded:: 3.3 @@ -601,7 +674,7 @@ def __ior__(self, other: Set[object]) -> Self: self.add(to_add) return self - def __isub__(self, other: Set[Any]) -> Self: + def __isub__(self, other: AbstractSet[Any]) -> Self: """Remove tags in-place. .. versionadded:: 3.3 @@ -611,34 +684,34 @@ def __isub__(self, other: Set[Any]) -> Self: return self -def _relations_lookup_add(world: World, origin: Entity, tag: object, target: Entity) -> None: +def _relations_lookup_add(registry: Registry, origin: Entity, tag: object, target: Entity) -> None: """Add a relation tag/component to the lookup table and handle side effects.""" - world._relations_lookup[(tag, target)].add(origin) - world._relations_lookup[(tag, ...)].add(origin) - world._relations_lookup[(origin, tag, None)].add(target) - world._relations_lookup[(..., tag, None)].add(target) - tcod.ecs.query._touch_relations(world, ((tag, target), (tag, ...), (origin, tag, None), (..., tag, None))) + registry._relations_lookup[(tag, target)].add(origin) + registry._relations_lookup[(tag, ...)].add(origin) + registry._relations_lookup[(origin, tag, None)].add(target) + registry._relations_lookup[(..., tag, None)].add(target) + tcod.ecs.query._touch_relations(registry, ((tag, target), (tag, ...), (origin, tag, None), (..., tag, None))) -def _relations_lookup_discard(world: World, origin: Entity, tag: object, target: Entity) -> None: +def _relations_lookup_discard(registry: Registry, origin: Entity, tag: object, target: Entity) -> None: """Discard a relation tag/component from the lookup table and handle side effects.""" - world._relations_lookup[(tag, target)].discard(origin) - if not world._relations_lookup[(tag, target)]: - del world._relations_lookup[(tag, target)] + registry._relations_lookup[(tag, target)].discard(origin) + if not registry._relations_lookup[(tag, target)]: + del registry._relations_lookup[(tag, target)] - world._relations_lookup[(..., tag, None)].discard(target) - if not world._relations_lookup[(..., tag, None)]: - del world._relations_lookup[(..., tag, None)] + registry._relations_lookup[(..., tag, None)].discard(target) + if not registry._relations_lookup[(..., tag, None)]: + del registry._relations_lookup[(..., tag, None)] - world._relations_lookup[(origin, tag, None)].discard(target) - if not world._relations_lookup[(origin, tag, None)]: - del world._relations_lookup[(origin, tag, None)] + registry._relations_lookup[(origin, tag, None)].discard(target) + if not registry._relations_lookup[(origin, tag, None)]: + del registry._relations_lookup[(origin, tag, None)] - world._relations_lookup[(tag, ...)].discard(origin) - if not world._relations_lookup[(tag, ...)]: - del world._relations_lookup[(tag, ...)] + registry._relations_lookup[(tag, ...)].discard(origin) + if not registry._relations_lookup[(tag, ...)]: + del registry._relations_lookup[(tag, ...)] - tcod.ecs.query._touch_relations(world, ((tag, target), (tag, ...), (origin, tag, None), (..., tag, None))) + tcod.ecs.query._touch_relations(registry, ((tag, target), (tag, ...), (origin, tag, None), (..., tag, None))) @attrs.define(eq=False, frozen=True, weakref_slot=False) @@ -660,29 +733,29 @@ def __attrs_post_init__(self) -> None: def add(self, target: Entity) -> None: """Add a relation target to this tag.""" - world = self.entity.world - world._relation_tags_by_entity[self.entity][self.key].add(target) + registry = self.entity.registry + registry._relation_tags_by_entity[self.entity][self.key].add(target) - _relations_lookup_add(world, self.entity, self.key, target) + _relations_lookup_add(registry, self.entity, self.key, target) def discard(self, target: Entity) -> None: """Discard a directly held relation target from this tag.""" - world = self.entity.world + registry = self.entity.registry - world._relation_tags_by_entity[self.entity][self.key].discard(target) - if not world._relation_tags_by_entity[self.entity][self.key]: - del world._relation_tags_by_entity[self.entity][self.key] - if not world._relation_tags_by_entity[self.entity]: - del world._relation_tags_by_entity[self.entity] + registry._relation_tags_by_entity[self.entity][self.key].discard(target) + if not registry._relation_tags_by_entity[self.entity][self.key]: + del registry._relation_tags_by_entity[self.entity][self.key] + if not registry._relation_tags_by_entity[self.entity]: + del registry._relation_tags_by_entity[self.entity] - _relations_lookup_discard(world, self.entity, self.key, target) + _relations_lookup_discard(registry, self.entity, self.key, target) def remove(self, target: Entity) -> None: """Remove a directly held relation target from this tag. This will raise KeyError of only an indirect relation target exists. """ - relations = self.entity.world._relation_tags_by_entity.get(self.entity) + relations = self.entity.registry._relation_tags_by_entity.get(self.entity) if relations is None: raise KeyError(target) targets = relations.get(self.key) @@ -692,7 +765,7 @@ def remove(self, target: Entity) -> None: def __contains__(self, target: Entity) -> bool: # type: ignore[override] """Return True if this relation contains the given value.""" - _relation_tags_by_entity = self.entity.world._relation_tags_by_entity + _relation_tags_by_entity = self.entity.registry._relation_tags_by_entity for entity in _traverse_entities(self.entity, self.traverse): by_entity = _relation_tags_by_entity.get(entity) if by_entity is None: @@ -703,7 +776,7 @@ def __contains__(self, target: Entity) -> bool: # type: ignore[override] def _as_set(self) -> set[Entity]: """Return the combined targets of this mapping via traversal with duplicates removed.""" - _relation_tags_by_entity = self.entity.world._relation_tags_by_entity + _relation_tags_by_entity = self.entity.registry._relation_tags_by_entity results: set[Entity] = set() for entity in _traverse_entities(self.entity, self.traverse): by_entity = _relation_tags_by_entity.get(entity) @@ -722,7 +795,7 @@ def __len__(self) -> int: def clear(self) -> None: """Discard all targets for this tag relation.""" - by_entity = self.entity.world._relation_tags_by_entity.get(self.entity) + by_entity = self.entity.registry._relation_tags_by_entity.get(self.entity) if by_entity is None: return for key in list(by_entity.get(self.key, ())): @@ -739,6 +812,13 @@ class EntityRelations(MutableMapping[object, EntityRelationsMapping]): entity: Entity traverse: tuple[object, ...] + def __call__(self, *, traverse: Iterable[object]) -> Self: + """Update this view with alternative parameters, such as a specific traversal relation. + + .. versionadded:: 5.0 + """ + return self.__class__(self.entity, tuple(traverse)) + def __getitem__(self, key: object) -> EntityRelationsMapping: """Return the relation mapping for a tag.""" return EntityRelationsMapping(self.entity, key, self.traverse) @@ -760,11 +840,11 @@ def __delitem__(self, key: object) -> None: def __iter__(self) -> Iterator[Any]: """Iterate over the unique relation tags of this entity.""" - _relation_tags_by_entity = self.entity.world._relation_tags_by_entity - EMPTY_DICT: dict[object, set[Entity]] = {} + _relation_tags_by_entity = self.entity.registry._relation_tags_by_entity + empty_dict: dict[object, set[Entity]] = {} yield from set().union( *( - _relation_tags_by_entity.get(entity, EMPTY_DICT).keys() + _relation_tags_by_entity.get(entity, empty_dict).keys() for entity in _traverse_entities(self.entity, self.traverse) ) ) @@ -775,7 +855,7 @@ def __len__(self) -> int: def clear(self) -> None: """Discard all tag relations from an entity.""" - for key in list(self.entity.world._relation_tags_by_entity.get(self.entity, ())): + for key in list(self.entity.registry._relation_tags_by_entity.get(self.entity, ())): del self[key] @@ -789,13 +869,20 @@ class EntityRelationsExclusive(MutableMapping[object, Entity]): entity: Entity traverse: tuple[object, ...] + def __call__(self, *, traverse: Iterable[object]) -> Self: + """Update this view with alternative parameters, such as a specific traversal relation. + + .. versionadded:: 5.0 + """ + return self.__class__(self.entity, tuple(traverse)) + def __getitem__(self, key: object) -> Entity: """Return the relation target for a key. If the relation has no target then raises KeyError. If the relation is not exclusive then raises ValueError. """ - _relation_tags_by_entity = self.entity.world._relation_tags_by_entity + _relation_tags_by_entity = self.entity.registry._relation_tags_by_entity for entity in _traverse_entities(self.entity, self.traverse): by_entity = _relation_tags_by_entity.get(entity) if by_entity is None: @@ -836,7 +923,7 @@ def clear(self) -> None: @attrs.define(eq=False, frozen=True, weakref_slot=False) -class EntityComponentRelationMapping(Generic[T], MutableMapping[Entity, T]): +class EntityComponentRelationMapping(MutableMapping[Entity, T], Generic[T]): """An entity-component mapping to access the relation target component objects. See :any:`Entity.relation_components`. @@ -852,9 +939,16 @@ def __attrs_post_init__(self) -> None: """Validate attributes.""" assert isinstance(self.entity, Entity), self.entity + def __call__(self, *, traverse: Iterable[object]) -> Self: + """Update this view with alternative parameters, such as a specific traversal relation. + + .. versionadded:: 5.4 + """ + return self.__class__(self.entity, self.key, tuple(traverse)) + def __getitem__(self, target: Entity) -> T: """Return the component related to a target entity.""" - _relation_components_by_entity = self.entity.world._relation_components_by_entity + _relation_components_by_entity = self.entity.registry._relation_components_by_entity for entity in _traverse_entities(self.entity, self.traverse): by_entity = _relation_components_by_entity.get(entity) if by_entity is None: @@ -869,32 +963,32 @@ def __getitem__(self, target: Entity) -> T: def __setitem__(self, target: Entity, component: T) -> None: """Assign a component to the target entity.""" - world = self.entity.world + registry = self.entity.registry - old_value = world._relation_components_by_entity[self.entity][self.key].get(target) + old_value = registry._relation_components_by_entity[self.entity][self.key].get(target) if old_value is None: # Relation added tcod.ecs.query._touch_relations( - world, ((self.key, target), (self.key, ...), (self.entity, self.key, None), (..., self.key, None)) + registry, ((self.key, target), (self.key, ...), (self.entity, self.key, None), (..., self.key, None)) ) - world._relation_components_by_entity[self.entity][self.key][target] = component + registry._relation_components_by_entity[self.entity][self.key][target] = component - _relations_lookup_add(world, self.entity, self.key, target) + _relations_lookup_add(registry, self.entity, self.key, target) def __delitem__(self, target: Entity) -> None: """Delete a component assigned to the target entity.""" - world = self.entity.world - del world._relation_components_by_entity[self.entity][self.key][target] - if not world._relation_components_by_entity[self.entity][self.key]: - del world._relation_components_by_entity[self.entity][self.key] - if not world._relation_components_by_entity[self.entity]: - del world._relation_components_by_entity[self.entity] + registry = self.entity.registry + del registry._relation_components_by_entity[self.entity][self.key][target] + if not registry._relation_components_by_entity[self.entity][self.key]: + del registry._relation_components_by_entity[self.entity][self.key] + if not registry._relation_components_by_entity[self.entity]: + del registry._relation_components_by_entity[self.entity] - _relations_lookup_discard(world, self.entity, self.key, target) + _relations_lookup_discard(registry, self.entity, self.key, target) - def keys(self) -> Set[Entity]: # type: ignore[override] + def keys(self) -> AbstractSet[Entity]: # type: ignore[override] """Return all entities with an associated component value.""" - _relation_components_by_entity = self.entity.world._relation_components_by_entity + _relation_components_by_entity = self.entity.registry._relation_components_by_entity result: set[Entity] = set() for entity in _traverse_entities(self.entity, self.traverse): by_entity = _relation_components_by_entity.get(entity) @@ -911,6 +1005,12 @@ def __len__(self) -> int: """Return the count of targets for this component relation.""" return len(self.keys()) + def clear(self) -> None: + """Remove any components stored directly in this entity relation.""" + if self.traverse: + return self(traverse=()).clear() + return super().clear() + @attrs.define(eq=False, frozen=True, weakref_slot=False) class EntityComponentRelations(MutableMapping[ComponentKey[Any], EntityComponentRelationMapping[Any]]): @@ -931,18 +1031,25 @@ def __attrs_post_init__(self) -> None: """Validate attributes.""" assert isinstance(self.entity, Entity), self.entity + def __call__(self, *, traverse: Iterable[object]) -> Self: + """Update this view with alternative parameters, such as a specific traversal relation. + + .. versionadded:: 5.0 + """ + return self.__class__(self.entity, tuple(traverse)) + 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 @@ -960,12 +1067,12 @@ def clear(self) -> None: Does not clear relations targeting this entity. """ - for component_key in list(self.entity.world._relation_components_by_entity.get(self.entity, ())): + for component_key in list(self.entity.registry._relation_components_by_entity.get(self.entity, ())): self[component_key].clear() - def keys(self) -> Set[ComponentKey[object]]: # type: ignore[override] + def keys(self) -> AbstractSet[ComponentKey[object]]: # type: ignore[override] """Returns the components keys this entity has relations for.""" - _relation_components_by_entity = self.entity.world._relation_components_by_entity + _relation_components_by_entity = self.entity.registry._relation_components_by_entity return set().union( *( _relation_components_by_entity.get(entity, ()) diff --git a/tcod/ecs/query.py b/tcod/ecs/query.py index fa820f4..cd4be36 100644 --- a/tcod/ecs/query.py +++ b/tcod/ecs/query.py @@ -1,23 +1,26 @@ -"""Tools for querying World objects.""" +"""Tools for querying Registry objects.""" + from __future__ import annotations import itertools import warnings from collections import defaultdict -from collections.abc import Set -from typing import TYPE_CHECKING, Any, Iterable, Iterator, Protocol, TypeVar, overload +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload from weakref import WeakKeyDictionary, WeakSet import attrs -from typing_extensions import Self +from typing_extensions import Self, deprecated import tcod.ecs.entity from tcod.ecs.constants import IsA -from tcod.ecs.typing import ComponentKey, _RelationQuery if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from collections.abc import Set as AbstractSet + from tcod.ecs.entity import Entity - from tcod.ecs.world import World + from tcod.ecs.registry import Registry + from tcod.ecs.typing import ComponentKey, _RelationQuery _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") @@ -26,7 +29,7 @@ _T5 = TypeVar("_T5") -_query_caches: WeakKeyDictionary[World, _QueryCache] = WeakKeyDictionary() +_query_caches: WeakKeyDictionary[Registry, _QueryCache] = WeakKeyDictionary() """The master table of cached queries.""" @@ -34,7 +37,7 @@ class _QueryCache: """Main data structure for the query cache.""" - queries: dict[_Query, Set[Entity]] = attrs.field(factory=dict) + queries: dict[_Query, AbstractSet[Entity]] = attrs.field(factory=dict) """Table of cached queries.""" by_components: defaultdict[ComponentKey[object], WeakSet[_Query]] = attrs.field( factory=lambda: defaultdict(WeakSet) @@ -45,8 +48,8 @@ class _QueryCache: by_relations: defaultdict[_RelationQuery, WeakSet[_Query]] = attrs.field(factory=lambda: defaultdict(WeakSet)) """Which queries depend on which relations.""" - dependencies: dict[_Query, set[tuple[World, _Query]]] = attrs.field(factory=lambda: defaultdict(set)) - """Tracks which queries depend on the queries of the current world. + dependencies: dict[_Query, set[tuple[Registry, _Query]]] = attrs.field(factory=lambda: defaultdict(set)) + """Tracks which queries depend on the queries of the current registry. `dependencies[dependency] = {dependant}` """ @@ -55,31 +58,31 @@ class _QueryCache: def _drop_cached_query(cache: _QueryCache, query: _Query) -> None: """Drop a cached query and all of its dependant queries.""" cache.queries.pop(query, None) - for sub_world, sub_query in cache.dependencies.pop(query, ()): - _drop_cached_query(_query_caches[sub_world], sub_query) + for sub_registry, sub_query in cache.dependencies.pop(query, ()): + _drop_cached_query(_query_caches[sub_registry], sub_query) -def _touch_component(world: World, component: ComponentKey[object]) -> None: +def _touch_component(registry: Registry, component: ComponentKey[object]) -> None: """Drop cached queries if a component change has invalidated them.""" - cache = _get_query_cache(world) + cache = _get_query_cache(registry) if component not in cache.by_components: return for touched_query in cache.by_components.pop(component, ()): _drop_cached_query(cache, touched_query) -def _touch_tag(world: World, tag: object) -> None: +def _touch_tag(registry: Registry, tag: object) -> None: """Drop cached queries if a tag change has invalidated them.""" - cache = _get_query_cache(world) + cache = _get_query_cache(registry) if tag not in cache.by_tags: return for touched_query in cache.by_tags.pop(tag, ()): _drop_cached_query(cache, touched_query) -def _touch_relations(world: World, relations: Iterable[_RelationQuery]) -> None: +def _touch_relations(registry: Registry, relations: Iterable[_RelationQuery]) -> None: """Drop cached queries if a relation change has invalidated them.""" - cache = _get_query_cache(world) + cache = _get_query_cache(registry) for relation in relations: if relation not in cache.by_relations: continue @@ -100,48 +103,47 @@ def _check_suspicious_tags(tags: Iterable[object], stacklevel: int = 2) -> None: ) -def _fetch_relation_table(world: World, relation: _RelationQuery) -> Set[Entity]: +def _fetch_relation_table(registry: Registry, relation: _RelationQuery) -> AbstractSet[Entity]: """Get the entity table for this relation. For simple cases where target/origin is `Entity | ...` this returns the set directly from the lookup table. - For advanced cases `WorldQuery` this returns the subset of entities following the query condition. + For advanced cases `BoundQuery` this returns the subset of entities following the query condition. """ if len(relation) == 2: # noqa: PLR2004 - tag, target = relation # type: ignore[misc] # https://github.com/python/mypy/issues/1178 - if not isinstance(target, WorldQuery): - return world._relations_lookup.get((tag, target), frozenset()) - - world = target.world - return set().union(*(world._relations_lookup.get((tag, entity), ()) for entity in target)) + tag, target = relation + if not isinstance(target, BoundQuery): + return registry._relations_lookup.get((tag, target), frozenset()) - origin, tag, target = relation # type: ignore[misc] # https://github.com/python/mypy/issues/1178 - if not isinstance(origin, WorldQuery): - return world._relations_lookup.get((origin, tag, target), frozenset()) + registry = target.registry + return set().union(*(registry._relations_lookup.get((tag, entity), ()) for entity in target)) + origin, tag, target_none = relation + if not isinstance(origin, BoundQuery): + return registry._relations_lookup.get((origin, tag, target_none), frozenset()) - world = origin.world - return set().union(*(world._relations_lookup.get((entity, tag, None), ()) for entity in origin)) + registry = origin.registry + return set().union(*(registry._relations_lookup.get((entity, tag, None), ()) for entity in origin)) -def _get_query_cache(world: World) -> _QueryCache: - """Return the global cache for the given world, creating it if it does not exist.""" - cache = _query_caches.get(world) +def _get_query_cache(registry: Registry) -> _QueryCache: + """Return the global cache for the given registry, creating it if it does not exist.""" + cache = _query_caches.get(registry) if cache is None: - cache = _query_caches[world] = _QueryCache() + cache = _query_caches[registry] = _QueryCache() return cache -def _get_query(world: World, query: _Query) -> Set[Entity]: - """Return the entities for the given query and world.""" - cache = _get_query_cache(world) +def _get_query(registry: Registry, query: _Query) -> AbstractSet[Entity]: + """Return the entities for the given query and registry.""" + cache = _get_query_cache(registry) if cache is not None: cached_entities = cache.queries.get(query) if cached_entities is not None: return cached_entities # Found a cached query # Not in cache, build the cache and return the results - cache.queries[query] = entities = query._compile(world, cache) - query._add_to_cache(world, cache) + cache.queries[query] = entities = query._compile(registry, cache) + query._add_to_cache(registry, cache) return entities @@ -151,12 +153,12 @@ def _normalize_query_relation(relation: _RelationQuery) -> _RelationQuery: This adds the inverse lookup to the sub-query so that this only matches entities which have a relation. """ if len(relation) == 2: # noqa: PLR2004 - tag, targets = relation # type: ignore[misc] # https://github.com/python/mypy/issues/1178 - if isinstance(targets, WorldQuery): # (tag, targets) + tag, targets = relation + if isinstance(targets, BoundQuery): # (tag, targets) return tag, targets.all_of(relations=[(..., tag, None)]) return relation - origin, tag, _ = relation # type: ignore[misc] # https://github.com/python/mypy/issues/1178 - if isinstance(origin, WorldQuery): # (origins, tag, None) + origin, tag, _ = relation + if isinstance(origin, BoundQuery): # (origins, tag, None) return origin.all_of(relations=[(tag, ...)]), tag, None return relation @@ -164,11 +166,11 @@ def _normalize_query_relation(relation: _RelationQuery) -> _RelationQuery: class _Query(Protocol): """Abstract query class.""" - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: """Add this query to the local cache.""" ... - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: """Compile the entities of this query, returning a set which must not be modified.""" ... @@ -179,11 +181,11 @@ class _QueryComponent: _component: ComponentKey[object] - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: # noqa: ARG002 cache.by_components[self._component].add(self) - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: - return world._components_by_type.get(self._component, {}).keys() + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: # noqa: ARG002 + return registry._components_by_type.get(self._component, {}).keys() @attrs.define(frozen=True) @@ -192,11 +194,11 @@ class _QueryTag: _tag: object - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: # noqa: ARG002 cache.by_tags[self._tag].add(self) - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: - return world._tags_by_key.get(self._tag, set()) + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: # noqa: ARG002 + return registry._tags_by_key.get(self._tag, set()) @attrs.define(frozen=True) @@ -205,24 +207,24 @@ class _QueryRelation: _relation: _RelationQuery = attrs.field(converter=_normalize_query_relation) - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: - """Add this query to the cache and mark it dependant on a world query if the relation uses one.""" + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: + """Add this query to the cache and mark it dependant on a registry query if the relation uses one.""" - def _get_world_query() -> WorldQuery | None: - """Return the world query of a relation if it exists.""" - if isinstance(self._relation[0], WorldQuery): + def _get_registry_query() -> BoundQuery | None: + """Return the bound query of a relation if it exists.""" + if isinstance(self._relation[0], BoundQuery): return self._relation[0] - if isinstance(self._relation[-1], WorldQuery): + if isinstance(self._relation[-1], BoundQuery): return self._relation[-1] return None cache.by_relations[self._relation].add(self) - w_query = _get_world_query() + w_query = _get_registry_query() if w_query is not None: - _get_query_cache(w_query.world).dependencies[w_query._query].add((world, self)) + _get_query_cache(w_query.registry).dependencies[w_query._query].add((registry, self)) - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: - return _fetch_relation_table(world, self._relation) + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: # noqa: ARG002 + return _fetch_relation_table(registry, self._relation) @attrs.define(frozen=True) @@ -239,21 +241,21 @@ def __attrs_post_init__(self) -> None: """Verify the current state.""" assert self._all_of.isdisjoint(self._none_of) - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: for dependency in itertools.chain(self._all_of, self._none_of): - cache.dependencies[dependency].add((world, self)) + cache.dependencies[dependency].add((registry, self)) - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: # noqa: ARG002 if len(self._all_of) == 1 and not self._none_of: # Only one sub-query, simply return the results of it - return _get_query(world, next(iter(self._all_of))) # Avoids an extra copy of a set + return _get_query(registry, next(iter(self._all_of))) # Avoids an extra copy of a set requires = sorted( # Place the smallest sets first to speed up intersections - (_get_query(world, q) for q in self._all_of), key=len + (_get_query(registry, q) for q in self._all_of), key=len ) entities = set(requires[0]) for required_set in requires[1:]: entities.intersection_update(required_set) for excluded_query in self._none_of: - entities.difference_update(_get_query(world, excluded_query)) + entities.difference_update(_get_query(registry, excluded_query)) return entities def __and__(self, other: _Query) -> Self: @@ -268,15 +270,15 @@ class _QueryLogicalOr: _any_of: frozenset[_Query] = frozenset() - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: for dependency in self._any_of: - cache.dependencies[dependency].add((world, self)) + cache.dependencies[dependency].add((registry, self)) - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: # noqa: ARG002 if len(self._any_of) == 1: # If there is only one sub-query then simply return the results of it - return _get_query(world, next(iter(self._any_of))) # Avoids an extra copy of a set + return _get_query(registry, next(iter(self._any_of))) # Avoids an extra copy of a set entities: set[Entity] = set() - entities.update(*(_get_query(world, q) for q in self._any_of)) + entities.update(*(_get_query(registry, q) for q in self._any_of)) return entities @@ -297,13 +299,15 @@ def _get_traverse_query(self) -> _QueryLogicalOr: any_of=frozenset(_QueryRelation((..., traverse_key, None)) for traverse_key in self._traverse_keys) ) - def _add_to_cache(self, world: World, cache: _QueryCache) -> None: - cache.dependencies[self._sub_query].add((world, self)) - cache.dependencies[self._get_traverse_query()].add((world, self)) + def _add_to_cache(self, registry: Registry, cache: _QueryCache) -> None: + cache.dependencies[self._sub_query].add((registry, self)) + cache.dependencies[self._get_traverse_query()].add((registry, self)) - def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: - cumulative_set = set(_get_query(world, self._sub_query)) # All entities touched by this traversal - relations_set = _get_query(world, self._get_traverse_query()) # The subset of entities which can propagate from + def _compile(self, registry: Registry, cache: _QueryCache) -> AbstractSet[Entity]: # noqa: ARG002 + cumulative_set = set(_get_query(registry, self._sub_query)) # All entities touched by this traversal + relations_set = _get_query( + registry, self._get_traverse_query() + ) # The subset of entities which can propagate from unchecked_set = cumulative_set & relations_set # Most recently touched entities which can propagate farther depth = 0 while unchecked_set and (self._max_depth is None or depth < self._max_depth): @@ -312,7 +316,7 @@ def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: empty_set: frozenset[Entity] = frozenset() for traverse_key in self._traverse_keys: for unchecked in unchecked_set: - new_set |= world._relations_lookup.get((traverse_key, unchecked), empty_set) + new_set |= registry._relations_lookup.get((traverse_key, unchecked), empty_set) new_set -= cumulative_set cumulative_set |= new_set unchecked_set = new_set @@ -320,20 +324,37 @@ def _compile(self, world: World, cache: _QueryCache) -> Set[Entity]: @attrs.define(frozen=True) -class WorldQuery: - """Collect a set of entities with the provided conditions.""" +class BoundQuery: + """Collect a set of entities with the provided conditions. + + This query is bound to a specific registry. + """ - world: World + registry: Registry _query: _Query = attrs.field(factory=_QueryLogicalAnd) - def get_entities(self) -> Set[Entity]: + @property + @deprecated("Use '.registry' instead of '.world'") + def world(self) -> Registry: + """Deprecated alias for registry. + + .. deprecated:: 5.1 + Use :any:`registry` instead. + """ + return self.registry + + def get_entities(self) -> AbstractSet[Entity]: """Return entities matching the current query as a read-only set. This is useful for post-processing the results of a query using set operations. .. versionadded:: 4.4 """ - return _get_query(self.world, self._query) + return _get_query(self.registry, self._query) + + def __bool__(self) -> bool: + """Return True if any entity matches this query.""" + return bool(self.get_entities()) @staticmethod def __as_queries( @@ -349,7 +370,7 @@ def __as_queries( yield from (_QueryTraversalPropagation(_QueryTag(tag), traverse, depth) for tag in tags) yield from (_QueryTraversalPropagation(_QueryRelation(relations), traverse, depth) for relations in relations) - def all_of( # noqa: PLR0913 + def all_of( self, components: Iterable[ComponentKey[object]] = (), *, @@ -361,12 +382,12 @@ def all_of( # noqa: PLR0913 """Filter entities based on having all of the provided elements.""" _check_suspicious_tags(tags, stacklevel=2) return self.__class__( - self.world, + self.registry, _QueryLogicalAnd(all_of=frozenset(self.__as_queries(components, tags, relations, traverse, depth))) & self._query, ) - def none_of( # noqa: PLR0913 + def none_of( self, components: Iterable[ComponentKey[object]] = (), *, @@ -378,52 +399,70 @@ def none_of( # noqa: PLR0913 """Filter entities based on having none of the provided elements.""" _check_suspicious_tags(tags, stacklevel=2) return self.__class__( - self.world, + self.registry, _QueryLogicalAnd(none_of=frozenset(self.__as_queries(components, tags, relations, traverse, depth))) & self._query, ) + def any_of( + self, + components: Iterable[ComponentKey[object]] = (), + *, + tags: Iterable[object] = (), + relations: Iterable[_RelationQuery] = (), + traverse: Iterable[object] = (IsA,), + depth: int | None = None, + ) -> Self: + """Filter entities based on having at least one of the provided elements. + + .. versionadded:: 5.3 + """ + _check_suspicious_tags(tags, stacklevel=2) + return self.__class__( + self.registry, + _QueryLogicalAnd( + all_of=frozenset( + [_QueryLogicalOr(any_of=frozenset(self.__as_queries(components, tags, relations, traverse, depth)))] + ) + ) + & self._query, + ) + def __iter__(self) -> Iterator[Entity]: """Iterate over the matching entities.""" return iter(self.get_entities()) @overload - def __getitem__(self, key: tuple[ComponentKey[_T1]]) -> Iterable[tuple[_T1]]: - ... + def __getitem__(self, key: tuple[ComponentKey[_T1]]) -> Iterable[tuple[_T1]]: ... @overload - def __getitem__(self, key: tuple[ComponentKey[_T1], ComponentKey[_T2]]) -> Iterable[tuple[_T1, _T2]]: - ... + def __getitem__(self, key: tuple[ComponentKey[_T1], ComponentKey[_T2]]) -> Iterable[tuple[_T1, _T2]]: ... @overload def __getitem__( self, key: tuple[ComponentKey[_T1], ComponentKey[_T2], ComponentKey[_T3]] - ) -> Iterable[tuple[_T1, _T2, _T3]]: - ... + ) -> Iterable[tuple[_T1, _T2, _T3]]: ... @overload def __getitem__( self, key: tuple[ComponentKey[_T1], ComponentKey[_T2], ComponentKey[_T3], ComponentKey[_T4]] - ) -> Iterable[tuple[_T1, _T2, _T3, _T4]]: - ... + ) -> Iterable[tuple[_T1, _T2, _T3, _T4]]: ... @overload def __getitem__( self, key: tuple[ComponentKey[_T1], ComponentKey[_T2], ComponentKey[_T3], ComponentKey[_T4], ComponentKey[_T5]], - ) -> Iterable[tuple[_T1, _T2, _T3, _T4, _T5]]: - ... + ) -> Iterable[tuple[_T1, _T2, _T3, _T4, _T5]]: ... @overload - def __getitem__(self, key: tuple[ComponentKey[object], ...]) -> Iterable[tuple[Any, ...]]: - ... + def __getitem__(self, key: tuple[ComponentKey[object], ...]) -> Iterable[tuple[Any, ...]]: ... def __getitem__(self, key: tuple[ComponentKey[object], ...]) -> Iterable[tuple[Any, ...]]: """Collect components from a query.""" assert key is not None assert isinstance(key, tuple) - Entity = tcod.ecs.entity.Entity + Entity = tcod.ecs.entity.Entity # noqa: N806 entities = list(self.all_of(components=set(key) - {Entity}).get_entities()) entity_components = [] @@ -431,6 +470,9 @@ def __getitem__(self, key: tuple[ComponentKey[object], ...]) -> Iterable[tuple[A if component_key is Entity: entity_components.append(entities) continue - world_components = self.world._components_by_type[component_key] - entity_components.append([world_components[entity] for entity in entities]) - return zip(*entity_components) + registry_components = self.registry._components_by_type[component_key] + entity_components.append([registry_components[entity] for entity in entities]) + return zip(*entity_components, strict=True) + + +WorldQuery = BoundQuery diff --git a/tcod/ecs/registry.py b/tcod/ecs/registry.py new file mode 100644 index 0000000..a2927f1 --- /dev/null +++ b/tcod/ecs/registry.py @@ -0,0 +1,318 @@ +"""Registry management tools.""" + +from __future__ import annotations + +import warnings +from collections import defaultdict +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, Final, NoReturn, TypeVar + +import attrs +from typing_extensions import deprecated + +import tcod.ecs._converter +import tcod.ecs.query +from tcod.ecs.entity import Entity + +if TYPE_CHECKING: + from tcod.ecs.typing import ComponentKey, _RelationTargetLookup + +_T1 = TypeVar("_T1") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") + + +def _defaultdict_of_set() -> defaultdict[_T1, set[_T2]]: + """Return a new defaultdict of sets.""" + return defaultdict(set) + + +def _defaultdict_of_dict() -> defaultdict[_T1, dict[_T2, _T3]]: + """Return a new defaultdict of dicts.""" + return defaultdict(dict) + + +def _components_by_entity_from( + 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[_T1], _T1]] = defaultdict(dict) + for component_key, components in by_type.items(): + for entity, component in components.items(): + by_entity[entity][component_key] = component + return by_entity + + +def _tags_by_key_from_tags_by_entity(by_entity: defaultdict[Entity, set[object]]) -> defaultdict[object, set[Entity]]: + """Return the tag lookup table from the tags sparse-set.""" + tags_by_key = defaultdict(set) + for entity, tags in by_entity.items(): + for tag in tags: + tags_by_key[tag].add(entity) + return tags_by_key + + +def _relations_lookup_from( + tags_by_entity: defaultdict[Entity, defaultdict[object, set[Entity]]], + components_by_entity: defaultdict[Entity, defaultdict[ComponentKey[object], dict[Entity, Any]]], +) -> defaultdict[tuple[Any, _RelationTargetLookup] | tuple[_RelationTargetLookup, Any, None], set[Entity]]: + """Return the relation lookup table from the relations sparse-sets.""" + relations_lookup: defaultdict[ + tuple[Any, _RelationTargetLookup] | tuple[_RelationTargetLookup, Any, None], set[Entity] + ] = defaultdict(set) + for origin, tags in tags_by_entity.items(): + for tag, targets in tags.items(): + for target in targets: + relations_lookup[(tag, ...)].add(origin) + relations_lookup[(tag, target)].add(origin) + relations_lookup[(origin, tag, None)].add(target) + relations_lookup[(..., tag, None)].add(target) + for origin, components in components_by_entity.items(): + for component_key, target_components in components.items(): + for target in target_components: + relations_lookup[(component_key, ...)].add(origin) + relations_lookup[(component_key, origin)].add(origin) + relations_lookup[(origin, component_key, None)].add(target) + relations_lookup[(..., component_key, None)].add(target) + + return relations_lookup + + +@attrs.define(eq=False) +class Registry: + """A container for entities and components.""" + + _components_by_entity: defaultdict[Entity, dict[ComponentKey[object], Any]] = attrs.field( + init=False, factory=lambda: defaultdict(dict) + ) + """Random access entity components. + + dict[Entity][ComponentKey] = component_instance + """ + _components_by_type: defaultdict[ComponentKey[object], dict[Entity, Any]] = attrs.field( + init=False, factory=lambda: defaultdict(dict) + ) + """Query table entity components. + + dict[ComponentKey] = {entities_with_component} + """ + + _tags_by_key: defaultdict[object, set[Entity]] = attrs.field(init=False, factory=lambda: defaultdict(set)) + """Query table entity tags. + + dict[tag] = {all_entities_with_tag} + """ + _tags_by_entity: defaultdict[Entity, set[Any]] = attrs.field(init=False, factory=lambda: defaultdict(set)) + """Random access entity tags. + + dict[Entity] = {all_tags_for_entity} + """ + + _relation_tags_by_entity: defaultdict[Entity, defaultdict[object, set[Entity]]] = attrs.field( + init=False, factory=lambda: defaultdict(_defaultdict_of_set) + ) + """Random access tag multi-relations. + + dict[entity][tag] = {target_entities} + """ + _relation_components_by_entity: defaultdict[Entity, defaultdict[ComponentKey[object], dict[Entity, Any]]] = ( + attrs.field(init=False, factory=lambda: defaultdict(_defaultdict_of_dict)) + ) + """Random access relations owning components. + + dict[entity][ComponentKey][target_entity] = component + """ + _relations_lookup: defaultdict[ + tuple[Any, _RelationTargetLookup] | tuple[_RelationTargetLookup, Any, None], set[Entity] + ] = attrs.field(init=False, factory=lambda: defaultdict(set)) + """Relations query table. Tags and components are mixed together. + + ``` + Tag: + dict[(tag, this_entity)] = {target_entities_for_entity} + dict[(tag, None)] = {target_entities_for_tag} + dict[(target_entity, tag, None)] = {origin_entities_for_target} + dict[(None, tag, None)] = {all_origen_entities_for_tag} + Component: + dict[(ComponentKey, target_entity)] = {origin_entities} + dict[(ComponentKey, None)] = {all_origin_entities} + dict[(origin_entity, ComponentKey, None)] = {target_entities} + dict[(None, ComponentKey, None)] = {all_target_entities} + ``` + """ + + _names_by_name: dict[object, Entity] = attrs.field(init=False, factory=dict) + """Name query table. + + dict[name] = named_entity + """ + _names_by_entity: dict[Entity, object] = attrs.field(init=False, factory=dict) + """Name lookup table. + + dict[Entity] = entities_name + """ + + @property + @deprecated( + "The 'registry.global_' attribute has been deprecated. Use 'registry[None]' to access this entity.", + category=FutureWarning, + ) + def global_(self) -> Entity: + """A unique globally accessible entity. + + This can be used to store globally accessible components in the registry itself without any extra boilerplate. + Otherwise this entity is not special and will show up with other entities in queries, etc. + + This entity has a `uid` of `None` and may be accessed that way. + This syntax my be better for globals in general since it can use any hashable object. + + .. versionadded:: 1.1 + + Example:: + + >>> registry[None].components[("turn", int)] = 0 + >>> registry[None].components[("turn", int)] + 0 + """ + return Entity(self, None) + + def __setstate__(self, state: dict[str, Any]) -> None: + """Unpickle this object and handle state migration.""" + global_: Entity | None = state.pop("global_", None) # Migrate from version <=1.2.0 + + # These attributes contain redundant data and will be removed + redundant_attributes: Final = frozenset( + { + "_components_by_entity", # <=3.4.0 + "_tags_by_key", # <=3.4.0 + "_relations_lookup", # <=3.4.0 + "_names_by_entity", # <=3.4.0 + } + ) + for ignored in redundant_attributes: + state.pop(ignored, None) + + converter = tcod.ecs._converter._get_converter() + # Apply defaultdict types to unpickled dictionaries + self._components_by_type = converter.structure( + state.pop("_components_by_type"), + defaultdict[Any, dict[Any, Any]], + ) + self._components_by_entity = _components_by_entity_from(self._components_by_type) + + self._tags_by_entity = converter.structure( + state.pop("_tags_by_entity"), + defaultdict[Any, set[Any]], + ) + self._tags_by_key = _tags_by_key_from_tags_by_entity(self._tags_by_entity) + + self._relation_tags_by_entity = converter.structure( + state.pop("_relation_tags_by_entity"), + defaultdict[Any, defaultdict[Any, set[Any]]], + ) + self._relation_components_by_entity = converter.structure( + state.pop("_relation_components_by_entity"), + defaultdict[Any, defaultdict[Any, dict[Any, Any]]], + ) + self._relations_lookup = _relations_lookup_from( + self._relation_tags_by_entity, self._relation_components_by_entity + ) + + self._names_by_name = state.pop("_names_by_name") + self._names_by_entity = {entity: name for name, entity in self._names_by_name.items()} + + if global_ is not None and global_.uid is not None: # Migrate from version <=1.2.0 + global_._force_remap(None) + + if state: + warnings.warn(f"These attributes were not unpacked {state.keys()}", RuntimeWarning, stacklevel=1) + + def __getstate__(self) -> dict[str, Any]: + """Pickle this object.""" + converter = tcod.ecs._converter._get_converter() + # Replace defaultdict types with plain dict when saving + return { + "_components_by_type": converter.structure(self._components_by_type, dict[Any, dict[Any, Any]]), + "_tags_by_entity": converter.structure(self._tags_by_entity, dict[Any, Any]), + "_relation_tags_by_entity": converter.structure(self._relation_tags_by_entity, dict[Any, dict[Any, Any]]), + "_relation_components_by_entity": converter.structure( + self._relation_components_by_entity, dict[Any, dict[Any, Any]] + ), + "_names_by_name": self._names_by_name, + } + + def __getitem__(self, uid: object) -> Entity: + """Return an entity associated with a unique id. + + Example:: + + >>> registry = Registry() + >>> foo = registry["foo"] # Referencing a new entity returns a new empty entity + >>> foo is registry["foo"] + True + >>> entity = registry.new_entity() + >>> registry[entity.uid] is entity # Anonymous entities can be referred to by their uid + True + """ + assert uid is not object, "This is reserved." + return Entity(self, uid) + + def __iter__(self) -> NoReturn: + """Raises TypeError, :any:`Registry` is not iterable.""" + msg = "'Registry' object is not iterable." + raise TypeError(msg) + + @property + def named(self) -> Mapping[object, Entity]: + """A view into this registries named entities. + + .. deprecated:: 3.1 + This feature has been deprecated. + """ + return self._names_by_name + + def new_entity( + self, + components: Iterable[object] | Mapping[ComponentKey[object], object] = (), + *, + name: object = None, + tags: Iterable[Any] = (), + ) -> Entity: + """Create and return a new entity. + + .. versionchanged:: 3.1 + `components` can now take a mapping. + + Example:: + + >>> entity = registry.new_entity( + ... components={ + ... ("name", str): "my name", + ... ("hp", int): 10, + ... }, + ... tags=["Actor"], + ... ) + >>> entity.components[("name", str)] + 'my name' + >>> "Actor" in entity.tags + True + """ + entity = Entity(self) + if isinstance(components, Mapping): + entity.components.update(components) + elif components: + entity.components.update_values(components) + entity_tags = entity.tags + for tag in tags: + entity_tags.add(tag) + if name is not None: + entity._set_name(name) + return entity + + @property + def Q(self) -> tcod.ecs.query.BoundQuery: # noqa: N802 + """Start a new Query for this registry. + + Alias for ``tcod.ecs.Query(registry)``. + """ + return tcod.ecs.query.BoundQuery(self) diff --git a/tcod/ecs/typing.py b/tcod/ecs/typing.py index f0c58ad..d262677 100644 --- a/tcod/ecs/typing.py +++ b/tcod/ecs/typing.py @@ -1,36 +1,32 @@ """Common type-hints for tcod.ecs.""" + from __future__ import annotations -import sys -import types -from typing import TYPE_CHECKING, Any, Tuple, Type, TypeVar, Union +from types import EllipsisType +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar -from typing_extensions import TypeAlias +from typing_extensions import TypeForm if TYPE_CHECKING: from tcod.ecs.entity import Entity - from tcod.ecs.query import WorldQuery + from tcod.ecs.query import BoundQuery else: Entity = Any - WorldQuery = Any + BoundQuery = Any -if sys.version_info >= (3, 10): # pragma: no cover - EllipsisType: TypeAlias = types.EllipsisType -else: # pragma: no cover - EllipsisType = Any _T = TypeVar("_T") -ComponentKey: TypeAlias = Union[Type[_T], Tuple[object, Type[_T]]] +ComponentKey: TypeAlias = TypeForm[_T] | tuple[object, TypeForm[_T]] """ComponentKey is plain `type` or tuple `(tag, type)`.""" -_RelationTargetLookup: TypeAlias = Union[Entity, EllipsisType] +_RelationTargetLookup: TypeAlias = Entity | EllipsisType """Possible target for stored relations.""" -_RelationQueryTarget: TypeAlias = Union[_RelationTargetLookup, WorldQuery] +_RelationQueryTarget: TypeAlias = _RelationTargetLookup | BoundQuery """Possible target for relation queries.""" -_RelationQuery: TypeAlias = Union[Tuple[object, _RelationQueryTarget], Tuple[_RelationQueryTarget, object, None]] +_RelationQuery: TypeAlias = tuple[object, _RelationQueryTarget] | tuple[_RelationQueryTarget, object, None] # noqa: PYI047 """Query format for relations. One of 4 formats: diff --git a/tcod/ecs/world.py b/tcod/ecs/world.py index 4da3e43..efd45b7 100644 --- a/tcod/ecs/world.py +++ b/tcod/ecs/world.py @@ -1,314 +1,8 @@ -"""World management tools.""" +# noqa: D100 from __future__ import annotations -import warnings -from collections import defaultdict -from typing import Any, DefaultDict, Dict, Iterable, Mapping, NoReturn, Set, TypeVar +__all__ = ("World",) -import attrs +from tcod.ecs.registry import Registry as World -import tcod.ecs._converter -import tcod.ecs.query -from tcod.ecs.entity import Entity -from tcod.ecs.typing import ComponentKey, _RelationTargetLookup - -_T1 = TypeVar("_T1") -_T2 = TypeVar("_T2") -_T3 = TypeVar("_T3") - - -def _defaultdict_of_set() -> defaultdict[_T1, set[_T2]]: - """Return a new defaultdict of sets.""" - return defaultdict(set) - - -def _defaultdict_of_dict() -> defaultdict[_T1, dict[_T2, _T3]]: - """Return a new defaultdict of dicts.""" - return defaultdict(dict) - - -def _components_by_entity_from( - by_type: defaultdict[ComponentKey[object], dict[Entity, Any]] -) -> defaultdict[Entity, dict[ComponentKey[object], Any]]: - """Return the component lookup table from the components sparse-set.""" - by_entity: defaultdict[Entity, dict[ComponentKey[object], Any]] = defaultdict(dict) - for component_key, components in by_type.items(): - for entity, component in components.items(): - by_entity[entity][component_key] = component - return by_entity - - -def _tags_by_key_from_tags_by_entity(by_entity: defaultdict[Entity, set[object]]) -> defaultdict[object, set[Entity]]: - """Return the tag lookup table from the tags sparse-set.""" - tags_by_key = defaultdict(set) - for entity, tags in by_entity.items(): - for tag in tags: - tags_by_key[tag].add(entity) - return tags_by_key - - -def _relations_lookup_from( - tags_by_entity: defaultdict[Entity, defaultdict[object, set[Entity]]], - components_by_entity: defaultdict[Entity, defaultdict[ComponentKey[object], dict[Entity, Any]]], -) -> defaultdict[tuple[Any, _RelationTargetLookup] | tuple[_RelationTargetLookup, Any, None], set[Entity]]: - """Return the relation lookup table from the relations sparse-sets.""" - relations_lookup: defaultdict[ - tuple[Any, _RelationTargetLookup] | tuple[_RelationTargetLookup, Any, None], set[Entity] - ] = defaultdict(set) - for origin, tags in tags_by_entity.items(): - for tag, targets in tags.items(): - for target in targets: - relations_lookup[(tag, ...)].add(origin) - relations_lookup[(tag, target)].add(origin) - relations_lookup[(origin, tag, None)].add(target) - relations_lookup[(..., tag, None)].add(target) - for origin, components in components_by_entity.items(): - for component_key, target_components in components.items(): - for target in target_components: - relations_lookup[(component_key, ...)].add(origin) - relations_lookup[(component_key, origin)].add(origin) - relations_lookup[(origin, component_key, None)].add(target) - relations_lookup[(..., component_key, None)].add(target) - - return relations_lookup - - -@attrs.define(eq=False) -class World: - """A container for entities and components.""" - - _components_by_entity: defaultdict[Entity, dict[ComponentKey[object], Any]] = attrs.field( - init=False, factory=lambda: defaultdict(dict) - ) - """Random access entity components. - - dict[Entity][ComponentKey] = component_instance - """ - _components_by_type: defaultdict[ComponentKey[object], dict[Entity, Any]] = attrs.field( - init=False, factory=lambda: defaultdict(dict) - ) - """Query table entity components. - - dict[ComponentKey] = {entities_with_component} - """ - - _tags_by_key: defaultdict[object, set[Entity]] = attrs.field(init=False, factory=lambda: defaultdict(set)) - """Query table entity tags. - - dict[tag] = {all_entities_with_tag} - """ - _tags_by_entity: defaultdict[Entity, set[Any]] = attrs.field(init=False, factory=lambda: defaultdict(set)) - """Random access entity tags. - - dict[Entity] = {all_tags_for_entity} - """ - - _relation_tags_by_entity: defaultdict[Entity, defaultdict[object, set[Entity]]] = attrs.field( - init=False, factory=lambda: defaultdict(_defaultdict_of_set) - ) - """Random access tag multi-relations. - - dict[entity][tag] = {target_entities} - """ - _relation_components_by_entity: defaultdict[ - Entity, defaultdict[ComponentKey[object], dict[Entity, Any]] - ] = attrs.field(init=False, factory=lambda: defaultdict(_defaultdict_of_dict)) - """Random access relations owning components. - - dict[entity][ComponentKey][target_entity] = component - """ - _relations_lookup: defaultdict[ - tuple[Any, _RelationTargetLookup] | tuple[_RelationTargetLookup, Any, None], set[Entity] - ] = attrs.field(init=False, factory=lambda: defaultdict(set)) - """Relations query table. Tags and components are mixed together. - - ``` - Tag: - dict[(tag, this_entity)] = {target_entities_for_entity} - dict[(tag, None)] = {target_entities_for_tag} - dict[(target_entity, tag, None)] = {origin_entities_for_target} - dict[(None, tag, None)] = {all_origen_entities_for_tag} - Component: - dict[(ComponentKey, target_entity)] = {origin_entities} - dict[(ComponentKey, None)] = {all_origin_entities} - dict[(origin_entity, ComponentKey, None)] = {target_entities} - dict[(None, ComponentKey, None)] = {all_target_entities} - ``` - """ - - _names_by_name: dict[object, Entity] = attrs.field(init=False, factory=dict) - """Name query table. - - dict[name] = named_entity - """ - _names_by_entity: dict[Entity, object] = attrs.field(init=False, factory=dict) - """Name lookup table. - - dict[Entity] = entities_name - """ - - @property - def global_(self) -> Entity: - """A unique globally accessible entity. - - This can be used to store globally accessible components in the world itself without any extra boilerplate. - Otherwise this entity is not special and will show up with other entities in queries, etc. - - This entity has a `uid` of `None` and may be accessed that way. - This syntax my be better for globals in general since it can use any hashable object. - - .. versionadded:: 1.1 - - Example:: - - >>> world[None].components[("turn", int)] = 0 - >>> world[None].components[("turn", int)] - 0 - """ - warnings.warn( - "The 'world.global_' attribute has been deprecated. Use 'world[None]' to access this entity.", - FutureWarning, - stacklevel=2, - ) - return Entity(self, None) - - def __setstate__(self, state: dict[str, Any]) -> None: - """Unpickle this object and handle state migration.""" - global_: Entity | None = state.pop("global_", None) # Migrate from version <=1.2.0 - - # These attributes contain redundant data and will be removed - REDUNDANT_ATTRIBUTES = frozenset( - { - "_components_by_entity", # <=3.4.0 - "_tags_by_key", # <=3.4.0 - "_relations_lookup", # <=3.4.0 - "_names_by_entity", # <=3.4.0 - } - ) - for ignored in REDUNDANT_ATTRIBUTES: - state.pop(ignored, None) - - converter = tcod.ecs._converter._get_converter() - # Apply defaultdict types to unpickled dictionaries - self._components_by_type = converter.structure( - state.pop("_components_by_type"), - DefaultDict[Any, Dict[Any, Any]], - ) - self._components_by_entity = _components_by_entity_from(self._components_by_type) - - self._tags_by_entity = converter.structure( - state.pop("_tags_by_entity"), - DefaultDict[Any, Set[Any]], - ) - self._tags_by_key = _tags_by_key_from_tags_by_entity(self._tags_by_entity) - - self._relation_tags_by_entity = converter.structure( - state.pop("_relation_tags_by_entity"), - DefaultDict[Any, DefaultDict[Any, Set[Any]]], - ) - self._relation_components_by_entity = converter.structure( - state.pop("_relation_components_by_entity"), - DefaultDict[Any, DefaultDict[Any, Dict[Any, Any]]], - ) - self._relations_lookup = _relations_lookup_from( - self._relation_tags_by_entity, self._relation_components_by_entity - ) - - self._names_by_name = state.pop("_names_by_name") - self._names_by_entity = {entity: name for name, entity in self._names_by_name.items()} - - if global_ is not None and global_.uid is not None: # Migrate from version <=1.2.0 - global_._force_remap(None) - - if state: - warnings.warn(f"These attributes were not unpacked {state.keys()}", RuntimeWarning, stacklevel=1) - - def __getstate__(self) -> dict[str, Any]: - """Pickle this object.""" - converter = tcod.ecs._converter._get_converter() - # Replace defaultdict types with plain dict when saving - return { - "_components_by_type": converter.structure(self._components_by_type, Dict[Any, Dict[Any, Any]]), - "_tags_by_entity": converter.structure(self._tags_by_entity, Dict[Any, Any]), - "_relation_tags_by_entity": converter.structure(self._relation_tags_by_entity, Dict[Any, Dict[Any, Any]]), - "_relation_components_by_entity": converter.structure( - self._relation_components_by_entity, Dict[Any, Dict[Any, Any]] - ), - "_names_by_name": self._names_by_name, - } - - def __getitem__(self, uid: object) -> Entity: - """Return an entity associated with a unique id. - - Example:: - - >>> world = World() - >>> foo = world["foo"] # Referencing a new entity returns a new empty entity - >>> foo is world["foo"] - True - >>> entity = world.new_entity() - >>> world[entity.uid] is entity # Anonymous entities can be referred to by their uid - True - """ - assert uid is not object, "This is reserved." - return Entity(self, uid) - - def __iter__(self) -> NoReturn: - """Raises TypeError, :any:`World` is not iterable.""" - msg = "'World' object is not iterable." - raise TypeError(msg) - - @property - def named(self) -> Mapping[object, Entity]: - """A view into this worlds named entities. - - .. deprecated:: 3.1 - This feature has been deprecated. - """ - return self._names_by_name - - def new_entity( - self, - components: Iterable[object] | Mapping[ComponentKey[object], object] = (), - *, - name: object = None, - tags: Iterable[Any] = (), - ) -> Entity: - """Create and return a new entity. - - .. versionchanged:: 3.1 - `components` can now take a mapping. - - Example:: - - >>> entity = world.new_entity( - ... components={ - ... ("name", str): "my name", - ... ("hp", int): 10, - ... }, - ... tags=["Actor"], - ... ) - >>> entity.components[("name", str)] - 'my name' - >>> "Actor" in entity.tags - True - """ - entity = Entity(self) - if isinstance(components, Mapping): - entity.components.update(components) - elif components: - entity.components.update_values(components, _stacklevel=2) - entity_tags = entity.tags - for tag in tags: - entity_tags.add(tag) - if name is not None: - entity._set_name(name, stacklevel=2) - return entity - - @property - def Q(self) -> tcod.ecs.query.WorldQuery: - """Start a new Query for this world. - - Alias for ``tcod.ecs.Query(world)``. - """ - return tcod.ecs.query.WorldQuery(self) +# This modules existence keeps backwards compatibly with tcod-ecs version <= 5.0.0 diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 0b2ee07..34a05e1 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -1,4 +1,5 @@ """Benchmarking tests.""" + from __future__ import annotations from typing import Any @@ -9,34 +10,34 @@ def test_component_missing(benchmark: Any) -> None: - entity = tcod.ecs.World().new_entity() + entity = tcod.ecs.Registry().new_entity() benchmark(lambda: entity.components.get(str)) def test_component_assign(benchmark: Any) -> None: - entity = tcod.ecs.World().new_entity() + entity = tcod.ecs.Registry().new_entity() - @benchmark # type: ignore[misc] + @benchmark # type: ignore[untyped-decorator] def _() -> None: entity.components[str] = "value" def test_component_found(benchmark: Any) -> None: - entity = tcod.ecs.World().new_entity() + entity = tcod.ecs.Registry().new_entity() entity.components[str] = "value" benchmark(lambda: entity.components[str]) def test_tag_missing(benchmark: Any) -> None: - entity = tcod.ecs.World().new_entity() + entity = tcod.ecs.Registry().new_entity() benchmark(lambda: "value" in entity.tags) def test_tag_assign(benchmark: Any) -> None: - entity = tcod.ecs.World().new_entity() + entity = tcod.ecs.Registry().new_entity() benchmark(lambda: entity.tags.add("value")) def test_tag_found(benchmark: Any) -> None: - entity = tcod.ecs.World().new_entity() + entity = tcod.ecs.Registry().new_entity() benchmark(lambda: "value" in entity.tags) diff --git a/tests/test_ecs.py b/tests/test_ecs.py index 6dbe7bf..95c334c 100644 --- a/tests/test_ecs.py +++ b/tests/test_ecs.py @@ -1,11 +1,13 @@ """Tests for tcod-ecs.""" + from __future__ import annotations import io import pickle import pickletools import sys -from typing import Callable, Iterator +from collections.abc import Callable, Iterator # noqa: TC003 +from typing import Final import pytest @@ -15,7 +17,7 @@ def test_world() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() with pytest.warns(): entity = world.new_entity([1, "test"]) assert entity.components[int] == 1 @@ -33,7 +35,7 @@ def test_world() -> None: def test_component_names() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() entity = world.new_entity() entity.components[("name", str)] = "name" entity.components[("foo", str)] = "foo" @@ -44,7 +46,7 @@ def test_component_names() -> None: def test_naming() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() with pytest.warns(): entity_a = world.new_entity(name="A") assert entity_a.name == "A" @@ -61,9 +63,9 @@ def test_naming() -> None: assert repr(world.new_entity(name="foo")) == "" -def sample_world_v1() -> tcod.ecs.World: +def sample_world_v1() -> tcod.ecs.Registry: """Return a sample world.""" - world = tcod.ecs.World() + world = tcod.ecs.Registry() with pytest.warns(): entity = world.new_entity(name="A") entity.components[str] = "str" @@ -76,7 +78,7 @@ def sample_world_v1() -> tcod.ecs.World: return world -def check_world_v1(world: tcod.ecs.World) -> None: +def check_world_v1(world: tcod.ecs.Registry) -> None: """Assert a sample world is as expected.""" entity = world.named["A"] assert entity.components[str] == "str" @@ -92,9 +94,9 @@ def check_world_v1(world: tcod.ecs.World) -> None: assert not world[None].components -def sample_world_v2() -> tcod.ecs.World: +def sample_world_v2() -> tcod.ecs.Registry: """Return a sample world.""" - world = tcod.ecs.World() + world = tcod.ecs.Registry() with pytest.warns(): world["A"].name = "A" assert world.named["A"] is world["A"] @@ -107,7 +109,7 @@ def sample_world_v2() -> tcod.ecs.World: return world -def check_world_v2(world: tcod.ecs.World) -> None: +def check_world_v2(world: tcod.ecs.Registry) -> None: """Assert a sample world is as expected.""" original = sample_world_v2() assert not world["X"].components @@ -121,13 +123,15 @@ def check_world_v2(world: tcod.ecs.World) -> None: PICKLED_SAMPLES = { "v2": { - "latest": b'\x80\x04\x95R\x01\x00\x00\x00\x00\x00\x00\x8c\x0etcod.ecs.world\x94\x8c\x05World\x94\x93\x94)\x81\x94}\x94(\x8c\x13_components_by_type\x94}\x94(\x8c\x08builtins\x94\x8c\x03str\x94\x93\x94}\x94\x8c\x0ftcod.ecs.entity\x94\x8c\x06Entity\x94\x93\x94h\x03\x8c\x01A\x94\x86\x94R\x94\x8c\x03str\x94s\x8c\x03foo\x94h\t\x86\x94}\x94h\x10h\x12sh\x07\x8c\x04bool\x94\x93\x94}\x94h\rh\x03N\x86\x94R\x94\x88su\x8c\x0f_tags_by_entity\x94}\x94h\x10\x8f\x94(\x8c\x03tag\x94\x90s\x8c\x18_relation_tags_by_entity\x94}\x94h\rh\x03\x8c\x01B\x94\x86\x94R\x94}\x94\x8c\x07ChildOf\x94\x8f\x94(h\x10\x90ss\x8c\x1e_relation_components_by_entity\x94}\x94h"}\x94h\t}\x94h\x10h\x11sss\x8c\x0e_names_by_name\x94}\x94h\x0eh\x10sub.', # cspell: disable-line + "latest": b'\x80\x04\x95X\x01\x00\x00\x00\x00\x00\x00\x8c\x11tcod.ecs.registry\x94\x8c\x08Registry\x94\x93\x94)\x81\x94}\x94(\x8c\x13_components_by_type\x94}\x94(\x8c\x08builtins\x94\x8c\x03str\x94\x93\x94}\x94\x8c\x0ftcod.ecs.entity\x94\x8c\x06Entity\x94\x93\x94h\x03\x8c\x01A\x94\x86\x94R\x94\x8c\x03str\x94s\x8c\x03foo\x94h\t\x86\x94}\x94h\x10h\x12sh\x07\x8c\x04bool\x94\x93\x94}\x94h\rh\x03N\x86\x94R\x94\x88su\x8c\x0f_tags_by_entity\x94}\x94h\x10\x8f\x94(\x8c\x03tag\x94\x90s\x8c\x18_relation_tags_by_entity\x94}\x94h\rh\x03\x8c\x01B\x94\x86\x94R\x94}\x94\x8c\x07ChildOf\x94\x8f\x94(h\x10\x90ss\x8c\x1e_relation_components_by_entity\x94}\x94h"}\x94h\t}\x94h\x10h\x11sss\x8c\x0e_names_by_name\x94}\x94h\x0eh\x10sub.', # cspell: disable-line + "5.0.0": b'\x80\x04\x95R\x01\x00\x00\x00\x00\x00\x00\x8c\x0etcod.ecs.world\x94\x8c\x05World\x94\x93\x94)\x81\x94}\x94(\x8c\x13_components_by_type\x94}\x94(\x8c\x08builtins\x94\x8c\x03str\x94\x93\x94}\x94\x8c\x0ftcod.ecs.entity\x94\x8c\x06Entity\x94\x93\x94h\x03\x8c\x01A\x94\x86\x94R\x94\x8c\x03str\x94s\x8c\x03foo\x94h\t\x86\x94}\x94h\x10h\x12sh\x07\x8c\x04bool\x94\x93\x94}\x94h\rh\x03N\x86\x94R\x94\x88su\x8c\x0f_tags_by_entity\x94}\x94h\x10\x8f\x94(\x8c\x03tag\x94\x90s\x8c\x18_relation_tags_by_entity\x94}\x94h\rh\x03\x8c\x01B\x94\x86\x94R\x94}\x94\x8c\x07ChildOf\x94\x8f\x94(h\x10\x90ss\x8c\x1e_relation_components_by_entity\x94}\x94h"}\x94h\t}\x94h\x10h\x11sss\x8c\x0e_names_by_name\x94}\x94h\x0eh\x10sub.', # cspell: disable-line "3.5.0": b"\x80\x04\x95<\x01\x00\x00\x00\x00\x00\x00\x8c\x08tcod.ecs\x94\x8c\x05World\x94\x93\x94)\x81\x94}\x94(\x8c\x13_components_by_type\x94}\x94(\x8c\x08builtins\x94\x8c\x03str\x94\x93\x94}\x94h\x00\x8c\x06Entity\x94\x93\x94h\x03\x8c\x01A\x94\x86\x94R\x94\x8c\x03str\x94s\x8c\x03foo\x94h\t\x86\x94}\x94h\x0fh\x11sh\x07\x8c\x04bool\x94\x93\x94}\x94h\x0ch\x03N\x86\x94R\x94\x88su\x8c\x0f_tags_by_entity\x94}\x94h\x0f\x8f\x94(\x8c\x03tag\x94\x90s\x8c\x18_relation_tags_by_entity\x94}\x94h\x0ch\x03\x8c\x01B\x94\x86\x94R\x94}\x94\x8c\x07ChildOf\x94\x8f\x94(h\x0f\x90ss\x8c\x1e_relation_components_by_entity\x94}\x94h!}\x94h\t}\x94h\x0fh\x10sss\x8c\x0e_names_by_name\x94}\x94h\rh\x0fsub.", # cspell: disable-line "3.4.0": b"\x80\x04\x95\xbb\x02\x00\x00\x00\x00\x00\x00\x8c\x08tcod.ecs\x94\x8c\x05World\x94\x93\x94)\x81\x94}\x94(\x8c\x13_components_by_type\x94\x8c\x0bcollections\x94\x8c\x0bdefaultdict\x94\x93\x94\x8c\x08builtins\x94\x8c\x04dict\x94\x93\x94\x85\x94R\x94(h\t\x8c\x03str\x94\x93\x94}\x94h\x00\x8c\x06Entity\x94\x93\x94h\x03\x8c\x01A\x94\x86\x94R\x94\x8c\x03str\x94s\x8c\x03foo\x94h\x0f\x86\x94}\x94h\x15h\x17sh\t\x8c\x04bool\x94\x93\x94}\x94h\x12h\x03N\x86\x94R\x94\x88su\x8c\x15_components_by_entity\x94h\x08h\t\x8c\x03set\x94\x93\x94\x85\x94R\x94(h\x15\x8f\x94(h\x18h\x0f\x90h\x1e\x8f\x94(h\x1b\x90u\x8c\x0c_tags_by_key\x94h\x08h!\x85\x94R\x94\x8c\x03tag\x94\x8f\x94(h\x15\x90s\x8c\x0f_tags_by_entity\x94h\x08h!\x85\x94R\x94h\x15\x8f\x94(h)\x90s\x8c\x18_relation_tags_by_entity\x94h\x08h\x00\x8c\x13_defaultdict_of_set\x94\x93\x94\x85\x94R\x94h\x12h\x03\x8c\x01B\x94\x86\x94R\x94h\x08h!\x85\x94R\x94\x8c\x07ChildOf\x94\x8f\x94(h\x15\x90ss\x8c\x1e_relation_components_by_entity\x94h\x08h\x00\x8c\x14_defaultdict_of_dict\x94\x93\x94\x85\x94R\x94h6h\x08h\x0b\x85\x94R\x94h\x0f}\x94h\x15h\x16sss\x8c\x11_relations_lookup\x94h\x08h!\x85\x94R\x94(h9h\x15\x86\x94\x8f\x94(h6\x90h9h\t\x8c\x08Ellipsis\x94\x93\x94\x86\x94\x8f\x94(h6\x90h6h9N\x87\x94\x8f\x94(h\x15\x90hIh9N\x87\x94\x8f\x94(h\x15\x90h\x0fh\x15\x86\x94\x8f\x94(h6\x90h\x0fhI\x86\x94\x8f\x94(h6\x90h6h\x0fN\x87\x94\x8f\x94(h\x15\x90hIh\x0fN\x87\x94\x8f\x94(h\x15\x90u\x8c\x0e_names_by_name\x94}\x94h\x13h\x15s\x8c\x10_names_by_entity\x94}\x94h\x15h\x13sub.", # cspell: disable-line "3.0.0": b"\x80\x04\x95\xc6\x02\x00\x00\x00\x00\x00\x00\x8c\x08tcod.ecs\x94\x8c\x05World\x94\x93\x94)\x81\x94}\x94(\x8c\x13_components_by_type\x94\x8c\x0bcollections\x94\x8c\x0bdefaultdict\x94\x93\x94\x8c\x08builtins\x94\x8c\x04dict\x94\x93\x94\x85\x94R\x94(h\t\x8c\x03str\x94\x93\x94}\x94h\x00\x8c\x06Entity\x94\x93\x94h\x03\x8c\x01A\x94\x86\x94R\x94\x8c\x03str\x94s\x8c\x03foo\x94h\x0f\x86\x94}\x94h\x15h\x17sh\t\x8c\x04bool\x94\x93\x94}\x94h\x12h\x03N\x86\x94R\x94\x88su\x8c\x15_components_by_entity\x94h\x08h\t\x8c\x03set\x94\x93\x94\x85\x94R\x94(h\x15\x8f\x94(h\x18h\x0f\x90h\x1e\x8f\x94(h\x1b\x90u\x8c\x0c_tags_by_key\x94h\x08h!\x85\x94R\x94\x8c\x03tag\x94\x8f\x94(h\x15\x90s\x8c\x0f_tags_by_entity\x94h\x08h!\x85\x94R\x94h\x15\x8f\x94(h)\x90s\x8c\x18_relation_tags_by_entity\x94h\x08\x8c\tfunctools\x94\x8c\x07partial\x94\x93\x94h\x08\x85\x94R\x94(h\x08h!\x85\x94}\x94Nt\x94b\x85\x94R\x94h\x12h\x03\x8c\x01B\x94\x86\x94R\x94h\x08h!\x85\x94R\x94\x8c\x07ChildOf\x94\x8f\x94(h\x15\x90ss\x8c\x1e_relation_components_by_entity\x94h\x08h2h\x08\x85\x94R\x94(h\x08h\x0b\x85\x94}\x94Nt\x94b\x85\x94R\x94h str: @pytest.mark.parametrize("sample_version", PICKLED_SAMPLES.keys()) def test_pickle(sample_version: str) -> None: """Test that pickled worlds are stable.""" - sample_world: Callable[[], tcod.ecs.World] = globals()[f"sample_world_{sample_version}"] - check_world: Callable[[tcod.ecs.World], None] = globals()[f"check_world_{sample_version}"] + sample_world: Callable[[], tcod.ecs.Registry] = globals()[f"sample_world_{sample_version}"] + check_world: Callable[[tcod.ecs.Registry], None] = globals()[f"check_world_{sample_version}"] sample_data = PICKLED_SAMPLES[sample_version]["latest"] pickled = pickle.dumps(sample_world(), protocol=4) print(pickled) assert pickle_disassemble(pickled) == pickle_disassemble(sample_data), "Check if data format has changed" - unpickled: tcod.ecs.World = pickle.loads(pickled) # noqa: S301 + unpickled: tcod.ecs.Registry = pickle.loads(pickled) # noqa: S301 check_world(unpickled) @@ -173,23 +177,23 @@ def test_pickle(sample_version: str) -> None: def test_unpickle(sample_version: str, ecs_version: str) -> None: """Test that pickled worlds are stable.""" globals()[f"sample_world_{sample_version}"] - check_world: Callable[[tcod.ecs.World], None] = globals()[f"check_world_{sample_version}"] + check_world: Callable[[tcod.ecs.Registry], None] = globals()[f"check_world_{sample_version}"] sample_data = PICKLED_SAMPLES[sample_version][ecs_version] - unpickled: tcod.ecs.World = pickle.loads(sample_data) # noqa: S301 + unpickled: tcod.ecs.Registry = pickle.loads(sample_data) # noqa: S301 check_world(unpickled) def test_global() -> None: - world = tcod.ecs.World() - with pytest.warns(match=r"world\[None\]"): + world = tcod.ecs.Registry() + with pytest.warns(match=r"registry\[None\]"): world.global_.components[int] = 1 - with pytest.warns(match=r"world\[None\]"): + with pytest.warns(match=r"registry\[None\]"): assert set(world.Q[tcod.ecs.Entity, int]) == {(world.global_, 1)} def test_by_name_type() -> None: - entity = tcod.ecs.World()[None] + entity = tcod.ecs.Registry()[None] with pytest.warns(): assert list(entity.components.by_name_type(int, int)) == [] entity.components[int] = 0 @@ -202,17 +206,17 @@ def test_by_name_type() -> None: def test_suspicious_tags() -> None: with pytest.warns(match=r"The tags parameter was given a str type"): - tcod.ecs.World().Q.all_of(tags="Tags") + tcod.ecs.Registry().Q.all_of(tags="Tags") def test_component_setdefault() -> None: - entity = tcod.ecs.World()[None] + entity = tcod.ecs.Registry()[None] assert entity.components.setdefault(int, 1) == 1 assert entity.components.setdefault(int, 2) == 1 def test_query_exclude_components() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() world["A"].components[int] = 0 world["A"].components[str] = "" world["B"].components[int] = 0 @@ -220,14 +224,14 @@ def test_query_exclude_components() -> None: def test_query_exclude_tags() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() world["A"].tags |= set("AB") world["B"].tags |= set("B") assert set(world.Q.all_of(tags=["B"]).none_of(tags=["A"])) == {world["B"]} def test_query_exclude_relations() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() world["B"].relation_tag["ChildOf"] = world["A"] world["C"].relation_tags_many["ChildOf"] = {world["A"], world["B"]} assert set(world.Q.all_of(relations=[("ChildOf", ...)]).none_of(relations=[("ChildOf", world["B"])])) == { @@ -236,7 +240,7 @@ def test_query_exclude_relations() -> None: def test_tag_query() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() assert not set(world.Q.all_of(tags=["A"])) world["A"].tags.add("A") assert set(world.Q.all_of(tags=["A"])) == {world["A"]} @@ -248,7 +252,7 @@ def test_tag_query() -> None: def test_entity_clear() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() entity = world["entity"] other = world["other"] entity.components[int] = 0 @@ -271,4 +275,36 @@ def test_entity_clear() -> None: def test_world_iter() -> None: with pytest.raises(TypeError, match=r"is not iterable"): - iter(tcod.ecs.World()) # Not iterable for now, maybe later + iter(tcod.ecs.Registry()) # Not iterable for now, maybe later + + +def test_world_query_bool() -> None: + world = tcod.ecs.Registry() + assert not world.Q.all_of(tags=["Foo"]) + world[None].tags.add("Foo") + assert world.Q.all_of(tags=["Foo"]) + + +def test_any_of() -> None: + world = tcod.ecs.Registry() + world[None].tags.add("foo") + 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) + + +PICKLED_ISA = b"\x80\x03ctcod.ecs.constants\n_sentinel_IsA\nq\x00X\x12\x00\x00\x00tcod.ecs.constantsq\x01X\x03\x00\x00\x00IsAq\x02\x86q\x03\x81q\x04}q\x05(X\r\x00\x00\x00instance_nameq\x06h\x02X\x0b\x00\x00\x00module_nameq\x07h\x01X\x0e\x00\x00\x00qualified_nameq\x08X\x16\x00\x00\x00tcod.ecs.constants.IsAq\tub." # cspell: disable-line + + +def test_unpickle_is_a() -> None: + assert pickle.loads(PICKLED_ISA) is tcod.ecs.IsA # noqa: S301 diff --git a/tests/test_relations.py b/tests/test_relations.py index 4f414b3..b0df2ea 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,4 +1,5 @@ """Tests for entity relations.""" + from __future__ import annotations from typing import Final @@ -13,7 +14,7 @@ def test_relations() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() entity_a = world["A"] entity_b = world["B"] entity_b.relation_tag[ChildOf] = entity_a @@ -37,13 +38,13 @@ def test_relations() -> None: def test_relations_old() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() with pytest.warns(FutureWarning): world[None].relation_tags["Foo"] = world[None] def test_relations_many() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() entity_a = world["A"] entity_b = world["B"] entity_c = world["C"] @@ -60,7 +61,7 @@ def test_relations_many() -> None: def test_relation_components() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() entity_a = world["A"] entity_b = world["B"] @@ -91,7 +92,7 @@ def test_relation_components() -> None: def test_conditional_relations() -> None: - world = tcod.ecs.World() + world = tcod.ecs.Registry() world["A"].relation_tag[ChildOf] = world["B"] world["C"].components[int] = 42 has_int_query = world.Q.all_of(components=[int]) @@ -106,7 +107,7 @@ def test_conditional_relations() -> None: def test_relation_component_tables() -> None: - w = tcod.ecs.World() + w = tcod.ecs.Registry() e1 = w["e1"] e2 = w["e2"] e3 = w["e3"] @@ -138,7 +139,7 @@ def test_relation_component_tables() -> None: def test_relation_tag_tables() -> None: - w = tcod.ecs.World() + w = tcod.ecs.Registry() e1 = w["e1"] e2 = w["e2"] e3 = w["e3"] @@ -167,3 +168,46 @@ def test_relation_tag_tables() -> None: assert set(w.Q.all_of(relations=[(..., "tag", None)])) == {e2} assert set(w.Q.all_of(relations=[(e1, "tag", None)])) == {e2} assert not set(w.Q.all_of(relations=[(e3, "tag", None)])) + + +def test_clear_with_relation() -> None: + world = tcod.ecs.Registry() + parent = world["parent"] + child = parent.instantiate() + entity_other = world[object()] + for i in range(5): + parent.components[(i, int)] = i + parent.tags.add(i) + parent.relation_components[(i, int)][entity_other] = i + parent.relation_tag[i] = entity_other + + for i in range(10): + child.components[(i, int)] = i + child.tags.add(i) + child.relation_components[(i, int)][entity_other] = i + child.relation_tag[i] = entity_other + + components_all = {(i, int) for i in range(10)} + components_after = {(i, int) for i in range(5)} + tags_all = set(range(10)) + tags_after = set(range(5)) + + assert child.components.keys() == components_all + assert child.relation_components.keys() == components_all + assert set(child.tags) == tags_all + assert set(child.relation_tag.keys()) == tags_all | {tcod.ecs.IsA} + + # Clear direct values, keeping values from parent as long as the IsA relation exists + child.components.clear() + assert child.components.keys() == components_after + child.relation_components.clear() + assert child.relation_components.keys() == components_after + child.tags.clear() + assert set(child.tags) == tags_after + + # Clearing relation_tags means breaking the IsA link to the parent + child.relation_tag.clear() + assert not child.relation_tag.keys() + assert not child.components.keys() + assert not child.tags + assert not child.relation_components.keys() diff --git a/tests/test_traversal.py b/tests/test_traversal.py index e7b798c..950a98f 100644 --- a/tests/test_traversal.py +++ b/tests/test_traversal.py @@ -1,15 +1,18 @@ """Inheritance tests.""" + +from __future__ import annotations + from typing import Final import pytest -from tcod.ecs import IsA, World +from tcod.ecs import IsA, Registry # ruff: noqa: D103 def test_component_traversal() -> None: - world = World() + world = Registry() assert not world.Q.all_of(components=[str]).get_entities() world["derived"].relation_tag[IsA] = world["base"] world["instance"].relation_tag[IsA] = world["derived"] @@ -54,7 +57,7 @@ def test_component_traversal() -> None: def test_component_traversal_alternate() -> None: - world = World() + world = Registry() world["base"].components[str] = "base" world["alt"].components[str] = "alt" world["derived"].relation_tag[IsA] = world["base"] @@ -81,9 +84,9 @@ def test_component_traversal_alternate() -> None: def test_multiple_inheritance() -> None: - world = World() - ViaA: Final = object() - ViaC: Final = object() + world = Registry() + ViaA: Final = object() # noqa: N806 + ViaC: Final = object() # noqa: N806 world["A"].components[str] = "A" world["B"].components[str] = "B" world["B"].components[int] = 0 @@ -110,7 +113,7 @@ def test_multiple_inheritance() -> None: def test_cyclic_inheritance() -> None: - world = World() + world = Registry() world["A"].relation_tag[IsA] = world["D"] world["B"].relation_tag[IsA] = world["A"] world["C"].relation_tag[IsA] = world["B"] @@ -127,7 +130,7 @@ def test_cyclic_inheritance() -> None: def test_tag_traversal() -> None: - world = World() + world = Registry() world["B"].relation_tag[IsA] = world["A"] world["C"].relation_tag[IsA] = world["B"] @@ -165,7 +168,7 @@ def test_tag_traversal() -> None: def test_relation_traversal() -> None: - world = World() + world = Registry() world["B"].relation_tag[IsA] = world["A"] world["C"].relation_tag[IsA] = world["B"] assert set(world["C"].relation_tags_many[IsA]) == {world["A"], world["B"]} @@ -175,10 +178,14 @@ def test_relation_traversal() -> None: world["A"].relation_tag["test"] = world["foo"] world["B"].relation_tag["test"] = world["bar"] + assert set(world["C"].relation_tags_many["test"]) == {world["foo"], world["bar"]} assert len(world["C"].relation_tags_many["test"]) == 2 # noqa: PLR2004 assert world["C"].relation_tag["test"] == world["bar"] assert world.Q.all_of(relations=[("test", ...)]).get_entities() == {world["A"], world["B"], world["C"]} + assert not set(world["C"].relation_tags_many(traverse=())["test"]) + with pytest.raises(KeyError): + world["C"].relation_tag(traverse=())["test"] del world["B"].relation_tag["test"] assert set(world["C"].relation_tags_many["test"]) == {world["foo"]} @@ -189,6 +196,7 @@ def test_relation_traversal() -> None: world["A"].relation_components[str][world["foo"]] = "foo" assert world.Q.all_of(relations=[(str, ...)]).get_entities() == {world["A"], world["B"], world["C"]} + assert not set(world["C"].relation_components(traverse=())) world["B"].relation_components[str][world["bar"]] = "bar" world["C"].relation_components[str][world["bar"]] = "replaced" @@ -197,3 +205,16 @@ def test_relation_traversal() -> None: assert len(world["C"].relation_components[str]) == 2 # noqa: PLR2004 world["C"].relation_components[int][world["foo"]] = 0 assert set(world["C"].relation_components) == {str, int} + + +def test_inherited_clear() -> None: + world = Registry() + world["A"].components[int] = 1 + world["A"].tags.add("foo") + world["A"].relation_components[str][world["B"]] = "bar" + with pytest.warns(): + world["A"].relation_tags["baz"] = world["B"] + child = world["A"].instantiate() + child.clear() # Could hang if broken + x = child.components + x[int] = "asd"