Skip to content
This repository has been archived by the owner on Feb 1, 2019. It is now read-only.

Commit

Permalink
Implement mutators
Browse files Browse the repository at this point in the history
Mutators allow the definition of raw python functions to
manipulate entities before returning them to the caller.

The Mutators implementation obsoletes function interpolation
  • Loading branch information
lottspot committed Aug 7, 2016
1 parent 5b1a25e commit fb12475
Show file tree
Hide file tree
Showing 13 changed files with 192 additions and 362 deletions.
61 changes: 0 additions & 61 deletions doc/source/operations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,64 +149,3 @@ value of ``dict_reference`` will actually be a dictionary.
You should now be ready to :doc:`use reclass <usage>`!

.. include:: substs.inc

Function interpolation
----------------------

Certain functions can be used to dynamically generate values. They are specified
like this::

parameters:
key: $<function(argument1, argument2, ...)>

The following functions are supported:

print
*****

This function simply concatenates all its parameters and returns a string. For
example, take this::

test: $<print(first, second, third)>

which results in this::

test: "first second third"

Yeah, it's quite useless. Now to something a bit more useful:

aggregate
*********

This can be used to extract values from hosts that satisfy certain conditions.
The syntax looks like this::

aggregate(filter, extractor)

The ``filter`` parameter specifies the condition the host has to fulfil, and
the ``extractor`` determines what will be taken from that host. Inside the
function, ``node`` refers to all parameters of a node, and regular python
dictionary functions can be used to access it.

The return value is always a dictionary that maps the nodename to the extracted
values.

Here is an example that extracts the IP address of every host in our domain
``example.com``::

hosts: $<aggregate(node['domain'] == 'example.com', node['ip'])>

which might result in something like this::

hosts:
first.example.com: 192.168.1.1
second.example.com: 192.168.1.2

Note that parameter interpolation can be used inside functions, but one has to
pay attention to proper quoting::

hosts: $<aggregate(node['domain'] == "${network:domain}", node['ip'])>

Note that chained references are not supported: If the result of a function
contains a function itself, this function will not be interpolated, but taken
verbatim. This also prevents circular references.
2 changes: 2 additions & 0 deletions reclass/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def main():
class_mappings = defaults.get('class_mappings')
reclass = Core(storage, class_mappings)

sys.path.append(options.inventory_base_uri)

if options.mode == MODE_NODEINFO:
data = reclass.nodeinfo(options.nodename)

Expand Down
32 changes: 21 additions & 11 deletions reclass/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
#import sys
import fnmatch
import shlex
from reclass.datatypes import Entity, Classes, Parameters
import inspect
from reclass.datatypes import Entity, Classes, Parameters, Mutators
from reclass.errors import MappingFormatError, ClassNotFound

class Core(object):
Expand All @@ -22,6 +23,7 @@ def __init__(self, storage, class_mappings, input_data=None):
self._storage = storage
self._class_mappings = class_mappings
self._input_data = input_data
self._mutators = Mutators()

@staticmethod
def _get_timestamp():
Expand Down Expand Up @@ -120,6 +122,7 @@ def _nodeinfo(self, nodename):
ret = self._recurse_entity(node_entity, merge_base, seen=seen,
nodename=node_entity.name)
ret.interpolate()
self._mutators.push(ret.mutators)
return ret

def _nodeinfo_as_dict(self, nodename, entity):
Expand All @@ -132,23 +135,29 @@ def _nodeinfo_as_dict(self, nodename, entity):
ret.update(entity.as_dict())
return ret

def _mutate(self, **kwargs):
params = set(kwargs.keys())
mutators = self._mutators.as_deque()
while len(mutators):
mutator = mutators.popleft()
args, varargs, keywords, defaults = inspect.getargspec(mutator)
required = set(args)
# If the caller passed us all parameters that this mutator requires,
# call the mutator with the arguments it wants
if (required & params) == required:
args = { arg: value for arg, value in kwargs.items() if arg in required }
mutator(**args)

def nodeinfo(self, nodename):
return self.inventory()['nodes'][nodename]
inventory = self.inventory()
self._mutate(inventory=inventory, nodename=nodename)
return inventory['nodes'][nodename]

def inventory(self):
entities = {}

# first run, reference parameters are expanded
for n in self._storage.enumerate_nodes():
entities[n] = self._nodeinfo(n)

# second run, function are executed
#all_parameters = {}
#for nodename, info in entities.items():
# all_parameters.update({nodename: info.parameters})
for nodename, node in entities.items():
node.expand_functions(inventory=entities)

nodes = {}
applications = {}
classes = {}
Expand All @@ -164,6 +173,7 @@ def inventory(self):
classes[c].append(f)
else:
classes[c] = [f]
self._mutate(nodes=nodes, classes=classes, applications=applications)

