Nodes#

Nodes are the recursive backbone backbone of the Configuration object. Nodes can contain other nodes under their attributes and in that way recurse deeper into the configuration. Nodes can also be used as types in dictionaries or lists.

Node classes contain the description of a node type in the configuration. Here’s an example to illustrate:

from bsb import config

@config.node
class CellType:
  name = config.attr(key=True)
  color = config.attr()
  radius = config.attr(type=float, required=True)

This node class describes the following configuration:

{
  "cell_type_name": {
    "radius": 13.0,
    "color": "red"
  }
}

Dynamic nodes#

Dynamic nodes are those whose node class is configurable from inside the configuration node itself. This is done through the use of the @dynamic decorator instead of the node decorator. This will automatically create a required cls attribute.

The value that is given to this attribute will be used to load the class of the node:

@config.dynamic
class PlacementStrategy:
  @abc.abstractmethod
  def place(self):
    pass

And in the configuration:

{
  "strategy": "bsb.placement.LayeredRandomWalk"
}

This would import the bsb.placement module and use its LayeredRandomWalk class to further process the node.

Note

The child class must inherit from the dynamic node class.

Configuring the dynamic attribute#

The same keyword arguments can be passed to the dynamic decorator as to regular attributes to specify the properties of the dynamic attribute; As an example we specify a new attribute name with attr_name="example_type", allow the dynamic attribute to be omitted required=False, and specify a fallback class with default="Example":

@config.dynamic(attr_name="example_type", required=False, default="Example")
class Example:
  pass

@config.node
class Explicit(Example):
  purpose = config.attr(required=True)

Example can then be defined as either:

{
  "example_type": "Explicit",
  "purpose": "show explicit dynamic node"
}

or, because of the default kwarg, Example can be implicitly used by omitting the dynamic attribute:

{
  "purpose": "show implicit fallback"
}

Class maps#

A preset map of shorter entries can be given to be mapped to an absolute or relative class path, or a class object:

@dynamic(classmap={"short": "pkg.with.a.long.name.DynClass"})
class Example:
    pass

If short is used the dynamic class will resolve to pkg.with.a.long.name.DynClass.

Automatic class maps#

Automatic class maps can be generated by setting the auto_classmap keyword argument. Child classes can then register themselves in the classmap of the parent by providing the classmap_entry keyword argument in their class definition argument list.

@dynamic(auto_classmap=True)
class Example:
  pass

class MappedChild(Example, classmap_entry="short"):
  pass

This will generate a mapping from short to the my.module.path.MappedChild class.

If the base class is not supposed to be abstract, it can be added to the classmap as well:

@dynamic(auto_classmap=True, classmap_entry="self")
class Example:
  pass

class MappedChild(Example, classmap_entry="short"):
  pass

Root node#

The root node is the Configuration object and is at the basis of the tree of nodes.

Pluggable nodes#

A part of your configuration file might be using plugins, these plugins can behave quite different from eachother and forcing them all to use the same configuration might hinder their function or cause friction for users to configure them properly. To solve this parts of the configuration are pluggable. This means that what needs to be configured in the node can be determined by the plugin that you select for it. Homogeneity can be enforced by defining slots. If a slot attribute is defined inside of a then the plugin must provide an attribute with the same name.

Note

Currently the provided attribute slots enforce just the presence, not any kind of inheritance or deeper inspection. It’s up to a plugin author to understand the purpose of the slot and to comply with its intentions.

Consider the following example:

import bsb.plugins, bsb.config

@bsb.config.pluggable(key="plugin", plugin_name="puppy generator")
class PluginNode:
  @classmethod
  def __plugins__(cls):
      if not hasattr(cls, "_plugins"):
          cls._plugins = bsb.plugins.discover("puppy_generators")
      return cls._plugins
{
  "plugin": "labradoodle",
  "labrador_percentage": 110,
  "poodle_percentage": 60
}

