From 88beac77c430461955a9833e03013d6d29d962d0 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Thu, 31 Oct 2024 21:37:54 +0100 Subject: [PATCH] Update docs --- CHANGELOG.md | 5 +- docs/jupyter/take_the_tour.ipynb | 346 +++++++++++++++----------- docs/sphinx/index.rst | 2 +- docs/sphinx/take_the_tour.md | 399 ++++++++++++++++++++++++++++++ docs/sphinx/take_the_tour.rst | 412 ------------------------------- nutree/mermaid.py | 78 +++--- nutree/node.py | 11 +- tox.ini | 3 +- 8 files changed, 651 insertions(+), 605 deletions(-) create mode 100644 docs/sphinx/take_the_tour.md delete mode 100644 docs/sphinx/take_the_tour.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5c5c8..19b0c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ - BREAKING: - Rename `shadow_attrs` argument to `forward_attrs`. - - Enforce that the same object is not added multiple times to one parent. + - Enforce that the same object instance is not added multiple times to one parent. - Rename `GenericNodeData` to `DictWrapper` and remove support for attribut access. - Drop support for Python 3.8 - mermaid: change mapper signatures and defaults - tree.to_rdf() is now available for Tree (not only TypedTree). - New method `node.up()` allows method chaining when adding nodes. -- Passes pyright 'typeCheckingMode = "standard"' +- Pass pyright 'typeCheckingMode = "standard"'. +- Use generic typing for improved type checking. ## 0.9.0 (2024-09-12) diff --git a/docs/jupyter/take_the_tour.ipynb b/docs/jupyter/take_the_tour.ipynb index a139817..2ae5dbd 100644 --- a/docs/jupyter/take_the_tour.ipynb +++ b/docs/jupyter/take_the_tour.ipynb @@ -50,7 +50,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Strings can be added to a tree, but in a real-world scenario we want to \n", + "Strings can be directly added to a tree, but in a real-world scenario we want to \n", "handle ordinary objects:" ] }, @@ -62,7 +62,7 @@ } }, "source": [ - "## Setup some sample classes and objects\n", + "## Set up some sample classes and objects\n", "Let's define a simple class hierarchy" ] }, @@ -140,13 +140,13 @@ "output_type": "stream", "text": [ "Tree<'Organization'>\n", - "├── <__main__.Department object at 0x1056fd100>\n", - "│ ├── <__main__.Department object at 0x106249130>\n", - "│ │ ╰── <__main__.Person object at 0x1062cf560>\n", - "│ ╰── <__main__.Person object at 0x1062cf830>\n", - "├── <__main__.Department object at 0x1062cfce0>\n", - "│ ╰── <__main__.Person object at 0x105d2b770>\n", - "╰── <__main__.Person object at 0x1062cffe0>\n" + "├── <__main__.Department object at 0x10da423f0>\n", + "│ ├── <__main__.Department object at 0x107ca8860>\n", + "│ │ ╰── <__main__.Person object at 0x10dbcea20>\n", + "│ ╰── <__main__.Person object at 0x10dbce2a0>\n", + "├── <__main__.Department object at 0x10da419d0>\n", + "│ ╰── <__main__.Person object at 0x10dbce390>\n", + "╰── <__main__.Person object at 0x10dbce1b0>\n" ] } ], @@ -221,7 +221,7 @@ { "data": { "text/plain": [ - "Node<'Person', data_id=274911230>" + "Node<'Person', data_id=282840603>" ] }, "execution_count": 6, @@ -233,15 +233,112 @@ "tree[alice]" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that we passed `alice` as index, which is an instance of `Person`, and received an instance of the `Node` container:" + ] + }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [], "source": [ + "from nutree import Node\n", + "\n", + "assert isinstance(tree[alice], Node)\n", "assert tree[alice].data is alice, \"nodes store objects in data attribute\"" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are other other search methods as well" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tree.find_all(lambda node: \"i\" in str(node.data))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Control the `data_id`\n", + "\n", + "If the object instances have a natural attribute that identifies them, we can use\n", + "it instead of the default `hash()`.
\n", + "This improves readability and helps to (de)serialize:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tree<'Organization'>\n", + "╰── Node<'Department', data_id=a8a94901-3e87-4c71-832a-95a3678c406f>\n", + " ╰── Node<'Person', data_id=1055ff94-ac20-4642-95ef-a92a650a3978>\n" + ] + } + ], + "source": [ + "tree_2 = Tree(\"Organization\", calc_data_id=lambda tree, data: str(data.guid))\n", + "dep_node_2 = tree_2.add(development_dep)\n", + "dep_node_2.add(bob)\n", + "tree_2.print(repr=\"{node}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "now we could also search by the guid, for example" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Node<'Person', data_id=1055ff94-ac20-4642-95ef-a92a650a3978>" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tree_2.find(data_id=str(bob.guid))" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -253,7 +350,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -273,7 +370,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 12, "metadata": {}, "outputs": [ { @@ -295,7 +392,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -325,7 +422,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -350,7 +447,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -358,27 +455,31 @@ "output_type": "stream", "text": [ "Tree<\"Copy of Tree<'Organization'>\">\n", - "├── Node<'Department', data_id=274136336>\n", - "│ ╰── Node<'Department', data_id=274876691>\n", - "╰── Node<'Department', data_id=274911182>\n" + "├── Node<'Department', data_id=282739263>\n", + "│ ╰── Node<'Department', data_id=276605062>\n", + "╰── Node<'Department', data_id=282739101>\n" ] } ], "source": [ - "tree_2 = tree.copy(predicate=lambda node: isinstance(node.data, Department))\n", - "tree_2.print(repr=\"{node}\")" + "tree_copy = tree.copy(predicate=lambda node: isinstance(node.data, Department))\n", + "tree_copy.print(repr=\"{node}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Mutation" + "## Mutation\n", + "\n", + "We can add, copy, move, remove, sort, etc.\n", + "\n", + "For example:" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -386,45 +487,55 @@ "output_type": "stream", "text": [ "Tree<'Organization'>\n", - "├── <__main__.Department object at 0x1056fd100>\n", - "│ ├── <__main__.Department object at 0x106249130>\n", - "│ │ ╰── <__main__.Person object at 0x1062cf560>\n", - "│ ╰── <__main__.Person object at 0x1062cf830>\n", - "├── <__main__.Department object at 0x1062cfce0>\n", - "│ ╰── <__main__.Person object at 0x105d2b770>\n", - "╰── <__main__.Person object at 0x1062cffe0>\n" + "├── Department\n", + "│ ├── Department\n", + "│ │ ╰── Person\n", + "│ ╰── Person\n", + "├── Department\n", + "│ ├── Person\n", + "│ ╰── Person\n", + "╰── Person\n" ] } ], "source": [ + "alice_node = tree[alice]\n", "bob_node = tree[bob]\n", - "# bob_node.move_to(marketing_dep_node)\n", - "tree.print()" + "\n", + "bob_node.move_to(mkt_node)\n", + "alice_node.copy_to(dev_node)\n", + "\n", + "tree.print(repr=\"{node.data}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Data IDs and Clones" + "## Data IDs and Clones\n", + "\n", + "In the example above, we duplicated the 'Alice' node, so we now have two \n", + "node instances that reference the same data object, as we can see from the\n", + "identical data_id:" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 17, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Node<'Department', data_id=274136336>\n", - "├── Node<'Department', data_id=274876691>\n", - "│ ╰── Node<'Person', data_id=274911062>\n", - "╰── Node<'Person', data_id=274911107>\n", - "Node<'Department', data_id=274911182>\n", - "╰── Node<'Person', data_id=274541431>\n", - "Node<'Person', data_id=274911230>\n" + "Node<'Department', data_id=282739263>\n", + "├── Node<'Department', data_id=276605062>\n", + "│ ╰── Node<'Person', data_id=282840738>\n", + "╰── Node<'Person', data_id=282840603>\n", + "Node<'Department', data_id=282739101>\n", + "├── Node<'Person', data_id=282840633>\n", + "╰── Node<'Person', data_id=282840618>\n", + "Node<'Person', data_id=282840603>\n" ] } ], @@ -432,6 +543,25 @@ "tree.print(repr=\"{node}\", title=False)" ] }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Node<'Person', data_id=282840603>, parent=None\n", + "Node<'Person', data_id=282840603>, parent=Node<'Department', data_id=282739263>\n" + ] + } + ], + "source": [ + "for clone in tree.find_all(alice):\n", + " print(f\"{clone}, parent={clone.parent}\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -444,14 +574,14 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 19, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Tree<'4398779184'>\n", + "Tree<'4525455792'>\n", "├── 'A'\n", "│ ├── 'a1'\n", "│ ╰── 'a2'\n", @@ -481,18 +611,18 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 20, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Tree<'4392651376'>\n", - "├── Node<'A', data_id=-9112120580527591933>\n", - "│ ╰── Node<\"DictWrapper<{'title': 'foo', 'id': 1}>\", data_id=4398795776>\n", - "╰── Node<'B', data_id=-7701919551774722043>\n", - " ╰── Node<\"DictWrapper<{'title': 'foo', 'id': 1}>\", data_id=4398795776>\n" + "Tree<'4525453728'>\n", + "├── Node<'A', data_id=6789757684237002091>\n", + "│ ╰── Node<\"DictWrapper<{'title': 'foo', 'id': 1}>\", data_id=4425631040>\n", + "╰── Node<'B', data_id=-9085030021599920704>\n", + " ╰── Node<\"DictWrapper<{'title': 'foo', 'id': 1}>\", data_id=4425631040>\n" ] } ], @@ -513,128 +643,48 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Serialization" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[{'data': 'A',\n", - " 'children': [{'data': \"DictWrapper<{'title': 'foo', 'id': 1}>\"}]},\n", - " {'data': 'B',\n", - " 'children': [{'data': \"DictWrapper<{'title': 'foo', 'id': 1}>\"}]}]" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "tree.to_dict_list()\n", - "# tree.to_dict_list(mapper=lambda node, data: node.data.name)" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[(0, 'A'), (1, {}), (0, 'B'), (3, 2)]" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "list(tree.to_list_iter())" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Tree<'4398778896'>\n", - "├── 'A'\n", - "│ ╰── 'C'\n", - "│ ╰── 'E'\n", - "├── 'B'\n", - "╰── 'D'\n" - ] - } - ], - "source": [ - "t = Tree._from_list([(0, \"A\"), (0, \"B\"), (1, \"C\"), (0, \"D\"), (3, \"E\")])\n", - "print(t.format())" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```mermaid\n", - "graph LR;\n", - " A--> B & C & D;\n", - " B--> A & E;\n", - " C--> A & E;\n", - " D--> A & E;\n", - " E--> B & C & D;\n", - "```" + "## Serialization\n", + "\n", + "Read the user guide for different methods to save, load, or convert a tree\n", + "to different output formats." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Advanced\n", + "## Typed Trees\n", "\n", - "### Chaining\n", - "\n", - "Some methods return a node instance, so we can chain calls.
\n", - "This allows for a more compact code and avoids some temporary variables:" + "The `TypedTree` subclass adds a 'kind' attribute to the nodes, and related \n", + "methods.
\n", + "This allows to organize objects in directed graphs:" ] }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 21, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "'A'\n", - "├── 'a1'\n", - "╰── 'a2'\n", - "'B'\n" + "TypedTree<'4425583216'>\n", + "╰── friend → Mia\n", + " ├── brother → Noah\n", + " ╰── sister → Olivia\n" ] } ], "source": [ - "Tree().add(\"A\").add(\"a1\").up().add(\"a2\").up(2).add(\"B\").tree.print(title=False)" + "from nutree import TypedTree\n", + "\n", + "typed_tree = TypedTree()\n", + "typed_tree.add(\"Mia\", kind=\"friend\").add(\"Noah\", kind=\"brother\").up().add(\n", + " \"Olivia\", kind=\"sister\"\n", + ")\n", + "typed_tree.print()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst index 4ad65ee..0d2af77 100644 --- a/docs/sphinx/index.rst +++ b/docs/sphinx/index.rst @@ -18,7 +18,7 @@ nutree Overview installation - take_the_tour + take_the_tour.md user_guide reference_guide development diff --git a/docs/sphinx/take_the_tour.md b/docs/sphinx/take_the_tour.md new file mode 100644 index 0000000..be84077 --- /dev/null +++ b/docs/sphinx/take_the_tour.md @@ -0,0 +1,399 @@ +# Take the Tour + +_(The tour is auto-generated from +[this jupyter notebook](https://github.com/mar10/nutree/blob/main/docs/jupyter/take_the_tour.ipynb).)_ + +Nutree organizes arbitrary object instances in an unobtrusive way.
+That means, we can add existing objects without having to derive from a common +base class or implement a specific protocol. + + +```python +from nutree import Tree + +tree = Tree("Hello") +tree.add("N").add("u").up(2).add("T").add("r").up().add("ee") +tree.print() +``` + + Tree<'Hello'> + ├── 'N' + │ ╰── 'u' + ╰── 'T' + ├── 'r' + ╰── 'ee' + + +Strings can be directly added to a tree, but in a real-world scenario we want to +handle ordinary objects: + +## Set up some sample classes and objects +Let's define a simple class hierarchy + + +```python +import uuid + + +class Department: + def __init__(self, name: str): + self.guid = uuid.uuid4() + self.name = name + + def __str__(self): + return f"Department<{self.name}>" + + +class Person: + def __init__(self, name: str, age: int): + self.guid = uuid.uuid4() + self.name = name + self.age = age + + def __str__(self): + return f"Person<{self.name} ({self.age})>" +``` + +and create some instances + + +```python +development_dep = Department("Development") +test__dep = Department("Test") +marketing_dep = Department("Marketing") + +alice = Person("Alice", 25) +bob = Person("Bob", 35) +claire = Person("Claire", 45) +dave = Person("Dave", 55) +``` + +Now that we have a bunch of instances, let's organize these objects in a +hierarchical structure using _nutree_: + + +```python +from nutree import Tree + +tree = Tree("Organization") + +dev_node = tree.add(development_dep) +test_node = dev_node.add(test__dep) +mkt_node = tree.add(marketing_dep) + +tree.add(alice) +dev_node.add(bob) +test_node.add(claire) +mkt_node.add(dave) + +tree.print() +``` + + Tree<'Organization'> + ├── <__main__.Department object at 0x10da423f0> + │ ├── <__main__.Department object at 0x107ca8860> + │ │ ╰── <__main__.Person object at 0x10dbcea20> + │ ╰── <__main__.Person object at 0x10dbce2a0> + ├── <__main__.Department object at 0x10da419d0> + │ ╰── <__main__.Person object at 0x10dbce390> + ╰── <__main__.Person object at 0x10dbce1b0> + + +Tree nodes store a reference to the object in the `node.data` attribute. + +The nodes are formatted for display by the object's `__repr__` implementation +by default.
+We can overide this by passing an +[f-string](https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals) +as `repr` argument.
+For example `"{node.data}"` will use the data instances `__str__` method instead: + + +```python +tree.print(repr="{node.data}") +``` + + Tree<'Organization'> + ├── Department + │ ├── Department + │ │ ╰── Person + │ ╰── Person + ├── Department + │ ╰── Person + ╰── Person + + +## Access Nodes +We can use the index syntax to get the node object for a given data object: + + +```python +tree[alice] +``` + + + + + Node<'Person', data_id=282840603> + + + +Note that we passed `alice` as index, which is an instance of `Person`, and received an instance of the `Node` container: + + +```python +from nutree import Node + +assert isinstance(tree[alice], Node) +assert tree[alice].data is alice, "nodes store objects in data attribute" +``` + +There are other other search methods as well + + +```python +tree.find_all(lambda node: "i" in str(node.data)) +``` + + + + + [] + + + +### Control the `data_id` + +If the object instances have a natural attribute that identifies them, we can use +it instead of the default `hash()`.
+This improves readability and helps to (de)serialize: + + +```python +tree_2 = Tree("Organization", calc_data_id=lambda tree, data: str(data.guid)) +dep_node_2 = tree_2.add(development_dep) +dep_node_2.add(bob) +tree_2.print(repr="{node}") +``` + + Tree<'Organization'> + ╰── Node<'Department', data_id=a8a94901-3e87-4c71-832a-95a3678c406f> + ╰── Node<'Person', data_id=1055ff94-ac20-4642-95ef-a92a650a3978> + + +now we could also search by the guid, for example + + +```python +tree_2.find(data_id=str(bob.guid)) +``` + + + + + Node<'Person', data_id=1055ff94-ac20-4642-95ef-a92a650a3978> + + + +## Iteration and Searching + +There are multiple methods to iterate the tree. + + +```python +res = [] +for node in tree: # depth-first, pre-orde traversal + res.append(node.data.name) +print(res) +``` + + ['Development', 'Test', 'Claire', 'Bob', 'Marketing', 'Dave', 'Alice'] + + + +```python +from nutree import IterMethod + +res = [] +for node in tree.iterator(method=IterMethod.POST_ORDER): + res.append(node.data.name) +print(res) +``` + + ['Claire', 'Test', 'Bob', 'Development', 'Dave', 'Marketing', 'Alice'] + + + +```python +tree.visit(lambda node, memo: print(node.data.name), method=IterMethod.LEVEL_ORDER) +``` + + Development + Marketing + Alice + Test + Bob + Dave + Claire + + +The above traversal methods are also available for single nodes: + + +```python +res = [node.data.name for node in dev_node] +print(res) +``` + + ['Test', 'Claire', 'Bob'] + + +## Filter + + +```python +tree_copy = tree.copy(predicate=lambda node: isinstance(node.data, Department)) +tree_copy.print(repr="{node}") +``` + + Tree<"Copy of Tree<'Organization'>"> + ├── Node<'Department', data_id=282739263> + │ ╰── Node<'Department', data_id=276605062> + ╰── Node<'Department', data_id=282739101> + + +## Mutation + +We can add, copy, move, remove, sort, etc. + +For example: + + +```python +alice_node = tree[alice] +bob_node = tree[bob] + +bob_node.move_to(mkt_node) +alice_node.copy_to(dev_node) + +tree.print(repr="{node.data}") +``` + + Tree<'Organization'> + ├── Department + │ ├── Department + │ │ ╰── Person + │ ╰── Person + ├── Department + │ ├── Person + │ ╰── Person + ╰── Person + + +## Data IDs and Clones + +In the example above, we duplicated the 'Alice' node, so we now have two +node instances that reference the same data object, as we can see from the +identical data_id: + + +```python +tree.print(repr="{node}", title=False) +``` + + Node<'Department', data_id=282739263> + ├── Node<'Department', data_id=276605062> + │ ╰── Node<'Person', data_id=282840738> + ╰── Node<'Person', data_id=282840603> + Node<'Department', data_id=282739101> + ├── Node<'Person', data_id=282840633> + ╰── Node<'Person', data_id=282840618> + Node<'Person', data_id=282840603> + + + +```python +for clone in tree.find_all(alice): + print(f"{clone}, parent={clone.parent}") +``` + + Node<'Person', data_id=282840603>, parent=None + Node<'Person', data_id=282840603>, parent=Node<'Department', data_id=282739263> + + +## Special Data Types +### Plain Strings + +We can add simple string objects the same way as any other object + + +```python +tree_str = Tree() +a = tree_str.add("A") +a.add("a1") +a.add("a2") +tree_str.add("B") +tree_str.print() +``` + + Tree<'4525455792'> + ├── 'A' + │ ├── 'a1' + │ ╰── 'a2' + ╰── 'B' + + +### Dictionaries + +We cannot add Python `dict` objects to a tree, because nutree cannot derive +a *data_id* for unhashable types.
+A a workaround, we can wrap it inside `DictWrapper` objects: + + +```python +from nutree import DictWrapper, Tree + +d = {"title": "foo", "id": 1} + +tree = Tree() +tree.add("A").up().add("B") +tree["A"].add(DictWrapper(d)) +tree["B"].add(DictWrapper(d)) +tree.print(repr="{node}") +# tree.find(d) +``` + + Tree<'4525453728'> + ├── Node<'A', data_id=6789757684237002091> + │ ╰── Node<"DictWrapper<{'title': 'foo', 'id': 1}>", data_id=4425631040> + ╰── Node<'B', data_id=-9085030021599920704> + ╰── Node<"DictWrapper<{'title': 'foo', 'id': 1}>", data_id=4425631040> + + +## Serialization + +Read the user guide for different methods to save, load, or convert a tree +to different output formats. + +## Typed Trees + +The `TypedTree` subclass adds a 'kind' attribute to the nodes, and related +methods.
+This allows to organize objects in directed graphs: + + +```python +from nutree import TypedTree + +typed_tree = TypedTree() +typed_tree.add("Mia", kind="friend").add("Noah", kind="brother").up().add( + "Olivia", kind="sister" +) +typed_tree.print() +``` + + TypedTree<'4425583216'> + ╰── friend → Mia + ├── brother → Noah + ╰── sister → Olivia + diff --git a/docs/sphinx/take_the_tour.rst b/docs/sphinx/take_the_tour.rst deleted file mode 100644 index 87bec9b..0000000 --- a/docs/sphinx/take_the_tour.rst +++ /dev/null @@ -1,412 +0,0 @@ -Take the Tour -============= - -*(The tour is auto-generated from*\ `this jupyter -notebook `__\ *.)* - -Nutree organizes arbitrary object instances in an unobtrusive way. That -means, we can add existing objects without having to derive from a -common base class or implement a specific protocol. - -.. code:: ipython3 - - from nutree import Tree - - tree = Tree("Hello") - tree.add("N").add("u").up(2).add("T").add("r").up().add("ee") - tree.print() - - -.. parsed-literal:: - - Tree<'Hello'> - ├── 'N' - │ ╰── 'u' - ╰── 'T' - ├── 'r' - ╰── 'ee' - - -Strings can be added to a tree, but in a real-world scenario we want to -handle ordinary objects: - -Setup some sample classes and objects -------------------------------------- - -Let’s define a simple class hierarchy - -.. code:: ipython3 - - import uuid - - - class Department: - def __init__(self, name: str): - self.guid = uuid.uuid4() - self.name = name - - def __str__(self): - return f"Department<{self.name}>" - - - class Person: - def __init__(self, name: str, age: int): - self.guid = uuid.uuid4() - self.name = name - self.age = age - - def __str__(self): - return f"Person<{self.name} ({self.age})>" - -and create some instances - -.. code:: ipython3 - - development_dep = Department("Development") - test__dep = Department("Test") - marketing_dep = Department("Marketing") - - alice = Person("Alice", 25) - bob = Person("Bob", 35) - claire = Person("Claire", 45) - dave = Person("Dave", 55) - -Now that we have a bunch of instances, let’s organize these objects in a -hierarchical structure using *nutree*: - -.. code:: ipython3 - - from nutree import Tree - - tree = Tree("Organization") - - dev_node = tree.add(development_dep) - test_node = dev_node.add(test__dep) - mkt_node = tree.add(marketing_dep) - - tree.add(alice) - dev_node.add(bob) - test_node.add(claire) - mkt_node.add(dave) - - tree.print() - - -.. parsed-literal:: - - Tree<'Organization'> - ├── <__main__.Department object at 0x1056fd100> - │ ├── <__main__.Department object at 0x106249130> - │ │ ╰── <__main__.Person object at 0x1062cf560> - │ ╰── <__main__.Person object at 0x1062cf830> - ├── <__main__.Department object at 0x1062cfce0> - │ ╰── <__main__.Person object at 0x105d2b770> - ╰── <__main__.Person object at 0x1062cffe0> - - -Tree nodes store a reference to the object in the ``node.data`` -attribute. - -The nodes are formatted for display by the object’s ``__repr__`` -implementation by default. We can overide this by passing an -`f-string `__ -as ``repr`` argument. For example ``"{node.data}"`` will use the data -instances ``__str__`` method instead: - -.. code:: ipython3 - - tree.print(repr="{node.data}") - - -.. parsed-literal:: - - Tree<'Organization'> - ├── Department - │ ├── Department - │ │ ╰── Person - │ ╰── Person - ├── Department - │ ╰── Person - ╰── Person - - -Access Nodes ------------- - -We can use the index syntax to get the node object for a given data -object: - -.. code:: ipython3 - - tree[alice] - - - - -.. parsed-literal:: - - Node<'Person', data_id=274911230> - - - -.. code:: ipython3 - - assert tree[alice].data is alice, "nodes store objects in data attribute" - -Iteration and Searching ------------------------ - -There are multiple methods to iterate the tree. - -.. code:: ipython3 - - res = [] - for node in tree: # depth-first, pre-orde traversal - res.append(node.data.name) - print(res) - - -.. parsed-literal:: - - ['Development', 'Test', 'Claire', 'Bob', 'Marketing', 'Dave', 'Alice'] - - -.. code:: ipython3 - - from nutree import IterMethod - - res = [] - for node in tree.iterator(method=IterMethod.POST_ORDER): - res.append(node.data.name) - print(res) - - -.. parsed-literal:: - - ['Claire', 'Test', 'Bob', 'Development', 'Dave', 'Marketing', 'Alice'] - - -.. code:: ipython3 - - tree.visit(lambda node, memo: print(node.data.name), method=IterMethod.LEVEL_ORDER) - - -.. parsed-literal:: - - Development - Marketing - Alice - Test - Bob - Dave - Claire - - -The above traversal methods are also available for single nodes: - -.. code:: ipython3 - - res = [node.data.name for node in dev_node] - print(res) - - -.. parsed-literal:: - - ['Test', 'Claire', 'Bob'] - - -Filter ------- - -.. code:: ipython3 - - tree_2 = tree.copy(predicate=lambda node: isinstance(node.data, Department)) - tree_2.print(repr="{node}") - - -.. parsed-literal:: - - Tree<"Copy of Tree<'Organization'>"> - ├── Node<'Department', data_id=274136336> - │ ╰── Node<'Department', data_id=274876691> - ╰── Node<'Department', data_id=274911182> - - -Mutation --------- - -.. code:: ipython3 - - bob_node = tree[bob] - # bob_node.move_to(marketing_dep_node) - tree.print() - - -.. parsed-literal:: - - Tree<'Organization'> - ├── <__main__.Department object at 0x1056fd100> - │ ├── <__main__.Department object at 0x106249130> - │ │ ╰── <__main__.Person object at 0x1062cf560> - │ ╰── <__main__.Person object at 0x1062cf830> - ├── <__main__.Department object at 0x1062cfce0> - │ ╰── <__main__.Person object at 0x105d2b770> - ╰── <__main__.Person object at 0x1062cffe0> - - -Data IDs and Clones -------------------- - -.. code:: ipython3 - - tree.print(repr="{node}", title=False) - - -.. parsed-literal:: - - Node<'Department', data_id=274136336> - ├── Node<'Department', data_id=274876691> - │ ╰── Node<'Person', data_id=274911062> - ╰── Node<'Person', data_id=274911107> - Node<'Department', data_id=274911182> - ╰── Node<'Person', data_id=274541431> - Node<'Person', data_id=274911230> - - -Special Data Types ------------------- - -Plain Strings -~~~~~~~~~~~~~ - -We can add simple string objects the same way as any other object - -.. code:: ipython3 - - tree_str = Tree() - a = tree_str.add("A") - a.add("a1") - a.add("a2") - tree_str.add("B") - tree_str.print() - - -.. parsed-literal:: - - Tree<'4398779184'> - ├── 'A' - │ ├── 'a1' - │ ╰── 'a2' - ╰── 'B' - - -Dictionaries -~~~~~~~~~~~~ - -We cannot add Python ``dict`` objects to a tree, because nutree cannot -derive a *data_id* for unhashable types. A a workaround, we can wrap it -inside ``DictWrapper`` objects: - -.. code:: ipython3 - - from nutree import DictWrapper, Tree - - d = {"title": "foo", "id": 1} - - tree = Tree() - tree.add("A").up().add("B") - tree["A"].add(DictWrapper(d)) - tree["B"].add(DictWrapper(d)) - tree.print(repr="{node}") - # tree.find(d) - - -.. parsed-literal:: - - Tree<'4392651376'> - ├── Node<'A', data_id=-9112120580527591933> - │ ╰── Node<"DictWrapper<{'title': 'foo', 'id': 1}>", data_id=4398795776> - ╰── Node<'B', data_id=-7701919551774722043> - ╰── Node<"DictWrapper<{'title': 'foo', 'id': 1}>", data_id=4398795776> - - -Serialization -------------- - -.. code:: ipython3 - - tree.to_dict_list() - # tree.to_dict_list(mapper=lambda node, data: node.data.name) - - - - -.. parsed-literal:: - - [{'data': 'A', - 'children': [{'data': "DictWrapper<{'title': 'foo', 'id': 1}>"}]}, - {'data': 'B', - 'children': [{'data': "DictWrapper<{'title': 'foo', 'id': 1}>"}]}] - - - -.. code:: ipython3 - - list(tree.to_list_iter()) - - - - -.. parsed-literal:: - - [(0, 'A'), (1, {}), (0, 'B'), (3, 2)] - - - -.. code:: ipython3 - - t = Tree._from_list([(0, "A"), (0, "B"), (1, "C"), (0, "D"), (3, "E")]) - print(t.format()) - - -.. parsed-literal:: - - Tree<'4398778896'> - ├── 'A' - │ ╰── 'C' - │ ╰── 'E' - ├── 'B' - ╰── 'D' - - -.. code:: mermaid - - graph LR; - A--> B & C & D; - B--> A & E; - C--> A & E; - D--> A & E; - E--> B & C & D; - -Advanced --------- - -Chaining -~~~~~~~~ - -Some methods return a node instance, so we can chain calls. This allows -for a more compact code and avoids some temporary variables: - -.. code:: ipython3 - - Tree().add("A").add("a1").up().add("a2").up(2).add("B").tree.print(title=False) - - -.. parsed-literal:: - - 'A' - ├── 'a1' - ╰── 'a2' - 'B' - - diff --git a/nutree/mermaid.py b/nutree/mermaid.py index fa42f56..5d8d78b 100644 --- a/nutree/mermaid.py +++ b/nutree/mermaid.py @@ -8,6 +8,7 @@ from __future__ import annotations +import io from pathlib import Path from subprocess import CalledProcessError, check_output from typing import IO, TYPE_CHECKING, Callable, Iterable, Iterator, Literal @@ -193,46 +194,49 @@ def _write(fp): ): fp.write(line + "\n") + if isinstance(target, io.StringIO): + if format: + raise RuntimeError("Need a filepath to convert Mermaid output.") + _write(target) + return + if isinstance(target, str): target = Path(target) - if isinstance(target, Path): - mm_path = target.with_suffix(".tmp") if format else target - - with open(mm_path, "w") as fp: - _write(fp) + if not isinstance(target, Path): + raise ValueError(f"target must be a Path, str, or StringIO: {target}") - if format: - # Convert Mermaid output using mmdc - # See https://github.com/mermaid-js/mermaid-cli - - # Make sure the source markdown stream is flushed - # fp.close() - - mmdc_options["-i"] = str(mm_path) - mmdc_options["-o"] = str(target) - mmdc_options["-e"] = format - - cmd = ["mmdc"] - for k, v in mmdc_options.items(): - cmd.extend((k, v)) - - try: - check_output(cmd) - except CalledProcessError as e: # pragma: no cover - raise RuntimeError( - f"Could not convert Mermaid output using {cmd}.\n" - f"Error: {e.output.decode()}" - ) from e - except FileNotFoundError as e: # pragma: no cover - raise RuntimeError( - f"Could not convert Mermaid output using {cmd}.\n" - "Mermaid CLI (mmdc) not found.\n" - "Please install it with `npm install -g mermaid.cli`." - ) from e - return + mm_path = target.with_suffix(".tmp") if format else target - elif format: - raise RuntimeError("Need a filepath to convert Mermaid output.") + with mm_path.open("w") as fp: + _write(fp) - raise AssertionError # pragma: no cover + if format: + # Convert Mermaid output using mmdc + # See https://github.com/mermaid-js/mermaid-cli + + # Make sure the source markdown stream is flushed + # fp.close() + + mmdc_options["-i"] = str(mm_path) + mmdc_options["-o"] = str(target) + mmdc_options["-e"] = format + + cmd = ["mmdc"] + for k, v in mmdc_options.items(): + cmd.extend((k, v)) + + try: + check_output(cmd) + except CalledProcessError as e: # pragma: no cover + raise RuntimeError( + f"Could not convert Mermaid output using {cmd}.\n" + f"Error: {e.output.decode()}" + ) from e + except FileNotFoundError as e: # pragma: no cover + raise RuntimeError( + f"Could not convert Mermaid output using {cmd}.\n" + "Mermaid CLI (mmdc) not found.\n" + "Please install it with `npm install -g mermaid.cli`." + ) from e + return diff --git a/nutree/node.py b/nutree/node.py index 4bd6577..c98f5a0 100644 --- a/nutree/node.py +++ b/nutree/node.py @@ -626,21 +626,24 @@ def add_child( return cast(Self, n) # need to return a node source_node = None - # factory: Type[Self] = self._tree.node_factory + if isinstance(child, Node): + # Adding an existing node means that we create a clone if deep is None: deep = False if deep and data_id is not None or node_id is not None: raise ValueError("Cannot set ID for deep copies.") + source_node = child if source_node._tree is self._tree: - if source_node._parent is self._parent: + if source_node._parent is self: raise UniqueConstraintError( - f"Same parent not allowed: {source_node}" + f"Cannot add a copy of {source_node} as child of {self}, " + "because it would create a 2nd instance in the same parent." ) else: pass - # raise NotImplementedError("Cross-tree adding") + if data_id and data_id != source_node._data_id: raise UniqueConstraintError(f"data_id conflict: {source_node}") diff --git a/tox.ini b/tox.ini index f7f9754..b54fcbc 100644 --- a/tox.ini +++ b/tox.ini @@ -112,7 +112,8 @@ allowlist_externals = jupyter changedir = docs commands = - jupyter nbconvert --to rst --output-dir sphinx jupyter/take_the_tour.ipynb + ; jupyter nbconvert --to rst --output-dir sphinx jupyter/take_the_tour.ipynb + jupyter nbconvert --to markdown --output-dir sphinx jupyter/take_the_tour.ipynb ; jupyter nbconvert --execute --to rst --output-dir sphinx jupyter/take_the_tour.ipynb # http://www.sphinx-doc.org/en/master/man/sphinx-build.html sphinx-build -b html sphinx sphinx-build