Henge tutorial

Introduction to henge

A henge is a management layer that overlays a database. You can use henge with a variety of back-end types that support key-value pair storage, such as a simple python dict object, a redis database, MongoDB, SQLite, etc.

The point of the henge management layer is to automatically mint unique, data-derived identifiers, and make it easy to retrieve the data using these identifiers. When you insert an arbitrary object into the Henge, it will return a unique digest for the object, which we refer to as a DRUID. The DRUID is a cryptographic digest/hash; it behaves like a fingerprint for the item you inserted. DRUIDs are computed deterministically from the item, so they represent globally unique identifiers. If you insert the same item repeatedly, it will produce the same DRUID -- this is even true across henges, as long as they share a data schema (explained more later). You can use DRUIDs as identifiers, and you can also use them to retrieve the original item again from the henge.

To introduce you to the basic idea, let's store simple strings, and make it possible to retrieve them with their digests. You can choose the digest algorithm; we'll use md5 for now. Henge will store the DRUID (md5 digest) and value (string) in a database, and allow retrieving the the string given its identifier.

Record the version used in this tutorial:

from platform import python_version 
import henge
print("Python version: {}; henge version: {}".format(python_version(), henge.__version__))
Python version: 3.8.5; henge version: 0.1.0-dev

# If you want you can turn debug text on with this command:
# import logmuse
# logmuse.init_logger("henge", "DEBUG", devmode=True)

Henge defines data types using JSON-schema. Let's define a data type called string which is just a string, or a sequence of characters:

!cat "../tests/data/string.yaml" 
description: "Simple schema for a string"
type: string
henge_class: mystring

Henge schemas are just JSON-schemas with one additional keyword: henge_class. For any item type that we want to digest as a separate entity in the henge, we need to declare a henge_class. Here, we called the class of this simple string mystring. We construct a henge object that is aware of this data type like this:

h = henge.Henge(database={}, schemas=["../tests/data/string.yaml"])
h
Henge object. Item types: mystring

The database in this case is just an empty python dict {}, which is useful for testing. Insert a sequence object which will be stored in the database, which in this case is just a dictionary. Inserting will return the digest for the item:

digest = h.insert("TCGA", item_type="mystring")

Just for kicks, let's take a look at what the digest actually is:

digest
'45d0ff9f1a9504cf2039f89c1ffb4c32'

That digest is a globally unique identifier for the item, derived from the item itself. You can retrieve the original item using the digest:

h.retrieve(digest)
'TCGA'

Since the digest is deterministic, repeated attempts to insert the same item will yield the same result. This item is already in the database, so it will not take up additional storage:

h.insert("TCGA", item_type="mystring")
'45d0ff9f1a9504cf2039f89c1ffb4c32'

This demonstrates how to use a henge on a basic, primitive data type like a string. All the henge layer is doing is simplifying the interface to: 1) create a unique identifier automatically; 2) insert your item into the database, keyed by the unique identifier; and 3) provide an easy lookup function to get your item back.

Next, what if we have a more complicated data, like an array, or an object with named attributes? The power of henge becomes more apparent when we want to store such objects. A DRUID builds on basic value-derived identifiers by allowing the objects to be decomposable and recursive. These are two powerful properties: decomposible means that the value stored in the database can have multiple elements. Recursive means that each element may be an identifier of another item that is stored independently in the database.

This will make more sense as we look at examples of more complicated objects next.

Decomposing: storing arrays and multi-property objects

Multi-property objects

To demonstrate, we'll first show an example with a data type that has more than one property. Let's say we want to make a henge that stores and retrieves objects of type Person. We define a JSON-schema for a Person, which has 2 attributes: a string name, and an integer age:

!cat "../tests/data/person.yaml"                                
description: "Person"
type: object
henge_class: person
properties:
  name:
    type: string
    description: "String attribute"
  age:
    type: integer
    description: "Integer attribute"
required:
  - name

