diff --git a/addon_examples/transfer_example.py b/addon_examples/transfer_example.py index f99b2ce..69ea5b5 100644 --- a/addon_examples/transfer_example.py +++ b/addon_examples/transfer_example.py @@ -47,7 +47,7 @@ class TransferExampleAddon(BaseAddon): file_name=inv_message["InventoryData"]["Filename"], file_path=XferFilePath.CACHE) inv_model = InventoryModel.from_bytes(xfer.reassemble_chunks()) first_script: Optional[InventoryItem] = None - for item in inv_model.items.values(): + for item in inv_model.all_items: if item.type == "lsltext": first_script = item if not first_script: diff --git a/addon_examples/xfer_example.py b/addon_examples/xfer_example.py index 592a4a5..ac83806 100644 --- a/addon_examples/xfer_example.py +++ b/addon_examples/xfer_example.py @@ -57,7 +57,7 @@ class XferExampleAddon(BaseAddon): await xfer inv_model = InventoryModel.from_bytes(xfer.reassemble_chunks()) - item_names = [item.name for item in inv_model.items.values()] + item_names = [item.name for item in inv_model.all_items] show_message(item_names) @handle_command() diff --git a/hippolyzer/lib/base/inventory.py b/hippolyzer/lib/base/inventory.py index 07c9f60..cbe9f08 100644 --- a/hippolyzer/lib/base/inventory.py +++ b/hippolyzer/lib/base/inventory.py @@ -7,9 +7,9 @@ from __future__ import annotations import dataclasses import datetime as dt -import itertools import logging import struct +import typing import weakref from io import StringIO from typing import * @@ -132,17 +132,16 @@ class InventoryBase(SchemaBase): writer.write("\t}\n") +class InventoryDifferences(typing.NamedTuple): + changed: List[InventoryNodeBase] + removed: List[InventoryNodeBase] + + class InventoryModel(InventoryBase): def __init__(self): - self.containers: Dict[UUID, InventoryContainerBase] = {} - self.items: Dict[UUID, InventoryItem] = {} + self.nodes: Dict[UUID, InventoryNodeBase] = {} self.root: Optional[InventoryContainerBase] = None - def __eq__(self, other): - if not isinstance(other, InventoryModel): - return False - return tuple(self.all_nodes) == tuple(other.all_nodes) - @classmethod def from_reader(cls, reader: StringIO, read_header=False) -> InventoryModel: model = cls() @@ -180,32 +179,43 @@ class InventoryModel(InventoryBase): LOG.warning(f"Unknown object type {obj_dict!r}") return model + @property + def ordered_nodes(self) -> Iterable[InventoryNodeBase]: + yield from self.all_containers + yield from self.all_items + + @property + def all_containers(self) -> Iterable[InventoryContainerBase]: + for node in self.nodes.values(): + if isinstance(node, InventoryContainerBase): + yield node + + @property + def all_items(self) -> Iterable[InventoryItem]: + for node in self.nodes.values(): + if not isinstance(node, InventoryContainerBase): + yield node + + def __eq__(self, other): + if not isinstance(other, InventoryModel): + return False + return set(self.nodes.values()) == set(other.nodes.values()) + def to_writer(self, writer: StringIO): - for container in self.containers.values(): - container.to_writer(writer) - for item in self.items.values(): - item.to_writer(writer) + for node in self.ordered_nodes: + node.to_writer(writer) def to_llsd(self): - vals = [] - for container in self.containers.values(): - vals.append(container.to_llsd()) - for item in self.items.values(): - vals.append(item.to_llsd()) - return vals + return list(node.to_llsd() for node in self.ordered_nodes) def add(self, node: InventoryNodeBase): - if node.node_id in self.items or node.node_id in self.containers: + if node.node_id in self.nodes: raise KeyError(f"{node.node_id} already exists in the inventory model") + self.nodes[node.node_id] = node if isinstance(node, InventoryContainerBase): - self.containers[node.node_id] = node if node.parent_id == UUID.ZERO: self.root = node - elif isinstance(node, InventoryItem): - self.items[node.node_id] = node - else: - raise ValueError(f"Unknown node type for {node!r}") node.model = weakref.proxy(self) def unlink(self, node: InventoryNodeBase) -> Sequence[InventoryNodeBase]: @@ -217,17 +227,35 @@ class InventoryModel(InventoryBase): if isinstance(node, InventoryContainerBase): for child in node.children: unlinked.extend(self.unlink(child)) - self.items.pop(node.node_id, None) - self.containers.pop(node.node_id, None) + self.nodes.pop(node.node_id, None) node.model = None return unlinked - @property - def all_nodes(self) -> Iterable[InventoryNodeBase]: - for container in self.containers.values(): - yield container - for item in self.items.values(): - yield item + def get_differences(self, other: InventoryModel) -> InventoryDifferences: + # Includes modified things with the same ID + changed_in_other = [] + removed_in_other = [] + + other_keys = set(other.nodes.keys()) + our_keys = set(self.nodes.keys()) + + # Removed + for key in our_keys - other_keys: + removed_in_other.append(self.nodes[key]) + + # Updated + for key in other_keys.intersection(our_keys): + other_node = other.nodes[key] + if other_node != self.nodes[key]: + changed_in_other.append(other_node) + + # Added + for key in other_keys - our_keys: + changed_in_other.append(other.nodes[key]) + return InventoryDifferences( + changed=changed_in_other, + removed=removed_in_other, + ) @dataclasses.dataclass @@ -266,9 +294,13 @@ class InventoryNodeBase(InventoryBase): def node_id(self) -> UUID: return getattr(self, self.ID_ATTR) + @node_id.setter + def node_id(self, val: UUID): + setattr(self, self.ID_ATTR, val) + @property def parent(self) -> Optional[InventoryContainerBase]: - return self.model.containers.get(self.parent_id) + return self.model.nodes.get(self.parent_id) def unlink(self) -> Sequence[InventoryNodeBase]: return self.model.unlink(self) @@ -282,6 +314,9 @@ class InventoryNodeBase(InventoryBase): return None return super()._obj_from_dict(obj_dict) + def __hash__(self): + return hash(self.node_id) + @dataclasses.dataclass class InventoryContainerBase(InventoryNodeBase): @@ -291,12 +326,13 @@ class InventoryContainerBase(InventoryNodeBase): @property def children(self) -> Sequence[InventoryNodeBase]: return tuple( - x for x in ( - itertools.chain(self.model.containers.values(), self.model.items.values()) - ) + x for x in self.model.nodes.values() if x.parent_id == self.node_id ) + # So autogenerated __hash__ doesn't kill our inherited one + __hash__ = InventoryNodeBase.__hash__ + @dataclasses.dataclass class InventoryObject(InventoryContainerBase): @@ -305,6 +341,8 @@ class InventoryObject(InventoryContainerBase): obj_id: UUID = schema_field(SchemaUUID) + __hash__ = InventoryNodeBase.__hash__ + @dataclasses.dataclass class InventoryCategory(InventoryContainerBase): @@ -316,6 +354,8 @@ class InventoryCategory(InventoryContainerBase): owner_id: UUID = schema_field(SchemaUUID) version: int = schema_field(SchemaInt) + __hash__ = InventoryNodeBase.__hash__ + @dataclasses.dataclass class InventoryItem(InventoryNodeBase): @@ -334,6 +374,8 @@ class InventoryItem(InventoryNodeBase): asset_id: Optional[UUID] = schema_field(SchemaUUID, default=None) shadow_id: Optional[UUID] = schema_field(SchemaUUID, default=None) + __hash__ = InventoryNodeBase.__hash__ + @property def true_asset_id(self) -> UUID: if self.asset_id is not None: diff --git a/tests/base/test_legacy_schema.py b/tests/base/test_legacy_schema.py index 16b3f50..a710086 100644 --- a/tests/base/test_legacy_schema.py +++ b/tests/base/test_legacy_schema.py @@ -49,7 +49,7 @@ class TestLegacyInv(unittest.TestCase): self.model = InventoryModel.from_str(SIMPLE_INV) def test_parse(self): - self.assertTrue(UUID('f4d91477-def1-487a-b4f3-6fa201c17376') in self.model.containers) + self.assertTrue(UUID('f4d91477-def1-487a-b4f3-6fa201c17376') in self.model.nodes) self.assertIsNotNone(self.model.root) def test_serialize(self): @@ -58,39 +58,38 @@ class TestLegacyInv(unittest.TestCase): self.assertEqual(self.model, new_model) def test_item_access(self): - - item = self.model.items[UUID('dd163122-946b-44df-99f6-a6030e2b9597')] + item = self.model.nodes[UUID('dd163122-946b-44df-99f6-a6030e2b9597')] self.assertEqual(item.name, "New Script") self.assertEqual(item.sale_info.sale_type, "not") self.assertEqual(item.model, self.model) def test_access_children(self): root = self.model.root - item = tuple(self.model.items.values())[0] + item = tuple(self.model.ordered_nodes)[1] self.assertEqual((item,), root.children) def test_access_parent(self): root = self.model.root - item = tuple(self.model.items.values())[0] + item = tuple(self.model.ordered_nodes)[1] self.assertEqual(root, item.parent) self.assertEqual(None, root.parent) def test_unlink(self): self.assertEqual(1, len(self.model.root.children)) - item = tuple(self.model.items.values())[0] + item = tuple(self.model.ordered_nodes)[1] self.assertEqual([item], item.unlink()) self.assertEqual(0, len(self.model.root.children)) self.assertEqual(None, item.model) def test_relink(self): - item = tuple(self.model.items.values())[0] + item = tuple(self.model.ordered_nodes)[1] for unlinked in item.unlink(): self.model.add(unlinked) self.assertEqual(self.model, item.model) self.assertEqual(1, len(self.model.root.children)) def test_eq_excludes_model(self): - item = tuple(self.model.items.values())[0] + item = tuple(self.model.ordered_nodes)[1] item_copy = copy.copy(item) item_copy.model = None self.assertEqual(item, item_copy) @@ -137,9 +136,33 @@ class TestLegacyInv(unittest.TestCase): def test_llsd_legacy_equality(self): new_model = InventoryModel.from_llsd(self.model.to_llsd()) self.assertEqual(self.model, new_model) - tuple(new_model.containers.values())[0].name = "foo" + new_model.root.name = "foo" self.assertNotEqual(self.model, new_model) + def test_difference_added(self): + new_model = InventoryModel.from_llsd(self.model.to_llsd()) + diff = self.model.get_differences(new_model) + self.assertEqual([], diff.changed) + self.assertEqual([], diff.removed) + + new_model.root.name = "foo" + diff = self.model.get_differences(new_model) + self.assertEqual([new_model.root], diff.changed) + self.assertEqual([], diff.removed) + + item = new_model.root.children[0] + item.unlink() + diff = self.model.get_differences(new_model) + self.assertEqual([new_model.root], diff.changed) + self.assertEqual([item], diff.removed) + + new_item = copy.copy(item) + new_item.node_id = UUID.random() + new_model.add(new_item) + diff = self.model.get_differences(new_model) + self.assertEqual([new_model.root, new_item], diff.changed) + self.assertEqual([item], diff.removed) + GIRL_NEXT_DOOR_SHAPE = """LLWearable version 22 Girl Next Door - C2 - med - Adam n Eve