From 4ccd6319996e5049c0c60c34c83ff880f3622043 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Thu, 12 Sep 2024 13:15:43 +0200 Subject: [PATCH] Reapply last changes --- CHANGELOG.md | 7 +- docs/sphinx/reference_guide.rst | 4 +- docs/sphinx/rg_modules.rst | 9 ++ docs/sphinx/ug_objects.rst | 94 +++++++++++-------- docs/sphinx/ug_randomize.rst | 155 ++++++++++++++++++-------------- nutree/common.py | 34 +------ nutree/tree_generator.py | 55 +----------- tests/test_objects.py | 33 +++++++ tests/test_tree_generator.py | 1 + tox.ini | 1 + 10 files changed, 199 insertions(+), 194 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d28461..a875f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## 0.9.0 (unreleased) -- Add `Tree.build_random_tree()` -- Add `GenericNodeData` +- Add `Tree.build_random_tree()` (experimental). +- Add `GenericNodeData` as wrapper for `dict` data. - Fixed #7 Tree.from_dict failing to recreate an arbitrary object tree with a mapper. ## 0.8.0 (2024-03-29) @@ -11,9 +11,8 @@ - BREAKING: Drop Python 3.7 support (EoL 2023-06-27). - `Tree.save()` accepts a `compression` argument that will enable compression. `Tree.load()` can detect if the input file has a compression header and will - decompress automatically. -- New traversal methods `LEVEL_ORDER`, `LEVEL_ORDER_RTL`, `ZIGZAG`, `ZIGZAG_RTL`. decompress transparently. +- New traversal methods `LEVEL_ORDER`, `LEVEL_ORDER_RTL`, `ZIGZAG`, `ZIGZAG_RTL`. - New compact connector styles `'lines32c'`, `'round43c'`, ... - Save as mermaid flow diagram. diff --git a/docs/sphinx/reference_guide.rst b/docs/sphinx/reference_guide.rst index 669d589..54d0c3e 100644 --- a/docs/sphinx/reference_guide.rst +++ b/docs/sphinx/reference_guide.rst @@ -5,14 +5,14 @@ Reference Guide Class Overview ============== -nutree classes +Nutree Classes -------------- .. inheritance-diagram:: nutree.tree nutree.node nutree.typed_tree nutree.common :parts: 2 :private-bases: -Random tree generator +Random Tree Generator --------------------- .. inheritance-diagram:: nutree.tree_generator diff --git a/docs/sphinx/rg_modules.rst b/docs/sphinx/rg_modules.rst index 3340b4a..2d619ab 100644 --- a/docs/sphinx/rg_modules.rst +++ b/docs/sphinx/rg_modules.rst @@ -42,3 +42,12 @@ nutree.common module :show-inheritance: :inherited-members: +nutree.tree_generator module +---------------------------- + +.. automodule:: nutree.tree_generator + :members: + :undoc-members: + :show-inheritance: + :inherited-members: + diff --git a/docs/sphinx/ug_objects.rst b/docs/sphinx/ug_objects.rst index a9ad9a7..9e5b831 100644 --- a/docs/sphinx/ug_objects.rst +++ b/docs/sphinx/ug_objects.rst @@ -131,26 +131,47 @@ Dictionaries (GenericNodeData) Python `dictionaries `_ -are unhashable and cannot be used as node data objects. |br| -We can handle this in different ways: +are unhashable and cannot be used as node data objects:: -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`. + d = {"a": 1, "b": 2} + tree.add(d) # ERROR: raises `TypeError: unhashable type: 'dict'` + +Adding Native Dictionaries +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We can handle this by explicitly setting the `data_id` when adding the dict:: + + node = tree.add({d, data_id="{123-456}") -The :class:`~nutree.common.GenericNodeData` class is a simple wrapper around a -dictionary that + assert node.data is d + assert node.data["a"] == 1 + +Alternatively, we can implement a custom `calc_data_id` callback function that +returns a unique key for the data object:: + + def _calc_id(tree, data): + if isinstance(data, dict): + return hash(data["guid"]) + return hash(data) -- is hashable, so it can be used added to the tree as ``node.data`` + tree = Tree(calc_data_id=_calc_id) + + d = {"a": 1, "b": 2, "guid": "{123-456}"} + tree.add(d) + +Wrapping Dictionaries +~~~~~~~~~~~~~~~~~~~~~ + +Finally, we can use the :class:`~nutree.common.GenericNodeData` which is a simple +wrapper around a dictionary that + +- is hashable, so it can be 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"]`` + accessed as ``node.name`` +- allows readonly access to dict keys by index, i.e. ``node.data["name"]`` Examples :: @@ -160,11 +181,10 @@ Examples :: d = {"a": 1, "b": 2} obj = GenericNodeData(d) - -We can now add the wrapped `dict` to the tree:: - node = tree.add_child(obj) +We can now access the dict keys as attributes:: + assert node.data._dict is d, "stored as reference" assert node.data._dict["a"] == 1 @@ -187,13 +207,22 @@ GenericNodeData can also be initialized with keyword args like this:: obj = GenericNodeData(a=1, b=2) +.. warning:: + The :class:`~nutree.common.GenericNodeData` provides a hash value because + any class that is hashable, so it can be used as a data object. However, the + hash value is NOT based on the internal dict but on the object itself. |br| + This means that two instances of GenericNodeData with the same dict content + will have different hash values. + +.. warning:: + The `shadow_attrs` feature is readonly, so you cannot modify the dict + through the shadow attributes. You need to access the dict directly for that. Dataclasses ----------- `Dataclasses `_ 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:: +to define simple classes that hold data. However, they are not hashable by default:: from dataclasses import dataclass @@ -205,32 +234,27 @@ We can handle this in different ways:: 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``). + tree.add(alice) # ERROR: raises `TypeError: unhashable type: 'dict'` -Example: Explicitly set the `data_id` when adding the dataclass instance:: +We can handle this in different ways byexplicitly 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 +Alternatively, we can implement a custom `calc_data_id` callback function that +returns a unique key for the data object:: - def __hash__(self): - return hash(self.guid) + def _calc_id(tree, data): + if hasattr(data, "guid"): + return hash(data.guid) + return hash(data) - alice = Person("Alice", age=23, guid="{123-456}") + tree = Tree(calc_data_id=_calc_id) tree.add(alice) -Example: Use a frozen dataclass instead, which is immutable and hashable by default:: +Finally, we can use a frozen dataclass instead, which is immutable and hashable by +default (or pass ``unsafe_hash=True``):: @dataclass(frozen=True) class Person: diff --git a/docs/sphinx/ug_randomize.rst b/docs/sphinx/ug_randomize.rst index b5edaa9..d0d6260 100644 --- a/docs/sphinx/ug_randomize.rst +++ b/docs/sphinx/ug_randomize.rst @@ -10,6 +10,10 @@ Generate Random Trees Nutree can generate random tree structures from a structure definition. +.. warning:: + + This feature is experimental and may change in future versions. + Nutree can generate random tree structures from a structure definition. This can be used to create hierarchical data for test, demo, or benchmarking of *nutree* itself. @@ -55,99 +59,114 @@ This definition is then passed to :meth:`tree.Tree.build_random_tree`:: Example:: structure_def = { - #: Name of the new tree (str, optiona) + # Name of the generated tree (optional) "name": "fmea", - #: Types define the default properties of the nodes + # Types define the default properties of the gernated nodes "types": { - #: Default properties for all node types - "*": { ... }, - #: Specific default properties for each node type (optional) - "TYPE_1": { ... }, - "TYPE_2": { ... }, - ... - }, - #: Relations define the possible parent / child relationships between - #: node types and optionally override the default properties. - "relations": { - "__root__": { - "TYPE_1": { - ":count": 10, - "ATTR_1": "Function {hier_idx}", - "expanded": True, - }, - }, - "function": { - "failure": { - ":count": RangeRandomizer(1, 3), - "title": "Failure {hier_idx}", - }, - }, - "failure": { - "cause": { - ":count": RangeRandomizer(1, 3), - "title": "Cause {hier_idx}", - }, - "effect": { - ":count": RangeRandomizer(1, 3), - "title": "Effect {hier_idx}", - }, + # '*' Defines default properties for all node types (optional) + "*": { + ":factory": GenericNodeData, # Default node class (optional) }, + # Specific default properties for each node type + "function": {"icon": "gear"}, + "failure": {"icon": "exclamation"}, + "cause": {"icon": "tools"}, + "effect": {"icon": "lightning"}, }, - } - tree = Tree.build_random_tree(structure_def) - tree.print() - assert type(tree) is Tree - assert tree.calc_height() == 3 - -Example:: - - structure_def = { - "name": "fmea", - #: Types define the default properties of the nodes - "types": { - #: Default properties for all node types - "*": {":factory": GenericNodeData}, - #: Specific default properties for each node type - "function": {"icon": "bi bi-gear"}, - "failure": {"icon": "bi bi-exclamation-triangle"}, - "cause": {"icon": "bi bi-tools"}, - "effect": {"icon": "bi bi-lightning"}, - }, - #: Relations define the possible parent / child relationships between - #: node types and optionally override the default properties. + # Relations define the possible parent / child relationships between + # node types and optionally override the default properties. "relations": { "__root__": { "function": { - ":count": 10, - "title": "Function {hier_idx}", + ":count": 3, + "title": TextRandomizer(("{idx}: Provide $(Noun:plural)",)), + "details": BlindTextRandomizer(dialect="ipsum"), "expanded": True, }, }, "function": { "failure": { ":count": RangeRandomizer(1, 3), - "title": "Failure {hier_idx}", + "title": TextRandomizer("$(Noun:plural) not provided"), }, }, "failure": { "cause": { ":count": RangeRandomizer(1, 3), - "title": "Cause {hier_idx}", + "title": TextRandomizer("$(Noun:plural) not provided"), }, "effect": { ":count": RangeRandomizer(1, 3), - "title": "Effect {hier_idx}", + "title": TextRandomizer("$(Noun:plural) not provided"), }, }, }, } - tree = Tree.build_random_tree(structure_def) - tree.print() - assert type(tree) is Tree + + tree = TypedTree.build_random_tree(structure_def) + + assert type(tree) is TypedTree assert tree.calc_height() == 3 + + tree.print() + +May produce:: + + TypedTree<'fmea'> + ├── function → GenericNodeData<{'icon': 'gear', 'title': '1: Provide Seniors', 'details': 'Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquid ex ea commodi consequat. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.', 'expanded': True}> + │ ├── failure → GenericNodeData<{'icon': 'exclamation', 'title': 'Streets not provided'}> + │ │ ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Decisions not provided'}> + │ │ ├── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Spaces not provided'}> + │ │ ╰── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Kings not provided'}> + │ ╰── failure → GenericNodeData<{'icon': 'exclamation', 'title': 'Entertainments not provided'}> + │ ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Programs not provided'}> + │ ├── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Dirts not provided'}> + │ ╰── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Dimensions not provided'}> + ├── function → GenericNodeData<{'icon': 'gear', 'title': '2: Provide Shots', 'details': 'Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.', 'expanded': True}> + │ ├── failure → GenericNodeData<{'icon': 'exclamation', 'title': 'Trainers not provided'}> + │ │ ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Girlfriends not provided'}> + │ │ ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Noses not provided'}> + │ │ ├── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Closets not provided'}> + │ │ ╰── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Potentials not provided'}> + │ ╰── failure → GenericNodeData<{'icon': 'exclamation', 'title': 'Punches not provided'}> + │ ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Inevitables not provided'}> + │ ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Fronts not provided'}> + │ ╰── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Worths not provided'}> + ╰── function → GenericNodeData<{'icon': 'gear', 'title': '3: Provide Shots', 'details': 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.', 'expanded': True}> + ╰── failure → GenericNodeData<{'icon': 'exclamation', 'title': 'Recovers not provided'}> + ├── cause → GenericNodeData<{'icon': 'tools', 'title': 'Viruses not provided'}> + ├── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Dirts not provided'}> + ╰── effect → GenericNodeData<{'icon': 'lightning', 'title': 'Readings not provided'}> + + +**A few things to note** + +- The generated tree contains nodes :class:`~common.GenericNodeData` as ``node.data`` + value.. + +- Every ``node.data`` contains items from the structure definition except for + the ones starting with a colon, for example ``":count"``. |br| + The node items are merged with the default properties defined in the `types` + section. + +- Randomizers are used to generate random data for each instance. + They derive from the :class:`~tree_generator.Randomizer` base class. + +- The :class:`~tree_generator.TextRandomizer` and + :class:`~tree_generator.BlindTextRandomizer` classes are used to generate + random text using the `Fabulist `_ library. + +- :meth:`tree.Tree.build_random_tree` creates instances of :class:`~tree.Tree`, while + :meth:`typed_tree.TypedTree.build_random_tree` creates instances of + :class:`~typed_tree.TypedTree`. + +- The generated tree contains instances of the :class:`~common.GenericNodeData` + class by default, but can be overridden for each node type by adding a + ``":factory": CLASS`` entry. - tree2 = TypedTree.build_random_tree(structure_def) - tree2.print() - assert type(tree2) is TypedTree - assert tree2.calc_height() == 3 +.. note:: + The random text generator is based on the `Fabulist `_ + library and can use any of its providers to generate random data. |br| + Make sure to install the `fabulist` package to use the text randomizers + :class:`~tree_generator.TextRandomizer` and :class:`~tree_generator.BlindTextRandomizer`. diff --git a/nutree/common.py b/nutree/common.py index 792107d..cb3c425 100644 --- a/nutree/common.py +++ b/nutree/common.py @@ -166,43 +166,15 @@ def __init__(self, value=None): class GenericNodeData: - """Can be used as `node.data` instance for dict-like data. + """Wrap a Python dict so it can be added to the tree. + + Makes the hashable and exposes the dict values as attributes. Initialized with a dictionary of values. The values can be accessed via the `node.data` attribute like `node.data["KEY"]`. If the Tree is initialized with `shadow_attrs=True`, the values are also available as attributes of the node like `node.KEY`. - If the tree is serialized, the values are copied to the serialized data. - - Examples:: - - tree = Tree(shadow_attrs=True) - - d = {"a": 1, "b": 2} - obj = GenericNodeData(d) - node = tree.add_child(obj) - - assert node.data.values is d, "stored as reference" - assert node.data.values["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" - - - Alternatively, the data can be initialized with keyword args like this:: - - obj = GenericNodeData(a=1, b=2) - - or with a dictionary like this. Note that in this case we unpack the dictionary - which creates a copy:: - - d = {"a": 1, "b": 2} - obj = GenericNodeData(**d) - See :ref:`generic-node-data` for details. """ diff --git a/nutree/tree_generator.py b/nutree/tree_generator.py index c81a02a..d90ecf4 100644 --- a/nutree/tree_generator.py +++ b/nutree/tree_generator.py @@ -1,59 +1,7 @@ """ Implements a generator that creates a random tree structure from a specification. -Returns a nutree.TypedTree with random data from a specification. - See :ref:`randomize` for details. - -Example: - -```py -from tree_generator import RangeRandomizer, TextRandomizer, generate_tree - - -structure_definition = { - "name": "fmea", - #: Types define the default properties of the nodes - "types": { - #: Default properties for all node types - # "*": {":factory": WbNode}, - #: Specific default properties for each node type - "function": {"icon": "bi bi-gear"}, - "failure": {"icon": "bi bi-exclamation-triangle"}, - "cause": {"icon": "bi bi-tools"}, - "effect": {"icon": "bi bi-lightning"}, - }, - #: Relations define the possible parent / child relationships between - #: node types and optionally override the default properties. - "relations": { - "__root__": { - "function": { - ":count": 10, - "title": TextRandomizer(("{i}: Provide $(Noun:plural)",)), - "expanded": True, - }, - }, - "function": { - "failure": { - ":count": RangeRandomizer(1, 3), - "title": TextRandomizer("$(Noun:plural) not provided"), - }, - }, - "failure": { - "cause": { - ":count": RangeRandomizer(1, 3), - "title": TextRandomizer("$(Noun:plural) not provided"), - }, - "effect": { - ":count": RangeRandomizer(1, 3), - "title": TextRandomizer("$(Noun:plural) not provided"), - }, - }, - }, -} -tree = generate_tree(structure_definition) -tree.print() -``` """ import random @@ -85,11 +33,10 @@ class Randomizer(ABC): """ Abstract base class for randomizers. + Args: probability (float, optional): The probability of using the randomizer. Must be in the range [0.0, 1.0]. Defaults to 1.0. - Attributes: - probability (float): The probability of using the randomizer. """ def __init__(self, *, probability: float = 1.0) -> None: diff --git a/tests/test_objects.py b/tests/test_objects.py index 0dc221c..9ffd622 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -248,6 +248,9 @@ class FrozenItem: with pytest.raises(FrozenInstanceError): item.count += 1 + # We can also add by passing the data_id as keyword argument: + _ = tree.add(item, data_id="123-456") + tree.print() assert isinstance(dict_node.data, FrozenItem) @@ -255,3 +258,33 @@ class FrozenItem: assert dict_node.price == 12.34, "should support attribute access via shadowing" with pytest.raises(AttributeError): _ = dict_node.foo + + def test_callback(self): + from dataclasses import dataclass + + d: dict = {"a": 1, "guid": "123-456"} + + @dataclass + class Item: + a: int + guid: str + + dc = Item(2, "234-567") + + def _calc_id(tree, data): + if isinstance(data, Item): + return hash(data.guid) + elif isinstance(data, dict): + return hash(data["guid"]) + return hash(data) + + tree = Tree(calc_data_id=_calc_id, shadow_attrs=True) + + n1 = tree.add(d) + n2 = tree.add(dc) + + assert n1.data is d + assert n1.data["a"] == 1 + + assert n2.data is dc + assert n2.a == 2 diff --git a/tests/test_tree_generator.py b/tests/test_tree_generator.py index bfc6711..803fe43 100644 --- a/tests/test_tree_generator.py +++ b/tests/test_tree_generator.py @@ -131,3 +131,4 @@ def test_fabulist(): tree = TypedTree.build_random_tree(structure_def) tree.print() assert type(tree) is TypedTree + # assert 0 diff --git a/tox.ini b/tox.ini index 0833798..470402e 100644 --- a/tox.ini +++ b/tox.ini @@ -116,6 +116,7 @@ deps = ; python-dateutil ; lxml furo + fabulist pydot rdflib recommonmark