From 1efc6672d12881743893f1767677a7cf51c2ff52 Mon Sep 17 00:00:00 2001 From: Peter Ye <44945378+yewentao256@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:40:38 +0800 Subject: [PATCH] add chromadb store (#86) --- lazyllm/tools/rag/data_loaders.py | 12 +- lazyllm/tools/rag/doc_impl.py | 95 +++++++----- lazyllm/tools/rag/index.py | 24 ++- lazyllm/tools/rag/store.py | 238 ++++++++++++++++++++++------- lazyllm/tools/rag/transform.py | 6 +- tests/basic_tests/test_doc_node.py | 11 +- tests/basic_tests/test_store.py | 52 +++++++ 7 files changed, 324 insertions(+), 114 deletions(-) create mode 100644 tests/basic_tests/test_store.py diff --git a/lazyllm/tools/rag/data_loaders.py b/lazyllm/tools/rag/data_loaders.py index 88125c29..6c54171d 100644 --- a/lazyllm/tools/rag/data_loaders.py +++ b/lazyllm/tools/rag/data_loaders.py @@ -1,5 +1,5 @@ from typing import List -from .store import DocNode +from .store import DocNode, LAZY_ROOT_NAME from lazyllm import LOG @@ -7,7 +7,7 @@ class DirectoryReader: def __init__(self, input_files: List[str]): self.input_files = input_files - def load_data(self, ntype: str = "root") -> List["DocNode"]: + def load_data(self, group: str = LAZY_ROOT_NAME) -> List["DocNode"]: from llama_index.core import SimpleDirectoryReader llama_index_docs = SimpleDirectoryReader( @@ -17,11 +17,11 @@ def load_data(self, ntype: str = "root") -> List["DocNode"]: for doc in llama_index_docs: node = DocNode( text=doc.text, - ntype=ntype, - metadata=doc.metadata, - excluded_embed_metadata_keys=doc.excluded_embed_metadata_keys, - excluded_llm_metadata_keys=doc.excluded_llm_metadata_keys, + group=group, ) + node.metadata = doc.metadata + node.excluded_embed_metadata_keys = doc.excluded_embed_metadata_keys + node.excluded_llm_metadata_keys = doc.excluded_llm_metadata_keys nodes.append(node) if not nodes: LOG.warning( diff --git a/lazyllm/tools/rag/doc_impl.py b/lazyllm/tools/rag/doc_impl.py index 897e379f..eeb0f6f2 100644 --- a/lazyllm/tools/rag/doc_impl.py +++ b/lazyllm/tools/rag/doc_impl.py @@ -1,21 +1,50 @@ -from functools import partial +import ast +from functools import partial, wraps from typing import Dict, List, Optional, Set -from lazyllm import ModuleBase, LOG +from lazyllm import ModuleBase, LOG, config, once_flag, call_once from lazyllm.common import LazyLlmRequest from .transform import FuncNodeTransform, SentenceSplitter -from .store import MapStore, DocNode +from .store import MapStore, DocNode, ChromadbStore, LAZY_ROOT_NAME from .data_loaders import DirectoryReader from .index import DefaultIndex +def embed_wrapper(func): + if not func: + return None + + @wraps(func) + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + return ast.literal_eval(result) + + return wrapper + + class DocImplV2: def __init__(self, embed, doc_files=Optional[List[str]], **kwargs): super().__init__() self.directory_reader = DirectoryReader(input_files=doc_files) - self.node_groups: Dict[str, Dict] = {} + self.node_groups: Dict[str, Dict] = {LAZY_ROOT_NAME: {}} self.create_node_group_default() - self.store = MapStore() - self.index = DefaultIndex(embed) + self.embed = embed_wrapper(embed) + self.init_flag = once_flag() + + def _lazy_init(self) -> None: + rag_store = config["rag_store"] + if rag_store == "map": + self.store = MapStore(node_groups=self.node_groups.keys()) + elif rag_store == "chroma": + self.store = ChromadbStore( + node_groups=self.node_groups.keys(), embed=self.embed + ) + else: + raise NotImplementedError(f"Not implemented store type for {rag_store}") + self.index = DefaultIndex(self.embed, self.store) + if not self.store.has_nodes(LAZY_ROOT_NAME): + docs = self.directory_reader.load_data() + self.store.add_nodes(LAZY_ROOT_NAME, docs) + LOG.debug(f"building {LAZY_ROOT_NAME} nodes: {docs}") def create_node_group_default(self): self.create_node_group( @@ -38,7 +67,7 @@ def create_node_group_default(self): ) def create_node_group( - self, name, transform, parent="_lazyllm_root", **kwargs + self, name, transform, parent=LAZY_ROOT_NAME, **kwargs ) -> None: if name in self.node_groups: LOG.warning(f"Duplicate group name: {name}") @@ -67,25 +96,17 @@ def _dynamic_create_nodes(self, group_name) -> None: if self.store.has_nodes(group_name): return transform = self._get_transform(group_name) - parent_name = node_group["parent_name"] - self._dynamic_create_nodes(parent_name) - - parent_nodes = self.store.traverse_nodes(parent_name) - - sub_nodes = transform(parent_nodes, group_name) - self.store.add_nodes(group_name, sub_nodes) - LOG.debug(f"building {group_name} nodes: {sub_nodes}") + parent_nodes = self._get_nodes(node_group["parent_name"]) + nodes = transform(parent_nodes, group_name) + self.store.add_nodes(group_name, nodes) + LOG.debug(f"building {group_name} nodes: {nodes}") def _get_nodes(self, group_name: str) -> List[DocNode]: - # lazy load files, if group isn't set, create the group - if not self.store.has_nodes("_lazyllm_root"): - docs = self.directory_reader.load_data() - self.store.add_nodes("_lazyllm_root", docs) - LOG.debug(f"building _lazyllm_root nodes: {docs}") self._dynamic_create_nodes(group_name) return self.store.traverse_nodes(group_name) def retrieve(self, query, group_name, similarity, index, topk, similarity_kws): + call_once(self.init_flag, self._lazy_init) if index: assert index == "default", "we only support default index currently" if isinstance(query, LazyLlmRequest): @@ -94,10 +115,10 @@ def retrieve(self, query, group_name, similarity, index, topk, similarity_kws): nodes = self._get_nodes(group_name) return self.index.query(query, nodes, similarity, topk, **similarity_kws) - def _find_parent(self, nodes: List[DocNode], name: str) -> List[DocNode]: + def _find_parent(self, nodes: List[DocNode], group: str) -> List[DocNode]: def recurse_parents(node: DocNode, visited: Set[DocNode]) -> None: if node.parent: - if node.parent.ntype == name: + if node.parent.group == group: visited.add(node.parent) recurse_parents(node.parent, visited) @@ -106,18 +127,18 @@ def recurse_parents(node: DocNode, visited: Set[DocNode]) -> None: recurse_parents(node, result) if not result: LOG.warning( - f"We can not find any nodes for name `{name}`, please check your input" + f"We can not find any nodes for group `{group}`, please check your input" ) - LOG.debug(f"Found parent node for {name}: {result}") + LOG.debug(f"Found parent node for {group}: {result}") return list(result) - def find_parent(self, name: str) -> List[DocNode]: - return partial(self._find_parent, name=name) + def find_parent(self, group: str) -> List[DocNode]: + return partial(self._find_parent, group=group) - def _find_children(self, nodes: List[DocNode], name: str) -> List[DocNode]: + def _find_children(self, nodes: List[DocNode], group: str) -> List[DocNode]: def recurse_children(node: DocNode, visited: Set[DocNode]) -> bool: - if name in node.children: - visited.update(node.children[name]) + if group in node.children: + visited.update(node.children[group]) return True found_in_any_child = False @@ -134,11 +155,11 @@ def recurse_children(node: DocNode, visited: Set[DocNode]) -> bool: result = set() # case when user hasn't used the group before. - _ = self._get_nodes(name) + _ = self._get_nodes(group) for node in nodes: - if name in node.children: - result.update(node.children[name]) + if group in node.children: + result.update(node.children[group]) else: LOG.log_once( f"Fetching children that are not in direct relationship might be slower. " @@ -149,21 +170,21 @@ def recurse_children(node: DocNode, visited: Set[DocNode]) -> bool: # Note: the input nodes are the same type if not recurse_children(node, result): LOG.warning( - f"Node {node} and its children do not contain any nodes with the name `{name}`. " + f"Node {node} and its children do not contain any nodes with the group `{group}`. " "Skipping further search in this branch." ) break if not result: LOG.warning( - f"We cannot find any nodes for name `{name}`, please check your input." + f"We cannot find any nodes for group `{group}`, please check your input." ) - LOG.debug(f"Found children nodes for {name}: {result}") + LOG.debug(f"Found children nodes for {group}: {result}") return list(result) - def find_children(self, name: str) -> List[DocNode]: - return partial(self._find_children, name=name) + def find_children(self, group: str) -> List[DocNode]: + return partial(self._find_children, group=group) class RetrieverV2(ModuleBase): diff --git a/lazyllm/tools/rag/index.py b/lazyllm/tools/rag/index.py index 42262013..27a333ae 100644 --- a/lazyllm/tools/rag/index.py +++ b/lazyllm/tools/rag/index.py @@ -1,4 +1,5 @@ -import ast +from typing import List, Callable +from .store import DocNode, BaseStore import numpy as np @@ -7,8 +8,9 @@ class DefaultIndex: registered_similarity = dict() - def __init__(self, embed, **kwargs): + def __init__(self, embed: Callable, store: BaseStore, **kwargs): self.embed = embed + self.store = store @classmethod def register_similarity(cls, func=None, mode=None, descend=True): @@ -18,16 +20,24 @@ def decorator(f): return decorator(func) if func else decorator - def query(self, query, nodes, similarity_name, topk=None, **kwargs): + def query( + self, + query: str, + nodes: List[DocNode], + similarity_name: str, + topk: int, + **kwargs, + ) -> List[DocNode]: similarity_func, mode, descend = self.registered_similarity[similarity_name] if mode == "embedding": assert self.embed, "Chosen similarity needs embed model." assert len(query) > 0, "Query should not be empty." - query_embedding = ast.literal_eval(self.embed(query)) + query_embedding = self.embed(query) for node in nodes: - if not node.embedding: - node.embedding = ast.literal_eval(self.embed(node.text)) + if not node.has_embedding(): + node.do_embedding(self.embed) + self.store.try_save_nodes(nodes[0].group, nodes) similarities = [ (node, similarity_func(query_embedding, node.embedding, **kwargs)) for node in nodes @@ -46,7 +56,7 @@ def query(self, query, nodes, similarity_name, topk=None, **kwargs): @DefaultIndex.register_similarity(mode="text", descend=True) -def dummy(query, node, **kwargs): +def dummy(query: str, node, **kwargs): return len(node.text) diff --git a/lazyllm/tools/rag/store.py b/lazyllm/tools/rag/store.py index 33075544..2f4cf3b3 100644 --- a/lazyllm/tools/rag/store.py +++ b/lazyllm/tools/rag/store.py @@ -1,6 +1,15 @@ +from abc import ABC, abstractmethod +from collections import defaultdict from enum import Enum, auto import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional +import chromadb +from lazyllm import LOG, config +from chromadb.api.models.Collection import Collection + +LAZY_ROOT_NAME = "lazyllm_root" +config.add("rag_store", str, "map", "RAG_STORE") # "map", "chroma" +config.add("rag_persistent_path", str, "./lazyllm_chroma", "RAG_PERSISTENT_PATH") class MetadataMode(str, Enum): @@ -15,62 +24,80 @@ def __init__( self, uid: Optional[str] = None, text: Optional[str] = None, - ntype: Optional[str] = None, + group: Optional[str] = None, embedding: Optional[List[float]] = None, - metadata: Optional[Dict[str, Any]] = None, - excluded_embed_metadata_keys: Optional[List[str]] = None, - excluded_llm_metadata_keys: Optional[List[str]] = None, parent: Optional["DocNode"] = None, ) -> None: self.uid: str = uid if uid else str(uuid.uuid4()) self.text: Optional[str] = text - self.ntype: Optional[str] = ntype - self.embedding: Optional[List[float]] = embedding - self.metadata: Dict[str, Any] = metadata if metadata is not None else {} + self.group: Optional[str] = group + self.embedding: Optional[List[float]] = embedding or None + self._metadata: Dict[str, Any] = {} # Metadata keys that are excluded from text for the embed model. - self.excluded_embed_metadata_keys: List[str] = ( - excluded_embed_metadata_keys - if excluded_embed_metadata_keys is not None - else [] - ) + self._excluded_embed_metadata_keys: List[str] = [] # Metadata keys that are excluded from text for the LLM. - self.excluded_llm_metadata_keys: List[str] = ( - excluded_llm_metadata_keys if excluded_llm_metadata_keys is not None else [] - ) - # Relationships to other node. + self._excluded_llm_metadata_keys: List[str] = [] self.parent = parent - self.children: Dict[str, List["DocNode"]] = {} + self.children: Dict[str, List["DocNode"]] = defaultdict(list) + self.is_saved = False @property def root_node(self) -> Optional["DocNode"]: root = self.parent while root and root.parent: root = root.parent - return root + return root or self + + @property + def metadata(self) -> Dict: + return self.root_node._metadata + + @metadata.setter + def metadata(self, metadata: Dict) -> None: + self._metadata = metadata + + @property + def excluded_embed_metadata_keys(self) -> List: + return self.root_node._excluded_embed_metadata_keys + + @excluded_embed_metadata_keys.setter + def excluded_embed_metadata_keys(self, excluded_embed_metadata_keys: List) -> None: + self._excluded_embed_metadata_keys = excluded_embed_metadata_keys + + @property + def excluded_llm_metadata_keys(self) -> List: + return self.root_node._excluded_llm_metadata_keys + + @excluded_llm_metadata_keys.setter + def excluded_llm_metadata_keys(self, excluded_llm_metadata_keys: List) -> None: + self._excluded_llm_metadata_keys = excluded_llm_metadata_keys + + def get_children_str(self) -> str: + return str( + {key: [node.uid for node in nodes] for key, nodes in self.children.items()} + ) def __str__(self) -> str: - children_str = { - key: [node.uid for node in self.children[key]] - for key in self.children.keys() - } return ( - f"DocNode(id: {self.uid}, ntype: {self.ntype}, text: {self.get_content()}) parent: " - f"{self.parent.uid if self.parent else None}, children: {children_str}" + f"DocNode(id: {self.uid}, group: {self.group}, text: {self.get_content()}) parent: " + f"{self.parent.uid if self.parent else None}, children: {self.get_children_str()} " + f"is_embed: {self.has_embedding()}" ) def __repr__(self) -> str: return str(self) - def get_embedding(self) -> List[float]: - if self.embedding is None: - raise ValueError("embedding not set.") - return self.embedding + def has_embedding(self) -> bool: + return self.embedding and self.embedding[0] != -1 # placeholder + + def do_embedding(self, embed: Callable) -> None: + self.embedding = embed(self.text) + self.is_saved = False def get_content(self, metadata_mode: MetadataMode = MetadataMode.NONE) -> str: metadata_str = self.get_metadata_str(mode=metadata_mode).strip() if not metadata_str: return self.text if self.text else "" - return f"{metadata_str}\n\n{self.text}".strip() def get_metadata_str(self, mode: MetadataMode = MetadataMode.ALL) -> str: @@ -94,32 +121,141 @@ def get_text(self) -> str: return self.get_content(metadata_mode=MetadataMode.NONE) -# TODO: Have a common Base store class -class MapStore: - def __init__(self): - self.store: Dict[str, Dict[str, DocNode]] = {} - - def add_nodes(self, category: str, nodes: List[DocNode]): - if category not in self.store: - self.store[category] = {} +class BaseStore(ABC): + def __init__(self, node_groups: List[str]) -> None: + self._store: Dict[str, Dict[str, DocNode]] = { + group: {} for group in node_groups + } + def _add_nodes(self, group: str, nodes: List[DocNode]) -> None: + if group not in self._store: + self._store[group] = {} for node in nodes: - self.store[category][node.uid] = node + self._store[group][node.uid] = node - def has_nodes(self, category: str) -> bool: - return category in self.store.keys() + def add_nodes(self, group: str, nodes: List[DocNode]) -> None: + self._add_nodes(group, nodes) + self.try_save_nodes(group, nodes) - def get_node(self, category: str, node_id: str) -> Optional[DocNode]: - return self.store.get(category, {}).get(node_id) + def has_nodes(self, group: str) -> bool: + return len(self._store[group]) > 0 - def delete_node(self, category: str, node_id: str): - if category in self.store and node_id in self.store[category]: - del self.store[category][node_id] - # TODO: delete node's relationship + def get_node(self, group: str, node_id: str) -> Optional[DocNode]: + return self._store.get(group, {}).get(node_id) - def traverse_nodes(self, category: str) -> List[DocNode]: - return list(self.store.get(category, {}).values()) + def traverse_nodes(self, group: str) -> List[DocNode]: + return list(self._store.get(group, {}).values()) + @abstractmethod + def try_save_nodes(self, group: str, nodes: List[DocNode]) -> None: + raise NotImplementedError("Not implemented yet.") + + @abstractmethod + def try_load_store(self) -> None: + raise NotImplementedError("Not implemented yet.") + + +class MapStore(BaseStore): + def __init__(self, node_groups: List[str], *args, **kwargs): + super().__init__(node_groups, *args, **kwargs) + + def try_save_nodes(self, group: str, nodes: List[DocNode]) -> None: + pass + + def try_load_store(self) -> None: + pass + + +class ChromadbStore(BaseStore): + def __init__( + self, node_groups: List[str], embed: Callable, *args, **kwargs + ) -> None: + super().__init__(node_groups, *args, **kwargs) + self._db_client = chromadb.PersistentClient(path=config["rag_persistent_path"]) + LOG.success(f"Initialzed chromadb in path: {config['rag_persistent_path']}") + self._collections: Dict[str, Collection] = { + group: self._db_client.get_or_create_collection(group) + for group in node_groups + } + self._placeholder = [-1] * len(embed("a")) + self.try_load_store() + + def try_load_store(self) -> None: + if not self._collections[LAZY_ROOT_NAME].peek(1)["ids"]: + LOG.info("No persistent data found, skip the rebuilding phrase.") + return + + # Restore all nodes + for group in self._collections.keys(): + results = self._peek_all_documents(group) + nodes = self._build_nodes_from_chroma(results) + self._add_nodes(group, nodes) + + # Rebuild relationships + for group, nodes_dict in self._store.items(): + for node in nodes_dict.values(): + if node.parent: + parent_uid = node.parent + parent_node = self._find_node_by_uid(parent_uid) + node.parent = parent_node + parent_node.children[node.group].append(node) + LOG.debug(f"build {group} nodes from chromadb: {nodes_dict.values()}") + LOG.success("Successfully Built nodes from chromadb.") + + def try_save_nodes(self, group: str, nodes: List[DocNode]) -> None: + ids, embeddings, metadatas, documents = [], [], [], [] + collection = self._collections.get(group) + assert ( + collection + ), f"Group {group} is not found in collections {self._collections}" + for node in nodes: + if node.is_saved: + continue + if not node.has_embedding(): + node.embedding = self._placeholder + ids.append(node.uid) + embeddings.append(node.embedding) + metadatas.append(self._make_chroma_metadata(node)) + documents.append(node.get_content(metadata_mode=MetadataMode.NONE)) + node.is_saved = True + if ids: + collection.upsert( + embeddings=embeddings, + ids=ids, + metadatas=metadatas, + documents=documents, + ) + LOG.debug(f"Saved {group} nodes {ids} to chromadb.") + + def _find_node_by_uid(self, uid: str) -> Optional[DocNode]: + for nodes_by_category in self._store.values(): + if uid in nodes_by_category: + return nodes_by_category[uid] + raise ValueError(f"UID {uid} not found in store.") + + def _build_nodes_from_chroma(self, results: Dict[str, List]) -> List[DocNode]: + nodes: List[DocNode] = [] + for i, uid in enumerate(results["ids"]): + chroma_metadata = results["metadatas"][i] + node = DocNode( + uid=uid, + text=results["documents"][i], + group=chroma_metadata["group"], + embedding=results["embeddings"][i], + parent=chroma_metadata["parent"], + ) + node.is_saved = True + nodes.append(node) + return nodes + + def _make_chroma_metadata(self, node: DocNode) -> Dict[str, Any]: + metadata = { + "group": node.group, + "parent": node.parent.uid if node.parent else "", + } + return metadata -class ChromadbStore: - pass + def _peek_all_documents(self, group: str) -> Dict[str, List]: + assert group in self._collections, f"group {group} not found." + collection = self._collections[group] + return collection.peek(collection.count()) diff --git a/lazyllm/tools/rag/transform.py b/lazyllm/tools/rag/transform.py index 0dbd1d30..9bf64422 100644 --- a/lazyllm/tools/rag/transform.py +++ b/lazyllm/tools/rag/transform.py @@ -22,11 +22,7 @@ def build_nodes_from_splits( continue node = DocNode( text=text_chunk, - ntype=node_group, - embedding=doc.embedding, - metadata=doc.metadata, - excluded_embed_metadata_keys=doc.excluded_embed_metadata_keys, - excluded_llm_metadata_keys=doc.excluded_llm_metadata_keys, + group=node_group, parent=doc, ) nodes.append(node) diff --git a/tests/basic_tests/test_doc_node.py b/tests/basic_tests/test_doc_node.py index 397e3890..7029c0c7 100644 --- a/tests/basic_tests/test_doc_node.py +++ b/tests/basic_tests/test_doc_node.py @@ -9,11 +9,11 @@ def setup_method(self): self.embedding = [0.1, 0.2, 0.3] self.node = DocNode( text=self.text, - metadata=self.metadata, embedding=self.embedding, - excluded_embed_metadata_keys=["author"], - excluded_llm_metadata_keys=["date"], ) + self.node.metadata = self.metadata + self.node.excluded_embed_metadata_keys = ["author"] + self.node.excluded_llm_metadata_keys = ["date"] def test_node_creation(self): """Test the creation of a DocNode.""" @@ -50,11 +50,6 @@ def test_get_metadata_str(self): metadata_str_none = self.node.get_metadata_str(mode=MetadataMode.NONE) assert metadata_str_none == "" - def test_get_embedding(self): - """Test the get_embedding method.""" - embedding = self.node.get_embedding() - assert embedding == self.embedding - def test_root_node(self): """Test the root_node property.""" child_node = DocNode(text="Child node", parent=self.node) diff --git a/tests/basic_tests/test_store.py b/tests/basic_tests/test_store.py new file mode 100644 index 00000000..52123ae2 --- /dev/null +++ b/tests/basic_tests/test_store.py @@ -0,0 +1,52 @@ +import unittest +from unittest.mock import MagicMock +from lazyllm.tools.rag.store import DocNode, ChromadbStore, LAZY_ROOT_NAME + + +# Test class for ChromadbStore +class TestChromadbStore(unittest.TestCase): + def setUp(self): + self.node_groups = [LAZY_ROOT_NAME, "group1", "group2"] + self.embed = MagicMock(side_effect=lambda text: [0.1, 0.2, 0.3]) + self.store = ChromadbStore(self.node_groups, self.embed) + self.store.add_nodes( + LAZY_ROOT_NAME, + [DocNode(uid="1", text="text1", group="group1", parent=None)], + ) + + def test_initialization(self): + self.assertEqual(set(self.store._collections.keys()), set(self.node_groups)) + + def test_add_and_traverse_nodes(self): + node1 = DocNode(uid="1", text="text1", group="type1") + node2 = DocNode(uid="2", text="text2", group="type2") + self.store.add_nodes("group1", [node1, node2]) + nodes = self.store.traverse_nodes("group1") + self.assertEqual(nodes, [node1, node2]) + + def test_save_nodes(self): + node1 = DocNode(uid="1", text="text1", group="type1") + node2 = DocNode(uid="2", text="text2", group="type2") + self.store.add_nodes("group1", [node1, node2]) + collection = self.store._collections["group1"] + self.assertEqual(collection.peek(collection.count())["ids"], ["1", "2"]) + + def test_try_load_store(self): + # Set up initial data to be loaded + node1 = DocNode(uid="1", text="text1", group="group1", parent=None) + node2 = DocNode(uid="2", text="text2", group="group1", parent=node1) + self.store.add_nodes("group1", [node1, node2]) + + # Reset store and load from "persistent" storage + self.store._store = {group: {} for group in self.node_groups} + self.store.try_load_store() + + nodes = self.store.traverse_nodes("group1") + self.assertEqual(len(nodes), 2) + self.assertEqual(nodes[0].uid, "1") + self.assertEqual(nodes[1].uid, "2") + self.assertEqual(nodes[1].parent.uid, "1") + + +if __name__ == "__main__": + unittest.main()