The decorator argument key determines which attribute will be read to find out which plugin the user wants to configure. The class method __plugins__ will be used to fetch the plugins every time a plugin is configured (usually finding these plugins isn’t that fast so caching them is recommended). The returned plugin objects should be configuration node classes. These classes will then be used to further handle the given configuration.

Node inheritance#

Classes decorated with node decorators have their class and metaclass machinery rewritten. Basic inheritance works like this:

@config.node
class NodeA:
    pass

@config.node
class NodeB(NodeA):
    pass

However, when inheriting from more than one node class you will run into a metaclass conflict. To solve it, use config.compose_nodes():

from bsb import config, compose_nodes

@config.node
class NodeA:
    pass

@config.node
class NodeB:
    pass

@config.node
class NodeC(compose_nodes(NodeA, NodeB)):
    pass

Configuration attributes#

An attribute can refer to a singular value of a certain type, a dict, list, reference, or to a deeper node. You can use the config.attr in node decorated classes to define your attribute:

from bsb import config

@config.node
class CandyStack:
  count = config.attr(type=int, required=True)
  candy = config.attr(type=CandyNode)
{
  "count": 12,
  "candy": {
    "name": "Hardcandy",
    "sweetness": 4.5
  }
}

Configuration dictionaries#

Configuration dictionaries hold configuration nodes. If you need a dictionary of values use the types.dict syntax instead.

from bsb import config

@config.node
class CandyNode:
  name = config.attr(key=True)
  sweetness = config.attr(type=float, default=3.0)

@config.node
class Inventory:
  candies = config.dict(type=CandyStack)
{
  "candies": {
    "Lollypop": {
      "sweetness": 12.0
    },
    "Hardcandy": {
      "sweetness": 4.5
    }
  }
}

Items in configuration dictionaries can be accessed using dot notation or indexing:

inventory.candies.Lollypop == inventory.candies["Lollypop"]

Using the key keyword argument on a configuration attribute will pass the key in the dictionary to the attribute so that inventory.candies.Lollypop.name == "Lollypop".

Configuration lists#

Configuration dictionaries hold unnamed collections of configuration nodes. If you need a list of values use the types.list syntax instead.

from bsb import config

@config.node
class InventoryList:
  candies = config.list(type=CandyStack)
{
  "candies": [
    {
      "count": 100,
      "candy": {
        "name": "Lollypop",
        "sweetness": 12.0
      }
    },
    {
      "count": 1200,
      "candy": {
        "name": "Hardcandy",
        "sweetness": 4.5
      }
    }
  ]
}

Configuration references#

References refer to other locations in the configuration. In the configuration the configured string will be fetched from the referenced node:

{
  "locations": {"A": "very close", "B": "very far"},
  "where": "A"
}

Assuming that where is a reference to locations, location A will be retrieved and placed under where so that in the config object:

>>> print(conf.locations)
{'A': 'very close', 'B': 'very far'}

>>> print(conf.where)
'very close'

>>> print(conf.where_reference)
'A'

References are defined inside of configuration nodes by passing a reference object to the config.ref() function:

@config.node
class Locations:
  locations = config.dict(type=str)
  where = config.ref(lambda root, here: here["locations"])

After the configuration has been cast all nodes are visited to check if they are a reference and if so the value from elsewhere in the configuration is retrieved. The original string from the configuration is also stored in node.<ref>_reference.

After the configuration is loaded it’s possible to either give a new reference key (usually a string) or a new reference value. In most cases the configuration will automatically detect what you’re passing into the reference:

>>> cfg = from_json("mouse_cerebellum.json")
>>> cfg.cell_types.granule_cell.placement.layer.name
'granular_layer'
>>> cfg.cell_types.granule_cell.placement.layer = 'molecular_layer'
>>> cfg.cell_types.granule_cell.placement.layer.name
'molecular_layer'
>>> cfg.cell_types.granule_cell.placement.layer = cfg.layers.purkinje_layer
>>> cfg.cell_types.granule_cell.placement.layer.name
'purkinje_layer'