return {'__reclass__' : {'timestamp': Core._get_timestamp()},
'nodes': nodes,
Expand Down
1 change: 1 addition & 0 deletions reclass/datatypes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
from classes import Classes
from entity import Entity
from parameters import Parameters
from mutators import Mutators
14 changes: 11 additions & 3 deletions reclass/datatypes/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from classes import Classes
from applications import Applications
from parameters import Parameters
from mutators import Mutators

class Entity(object):
'''
Expand All @@ -17,13 +18,15 @@ class Entity(object):
uri of the Entity that is being merged.
'''
def __init__(self, classes=None, applications=None, parameters=None,
uri=None, name=None, environment=None):
uri=None, name=None, environment=None, mutators=None):
if classes is None: classes = Classes()
self._set_classes(classes)
if applications is None: applications = Applications()
self._set_applications(applications)
if parameters is None: parameters = Parameters()
self._set_parameters(parameters)
if mutators is None: mutators = Mutators()
self._set_mutators(mutators)
self._uri = uri or ''
self._name = name or ''
self._environment = environment or ''
Expand All @@ -34,6 +37,7 @@ def __init__(self, classes=None, applications=None, parameters=None,
classes = property(lambda s: s._classes)
applications = property(lambda s: s._applications)
parameters = property(lambda s: s._parameters)
mutators = property(lambda s: s._mutators)

def _set_classes(self, classes):
if not isinstance(classes, Classes):
Expand All @@ -53,13 +57,17 @@ def _set_parameters(self, parameters):
'instance of type %s' % type(parameters))
self._parameters = parameters

def expand_functions(self, inventory):
self.parameters.interpolate_functions(inventory)
def _set_mutators(self, mutators):
if not isinstance(mutators, Mutators):
raise TypeError('Entity.mutators cannot be set to '\
'instance of type %s' % type(mutators))
self._mutators = mutators

def merge(self, other):
self._classes.merge_unique(other._classes)
self._applications.merge_unique(other._applications)
self._parameters.merge(other._parameters)
self._mutators.push(other._mutators)
self._name = other.name
self._uri = other.uri
self._environment = other.environment
Expand Down
62 changes: 62 additions & 0 deletions reclass/datatypes/mutators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#
# -*- coding: utf-8 -*-
#
# This file is part of reclass (http://github.com/madduck/reclass)
#
# Copyright © 2007–14 martin f. krafft <[email protected]>
# Released under the terms of the Artistic Licence 2.0
#

from collections import deque

class Mutators(object):
'''
A class to maintain a push-only stack of python callables, with the following specialty:
+ If a callable is being pushed onto the stack which is already there,
it is removed from its previous position and placed back on top
Class also provides a static method for building lists of callables from iterables
of module paths
'''
@staticmethod
def load_callables(iterable):
callables = []
for path in iterable:
modname, callname = path.rsplit('.', 1)
module = __import__(modname, globals(), locals(), [callname], -1)
call = getattr(module, callname)
callables.append(call)
return callables
def __init__(self, iterable=None):
self._stack = deque()
if iterable is not None:
self.push(iterable)
def __repr__(self):
return '%s(%r)' % (self.__class__.__name__,
self.as_list())
def __len__(self):
return len(self._stack)
def __eq__(self, rhs):
if isinstance(rhs, deque):
return self._stack == rhs
if isinstance(rhs, list):
return list(self._stack) == rhs
else:
try:
return self._stack == rhs._stack
except AttributeError:
return False
def __ne__(self, rhs):
return not self.__eq__(rhs)
def push(self, iterable):
if isinstance(iterable, self.__class__):
iterable = iterable.as_list()
for item in iterable:
if item in self._stack:
self._stack.remove(item)
self._stack.appendleft(item)
def as_list(self):
return list(self._stack)
def as_deque(self):
return deque(self._stack)
50 changes: 17 additions & 33 deletions reclass/datatypes/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@

from reclass.defaults import PARAMETER_INTERPOLATION_DELIMITER
from reclass.utils.dictpath import DictPath
from reclass.utils.refvalue import (ReferenceStringParameter, ReferenceParameter,
ReferenceFunction, ReferenceStringFunction)
from reclass.errors import (InfiniteRecursionError, UndefinedVariableError,
UndefinedFunctionError)
from reclass.utils.refvalue import RefValue
from reclass.errors import InfiniteRecursionError, UndefinedVariableError