Notice again that we have henge_class: person in there. We did not define henge classes for the individual property elements of name and age. This means that the Person object will be digested, but the name and age elements will not be seperately digested -- they will only exist as elements of a Person.

Now we will create a henge using this schema. As a side note, you can pass the schema either as a dict object or as a path to a yaml file:

import henge
person_henge = henge.Henge(database={}, schemas=["../tests/data/person.yaml"])

You can see which types of items your henge can process by looking at the item_types property:

person_henge.item_types
['person']

Use insert to add an item to the henge, providing the object and its type. The henge will use JSON-schema to make sure the object satisfies the schema. In the case below, we create a Person object (Pat, age 38), and insert in specifying that it's an object of type person.

druid1 = person_henge.insert({"name":"Pat", "age":38}, item_type="person")
print(druid1)
b72919f8796567779510252008cb4d1f

When you insert an item into the henge, it returns the unique identifier (or, the DRUID) for that item. Then, you can use the unique identifier to retrieve the item from the henge.

person_henge.retrieve(druid1)
{'age': '38', 'name': 'Pat'}

Our schema listed name as a required attribute. Here's what happens if we try to insert non-conforming data:

try:
    person_henge.insert({"first_name":"Pat", "age":38}, item_type="person")
except:
    pass
Not valid data. Item type: person. Attempting to insert item: {'age': 38}

'name' is a required property

Failed validating 'required' in schema:
    {'description': 'Person',
     'henge_class': 'person',
     'properties': {'age': {'description': 'Integer attribute',
                            'type': 'integer'},
                    'name': {'description': 'String attribute',
                             'type': 'string'}},
     'recursive': [],
     'required': ['name'],
     'type': 'object'}

On instance:
    {'age': 38}

But we can insert a person with a name and no age, because age was not listed as required in the schema:

pat = person_henge.insert({"name":"Pat"}, item_type="person")
person_henge.retrieve(pat)
{'name': 'Pat'}

Here we take a quick look at what is in the database:

person_henge.database
{'b72919f8796567779510252008cb4d1f': 'age,38,name,Pat',
 'b72919f8796567779510252008cb4d1f_item_type': 'person',
 'b72919f8796567779510252008cb4d1f_digest_version': 'md5',
 '9eb9147f0bf1b5cd591201c8e5a28f66': 'name,Pat',
 '9eb9147f0bf1b5cd591201c8e5a28f66_item_type': 'person',
 '9eb9147f0bf1b5cd591201c8e5a28f66_digest_version': 'md5'}

Arrays

Next, let's consider an array. Here's a quick example with array data. Once again, we must define a JSON-schema describing the data type that our henge will understand.

!cat "../tests/data/simple_array.yaml"
description: "An array of items."
type: array
henge_class: array
items:
  type: string


arrhenge = henge.Henge(database={}, schemas=["../tests/data/simple_array.yaml"])
digest = arrhenge.insert(["a", "b", "c"], item_type="array")
print(digest)
a44c56c8177e32d3613988f4dba7962e

arrhenge.retrieve(digest)
['a', 'b', 'c']
arrhenge.database
{'a44c56c8177e32d3613988f4dba7962e': 'a,b,c',
 'a44c56c8177e32d3613988f4dba7962e_item_type': 'array',
 'a44c56c8177e32d3613988f4dba7962e_digest_version': 'md5'}

We've just seen how the DRUID concept works for structured data with multiple attributes. One nice thing about this is that henge is handling all the details of the digest algorithm, which can start to get complicated once your data is more than just a single element. For example -- how do you integrate property names? How do you delimit items? These things are specified independent of data types as part of generic henge digest specification. Therefore, when using henge, you're using a standardized digest algorithm that is independent of data type.

This standardization of the digest algorithm is important for making the identifiers globally useful. If I were to create another henge on a different computer using the same JSON-schema, then I'm guarenteed that the same data will produce the same digest, making it possible to share these digests across servers.

