Skip to content

Commit

Permalink
Add Tree.build_random_tree() (#9)
Browse files Browse the repository at this point in the history
* Add Tree.build_random_tree()

* Fix tests and format

* wip

* Update docs

* 2024
  • Loading branch information
mar10 authored Sep 12, 2024
1 parent 6d7c10e commit 4fd579a
Show file tree
Hide file tree
Showing 44 changed files with 2,140 additions and 858 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Changelog

## 0.8.1 (unreleased)
## 0.9.0 (unreleased)

- Add `Tree.build_random_tree()`
- Add `GenericNodeData`
- Fixed #7 Tree.from_dict failing to recreate an arbitrary object tree with a mapper.

## 0.8.0 (2024-03-29)
Expand Down
8 changes: 3 additions & 5 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ name = "pypi"

[dev-packages]
black = { version = "~=24.3", extras = ["jupyter"] }
# coverage = "*"
fabulist="*"
isort = "*"
# pylint = "*"
# TODO: remove this line:
# pytest-html = "!=3.2.0" # wait for https://github.com/pytest-dev/pytest-html/pull/583
pytest = "*"
pytest-cov = "*"
PyYAML = "*"
Expand All @@ -22,14 +21,13 @@ tox = "*"
twine = "*"
wheel = "*"
yabs = "*"
nutree = {path = ".",editable = true}
ipykernel = "*"
notebook = "*"
nutree = {path = ".",editable = true}

[packages]

[requires]
python_version = "3.12"

[pipenv]
# allow_prereleases = true
1,567 changes: 796 additions & 771 deletions Pipfile.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Nodes can be plain strings or objects <br>
(De)Serialize to (compressed) JSON <br>
Save as Mermaid flow diagram <br>
Different traversal methods <br>
Generate random trees <br>
Fully type annotated <br>
Convert to RDF graph <br>
Typed child nodes <br>
Expand Down
24 changes: 15 additions & 9 deletions docs/sphinx/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@

# General information about the project.
project = u'nutree'
copyright = u'2021-2023, <a href="https://wwwendt.de">Martin Wendt</a>'
copyright = u'2021-2024, <a href="https://wwwendt.de">Martin Wendt</a>'
author = u'Martin Wendt'

# The version info for the project you're documenting, acts as replacement for
Expand All @@ -130,19 +130,25 @@
#version = '1.0'
# The full version, including alpha/beta/rc tags.
#release = '1.0'
import pkg_resources
import importlib

try:
release = pkg_resources.get_distribution('nutree').version
# print( "release", release)
del pkg_resources
except pkg_resources.DistributionNotFound:
print('To build the documentation, The distribution information')
print('Has to be available. Either install the package into your')
# release = pkg_resources.get_distribution("nutree").version
release = importlib.metadata.version("nutree")
except importlib.metadata.PackageNotFoundError:
print("To build the documentation, The distribution information")
print("has to be available. Either install the package into your")
print('development environment or run "setup.py develop" to setup the')
print('metadata. A virtualenv is recommended!')
print("metadata. A virtualenv is recommended!")

print(f"sys.path: {sys.path}")
print(f"package_root: {package_root}")
for fn in os.listdir(package_root):
print("-", fn)
sys.exit(1)

del importlib.metadata

version = '.'.join(release.split('.')[:2])

# The language for content autogenerated by Sphinx. Refer to documentation
Expand Down
3 changes: 2 additions & 1 deletion docs/sphinx/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ Nutree Facts
* :ref:`(De)Serialize to (compressed) JSON <serialize>`
* :ref:`Save as Mermaid flow diagram <save-mermaid>`
* :ref:`Different traversal methods <traversal>`
* :ref:`Fully type annotated <api-reference>`
* :ref:`Generate random trees <randomize>`
* :ref:`Convert to RDF graph <save-rdf>`
* :ref:`Fully type annotated <api-reference>`
* :ref:`Typed child nodes <typed-tree>`
* :ref:`Pretty print <pretty-print>`
* :ref:`Navigation <navigate>`
Expand Down
10 changes: 9 additions & 1 deletion docs/sphinx/reference_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ Reference Guide
Class Overview
==============

nutree classes
--------------

.. inheritance-diagram:: nutree.tree nutree.node nutree.typed_tree nutree.common
:parts: 2
:private-bases:
:caption: nutree classes

Random tree generator
---------------------

.. inheritance-diagram:: nutree.tree_generator
:parts: 2


.. API
Expand Down
10 changes: 8 additions & 2 deletions docs/sphinx/ug_basics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Basics

.. py:currentmodule:: nutree
.. admonition:: TL;DR

Nutree is a Python library for managing hierarchical data structures.
It stores arbitrary data objects in nodes and provides methods for
navigation, searching, and iteration.


Adding Nodes
------------
Expand Down Expand Up @@ -35,8 +41,8 @@ Nodes are usually created by adding a new data instance to a parent::

.. seealso::

See :doc:`ug_objects` for details on how to manage arbitrary objects instead
of plain strings.
See :doc:`ug_objects` for details on how to manage arbitrary objects, dicts,
etc. instead of plain strings.


Info and Navigation
Expand Down
4 changes: 4 additions & 0 deletions docs/sphinx/ug_clones.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Multiple Instances ('Clones')

.. py:currentmodule:: nutree
.. admonition:: TL;DR

Nutree allows to store multiple references to the same data object in a tree.

Every :class:`~nutree.node.Node` instance is unique within the tree and
also has a unique `node.node_id` value.

Expand Down
4 changes: 4 additions & 0 deletions docs/sphinx/ug_diff.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Diff and Merge

.. py:currentmodule:: nutree
.. admonition:: TL;DR

Nutree provides a `diff` method to compare two trees and calculate the differences.

The :meth:`~nutree.tree.Tree.diff` method compares a tree (`T0`) against another
one (`T1`) and returns a merged, annotated copy.

Expand Down
9 changes: 9 additions & 0 deletions docs/sphinx/ug_graphs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Graphs

.. py:currentmodule:: nutree
.. admonition:: TL;DR

Nutree implements conversion to `DOT <https://en.wikipedia.org/wiki/DOT_(graph_description_language)>`_
and `Mermaid <https://mermaid.js.org>`_ formats. |br|
This allows to visualize trees as graphs in various formats like `png`, `svg`, etc. |br|
The :class:`~nutree.typed_tree.TypedTree` class introduces the concept of
`typed nodes`, which allows to generate labelled edges in the graph representation.


.. note::
:class:`~nutree.tree.Tree` (and :class:`~nutree.typed_tree.TypedTree` even
more so) has features that make mapping to a graph easy.
Expand Down
6 changes: 6 additions & 0 deletions docs/sphinx/ug_mutation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Mutation

.. py:currentmodule:: nutree
.. admonition:: TL;DR

Nutree provides methods to modify the tree structure in-place. |br|
This includes adding, moving, and deleting nodes, as well as filtering and sorting.


Some in-place modifications are available::

# Tree
Expand Down
144 changes: 134 additions & 10 deletions docs/sphinx/ug_objects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Working with Objects

.. py:currentmodule:: nutree
.. admonition:: TL;DR

Nutree allows to store arbitrary objects in its nodes without the
need to modify them or derive from a common base class. |br|
It also supports shadow attributes for direct access to object attributes. |br|
Some objects like *dicts* or *dataclasses* are unhashable and require special
handling.

The previous examples used plain strings as data objects. However, any Python
object can be stored, as long as it is `hashable`.

Expand Down Expand Up @@ -37,7 +45,7 @@ We can add instances of these classes to our tree::
...

For bookkeeping, lookups, and serialization, every data object needs a `data_id`.
This value defaults to ``hash(data)``, which is good enough in many cases. ::
This value defaults to ``hash(data)``, which is good enough in many cases::

assert tree[alice].data_id == hash(alice)

Expand All @@ -46,7 +54,7 @@ to be useful for persistence. In our example, we already have object GUIDs, whic
we want to use instead. This can be achieved by passing a callback to the tree::

def _calc_id(tree, data):
if isinstance(data, fixture.Person):
if isinstance(data, Person):
return data.guid
return hash(data)

Expand Down Expand Up @@ -79,13 +87,12 @@ Shadow Attributes (Attribute Aliasing)

When storing arbitrary objects within a tree node, all its attributes must be
accessed through the ``node.data`` attribute. |br|
This can be simplified by using the ``shadow_attrs`` argument, which allow to
This can be simplified by using the ``shadow_attrs`` argument, which allows to
access ``node.data.age`` as ``node.age`` for example::

tree = Tree("Persons", shadow_attrs=True)
dev = tree.add(Department("Development"))
alice = Person("Alice", age=23, guid="{123-456}")
alice_node = dev.add(alice)
alice_node = tree.add(alice)

# Standard access using `node.data`:
assert alice_node.data is alice
Expand All @@ -96,15 +103,17 @@ access ``node.data.age`` as ``node.age`` for example::
assert alice_node.guid == "{123-456}"
assert alice_node.age == 23
# Note also: shadow attributes are readonly:
alice_node.age = 24 # ERROR: raises AttributeError
# But we can still modify the data object directly:
alice_node.data.age = 24 # OK!

# Note caveat: `node.name` is not shadowed, but a native property:
assert alice.data.name == "Alice"
assert alice.name == "Person<Alice, 23>"

# Note also: shadow attributes are readonly:
alice.age = 24 # ERROR: raises AttributeError
alice.data.age = 24 # OK!

.. note::
.. warning::

Aliasing only works for attribute names that are **not** part of the native
:class:`~nutree.node.Node` data model. So these attributes will always return
Expand All @@ -114,3 +123,118 @@ access ``node.data.age`` as ``node.age`` for example::

Note also that shadow attributes are readonly.


.. _generic-node-data:

Dictionaries (GenericNodeData)
------------------------------

Python
`dictionaries <https://docs.python.org/3/tutorial/datastructures.html#dictionaries>`_
are unhashable and cannot be used as node data objects. |br|
We can handle this in different ways:

1. Explicitly set the `data_id` when adding the dict: |br|
``tree.add({"name": "Alice", "age": 23, "guid": "{123-456}"}, data_id="{123-456}")``
2. Use a custom `calc_data_id` callback function that returns a unique key for
the data object (see example above).
3. Wrap the dict in :class:`~nutree.common.GenericNodeData`.

The :class:`~nutree.common.GenericNodeData` class is a simple wrapper around a
dictionary that

- is hashable, so it can be used added to the tree as ``node.data``
- stores a reference to the original dict internally as ``node.data._dict``
- allows readonly access to dict keys as shadow attributes, i.e.
``node.data._dict["name"]`` can be accessed as ``node.data.name``. |br|
If ``shadow_attrs=True`` is passed to the tree constructor, it can also be
accessed as ``node.name``. |br|
Note that shadow attributes are readonly.
- allows access to dict keys by index, i.e. ``node.data["name"]``

Examples ::

from nutree import Tree, GenericNodeData

tree = Tree(shadow_attrs=True)

d = {"a": 1, "b": 2}
obj = GenericNodeData(d)

We can now add the wrapped `dict` to the tree::

node = tree.add_child(obj)

assert node.data._dict is d, "stored as reference"
assert node.data._dict["a"] == 1

assert node.data.a == 1, "accessible as data attribute"
assert node.data["a"] == 1, "accessible by index"

# Since we enabled shadow_attrs, this is also possible:
assert node.a == 1, "accessible as node attribute"

# Note: shadow attributes are readonly:
node.a = 99 # ERROR: raises AttributeError
node.data["a"] = 99 # ERROR: raises TypeError

# We need to access the dict directly to modify it
node.data._dict["a"] = 99
assert node.a == 99, "should reflect changes in dict"


GenericNodeData can also be initialized with keyword args like this::

obj = GenericNodeData(a=1, b=2)


Dataclasses
-----------

`Dataclasses <https://docs.python.org/3/library/dataclasses.html>`_ are a great way
to define simple classes that hold data. However, they are not hashable by default. |br|
We can handle this in different ways::

from dataclasses import dataclass

@dataclass
class Person:
name: str
age: int
guid: str = None

alice = Person("Alice", age=23, guid="{123-456}")

.. 1. Explicitly set the `data_id` when adding the dataclass instance.
.. ``tree.add(, data_id="{123-456}")``
.. 2. Use a custom `calc_data_id` function that returns a unique key for the data object.
.. 3. Make the dataclass hashable by adding a `__hash__` method.
.. 4. Make the dataclass ``frozen=True`` (or ``unsafe_hash=True``).
Example: Explicitly set the `data_id` when adding the dataclass instance::

tree.add(alice, data_id=alice.guid)

Example: make the dataclass hashable by adding a `__hash__` method::

@dataclass
class Person:
name: str
age: int
guid: str = None

def __hash__(self):
return hash(self.guid)

alice = Person("Alice", age=23, guid="{123-456}")

tree.add(alice)

Example: Use a frozen dataclass instead, which is immutable and hashable by default::

@dataclass(frozen=True)
class Person:
name: str
age: int
guid: str = None

Loading

0 comments on commit 4fd579a

Please sign in to comment.