class Parameters(object):
'''
Expand Down Expand Up @@ -72,37 +70,37 @@ def as_dict(self):
return self._base.copy()

def _update_scalar(self, cur, new, path):
if isinstance(cur, ReferenceStringParameter) and path in self._occurrences:
# If the current value already holds a ReferenceStringParameter, we better forget
if isinstance(cur, RefValue) and path in self._occurrences:
# If the current value already holds a RefValue, we better forget
# the occurrence, or else interpolate() will later overwrite
# unconditionally. If the new value is a ReferenceStringParameter, the occurrence
# unconditionally. If the new value is a RefValue, the occurrence
# will be added again further on
del self._occurrences[path]

if self.delimiter is None or not isinstance(new, (types.StringTypes,
ReferenceStringParameter)):
RefValue)):
# either there is no delimiter defined (and hence no references
# are being used), or the new value is not a string (and hence
# cannot be turned into a ReferenceStringParameter), and not a ReferenceStringParameter. We can
# cannot be turned into a RefValue), and not a RefValue. We can
# shortcut and just return the new scalar
return new

elif isinstance(new, ReferenceStringParameter):
# the new value is (already) a ReferenceStringParameter, so we need not touch it
elif isinstance(new, RefValue):
# the new value is (already) a RefValue, so we need not touch it
# at all
ret = new

else:
# the new value is a string, let's see if it contains references,
# by way of wrapping it in a ReferenceStringParameter and querying the result
ret = ReferenceStringParameter(new, self.delimiter)
# by way of wrapping it in a RefValue and querying the result
ret = RefValue(new, self.delimiter)
if not ret.has_references():
# do not replace with ReferenceStringParameter instance if there are no
# references, i.e. discard the ReferenceStringParameter in ret, just return
# do not replace with RefValue instance if there are no
# references, i.e. discard the RefValue in ret, just return
# the new value
return new

# So we now have a ReferenceStringParameter. Let's, keep a reference to the instance
# So we now have a RefValue. Let's, keep a reference to the instance
# we just created, in a dict indexed by the dictionary path, instead
# of just a list. The keys are required to resolve dependencies during
# interpolation
Expand Down Expand Up @@ -173,21 +171,6 @@ def merge(self, other):
def has_unresolved_refs(self):
return len(self._occurrences) > 0

def interpolate_functions(self, inventory):
self._interpolate_functions_inner(self._base, inventory)

def _interpolate_functions_inner(self, node, inventory):
for k, v in node.items():
if isinstance(v, dict):
self._interpolate_functions_inner(node[k], inventory)
elif isinstance(v, types.StringTypes):
refval = ReferenceStringFunction(v)
if refval.has_references():
try:
node[k] = refval.render(inventory)
except UndefinedFunctionError as e:
raise UndefinedFunctionError(e.var)

def interpolate(self):
while self.has_unresolved_refs():
# we could use a view here, but this is simple enough:
Expand All @@ -199,7 +182,7 @@ def interpolate(self):
def _interpolate_inner(self, path, refvalue):
self._occurrences[path] = True # mark as seen
for ref in refvalue.get_references():
path_from_ref = DictPath(self.delimiter, ref.string)
path_from_ref = DictPath(self.delimiter, ref)
try:
refvalue_inner = self._occurrences[path_from_ref]

Expand All @@ -218,13 +201,14 @@ def _interpolate_inner(self, path, refvalue):
# Therefore, if we encounter True instead of a refvalue,
# it means that we have already processed it and are now
# faced with a cyclical reference.
raise InfiniteRecursionError(path, ref.string)
raise InfiniteRecursionError(path, ref)
self._interpolate_inner(path_from_ref, refvalue_inner)

except KeyError as e:
# not actually an error, but we are done resolving all
# dependencies of the current ref, so move on
continue

try:
new = refvalue.render(self._base)
path.set_value(self._base, new)
Expand Down
2 changes: 0 additions & 2 deletions reclass/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,3 @@

PARAMETER_INTERPOLATION_SENTINELS = ('${', '}')
PARAMETER_INTERPOLATION_DELIMITER = ':'

FUNCTION_INTERPOLATION_SENTINELS = ('$<', '>')
Loading

3 comments on commit fb12475

@bbinet
Copy link

@bbinet bbinet commented on fb12475 Feb 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain how to use mutators compared to function interpolation?

@lottspot
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function interpolation feature which was not merged differs from this approach on two key usage points:

  1. The definition of a function which could be used in interpolation needed to happen within the reclass source code itself. This approach is modular, allowing the functions to be defined within modules anywhere along PYTHONPATH.
  2. Mutators are not applied by performing an inline function call which is interpolated. Instead they are specified under their own datatype within a class definition file, and are passed the parameters defined by that class to change as they please.

There is a usage example available in the PR (which still needs some work done before it can be merged)

@bbinet
Copy link

@bbinet bbinet commented on fb12475 Feb 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your explanation, that makes sense.

Please sign in to comment.