Add InventoryModel diffing

This commit is contained in:
Salad Dais
2022-07-09 02:48:23 +00:00
parent f3c8015366
commit 289073be8e
4 changed files with 112 additions and 47 deletions

View File

@@ -47,7 +47,7 @@ class TransferExampleAddon(BaseAddon):
file_name=inv_message["InventoryData"]["Filename"], file_path=XferFilePath.CACHE) file_name=inv_message["InventoryData"]["Filename"], file_path=XferFilePath.CACHE)
inv_model = InventoryModel.from_bytes(xfer.reassemble_chunks()) inv_model = InventoryModel.from_bytes(xfer.reassemble_chunks())
first_script: Optional[InventoryItem] = None first_script: Optional[InventoryItem] = None
for item in inv_model.items.values(): for item in inv_model.all_items:
if item.type == "lsltext": if item.type == "lsltext":
first_script = item first_script = item
if not first_script: if not first_script:

View File

@@ -57,7 +57,7 @@ class XferExampleAddon(BaseAddon):
await xfer await xfer
inv_model = InventoryModel.from_bytes(xfer.reassemble_chunks()) 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) show_message(item_names)
@handle_command() @handle_command()

View File

@@ -7,9 +7,9 @@ from __future__ import annotations
import dataclasses import dataclasses
import datetime as dt import datetime as dt
import itertools
import logging import logging
import struct import struct
import typing
import weakref import weakref
from io import StringIO from io import StringIO
from typing import * from typing import *
@@ -132,17 +132,16 @@ class InventoryBase(SchemaBase):
writer.write("\t}\n") writer.write("\t}\n")
class InventoryDifferences(typing.NamedTuple):
changed: List[InventoryNodeBase]
removed: List[InventoryNodeBase]
class InventoryModel(InventoryBase): class InventoryModel(InventoryBase):
def __init__(self): def __init__(self):
self.containers: Dict[UUID, InventoryContainerBase] = {} self.nodes: Dict[UUID, InventoryNodeBase] = {}
self.items: Dict[UUID, InventoryItem] = {}
self.root: Optional[InventoryContainerBase] = None 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 @classmethod
def from_reader(cls, reader: StringIO, read_header=False) -> InventoryModel: def from_reader(cls, reader: StringIO, read_header=False) -> InventoryModel:
model = cls() model = cls()
@@ -180,32 +179,43 @@ class InventoryModel(InventoryBase):
LOG.warning(f"Unknown object type {obj_dict!r}") LOG.warning(f"Unknown object type {obj_dict!r}")
return model 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): def to_writer(self, writer: StringIO):
for container in self.containers.values(): for node in self.ordered_nodes:
container.to_writer(writer) node.to_writer(writer)
for item in self.items.values():
item.to_writer(writer)
def to_llsd(self): def to_llsd(self):
vals = [] return list(node.to_llsd() for node in self.ordered_nodes)
for container in self.containers.values():
vals.append(container.to_llsd())
for item in self.items.values():
vals.append(item.to_llsd())
return vals
def add(self, node: InventoryNodeBase): 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") raise KeyError(f"{node.node_id} already exists in the inventory model")
self.nodes[node.node_id] = node
if isinstance(node, InventoryContainerBase): if isinstance(node, InventoryContainerBase):
self.containers[node.node_id] = node
if node.parent_id == UUID.ZERO: if node.parent_id == UUID.ZERO:
self.root = node 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) node.model = weakref.proxy(self)
def unlink(self, node: InventoryNodeBase) -> Sequence[InventoryNodeBase]: def unlink(self, node: InventoryNodeBase) -> Sequence[InventoryNodeBase]:
@@ -217,17 +227,35 @@ class InventoryModel(InventoryBase):
if isinstance(node, InventoryContainerBase): if isinstance(node, InventoryContainerBase):
for child in node.children: for child in node.children:
unlinked.extend(self.unlink(child)) unlinked.extend(self.unlink(child))
self.items.pop(node.node_id, None) self.nodes.pop(node.node_id, None)
self.containers.pop(node.node_id, None)
node.model = None node.model = None
return unlinked return unlinked
@property def get_differences(self, other: InventoryModel) -> InventoryDifferences:
def all_nodes(self) -> Iterable[InventoryNodeBase]: # Includes modified things with the same ID
for container in self.containers.values(): changed_in_other = []
yield container removed_in_other = []
for item in self.items.values():
yield item 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 @dataclasses.dataclass
@@ -266,9 +294,13 @@ class InventoryNodeBase(InventoryBase):
def node_id(self) -> UUID: def node_id(self) -> UUID:
return getattr(self, self.ID_ATTR) return getattr(self, self.ID_ATTR)
@node_id.setter
def node_id(self, val: UUID):
setattr(self, self.ID_ATTR, val)
@property @property
def parent(self) -> Optional[InventoryContainerBase]: 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]: def unlink(self) -> Sequence[InventoryNodeBase]:
return self.model.unlink(self) return self.model.unlink(self)
@@ -282,6 +314,9 @@ class InventoryNodeBase(InventoryBase):
return None return None
return super()._obj_from_dict(obj_dict) return super()._obj_from_dict(obj_dict)
def __hash__(self):
return hash(self.node_id)
@dataclasses.dataclass @dataclasses.dataclass
class InventoryContainerBase(InventoryNodeBase): class InventoryContainerBase(InventoryNodeBase):
@@ -291,12 +326,13 @@ class InventoryContainerBase(InventoryNodeBase):
@property @property
def children(self) -> Sequence[InventoryNodeBase]: def children(self) -> Sequence[InventoryNodeBase]:
return tuple( return tuple(
x for x in ( x for x in self.model.nodes.values()
itertools.chain(self.model.containers.values(), self.model.items.values())
)
if x.parent_id == self.node_id if x.parent_id == self.node_id
) )
# So autogenerated __hash__ doesn't kill our inherited one
__hash__ = InventoryNodeBase.__hash__
@dataclasses.dataclass @dataclasses.dataclass
class InventoryObject(InventoryContainerBase): class InventoryObject(InventoryContainerBase):
@@ -305,6 +341,8 @@ class InventoryObject(InventoryContainerBase):
obj_id: UUID = schema_field(SchemaUUID) obj_id: UUID = schema_field(SchemaUUID)
__hash__ = InventoryNodeBase.__hash__
@dataclasses.dataclass @dataclasses.dataclass
class InventoryCategory(InventoryContainerBase): class InventoryCategory(InventoryContainerBase):
@@ -316,6 +354,8 @@ class InventoryCategory(InventoryContainerBase):
owner_id: UUID = schema_field(SchemaUUID) owner_id: UUID = schema_field(SchemaUUID)
version: int = schema_field(SchemaInt) version: int = schema_field(SchemaInt)
__hash__ = InventoryNodeBase.__hash__
@dataclasses.dataclass @dataclasses.dataclass
class InventoryItem(InventoryNodeBase): class InventoryItem(InventoryNodeBase):
@@ -334,6 +374,8 @@ class InventoryItem(InventoryNodeBase):
asset_id: Optional[UUID] = schema_field(SchemaUUID, default=None) asset_id: Optional[UUID] = schema_field(SchemaUUID, default=None)
shadow_id: Optional[UUID] = schema_field(SchemaUUID, default=None) shadow_id: Optional[UUID] = schema_field(SchemaUUID, default=None)
__hash__ = InventoryNodeBase.__hash__
@property @property
def true_asset_id(self) -> UUID: def true_asset_id(self) -> UUID:
if self.asset_id is not None: if self.asset_id is not None:

View File

@@ -49,7 +49,7 @@ class TestLegacyInv(unittest.TestCase):
self.model = InventoryModel.from_str(SIMPLE_INV) self.model = InventoryModel.from_str(SIMPLE_INV)
def test_parse(self): 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) self.assertIsNotNone(self.model.root)
def test_serialize(self): def test_serialize(self):
@@ -58,39 +58,38 @@ class TestLegacyInv(unittest.TestCase):
self.assertEqual(self.model, new_model) self.assertEqual(self.model, new_model)
def test_item_access(self): def test_item_access(self):
item = self.model.nodes[UUID('dd163122-946b-44df-99f6-a6030e2b9597')]
item = self.model.items[UUID('dd163122-946b-44df-99f6-a6030e2b9597')]
self.assertEqual(item.name, "New Script") self.assertEqual(item.name, "New Script")
self.assertEqual(item.sale_info.sale_type, "not") self.assertEqual(item.sale_info.sale_type, "not")
self.assertEqual(item.model, self.model) self.assertEqual(item.model, self.model)
def test_access_children(self): def test_access_children(self):
root = self.model.root root = self.model.root
item = tuple(self.model.items.values())[0] item = tuple(self.model.ordered_nodes)[1]
self.assertEqual((item,), root.children) self.assertEqual((item,), root.children)
def test_access_parent(self): def test_access_parent(self):
root = self.model.root 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(root, item.parent)
self.assertEqual(None, root.parent) self.assertEqual(None, root.parent)
def test_unlink(self): def test_unlink(self):
self.assertEqual(1, len(self.model.root.children)) 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([item], item.unlink())
self.assertEqual(0, len(self.model.root.children)) self.assertEqual(0, len(self.model.root.children))
self.assertEqual(None, item.model) self.assertEqual(None, item.model)
def test_relink(self): def test_relink(self):
item = tuple(self.model.items.values())[0] item = tuple(self.model.ordered_nodes)[1]
for unlinked in item.unlink(): for unlinked in item.unlink():
self.model.add(unlinked) self.model.add(unlinked)
self.assertEqual(self.model, item.model) self.assertEqual(self.model, item.model)
self.assertEqual(1, len(self.model.root.children)) self.assertEqual(1, len(self.model.root.children))
def test_eq_excludes_model(self): 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 = copy.copy(item)
item_copy.model = None item_copy.model = None
self.assertEqual(item, item_copy) self.assertEqual(item, item_copy)
@@ -137,9 +136,33 @@ class TestLegacyInv(unittest.TestCase):
def test_llsd_legacy_equality(self): def test_llsd_legacy_equality(self):
new_model = InventoryModel.from_llsd(self.model.to_llsd()) new_model = InventoryModel.from_llsd(self.model.to_llsd())
self.assertEqual(self.model, new_model) 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) 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_SHAPE = """LLWearable version 22
Girl Next Door - C2 - med - Adam n Eve Girl Next Door - C2 - med - Adam n Eve