As you can see, by passing the reference a string the object is fetched from the reference location, but we can also directly pass the object the reference string would point to. This behavior is controlled by the ref_type keyword argument on the config.ref call and the is_ref method on the reference object. If neither is given it defaults to checking whether the value is an instance of str:

@config.node
class CandySelect:
  candies = config.dict(type=Candy)
  special_candy = config.ref(lambda root, here: here.candies, ref_type=Candy)

class CandyReference(config.refs.Reference):
  def __call__(self, root, here):
    return here.candies

  def is_ref(self, value):
    return isinstance(value, Candy)

@config.node
class CandySelect:
  candies = config.dict(type=Candy)
  special_candy = config.ref(CandyReference())

The above code will make sure that only Candy objects are seen as references and all other types are seen as keys that need to be looked up. It is recommended you do this even in trivial cases to prevent bugs.

Reference object#

The reference object is a callable object that takes 2 arguments: the configuration root node and the referring node. Using these 2 locations it should return a configuration node from which the reference value can be retrieved.

def locations_reference(root, here):
  return root.locations

This reference object would create the link seen in the first reference example.

Reference lists#

Reference lists are akin to references but instead of a single key they are a list of reference keys:

{
  "locations": {"A": "very close", "B": "very far"},
  "where": ["A", "B"]
}

Results in cfg.where == ["very close", "very far"]. As with references you can set a new list and all items will either be looked up or kept as is if they’re a reference value already.

Warning

Appending elements to these lists currently does not convert the new value. Also note that reference lists are quite indestructible; setting them to None just resets them and the reference key list (.<attr>_references) to [].

Bidirectional references#

The object that a reference points to can be “notified” that it is being referenced by the populate mechanism. This mechanism stores the referrer on the referee creating a bidirectional reference. If the populate argument is given to the config.ref call the referrer will append itself to the list on the referee under the attribute given by the value of the populate kwarg (or create a new list if it doesn’t exist).

{
  "containers": {
    "A": {}
  },
  "elements": {
    "a": {"container": "A"}
  }
}
@config.node
class Container:
  name = config.attr(key=True)
  elements = config.attr(type=list, default=list, call_default=True)

@config.node
class Element:
  container = config.ref(container_ref, populate="elements")

This would result in cfg.containers.A.elements == [cfg.elements.a].

You can overwrite the default append or create population behavior by creating a descriptor for the population attribute and define a __populate__ method on it:

class PopulationAttribute:
  # Standard property-like descriptor protocol
  def __get__(self, instance, objtype=None):
    if instance is None:
      return self
    if not hasattr(instance, "_population"):
      instance._population = []
    return instance._population

  # Prevent population from being overwritten
  # Merge with new values into a unique list instead
  def __set__(self, instance, value):
    instance._population = list(set(instance._population) + set(value))

  # Example that only stores referrers if their name in the configuration is "square".
  def __populate__(self, instance, value):
    print("We're referenced in", value.get_node_name())
    if value.get_node_name().endswith(".square"):
      self.__set__(instance, [value])
    else:
      print("We only store referrers coming from a .square configuration attribute")

todo: Mention pop_unique

Casting#

When the Configuration object is loaded it is cast from a tree to an object. This happens recursively starting at a configuration root. The default Configuration root is defined in scaffold/config/_config.py and describes how the scaffold builder will read a configuration tree.

You can cast from configuration trees to configuration nodes yourself by using the class method __cast__:

inventory = {
  "candies": {
    "Lollypop": {
      "sweetness": 12.0
    },
    "Hardcandy": {
      "sweetness": 4.5
    }
  }
}

# The second argument would be the node's parent if it had any.
conf = Inventory.__cast__(inventory, None)
print(conf.candies.Lollypop.sweetness)
>>> 12.0

Casting from a root node also resolves references.