Next, we'll expand into the area where henge becomes very powerful: what if the data are hierarchical, with nested objects?

Recursion: storing structured data

Next, we'll show an example of a data type that contains other complex data types. Let's define a Family as an array of parents and an array of children:

!cat "../tests/data/family.yaml" 
description: "Family"
type: object
henge_class: family
properties:
  domicile:
    type: object
    henge_class: location
    properties:
      address:
        type: string
  parents:
    type: array
    henge_class: people
    items:
      type: object
      henge_class: person
      properties:
        name:
          type: string
          description: "String attribute"
        age:
          type: integer
          description: "Integer attribute"
      required:
        - name
  children:
    type: array
    henge_class: people
    items:
      type: object
      henge_class: person
      properties:
        name:
          type: string
          description: "String attribute"
        age:
          type: integer
          description: "Integer attribute"
      required:
        - name
required:
  - parents

In our family object, parents are required, which is a People object, which is an array with one or more Person objects. The children attribute is optional, which is also a People object with one or more Person objects. Our Family object also has a domicile attribute, which is a Location object that has an address property.

Notice where we've put henge_class keywords in this object. Not only the top-level family object has a henge_class, but also several other properties, including people, person, and domicile, which are either arrays or objects. We'll see below how this type of schema will automatically create first-class database entries, with their own unique identifiers, for each of these nested data types. Therefore, you can not only retrieve family objects using DRUIDS, but you can also retrieve people, person, or domicile objects with their own DRUIDs as well. Check it out:

famhenge = henge.Henge(database={}, schemas=["../tests/data/family.yaml"])
famhenge.item_types
['family', 'location', 'people', 'person']

Now, this henge can accommodate objects that subscribe to this structure data type. Let's build a simple family object and store it in the henge:

myfam = {'domicile': '',
 'parents': [{'name': 'Pat', 'age': 38}, {'name': 'Kelly', 'age': 35}],
 'children': [{'name': 'Oedipus', 'age': 2}]}
myfam_druid = famhenge.insert(myfam, "family")
myfam_druid
'c1b783f9ad69ff52f558284fe046d781'

As before, we can retrieve the complete structured data using the digest:

famhenge.retrieve(myfam_druid)
{'children': [{'age': '2', 'name': 'Oedipus'}],
 'domicile': {},
 'parents': [{'age': '38', 'name': 'Pat'}, {'age': '35', 'name': 'Kelly'}]}

Already we see that this is something useful: as before, henge is handling the algorithmic details to create your unique identifier, and it even works with these more complicated data types! You will give you the same DRUID wherever you have this particular family, for any henge that uses the same schema.

And it gets better: one of the powerful features of Henge is that, under the hood, henge is actually storing objects as separate elements, each with its own identifiers, and you can retrieve them individually. This becomes more apparent when we use the reclimit argument to limit the number of recursive steps when we retrieve data. If we allow no recursion, we'll pull out the digests for the People objects:

famhenge.retrieve(myfam_druid, reclimit=0)
{'children': '3692faf65ba433320207fe4cb3659c4c',
 'domicile': 'd41d8cd98f00b204e9800998ecf8427e',
 'parents': 'af8b184833fd3f6e59108cf2435df17e'}

Notice here that each of these elements has its own digest. That means we could actually retrieve just a part of our object using the digest from that part. For example, here's a retrieval of just the parents of this family object:

parent_digest = famhenge.retrieve(myfam_druid, reclimit=0)['parents']
print(parent_digest)
famhenge.retrieve(parent_digest)
af8b184833fd3f6e59108cf2435df17e

[{'age': '38', 'name': 'Pat'}, {'age': '35', 'name': 'Kelly'}]

If there were another family with the same set of parents, it would share the data (it would not be duplicated in the database). Back to the reclimit parameter, we can recurse one step further to get digests for the individual Person objects that make up the People object referenced as parents:

famhenge.retrieve(myfam_druid, reclimit=1)
{'children': ['45c416044ca980e4bcb1d8184cfcfe47'],
 'domicile': {},
 'parents': ['b72919f8796567779510252008cb4d1f',
  '603c37a8fb8c520f1f76458ebc345eb2']}

These identifiers can be used individually to pull individual items from the database:

digest = famhenge.retrieve(myfam_druid, reclimit=1)['parents'][1]
print(digest)
famhenge.retrieve(digest)
603c37a8fb8c520f1f76458ebc345eb2

{'age': '35', 'name': 'Kelly'}

You can also insert the sub-components (like People or Person) directly into the database:

druid1 = famhenge.insert({"name":"Pat", "age":38}, item_type="person")
druid2 = famhenge.insert({"name":"Kelly", "age":35}, item_type="person")
famhenge.retrieve(druid1)
{'age': '38', 'name': 'Pat'}

Notice here that we re-inserted an object that was already in the database; this will not duplicate anything in the database, and the same identifier is returned here as the one used when this Person was part of the Family object.

print(druid2)
druid2 == digest

603c37a8fb8c520f1f76458ebc345eb2

True

Advanced example

Now, we'll modify our family to introduce 2 other features:

!cat "../tests/data/family_with_pets.yaml" 
description: "Family"
type: object
henge_class: family
properties:
  name:
    type: string
    description: "Name of the family."
  coordinates:
    type: string
    henge_class: "recprim"
    description: "A recursive primitive"
  pets:
    type: array
    henge_class: array
    items:
      type: string
  friends:
    type: array
    henge_class: friends_array
    items:
      type: string
      henge_class: friend_string
  domicile:
    type: object
    henge_class: location
    properties:
      address:
        type: string
      state:
        type: string
      city:
        type: string
  parents:
    type: array
    henge_class: people
    items:
      type: object
      henge_class: person
      properties:
        name:
          type: string
          description: "String attribute"
        age:
          type: integer
          description: "Integer attribute"
      required:
        - name
  children:
    type: array
    henge_class: people
    items:
      type: object
      henge_class: person
      properties:
        name:
          type: string
          description: "String attribute"
        age:
          type: integer
          description: "Integer attribute"
      required:
        - name
required:
  - parents

What's different here? First, we added an object property called pets that is in array. This will demonstrate that henge handles nesting of arrays and properties.

Second, we added object properties that are simple primitive string, either with or without henge_class defined: name is a new property without a henge_class, which means this will not be stored as a first-class object in the database with it's own identifier. The coordinates property, like name is just a simple string property, but the difference is that it is listed with a henge_class. Therefore, it will get a unique identifier as it's own object type in the henge. We'll see below how this shows up in our object types.

import henge 
import logmuse 
pethenge = henge.Henge(database={}, schemas=["../tests/data/family_with_pets.yaml"])

myfam = {'name': "Jones",
         'domicile': { 'state': "VA", 'address':"123 Sesame St", 'city': "Charlottesville"},
         'friends' : ["Jimmy", "Jackie", "Jill"],
         'pets': ["Sparky", "Pluto", "Max"],
         'coordinates': '30W300x-40E400x',
         'parents': [{'name': 'Pat', 'age': 38}, {'name': 'Kelly', 'age': 35}],
         'children': [{'name': 'Oedipus', 'age': 2}] }

Even for this complicted object with nested objects and arrays, the interface works in exactly the same way. As long as your data validates against your provided schema, just insert your object to get a globally-useful DRUID:

digest = pethenge.insert(myfam, item_type="family")
print(digest)
55f35b96a7ba03d1195f9c99207eaa26

And, as expected, retrieve the object in its original structure from the DRUID:

pethenge.retrieve(digest)
{'children': [{'age': '2', 'name': 'Oedipus'}],
 'coordinates': '30W300x-40E400x',
 'domicile': {'address': '123 Sesame St',
  'city': 'Charlottesville',
  'state': 'VA'},
 'friends': ['Jimmy', 'Jackie', 'Jill'],
 'name': 'Jones',
 'parents': [{'age': '38', 'name': 'Pat'}, {'age': '35', 'name': 'Kelly'}],
 'pets': ['Sparky', 'Pluto', 'Max']}

