easytree

A fluent tree builder, useful to create multi-level, nested JSON configurations.

https://badge.fury.io/py/easytree.svg https://readthedocs.org/projects/easytree/badge/?version=latest https://img.shields.io/pypi/dd/easytree https://img.shields.io/badge/code%20style-black-000000.svg

Quickstart

Installing easytree is simple with pip:

pip install easytree

Using easytree is also easy

>>> import easytree

#let's create a deeply-nested chart configuration
>>> chart = easytree.Tree()
>>> chart.chart.type = "bar"
>>> chart.title.text = "France Olympic Medals"
>>> chart.xAxis.categories = ["Gold", "Silver", "Bronze"]
>>> chart.yAxis.title.text = "Count"
>>> chart.series.append(name="2016", data=[10, 18, 14])
>>> chart.series.append({"name":"2012"})
>>> chart.series[1].data = [11, 11, 13] #list items recursively become nodes

>>> easytree.serialize(chart)
{
    "chart": {
        "type": "bar"
    },
    "title": {
        "text": "France Olympic Medals"
    },
    "xAxis": {
        "categories": [
            "Gold",
            "Silver",
            "Bronze"
        ]
    },
    "yAxis": {
        "title": {
            "text": "Count"
        }
    },
    "series": [
        {
            "name": "2016",
            "data": [
                10,
                18,
                14
            ]
        },
        {
            "name": "2012",
            "data": [
                11,
                11,
                13
            ]
        }
    ]
}

Writing deeply-nested trees with list nodes is easy with a context-manager:

>>> chart = easytree.Tree()
>>> with chart.axes.append({}) as axis:
...     axis.title.text = "primary axis"
>>> with chart.axes.append({}) as axis:
...     axis.title.text = "secondary axis"
>>> chart.serialize()
{
    "axes": [
        {
            "title": {
                "text": "primary axis"
            }
        },
        {
            "title": {
                "text": "secondary axis"
            }
        }
    ]
}

Tutorial

Consider the following tree, as an example:

config = easytree.Tree({
    "date":"2020-02-20",
    "size":{
        "height":"1.56 cm",
        "width":"30.41 cm"
    },
    "users":[
        "Alice",
        "Bob"
    ]
})

There are three types of nodes in this tree:

  • value leaves (e.g. “2020-02-20”, “1.56 cm” or “Alice”)
  • dict nodes (e.g. “size”)
  • list nodes (e.g. “users”)

Instead of raising an AttributeError, reading or setting a new attribute on a dict node creates and returns a new child node.

>>> config.memory.size = "512GB SSD" #memory node is created on the fly
>>> config
{
    "date":"2020-02-20",
    "size":{
        "height":"1.56 cm",
        "width":"30.41 cm"
    },
    "users":[
        "Alice",
        "Bob"
    ]
    "memory":{
        "size": "512GB SSD"
    }
}

You can recursively create list and dict nodes on the fly:

>>> config.events.append(name="create", user="Alice") #events node is created on the fly
>>> config.events.append({"name:"edit", "user":"Bob"})
>>> config
{
    "date":"2020-02-20",
    "size":{
        "height":"1.56 cm",
        "width":"30.41 cm"
    },
    "users":[
        "Alice",
        "Bob"
    ]
    "memory":{
        "size": "512GB SSD"
    },
    "events":[
        {
            "name": "create",
            "user": "Alice"
        },
        {
            "name": "edit",
            "user": "Bob"
        }
    ]
}

The type of each newly-created node, unless given an explicit value, is initially undefined, and can change into a list node, a dict node (e.g. memory) or a value-node (e.g. 512GB SSD). The type of an undefined node is dynamically determined by subsequent interactions.

  • if the append method is called on an undefined node, that node becomes a list node.
  • if an attribute is called on an undefined node (e.g. node.name), or a key is retrieved (e.g. node["name"]), that node becomes a dict node.
  • any value appended or assigned at a node recursively becomes a node, whose type is determined by the type of the given value (list node for iterables (list, tuple, set), dict node for dictionaries, value-nodes for other types).

Example:

>>> root = easytree.Tree()           #root is an undefined node
>>> root.name = "David"              #root is now a dict node, and name is a string
>>> root.colors = ["blue", "brown"]  #colors is a list node of strings
>>> root.cities.append(name="Paris") #cities is a list node of dict nodes
>>> root
{
    "name": "David",
    "colors": [
        "blue",
        "brown"
    ],
    "cities": [
        {
            "name": "Paris"
        }
    ]
}

Note

A dict node has only two methods: get and serialize. Any other attribute called on an instance will create a new node, attach it to the instance and return it.

A list node has only two methods: append and serialize. Any other attribute called on an instance raise an AttributeError.

Once the type of a node is determined, it cannot morph into another type. For example:

>>> root = easytree.Tree({}) #explicitely set as dict node
>>> root.append(1)
AttributeError: 'dict' object has no attribute 'append'

Warning

For an undefined node, retrieving an integer key (e.g. node[0]) is intrinsically ambiguous: did the user expect the first value of a list node (which should raise an IndexError given that the list is then empty), or the value at the key 0 of a dict node? To avoid unintentionally creating dict nodes, an AmbiguityError will be raised.

How does easytree compare with jsontree

easytree differs from jsontree (see here) in two important ways:

  1. elements, when attached or appended to an easytree.Tree, recursively become tree branches if they are themselves lists, sets, tuples or dictionaries.
  2. serialization of an easytree.Tree merely converts the tree to a dictionary, list or underlying value (for leaves). It does not serialize to JSON.
  3. starting with 0.1.8, easytree supports freezing or sealing trees to avoid accidentally create new nodes

Compare:

>>> import easytree

>>> tree = easytree.Tree()
>>> tree.friends = [{"name":"David"},{"name":"Celine"}]
>>> tree.friends[0].age = 29 #this works
>>> easytree.serialize(tree)
{'friends': [{'age': 29, 'name': 'David'}, {'name': 'Celine'}]}

with:

>>> import jsontree

>>> tree = jsontree.jsontree()
>>> tree.friends = [{"name":"David"},{"name":"Celine"}]
>>> tree.friends[0].age = 29 #this does not work
AttributeError: 'dict' object has no attribute 'age'

Sealing and freezing

Note

New with version 0.1.8

While easytree makes it easy to create deeply-nested trees, it can also make it error prone when reading back properties. Sealing and freezing allow to protect trees by restricting some or all mutations to a tree.

  default sealed frozen
read an existing node
create a new node
edit a node
delete a node

Sealing

Sealing a tree prevents the user from accidentally creating new nodes; it does allow to edit leaf values.

>>> person = easytree.Tree({"name:"Bob", "address":{"city":"New York"}}, sealed=True)
>>> person.name = "Alice" #you can still edit leaf values
>>> person.adress.city    #typo spelling address
AttributeError: sealed node has no attribute 'adress'

You can seal and unseal a tree with the dedicated root-level functions. These functions return a new tree (i.e. these functions are not in-place).

Freezing

Freezing a tree prevents the user from accidentally creating new nodes or changing existing nodes.

>>> person = easytree.Tree({"name:"Bob", "address":{"city":"New York"}}, frozen=True)
>>> person.address.city = "Los Angeles"
AttributeError: cannot set attribute 'city' on frozen node

You can freeze and unfreeze a tree with the dedicated root-level functions. These functions return a new tree (i.e. these functions are not in-place).

API Documentation

easytree.Tree

alias of easytree.tree.Node

class easytree.Node(value=None, *, sealed=False, frozen=False)
append(*args, **kwargs)

Appends a value to a list node. If the node type was previously undefined, the node becomes a list.

Note

The append method can take either one positional argument or one-to-many named (keyword) arguments. If passed one-to-many keyword arguments, the kwargs dictionary is added to the list.

Example

>>> tree = easytree.Tree()                                 #undefined node
>>> tree.append("hello world")                            #casts node to list
>>> tree.append(name="David", age=29)                     #call with kwargs
>>> tree.append({"animal":"elephant", "country":"India"}) #call with args
>>> easytree.serialize(tree)
["Hello world",{"name":"David","age":29},{"animal":"elephant", "country":"India"}]

Note

The append method intentionally returns a reference to the last-added item, if that item is a new node. This allows for more fluent code using the context manager.

Example

>>> chart = easytree.Tree()
>>> with chart.axes.append({}) as axis:
...     axis.title.text = "primary axis"
>>> with chart.axes.append({}) as axis:
...     axis.title.text = "secondary axis"
>>> chart.serialize()
{
    "axes": [
        {
            "title": {
                "text": "primary axis"
            }
        },
        {
            "title": {
                "text": "secondary axis"
            }
        }
    ]
}
get(key, default=None)

Returns the value at a given key, or default if the key does not exists.

Example

>>> config = easytree.Tree({"context":{"starting":"2016-03-31"}})
>>> config.context.get("starting", "2014-01-01")
2016-03-31
>>> config.context.get("ending", "2021-12-31")
2021-12-31
>>> config.context.get("calendar")
None
serialize()

Recursively converts itself to a native python type (dict, list or None).

Example

>>> chart = easytree.Tree()
>>> chart.chart.type = "bar"
>>> chart.title.text = "France Olympic Medals"
>>> chart.xAxis.categories = ["Gold", "Silver", "Bronze"]
>>> chart.yAxis.title.text = "Count"
>>> chart.series.append(name="2016", data=[10, 18, 14])
>>> chart.series.append({"name":"2012", "data":[11,11,13]})
>>> chart.serialize()
{
    "chart": {
        "type": "bar"
    },
    "title": {
        "text": "France Olympic Medals"
    },
    "xAxis": {
        "categories": [
            "Gold",
            "Silver",
            "Bronze"
        ]
    },
    "yAxis": {
        "title": {
            "text": "Count"
        }
    },
    "series": [
        {
            "name": "2016",
            "data": [
                10,
                18,
                14
            ]
        },
        {
            "name": "2012",
            "data": [
                11,
                11,
                13
            ]
        }
    ]
}
easytree.new(root=None, *, sealed=False, frozen=False)

Creates a new easytree.Tree

easytree.serialize(tree)

Recursively converts an easytree.Tree to a native python type.

Example

>>> chart = easytree.Tree()
>>> chart.chart.type = "bar"
>>> chart.title.text = "France Olympic Medals"
>>> chart.xAxis.categories = ["Gold", "Silver", "Bronze"]
>>> chart.yAxis.title.text = "Count"
>>> chart.series.append(name="2016", data=[10, 18, 14])
>>> chart.series.append({"name":"2012", "data":[11,11,13]})
>>> easytree.serialize(chart)
{
    "chart": {
        "type": "bar"
    },
    "title": {
        "text": "France Olympic Medals"
    },
    "xAxis": {
        "categories": [
            "Gold",
            "Silver",
            "Bronze"
        ]
    },
    "yAxis": {
        "title": {
            "text": "Count"
        }
    },
    "series": [
        {
            "name": "2016",
            "data": [
                10,
                18,
                14
            ]
        },
        {
            "name": "2012",
            "data": [
                11,
                11,
                13
            ]
        }
    ]
}
easytree.frozen(tree)

Returns True if the tree is frozen

easytree.freeze(tree)

Returns a new frozen copy of the tree

easytree.unfreeze(tree)

Returns a new unfrozen copy of the tree

easytree.sealed(tree)

Returns True if the tree is sealed

easytree.seal(tree)

Returns a new sealed copy of the tree

easytree.unseal(tree)

Returns a new unsealed copy of the tree

easytree.load(stream, *args, frozen=False, sealed=False, **kwargs)

Deserialize a text file or binary file containing a JSON document to an Tree object

*args and **kwargs are passed to the json.load function

Example

>>> with open("data.json", "r") as file:
...     tree = easytree.load(file)
easytree.loads(s, *args, frozen=False, sealed=False, **kwargs)

Deserialize s (a str, bytes or bytearray instance containing a JSON document) to an Tree object

*args and **kwargs are passed to the json.loads function

easytree.dump(obj, stream, *args, **kwargs)

Serialize Tree object as a JSON formatted string to stream (a .write()-supporting file-like object).

*args and **kwargs are passed to the json.dump function

Example

>>> tree = easytree.Tree({"foo": "bar"})
>>> with open("data.json", "w") as file:
...     easytree.dump(tree, file, indent=4)
easytree.dumps(obj, *args, **kwargs)

Serialize Tree to a JSON formatted string.

*args and **kwargs are passed to the json.dumps function

Source code

The source code is hosted on github.

Changelog

Version 0.1.0 (2020-08-01)

  • created easytree

Version 0.1.1 (2020-08-02)

  • added ability to append dictionary using keyword arguments
  • added ability to iterate over a tree
  • added ability to compute the length of a tree (for list nodes and dict nodes)

Version 0.1.2 (2020-08-03)

  • overrode the __new__ method to filter out primitive and object types
  • added ability to check for contains

Version 0.1.3 (2020-08-04)

  • added the serialize method to the tree

Version 0.1.4 (2020-08-05)

  • added context manager to return a reference to itself
  • addressed infinite recursion in __getattr__
  • append now returns a reference to last added node, if it is a node

Version 0.1.5 (2020-08-08)

  • refactored the Tree object into a Tree and Node object
  • removed the __new__ from the Tree root object to allow for inheritence

Version 0.1.6 (2020-08-16)

  • fixed a bug where overriding an attribute would fail if it was already in the node

Version 0.1.7 (2021-05-01)

  • passing an easytree.Tree object as an instanciation argument will copy the tree
  • added support for list slicing in list nodes
  • improved error messages
  • removed unncessary code in append method
  • factored out constants
  • repr now delegates to underlying value
  • addressed ipython canary
  • added get method for dict nodes
  • implemented __delitem__, __delattr__ and __bool__

Version 0.1.8 (2021-06-13)

  • formatted code using Black
  • added convenience i/o functions (e.g. load and dump)
  • added support for abc.KeysView and abc.ValuesView values
  • merged root and node classes
  • refactored __value__ attribute to _value
  • added support for freezing and sealing trees