Now, let's explore how the different sub-object types behave by looking at what happens when we recurse to different levels:

pethenge.retrieve(digest, reclimit=0)
{'children': '3692faf65ba433320207fe4cb3659c4c',
 'coordinates': '1a74f8f147bb18aff8ab572184acc191',
 'domicile': 'e0f5f7fe2b319df11d1148e6505e8c7a',
 'friends': '28ef899f2bc7d374d236f15a03975115',
 'name': 'Jones',
 'parents': 'af8b184833fd3f6e59108cf2435df17e',
 'pets': '9d9cdda81935946a4b3bfa596fcdb7ac'}

Notice here the difference between name and coordinates -- the name did not have a henge_class, so it's going to be stored in the database directly under the parent family digest. There's no separate digest to retrieve it as it's own entity. In contrast, coordinates got its own digest and can therefore be retrieved individually, re-used across data, etc:

coord_digest = pethenge.retrieve(digest, reclimit=0)['coordinates']
pethenge.retrieve(coord_digest)
'30W300x-40E400x'

You can see how the other elements behave with multiple layers of recursion by adjusting reclimit:

pethenge.retrieve(digest, reclimit=1)
{'children': ['45c416044ca980e4bcb1d8184cfcfe47'],
 'coordinates': '30W300x-40E400x',
 'domicile': {'address': '123 Sesame St',
  'city': 'Charlottesville',
  'state': 'VA'},
 'friends': ['495b3121d23f5988b133882b36aa7214',
  '6cfe37b82c14779526a2bb86a560aaa4',
  '2ab45b80a312bb97190187c6f66fdd58'],
 'name': 'Jones',
 'parents': ['b72919f8796567779510252008cb4d1f',
  '603c37a8fb8c520f1f76458ebc345eb2'],
 'pets': ['Sparky', 'Pluto', 'Max']}
pethenge.retrieve(digest, reclimit=2)
{'children': [{'age': '2', 'name': 'Oedipus'}],
 'coordinates': '30W300x-40E400x',
 'domicile': {'address': '123 Sesame St',
  'city': 'Charlottesville',
  'state': 'VA'},
 'friends': ['Jimmy', 'Jackie', 'Jill'],
 'name': 'Jones',
 'parents': [{'age': '38', 'name': 'Pat'}, {'age': '35', 'name': 'Kelly'}],
 'pets': ['Sparky', 'Pluto', 'Max']}
pethenge.retrieve(digest, reclimit=3)
{'children': [{'age': '2', 'name': 'Oedipus'}],
 'coordinates': '30W300x-40E400x',
 'domicile': {'address': '123 Sesame St',
  'city': 'Charlottesville',
  'state': 'VA'},
 'friends': ['Jimmy', 'Jackie', 'Jill'],
 'name': 'Jones',
 'parents': [{'age': '38', 'name': 'Pat'}, {'age': '35', 'name': 'Kelly'}],
 'pets': ['Sparky', 'Pluto', 'Max']}

By the time we get to reclimit=3, we're done recursing and we've populated the whole object from its components. Each of these henge_class objects at any level in the hierarchy can be used just as if they were top-level objects in the henge:

children_digest=pethenge.retrieve(digest, reclimit=0)["children"]
print(children_digest)
pethenge.retrieve(children_digest)
3692faf65ba433320207fe4cb3659c4c

[{'age': '2', 'name': 'Oedipus'}]
pethenge.database
{'e0f5f7fe2b319df11d1148e6505e8c7a': 'address,123 Sesame St,city,Charlottesville,state,VA',
 'e0f5f7fe2b319df11d1148e6505e8c7a_item_type': 'location',
 'e0f5f7fe2b319df11d1148e6505e8c7a_digest_version': 'md5',
 '495b3121d23f5988b133882b36aa7214': 'Jimmy',
 '495b3121d23f5988b133882b36aa7214_item_type': 'friend_string',
 '495b3121d23f5988b133882b36aa7214_digest_version': 'md5',
 '6cfe37b82c14779526a2bb86a560aaa4': 'Jackie',
 '6cfe37b82c14779526a2bb86a560aaa4_item_type': 'friend_string',
 '6cfe37b82c14779526a2bb86a560aaa4_digest_version': 'md5',
 '2ab45b80a312bb97190187c6f66fdd58': 'Jill',
 '2ab45b80a312bb97190187c6f66fdd58_item_type': 'friend_string',
 '2ab45b80a312bb97190187c6f66fdd58_digest_version': 'md5',
 '28ef899f2bc7d374d236f15a03975115': '495b3121d23f5988b133882b36aa7214,6cfe37b82c14779526a2bb86a560aaa4,2ab45b80a312bb97190187c6f66fdd58',
 '28ef899f2bc7d374d236f15a03975115_item_type': 'friends_array',
 '28ef899f2bc7d374d236f15a03975115_digest_version': 'md5',
 '9d9cdda81935946a4b3bfa596fcdb7ac': 'Sparky,Pluto,Max',
 '9d9cdda81935946a4b3bfa596fcdb7ac_item_type': 'array',
 '9d9cdda81935946a4b3bfa596fcdb7ac_digest_version': 'md5',
 '1a74f8f147bb18aff8ab572184acc191': '30W300x-40E400x',
 '1a74f8f147bb18aff8ab572184acc191_item_type': 'recprim',
 '1a74f8f147bb18aff8ab572184acc191_digest_version': 'md5',
 'b72919f8796567779510252008cb4d1f': 'age,38,name,Pat',
 'b72919f8796567779510252008cb4d1f_item_type': 'person',
 'b72919f8796567779510252008cb4d1f_digest_version': 'md5',
 '603c37a8fb8c520f1f76458ebc345eb2': 'age,35,name,Kelly',
 '603c37a8fb8c520f1f76458ebc345eb2_item_type': 'person',
 '603c37a8fb8c520f1f76458ebc345eb2_digest_version': 'md5',
 'af8b184833fd3f6e59108cf2435df17e': 'b72919f8796567779510252008cb4d1f,603c37a8fb8c520f1f76458ebc345eb2',
 'af8b184833fd3f6e59108cf2435df17e_item_type': 'people',
 'af8b184833fd3f6e59108cf2435df17e_digest_version': 'md5',
 '45c416044ca980e4bcb1d8184cfcfe47': 'age,2,name,Oedipus',
 '45c416044ca980e4bcb1d8184cfcfe47_item_type': 'person',
 '45c416044ca980e4bcb1d8184cfcfe47_digest_version': 'md5',
 '3692faf65ba433320207fe4cb3659c4c': '45c416044ca980e4bcb1d8184cfcfe47',
 '3692faf65ba433320207fe4cb3659c4c_item_type': 'people',
 '3692faf65ba433320207fe4cb3659c4c_digest_version': 'md5',
 '55f35b96a7ba03d1195f9c99207eaa26': 'children,3692faf65ba433320207fe4cb3659c4c,coordinates,1a74f8f147bb18aff8ab572184acc191,domicile,e0f5f7fe2b319df11d1148e6505e8c7a,friends,28ef899f2bc7d374d236f15a03975115,name,Jones,parents,af8b184833fd3f6e59108cf2435df17e,pets,9d9cdda81935946a4b3bfa596fcdb7ac',
 '55f35b96a7ba03d1195f9c99207eaa26_item_type': 'family',
 '55f35b96a7ba03d1195f9c99207eaa26_digest_version': 'md5'}

So what?

What's so great about henge? Well, there are a few interesting and useful properties that emerge from this appraoch:

  1. You save storage space in your database for duplicate items. Identical items are only stored once, so if you have highly duplicated data, like collections of big objects that would show up in lots of things, then this can be an efficient way to store it.
  2. You can retrieve structured data of arbitrary complexity. Admittedly, the family example is a bit contrived, but imagine if your data objects have components that are repeated as elements of other objects. Instead of storing each individually, henge stores each item exactly once.
  3. You get DRUIDs -- globally unique identifiers that are derived from the data itself, that adhere to an independent algorithm that doesn't depend on the data type.
  4. The recursive identifiers allow you to assess identity of layers of data. Because henge mints identifiers at multiple layers, you can establish identity between attributes of different objects just by looking at the identifier; you don't need to recurse down to the content itself. This allows for fast comparisons among objects.

An example with a collection of arrays

!cat "../tests/data/array_set.yaml"

description: "A collection of arrays"
type: object
henge_class: arrayset
properties:
  array1:
    description: "An array of items."
    type: array
    henge_class: array
    items:
      type: string
  array2:
    description: "An array of items."
    type: array
    henge_class: array
    items:
      type: string
  array3:
    description: "An array of items. Items are henge-classed"
    type: array
    henge_class: array2
    items:
      type: string
      henge_class: str

# If you want you can turn debug text on with this command:
# import logmuse
# logmuse.init_logger("henge", "INFO", devmode=True)
import henge 
import logmuse 
ashenge = henge.Henge(database={}, schemas=["../tests/data/array_set.yaml"])

myas = {'array3': ["1", "2"],
         'array2': ["a", "b", "c"],
         'array1': ["Sparky", "Pluto", "Max"],
}
digest = ashenge.insert(myas, "arrayset")
ashenge.retrieve(digest)
{'array1': ['Sparky', 'Pluto', 'Max'],
 'array2': ['a', 'b', 'c'],
 'array3': ['1', '2']}
ashenge.retrieve(digest, reclimit=0)
{'array1': '9d9cdda81935946a4b3bfa596fcdb7ac',
 'array2': 'a44c56c8177e32d3613988f4dba7962e',
 'array3': 'af7afca24bd6def721f0c60b260122d6'}
ashenge.database
{'c4ca4238a0b923820dcc509a6f75849b': '1',
 'c4ca4238a0b923820dcc509a6f75849b_item_type': 'str',
 'c4ca4238a0b923820dcc509a6f75849b_digest_version': 'md5',
 'c81e728d9d4c2f636f067f89cc14862c': '2',
 'c81e728d9d4c2f636f067f89cc14862c_item_type': 'str',
 'c81e728d9d4c2f636f067f89cc14862c_digest_version': 'md5',
 'af7afca24bd6def721f0c60b260122d6': 'c4ca4238a0b923820dcc509a6f75849b,c81e728d9d4c2f636f067f89cc14862c',
 'af7afca24bd6def721f0c60b260122d6_item_type': 'array2',
 'af7afca24bd6def721f0c60b260122d6_digest_version': 'md5',
 'a44c56c8177e32d3613988f4dba7962e': 'a,b,c',
 'a44c56c8177e32d3613988f4dba7962e_item_type': 'array',
 'a44c56c8177e32d3613988f4dba7962e_digest_version': 'md5',
 '9d9cdda81935946a4b3bfa596fcdb7ac': 'Sparky,Pluto,Max',
 '9d9cdda81935946a4b3bfa596fcdb7ac_item_type': 'array',
 '9d9cdda81935946a4b3bfa596fcdb7ac_digest_version': 'md5',
 '45c16dc1234f1a773dd051950625db36': 'array1,9d9cdda81935946a4b3bfa596fcdb7ac,array2,a44c56c8177e32d3613988f4dba7962e,array3,af7afca24bd6def721f0c60b260122d6',
 '45c16dc1234f1a773dd051950625db36_item_type': 'arrayset',
 '45c16dc1234f1a773dd051950625db36_digest_version': 'md5'}