Import of working code base

Signed-off-by: martin f. krafft <madduck@madduck.net>
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..169396b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.pyc
+/reclass-config.yml
diff --git a/adapters/ansible b/adapters/ansible
new file mode 100755
index 0000000..bb35e3f
--- /dev/null
+++ b/adapters/ansible
@@ -0,0 +1,81 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# ansible-adapter — adapter between Ansible and reclass
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import os, sys, posix, stat
+
+def usage_error(msg):
+    print >>sys.stderr, msg
+    sys.exit(posix.EX_USAGE)
+
+if len(sys.argv) == 1:
+    usage_error('Need to specify --list or --host.')
+
+ansible_dir = os.path.dirname(sys.argv[0])
+
+# In order to be able to use reclass as modules, manipulate the search path,
+# starting from the location of the adapter. Realpath will make sure that
+# symlinks are resolved.
+realpath = os.path.realpath(sys.argv[0] + '/../../')
+sys.path.insert(0, realpath)
+import reclass, config
+
+# The adapter resides in the Ansible directory, so let's look there for an
+# optional configuration file called reclass-config.yml.
+options = {'output':'json', 'pretty_print':True}
+config_path = os.path.join(ansible_dir, 'reclass-config.yml')
+if os.path.exists(config_path) and os.access(config_path, os.R_OK):
+    options.update(config.read_config_file(config_path))
+
+# Massage options into shape
+if 'storage_type' not in options:
+    options['storage_type'] = 'yaml_fs'
+
+if 'nodes_uri' not in options:
+    nodes_uri = os.path.join(ansible_dir, 'nodes')
+    if stat.S_ISDIR(os.stat(nodes_uri).st_mode):
+        options['nodes_uri'] = nodes_uri
+    else:
+        usage_error('nodes_uri not specified')
+
+if 'classes_uri' not in options:
+    classes_uri = os.path.join(ansible_dir, 'classes')
+    if not stat.S_ISDIR(os.stat(classes_uri).st_mode):
+        classes_uri = options['nodes_uri']
+    options['classes_uri'] = classes_uri
+
+# Invoke reclass according to what Ansible wants.
+# If the 'node' option is set, we want node information. If the option is
+# False instead, we print the inventory. Yeah for option abuse!
+if sys.argv[1] == '--list':
+    if len(sys.argv) > 2:
+        usage_error('Unknown arguments: ' + ' '.join(sys.argv[2:]))
+    options['node'] = False
+
+elif sys.argv[1] == '--host':
+    if len(sys.argv) < 3:
+        usage_error('Missing hostname.')
+    elif len(sys.argv) > 3:
+        usage_error('Unknown arguments: ' + ' '.join(sys.argv[3:]))
+    options['node'] = sys.argv[2]
+
+else:
+    usage_error('Unknown mode (--list or --host required).')
+
+data = reclass.get_data(options['storage_type'], options['nodes_uri'],
+                        options['classes_uri'], options['node'])
+
+if options['node']:
+    # Massage and shift the data like Ansible wants it
+    data['parameters']['RECLASS'] = data['RECLASS']
+    for i in ('classes', 'applications'):
+        data['parameters']['RECLASS'][i] = data[i]
+    data = data['parameters']
+
+print reclass.output(data, options['output'], options['pretty_print'])
+
+sys.exit(posix.EX_OK)
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..58c29c5
--- /dev/null
+++ b/config.py
@@ -0,0 +1,81 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import yaml, os, optparse, posix, sys
+
+def _make_parser(name, version, description, defaults={}):
+    parser = optparse.OptionParser(version=version)
+    parser.prog = name
+    parser.version = version
+    parser.description = description
+    parser.usage = '%prog [options] ( --inventory | --nodeinfo <nodename> )'
+
+    options_group = optparse.OptionGroup(parser, 'Options',
+                                         'Configure the way {0} works'.format(name))
+    options_group.add_option('-t', '--storage-type', dest='storage_type',
+                             default=defaults.get('storage_type', 'yaml_fs'),
+                             help='the type of storage backend to use [%default]')
+    options_group.add_option('-u', '--nodes-uri', dest='nodes_uri',
+                             default=defaults.get('nodes_uri', None),
+                             help='the URI to the nodes storage [%default]'),
+    options_group.add_option('-c', '--classes-uri', dest='classes_uri',
+                             default=defaults.get('classes_uri', None),
+                             help='the URI to the classes storage [%default]')
+    options_group.add_option('-o', '--output', dest='output',
+                             default=defaults.get('output', 'yaml'),
+                             help='output format (yaml or json) [%default]')
+    options_group.add_option('-p', '--pretty-print', dest='pretty_print',
+                             default=defaults.get('pretty_print', False),
+                             action="store_true",
+                             help='try to make the output prettier [%default]')
+    parser.add_option_group(options_group)
+
+    run_modes = optparse.OptionGroup(parser, 'Modes',
+                                     'Specify one of these to determine what to do.')
+    run_modes.add_option('-i', '--inventory', action='store_false', dest='node',
+                         help='output the entire inventory')
+    run_modes.add_option('-n', '--nodeinfo', action='store', dest='node',
+                         default=None,
+                         help='output information for a specific node')
+    parser.add_option_group(run_modes)
+
+    return parser
+
+def _parse_and_check_options(parser):
+    options, args = parser.parse_args()
+
+    def usage_error(msg):
+        sys.stderr.write(msg + '\n\n')
+        parser.print_help(sys.stderr)
+        sys.exit(posix.EX_USAGE)
+
+    if len(args) > 0:
+        usage_error('No arguments allowed')
+    elif options.node is None:
+        usage_error('You need to either pass --inventory or --nodeinfo <nodename>')
+    elif options.output not in ('json', 'yaml'):
+        usage_error('Unknown output format: {0}'.format(options.output))
+    elif options.nodes_uri is None:
+        usage_error('Must specify at least --nodes-uri')
+
+    options.classes_uri = options.classes_uri or options.nodes_uri
+
+    return options
+
+def read_config_file(path):
+    if os.path.exists(path):
+        return yaml.safe_load(file(path))
+    else:
+        return {}
+
+def get_options(name, version, description, config_file=None):
+    config_data = {}
+    if config_file is not None:
+        config_data.update(read_config_file(config_file))
+    parser = _make_parser(name, version, description, config_data)
+    return _parse_and_check_options(parser)
diff --git a/datatypes/__init__.py b/datatypes/__init__.py
new file mode 100644
index 0000000..919ea86
--- /dev/null
+++ b/datatypes/__init__.py
@@ -0,0 +1,12 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from applications import Applications
+from classes import Classes
+from entity import Entity
+from parameters import Parameters
diff --git a/datatypes/applications.py b/datatypes/applications.py
new file mode 100644
index 0000000..0a8f402
--- /dev/null
+++ b/datatypes/applications.py
@@ -0,0 +1,40 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.list import SetExtend
+
+class ApplicationsMerger(SetExtend):
+
+    def __init__(self, negater='~'):
+        self._negater=negater
+
+    def _combine(self, first, second):
+        for i in second:
+            remove = False
+            if i.startswith('~'):
+                i = i[1:]
+                remove = True
+            if i not in first:
+                if not remove:
+                    first.append(i)
+            elif remove:
+                first.remove(i)
+        return first
+
+
+class Applications(list):
+
+    def __init__(self, *args, **kwargs):
+        super(Applications, self).__init__(*args, **kwargs)
+
+    def merge(self, other, negater='~'):
+        merger = ApplicationsMerger(negater)
+        self[:] = merger.merge(self, other)
+
+    def __repr__(self):
+        return '<Applications {0}>'.format(super(Applications, self).__repr__())
diff --git a/datatypes/classes.py b/datatypes/classes.py
new file mode 100644
index 0000000..facaf93
--- /dev/null
+++ b/datatypes/classes.py
@@ -0,0 +1,21 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.list import SetExtend
+
+class Classes(list):
+
+    def __init__(self, *args, **kwargs):
+        super(Classes, self).__init__(*args, **kwargs)
+
+    def merge(self, other):
+        merger = SetExtend()
+        self[:] = merger.merge(self, other)
+
+    def __repr__(self):
+        return '<Classes {0}>'.format(super(Classes, self).__repr__())
diff --git a/datatypes/entity.py b/datatypes/entity.py
new file mode 100644
index 0000000..e493c8b
--- /dev/null
+++ b/datatypes/entity.py
@@ -0,0 +1,32 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from classes import Classes
+from parameters import Parameters
+from applications import Applications
+
+class Entity(object):
+
+    def __init__(self, classes=Classes(), parameters=Parameters(),
+                 applications=Applications()):
+        self._applications = applications
+        self._classes = classes
+        self._parameters = parameters
+
+    applications = property(lambda self: self._applications)
+    classes = property(lambda self: self._classes)
+    parameters = property(lambda self: self._parameters)
+
+    def merge(self, other):
+        self.applications.merge(other.applications)
+        self.classes.merge(other.classes)
+        self.parameters.merge(other.parameters)
+
+    def __repr__(self):
+        return '<Entity classes:{0} parameters:{1} applications:{2}>'.format(
+            len(self.classes), len(self.parameters), len(self.applications))
diff --git a/datatypes/parameters.py b/datatypes/parameters.py
new file mode 100644
index 0000000..d5c25d6
--- /dev/null
+++ b/datatypes/parameters.py
@@ -0,0 +1,20 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.dict import DictRecursivePolicyUpdate
+
+class Parameters(dict):
+
+    def __init__(self, *args, **kwargs):
+        super(Parameters, self).__init__(*args, **kwargs)
+
+    def merge(self, other, merger=DictRecursivePolicyUpdate()):
+        self.update(merger.merge(self, other))
+
+    def __repr__(self):
+        return '<Parameters {0}>'.format(super(Parameters, self).__repr__())
diff --git a/datatypes/tests/test_applications.py b/datatypes/tests/test_applications.py
new file mode 100644
index 0000000..b18c07e
--- /dev/null
+++ b/datatypes/tests/test_applications.py
@@ -0,0 +1,57 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from datatypes import Applications
+
+class TestApplications:
+
+    def test_constructor0(self):
+        c = Applications()
+        assert len(c) == 0
+
+    def test_constructor1(self):
+        DATA = ['one', 'two', 'three', 'four']
+        c = Applications(DATA)
+        assert len(c) == len(DATA)
+        for i in range(0, len(c)):
+            assert DATA[i] == c[i]
+
+    def test_merge(self):
+        DATA0 = ['one', 'two', 'three', 'four']
+        DATA1 = ['one', 'three', 'five', 'seven']
+        c = Applications(DATA0)
+        c.merge(DATA1)
+        assert len(c) == 6
+        assert c[:4] == DATA0
+        assert c[4] == DATA1[2]
+        assert c[5] == DATA1[3]
+
+    def test_merge_negate(self):
+        negater = '~'
+        DATA0 = ['red', 'green', 'blue']
+        DATA1 = [negater + 'red', 'yellow', 'black']
+        c = Applications(DATA0)
+        c.merge(DATA1, negater)
+        assert len(c) == len(DATA0)-1 + len(DATA1)-1
+        assert c[0] == DATA0[1]
+        assert c[1] == DATA0[2]
+        assert c[2] == DATA1[1]
+        assert c[3] == DATA1[2]
+
+    def test_merge_negate_default(self):
+        c = Applications(['red'])
+        c.merge(['~red'])
+        assert len(c) == 0
+
+    def test_merge_negate_nonexistent(self):
+        c = Applications(['blue'])
+        c.merge(['~red'])
+        assert len(c) == 1
+        assert 'red' not in c
+        assert '~red' not in c
+        assert 'blue' in c
diff --git a/datatypes/tests/test_classes.py b/datatypes/tests/test_classes.py
new file mode 100644
index 0000000..deca77b
--- /dev/null
+++ b/datatypes/tests/test_classes.py
@@ -0,0 +1,32 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from datatypes import Classes
+
+class TestClasses:
+
+    def test_constructor0(self):
+        c = Classes()
+        assert len(c) == 0
+
+    def test_constructor1(self):
+        DATA = ['one', 'two', 'three', 'four']
+        c = Classes(DATA)
+        assert len(c) == len(DATA)
+        for i in range(0, len(c)):
+            assert DATA[i] == c[i]
+
+    def test_merge(self):
+        DATA0 = ['one', 'two', 'three', 'four']
+        DATA1 = ['one', 'three', 'five', 'seven']
+        c = Classes(DATA0)
+        c.merge(DATA1)
+        assert len(c) == 6
+        assert c[:4] == DATA0
+        assert c[4] == DATA1[2]
+        assert c[5] == DATA1[3]
diff --git a/datatypes/tests/test_entity.py b/datatypes/tests/test_entity.py
new file mode 100644
index 0000000..1b1ac7f
--- /dev/null
+++ b/datatypes/tests/test_entity.py
@@ -0,0 +1,29 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from datatypes import Entity, Classes, Parameters, Applications
+
+class TestEntity:
+
+    def test_constructor0(self):
+        e = Entity()
+        assert isinstance(e.classes, Classes)
+        assert len(e.classes) == 0
+        assert isinstance(e.parameters, Parameters)
+        assert len(e.parameters) == 0
+        assert isinstance(e.applications, Applications)
+        assert len(e.applications) == 0
+
+    def test_constructor1(self):
+        c = Classes(['one', 'two'])
+        p = Parameters({'blue':'white', 'black':'yellow'})
+        a = Applications(['three', 'four'])
+        e = Entity(c, p, a)
+        assert e.classes == c
+        assert e.parameters == p
+        assert e.applications == a
diff --git a/datatypes/tests/test_parameters.py b/datatypes/tests/test_parameters.py
new file mode 100644
index 0000000..e6efbb8
--- /dev/null
+++ b/datatypes/tests/test_parameters.py
@@ -0,0 +1,22 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from datatypes import Parameters
+
+class TestParameters:
+
+    def test_constructor0(self):
+        c = Parameters()
+        assert len(c) == 0
+
+    def test_constructor1(self):
+        DATA = {'blue':'white', 'black':'yellow'}
+        c = Parameters(DATA)
+        assert len(c) == len(DATA)
+        for i in c.iterkeys():
+            assert DATA[i] == c[i]
diff --git a/mergers/__init__.py b/mergers/__init__.py
new file mode 100644
index 0000000..ada8bd8
--- /dev/null
+++ b/mergers/__init__.py
@@ -0,0 +1,9 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+
diff --git a/mergers/base.py b/mergers/base.py
new file mode 100644
index 0000000..b9a23dc
--- /dev/null
+++ b/mergers/base.py
@@ -0,0 +1,12 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+class BaseMerger(object):
+
+    def merge(self, first, second):
+        raise NotImplementedError
diff --git a/mergers/dict.py b/mergers/dict.py
new file mode 100644
index 0000000..77f7239
--- /dev/null
+++ b/mergers/dict.py
@@ -0,0 +1,10 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from update import Update
+from recursive_extend import RecursiveExtend
diff --git a/mergers/dict/__init__.py b/mergers/dict/__init__.py
new file mode 100644
index 0000000..8346017
--- /dev/null
+++ b/mergers/dict/__init__.py
@@ -0,0 +1,11 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from update import DictUpdate
+from recursive_update import DictRecursiveUpdate
+from recursive_policy_update import DictRecursivePolicyUpdate
diff --git a/mergers/dict/base.py b/mergers/dict/base.py
new file mode 100644
index 0000000..92fdd1b
--- /dev/null
+++ b/mergers/dict/base.py
@@ -0,0 +1,12 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.base import BaseMerger
+
+class BaseDictMerger(BaseMerger):
+    pass
diff --git a/mergers/dict/recursive_policy_update.py b/mergers/dict/recursive_policy_update.py
new file mode 100644
index 0000000..f3bcb3e
--- /dev/null
+++ b/mergers/dict/recursive_policy_update.py
@@ -0,0 +1,32 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from base import BaseDictMerger
+
+class DictRecursivePolicyUpdate(BaseDictMerger):
+
+    def __init__(self, policy=None):
+        super(DictRecursivePolicyUpdate, self).__init__()
+        if policy is None:
+            policy = {(dict,dict) : self.merge,
+                      (list,list) : lambda x,y: x+y,
+                      (dict,list) : lambda x,y: self.merge(x, dict(y)),
+                      None        : lambda x,y: y
+                     }
+        self._policy = policy
+
+    def merge(self, first, second):
+        ret = first.copy()
+        for k,v in second.iteritems():
+            if k in ret:
+                pfn = self._policy.get((type(ret[k]), type(v)),
+                                       self._policy.get(None))
+                ret[k] = pfn(ret[k], v)
+            else:
+                ret[k] = v
+        return ret
diff --git a/mergers/dict/recursive_update.py b/mergers/dict/recursive_update.py
new file mode 100644
index 0000000..ba94c1f
--- /dev/null
+++ b/mergers/dict/recursive_update.py
@@ -0,0 +1,25 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from base import BaseDictMerger
+
+class DictRecursiveUpdate(BaseDictMerger):
+
+    def merge(self, first, second):
+        ret = first.copy()
+        for k,v in second.iteritems():
+            if k in ret:
+                if isinstance(ret[k], dict):
+                    if isinstance(v, (list, tuple)):
+                        v = dict(v)
+                    ret[k] = self.merge(ret[k], v)
+                else:
+                    ret[k] = v
+            else:
+                ret[k] = v
+        return ret
diff --git a/mergers/dict/tests/test_recursive_policy_update.py b/mergers/dict/tests/test_recursive_policy_update.py
new file mode 100644
index 0000000..f44ada6
--- /dev/null
+++ b/mergers/dict/tests/test_recursive_policy_update.py
@@ -0,0 +1,26 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from test_recursive_update import TestDictRecursiveUpdate
+from mergers.dict import DictRecursivePolicyUpdate
+
+class TestDictRecursivePolicyUpdate(TestDictRecursiveUpdate):
+
+    def setUp(self):
+        self.merger = DictRecursivePolicyUpdate()
+
+    def test_nested_lists_extend(self):
+        first = {'one': [1,2],
+                 'two': {'one': [1,2]}}
+        second = {'one': [3,4], 
+                  'two': {'one': [3,4]}}
+        ret = self.merger.merge(first, second)
+        assert len(ret['one']) == 4
+        assert ret['one'][2] == 3
+        assert len(ret['two']['one']) == 4
+        assert ret['two']['one'][3] == 4
diff --git a/mergers/dict/tests/test_recursive_update.py b/mergers/dict/tests/test_recursive_update.py
new file mode 100644
index 0000000..c1e8501
--- /dev/null
+++ b/mergers/dict/tests/test_recursive_update.py
@@ -0,0 +1,45 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from test_update import TestDictUpdate
+from mergers.dict import DictRecursiveUpdate
+
+class TestDictRecursiveUpdate(TestDictUpdate):
+
+    def setUp(self):
+        self.merger = DictRecursiveUpdate()
+
+    def test_simple_recursive_dict_update(self):
+        first = {'one':{1:1,2:3,3:2}}
+        second = {'one':{2:2,3:3,4:4}}
+        ret = self.merger.merge(first, second)
+        assert len(ret) == 1
+        for k,v in ret['one'].iteritems():
+            assert k == v
+
+    def test_complex_recursive_dict_update(self):
+        first = {'one': 1,
+                 'two': {'a':92,'b':94},
+                 'three': {'third':0.33,'two thirds':0.67},
+                 'four': {1:{1:1},2:{2:2},3:{3:4}}
+                }
+        second = {'five': 5,
+                  'one': 1,
+                  'two': {'b':93,'c':94},
+                  'four': {4:{4:4}, 3:{3:3}},
+                 }
+        ret = self.merger.merge(first, second)
+        assert ret['one'] == 1
+        assert len(ret['two']) == 3
+        assert ret['two']['b'] == 93
+        assert len(ret['three']) == 2
+        assert len(ret['four']) == 4
+        for i in range(1,4):
+            assert len(ret['four'][i]) == 1
+            for k,v in ret['four'][i].iteritems():
+                assert k == v
diff --git a/mergers/dict/tests/test_update.py b/mergers/dict/tests/test_update.py
new file mode 100644
index 0000000..8e35c53
--- /dev/null
+++ b/mergers/dict/tests/test_update.py
@@ -0,0 +1,22 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.dict import DictUpdate
+
+class TestDictUpdate:
+
+    def setUp(self):
+        self.merger = DictUpdate()
+
+    def test_dict_update(self):
+        first = {1:1,2:3,3:2}
+        second = {2:2,3:3,4:4}
+        ret = self.merger.merge(first, second)
+        assert len(ret) == 4
+        for k,v in ret.iteritems():
+            assert k == v
diff --git a/mergers/dict/update.py b/mergers/dict/update.py
new file mode 100644
index 0000000..dd834f0
--- /dev/null
+++ b/mergers/dict/update.py
@@ -0,0 +1,16 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from base import BaseDictMerger
+
+class DictUpdate(BaseDictMerger):
+
+    def merge(self, first, second):
+        ret = first.copy()
+        ret.update(second)
+        return ret
diff --git a/mergers/list.py b/mergers/list.py
new file mode 100644
index 0000000..5885147
--- /dev/null
+++ b/mergers/list.py
@@ -0,0 +1,9 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from list import Extend
diff --git a/mergers/list/__init__.py b/mergers/list/__init__.py
new file mode 100644
index 0000000..51ee818
--- /dev/null
+++ b/mergers/list/__init__.py
@@ -0,0 +1,10 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from extend import ListExtend
+from set import SetExtend
diff --git a/mergers/list/base.py b/mergers/list/base.py
new file mode 100644
index 0000000..a7bbc17
--- /dev/null
+++ b/mergers/list/base.py
@@ -0,0 +1,16 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.base import BaseMerger
+
+class BaseListMerger(BaseMerger):
+
+    def merge(self, first, second):
+        first = [first] if not isinstance(first, list) else first[:]
+        second = [second] if not isinstance(second, list) else second[:]
+        return self._combine(first, second)
diff --git a/mergers/list/extend.py b/mergers/list/extend.py
new file mode 100644
index 0000000..f4acc35
--- /dev/null
+++ b/mergers/list/extend.py
@@ -0,0 +1,18 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from base import BaseListMerger
+
+class ListExtend(BaseListMerger):
+
+    def _combine(self, first, second):
+        if isinstance(second, list):
+            first.extend(second)
+        else:
+            first.append(second)
+        return first
diff --git a/mergers/list/set.py b/mergers/list/set.py
new file mode 100644
index 0000000..ddb0eb4
--- /dev/null
+++ b/mergers/list/set.py
@@ -0,0 +1,17 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from extend import BaseListMerger
+
+class SetExtend(BaseListMerger):
+
+    def _combine(self, first, second):
+        for i in second:
+            if i not in first:
+                first.append(i)
+        return first
diff --git a/mergers/list/tests/test_extend.py b/mergers/list/tests/test_extend.py
new file mode 100644
index 0000000..bdc40ca
--- /dev/null
+++ b/mergers/list/tests/test_extend.py
@@ -0,0 +1,66 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from mergers.list import ListExtend
+
+class TestListExtend:
+
+    def setUp(self):
+        self.merger = ListExtend()
+
+    def _test_merge(self, one, two, target):
+        res = self.merger.merge(one, two)
+        print res, target
+        return res == target
+
+    def test_merge_scalars(self):
+        assert self._test_merge(1, 2, [1,2])
+
+    def test_merge_tuples(self):
+        t1 = (1,2,3)
+        t2 = (6,5,4)
+        target = [t1, t2]
+        assert self._test_merge(t1, t2, target)
+
+    def test_merge_lists(self):
+        l1 = [1,2,3]
+        l2 = [6,5,4]
+        target = l1 + l2
+        assert self._test_merge(l1, l2, target)
+
+    def test_merge_scalar_tuple(self):
+        s = 'one'
+        t = (2,3)
+        target = [s, t]
+        assert self._test_merge(s, t, target)
+
+    def test_merge_scalar_list(self):
+        s = 'foo'
+        l = [1,2,3]
+        target = [s]
+        target.extend(l)
+        assert self._test_merge(s, l, target)
+
+    def test_merge_list_scalar(self):
+        l = [1,2,3]
+        s = 'bar'
+        target = l[:]
+        target.append(s)
+        assert self._test_merge(l, s, target)
+
+    def test_merge_duplicates_scalar(self):
+        s1 = 2
+        s2 = 2
+        target = [2,2]
+        assert self._test_merge(s1, s2, target)
+
+    def test_merge_duplicates_list(self):
+        l1 = [1,2,3]
+        l2 = [3,2,1]
+        target = l1 + l2
+        assert self._test_merge(l1, l2, target)
diff --git a/mergers/list/tests/test_set.py b/mergers/list/tests/test_set.py
new file mode 100644
index 0000000..d3e8183
--- /dev/null
+++ b/mergers/list/tests/test_set.py
@@ -0,0 +1,27 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from test_extend import TestListExtend
+from mergers.list import SetExtend
+
+class TestSetExtend(TestListExtend):
+
+    def setUp(self):
+        self.merger = SetExtend()
+
+    def test_merge_duplicates_scalar(self):
+        s1 = 2
+        s2 = 2
+        target = [2]
+        assert self._test_merge(s1, s2, target)
+
+    def test_merge_duplicates_list(self):
+        l1 = [1,2,3]
+        l2 = [3,2,1]
+        target = l1
+        assert self._test_merge(l1, l2, target)
diff --git a/mergers/recursive_extend.py b/mergers/recursive_extend.py
new file mode 100644
index 0000000..f8487af
--- /dev/null
+++ b/mergers/recursive_extend.py
@@ -0,0 +1,14 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from base import BaseMerger
+
+class RecursiveExtend(BaseMerger):
+
+    def merge(self, first, second):
+        for
diff --git a/output/__init__.py b/output/__init__.py
new file mode 100644
index 0000000..75f6f4a
--- /dev/null
+++ b/output/__init__.py
@@ -0,0 +1,33 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+class OutputterBase(object):
+
+    def __init__(self):
+        pass
+
+    def dump(self, data, pretty_print=False):
+        raise NotImplementedError, "dump() method not yet implemented"
+
+
+class OutputLoader(object):
+
+    def __init__(self, outputter):
+        self._name = outputter + '_outputter'
+        try:
+            self._module = __import__(self._name, globals(), locals(),
+                                      self._name)
+        except ImportError:
+            raise NotImplementedError
+
+    def load(self, attr='Outputter'):
+        klass = getattr(self._module, attr, None)
+        if klass is None:
+            raise AttributeError, \
+                'Outputter class {0} does not export "{1}"'.format(self._name, klass)
+        return klass
diff --git a/output/json_outputter.py b/output/json_outputter.py
new file mode 100644
index 0000000..25fa8bc
--- /dev/null
+++ b/output/json_outputter.py
@@ -0,0 +1,17 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from output import OutputterBase
+import json
+
+class Outputter(OutputterBase):
+
+    def dump(self, data, pretty_print=False):
+        separators = (',', ': ') if pretty_print else (',', ':')
+        indent = 2 if pretty_print else None
+        return json.dumps(data, indent=indent, separators=separators)
diff --git a/output/yaml_outputter.py b/output/yaml_outputter.py
new file mode 100644
index 0000000..493f5cb
--- /dev/null
+++ b/output/yaml_outputter.py
@@ -0,0 +1,15 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from output import OutputterBase
+import yaml
+
+class Outputter(OutputterBase):
+
+    def dump(self, data, pretty_print=False):
+        return yaml.dump(data, default_flow_style=not pretty_print)
diff --git a/reclass.py b/reclass.py
new file mode 100755
index 0000000..f0c24ce
--- /dev/null
+++ b/reclass.py
@@ -0,0 +1,53 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# reclass — recursive external node classifier
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+__prog__ = 'reclass'
+__description__ = 'classify nodes based on an external data source'
+__version__ = '1.0'
+__author__ = 'martin f. krafft <madduck@madduck.net>'
+__copyright__ = 'Copyright © 2007–13 ' + __author__
+__licence__ = 'Artistic Licence 2.0'
+
+import sys, os, posix, time
+import config
+from output import OutputLoader
+from storage import StorageBackendLoader
+
+def get_options(config_file=None):
+    return config.get_options(__name__, __version__, __description__, config_file)
+
+def get_data(storage_type, nodes_uri, classes_uri, node):
+    storage_class = StorageBackendLoader(storage_type).load()
+    storage = storage_class(os.path.abspath(os.path.expanduser(nodes_uri)),
+                            os.path.abspath(os.path.expanduser(classes_uri)))
+    if node is False:
+        ret = storage.inventory()
+    else:
+        ret = storage.nodeinfo(node)
+        ret['RECLASS']['timestamp'] = time.strftime('%c')
+
+    return ret
+
+def output(data, fmt, pretty_print=False):
+    output_class = OutputLoader(fmt).load()
+    outputter = output_class()
+    return outputter.dump(data, pretty_print=pretty_print)
+
+if __name__ == '__main__':
+    __name__ = __prog__
+    config_file = None
+    for d in (os.getcwd(), os.path.dirname(sys.argv[0])):
+        f = os.path.join(d, __name__ + '-config.yml')
+        if os.access(f, os.R_OK):
+            config_file = f
+            break
+    options = get_options(config_file)
+    data = get_data(options.storage_type, options.nodes_uri,
+                    options.classes_uri, options.node)
+    print output(data, options.output, options.pretty_print)
+    sys.exit(posix.EX_OK)
diff --git a/run_tests.py b/run_tests.py
new file mode 100755
index 0000000..7658f91
--- /dev/null
+++ b/run_tests.py
@@ -0,0 +1,10 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import nose
+nose.main()
diff --git a/storage/__init__.py b/storage/__init__.py
new file mode 100644
index 0000000..0613bfe
--- /dev/null
+++ b/storage/__init__.py
@@ -0,0 +1,52 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+class NodeStorageBase(object):
+
+    def __init__(self, nodes_uri, classes_uri):
+        self._nodes_uri = nodes_uri
+        self._classes_uri = classes_uri
+
+    nodes_uri = property(lambda self: self._nodes_uri)
+    classes_uri = property(lambda self: self._classes_uri)
+
+    def _read_entity(self, node, base_uri, seen={}):
+        raise NotImplementedError, "Storage class not implement node info retrieval"
+
+    def nodeinfo(self, node):
+        entity, uri = self._read_nodeinfo(node, self.nodes_uri, {})
+        return {'RECLASS' : {'node': node, 'node_uri': uri},
+                'classes': list(entity.classes),
+                'applications': list(entity.applications),
+                'parameters': dict(entity.parameters)
+               }
+
+    def _list_inventory(self):
+        raise NotImplementedError, "Storage class does not implement inventory listing"
+
+    def inventory(self):
+        entity, applications, classes = self._list_inventory()
+        ret = classes
+        ret.update([(k + '_hosts',v) for k,v in applications.iteritems()])
+        return ret
+
+class StorageBackendLoader(object):
+
+    def __init__(self, storage_type):
+        self._name = storage_type
+        try:
+            self._module = __import__(storage_type, globals(), locals(), storage_type)
+        except ImportError:
+            raise NotImplementedError
+
+    def load(self, attr='ExternalNodeStorage'):
+        klass = getattr(self._module, attr, None)
+        if klass is None:
+            raise AttributeError, \
+                'Storage backend class {0} does not export "{1}"'.format(self._name, klass)
+        return klass
diff --git a/storage/yaml_fs/__init__.py b/storage/yaml_fs/__init__.py
new file mode 100644
index 0000000..0eff9ed
--- /dev/null
+++ b/storage/yaml_fs/__init__.py
@@ -0,0 +1,59 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import os
+from storage import NodeStorageBase
+from yamlfile import YamlFile
+from directory import Directory
+
+FILE_EXTENSION = '.yml'
+
+class ExternalNodeStorage(NodeStorageBase):
+
+    def __init__(self, nodes_uri, classes_uri):
+        super(ExternalNodeStorage, self).__init__(nodes_uri, classes_uri)
+
+    def _read_nodeinfo(self, name, base_uri, seen):
+        path = os.path.join(base_uri, name + FILE_EXTENSION)
+        entity = YamlFile(path).entity
+        seen[name] = True
+        for klass in entity.classes:
+            if klass not in seen:
+                ret = self._read_nodeinfo(klass, self.classes_uri, seen)[0]
+                ret.merge(entity)
+                entity = ret
+        return entity, path
+
+    def _list_inventory(self):
+        d = Directory(self.nodes_uri)
+
+        entities = {}
+
+        def register_fn(dirpath, filenames):
+            for f in filter(lambda f: f.endswith(FILE_EXTENSION), filenames):
+                name = f[:-len(FILE_EXTENSION)]
+                nodeinfo = self.nodeinfo(name)
+                entities[name] = nodeinfo
+
+        d.walk(register_fn)
+
+        applications = {}
+        classes = {}
+        for f, nodeinfo in entities.iteritems():
+            for a in nodeinfo['applications']:
+                if a in applications:
+                    applications[a].append(f)
+                else:
+                    applications[a] = [f]
+            for c in nodeinfo['classes']:
+                if c in classes:
+                    classes[c].append(f)
+                else:
+                    classes[c] = [f]
+
+        return entities, applications, classes
diff --git a/storage/yaml_fs/directory.py b/storage/yaml_fs/directory.py
new file mode 100644
index 0000000..c085731
--- /dev/null
+++ b/storage/yaml_fs/directory.py
@@ -0,0 +1,53 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import os
+import sys
+
+SKIPDIRS = ( '.git' , '.svn' , 'CVS', 'SCCS', '.hg', '_darcs' )
+FILE_EXTENSION = '.yml'
+
+def vvv(msg):
+    #print >>sys.stderr, msg
+    pass
+
+class Directory(object):
+
+    def __init__(self, path, fileclass=None):
+        ''' Initialise a directory object '''
+        self._path = path
+        self._fileclass = fileclass
+        self._files = {}
+
+    def _register_files(self, dirpath, filenames):
+        for f in filter(lambda f: f.endswith(FILE_EXTENSION), filenames):
+            vvv('REGISTER {0}'.format(f))
+            f = os.path.join(dirpath, f)
+            ptr = None if not self._fileclass else self._fileclass(f)
+            self._files[f] = ptr
+
+    files = property(lambda self: self._files)
+
+    def walk(self, register_fn=None):
+        def _error(error):
+            raise Exception('{0}: {1} ({2})'.format(error.filename, error.strerror, error.errno))
+        if not callable(register_fn): register_fn = self._register_files
+        for dirpath, dirnames, filenames in os.walk(self._path,
+                                                      topdown=True,
+                                                      onerror=_error,
+                                                      followlinks=True):
+            vvv('RECURSE {0}, {1} files, {2} subdirectories'.format(
+                dirpath.replace(os.getcwd(), '.'), len(filenames), len(dirnames)))
+            for d in SKIPDIRS:
+                if d in dirnames:
+                    vvv('   SKIP subdirectory {0}'.format(d))
+                    dirnames.remove(d)
+            register_fn(dirpath, filenames)
+
+    def __repr__(self):
+        return '<{0} {1}>'.format(self.__class__.__name__, self._path)
diff --git a/storage/yaml_fs/tests/classes/basenode.yml b/storage/yaml_fs/tests/classes/basenode.yml
new file mode 100644
index 0000000..d9d997a
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/basenode.yml
@@ -0,0 +1,5 @@
+applications:
+- motd
+- firewall
+parameters:
+  realm: madduck.net
diff --git a/storage/yaml_fs/tests/classes/debiannode.yml b/storage/yaml_fs/tests/classes/debiannode.yml
new file mode 100644
index 0000000..43e35e4
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/debiannode.yml
@@ -0,0 +1,4 @@
+classes:
+- basenode
+applications:
+- apt
diff --git a/storage/yaml_fs/tests/classes/debiannode@sid.yml b/storage/yaml_fs/tests/classes/debiannode@sid.yml
new file mode 100644
index 0000000..806612a
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/debiannode@sid.yml
@@ -0,0 +1,4 @@
+classes:
+- debiannode
+parameters:
+  debian_codename: sid
diff --git a/storage/yaml_fs/tests/classes/debiannode@squeeze.yml b/storage/yaml_fs/tests/classes/debiannode@squeeze.yml
new file mode 100644
index 0000000..08075b5
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/debiannode@squeeze.yml
@@ -0,0 +1,4 @@
+classes:
+- debiannode
+parameters:
+  debian_codename: squeeze
diff --git a/storage/yaml_fs/tests/classes/debiannode@wheezy.yml b/storage/yaml_fs/tests/classes/debiannode@wheezy.yml
new file mode 100644
index 0000000..5ced15a
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/debiannode@wheezy.yml
@@ -0,0 +1,4 @@
+classes:
+- debiannode
+parameters:
+  debian_codename: wheezy
diff --git a/storage/yaml_fs/tests/classes/hosted@munich.yml b/storage/yaml_fs/tests/classes/hosted@munich.yml
new file mode 100644
index 0000000..1d14dad
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/hosted@munich.yml
@@ -0,0 +1,4 @@
+parameters:
+  location: munich
+  apt:
+    mirror_base: uni-erlangen
diff --git a/storage/yaml_fs/tests/classes/hosted@zurich.yml b/storage/yaml_fs/tests/classes/hosted@zurich.yml
new file mode 100644
index 0000000..050af27
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/hosted@zurich.yml
@@ -0,0 +1,4 @@
+parameters:
+  location: zurich
+  apt:
+    mirror_base: switch
diff --git a/storage/yaml_fs/tests/classes/mailserver.yml b/storage/yaml_fs/tests/classes/mailserver.yml
new file mode 100644
index 0000000..37d6673
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/mailserver.yml
@@ -0,0 +1,5 @@
+applications:
+- postfix
+parameters:
+  firewall:
+    openport: 25/tcp
diff --git a/storage/yaml_fs/tests/classes/subdir/subclass.yml b/storage/yaml_fs/tests/classes/subdir/subclass.yml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/subdir/subclass.yml
diff --git a/storage/yaml_fs/tests/classes/webserver.yml b/storage/yaml_fs/tests/classes/webserver.yml
new file mode 100644
index 0000000..5db36ff
--- /dev/null
+++ b/storage/yaml_fs/tests/classes/webserver.yml
@@ -0,0 +1,5 @@
+applications:
+- lighttpd
+parameters:
+  firewall:
+    openport: 80/tcp
diff --git a/storage/yaml_fs/tests/nodes/blue.yml b/storage/yaml_fs/tests/nodes/blue.yml
new file mode 100644
index 0000000..d7d06ee
--- /dev/null
+++ b/storage/yaml_fs/tests/nodes/blue.yml
@@ -0,0 +1,9 @@
+classes:
+- debiannode@squeeze
+- hosted@munich
+- mailserver
+- webserver
+parameters:
+  motd:
+    greeting: This node is $nodename
+  colour: blue
diff --git a/storage/yaml_fs/tests/nodes/green.yml b/storage/yaml_fs/tests/nodes/green.yml
new file mode 100644
index 0000000..1f3d6d6
--- /dev/null
+++ b/storage/yaml_fs/tests/nodes/green.yml
@@ -0,0 +1,10 @@
+classes:
+- debiannode@wheezy
+- hosted@zurich
+- mailserver
+applications:
+- ~firewall
+parameters:
+  motd:
+    greeting: This node is $nodename
+  colour: green
diff --git a/storage/yaml_fs/tests/nodes/red.yml b/storage/yaml_fs/tests/nodes/red.yml
new file mode 100644
index 0000000..5fe618d
--- /dev/null
+++ b/storage/yaml_fs/tests/nodes/red.yml
@@ -0,0 +1,8 @@
+classes:
+- debiannode@sid
+- hosted@zurich
+- webserver
+parameters:
+  motd:
+    greeting: This node is $nodename
+  colour: red
diff --git a/storage/yaml_fs/tests/test_directory.py b/storage/yaml_fs/tests/test_directory.py
new file mode 100644
index 0000000..4c2604a
--- /dev/null
+++ b/storage/yaml_fs/tests/test_directory.py
@@ -0,0 +1,30 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import storage.yaml_fs.directory as directory
+import os, sys
+
+TESTDIR = os.path.join(sys.path[0], 'classes')
+FILECOUNT = 10
+
+class TestDirectory:
+
+    def setUp(self):
+        self._dir = directory.Directory(TESTDIR)
+
+    def test_walk_registry(self):
+        def count_fn(d, f):
+            count_fn.c += len(f)
+        count_fn.c = 0
+        self._dir.walk(register_fn=count_fn)
+        assert count_fn.c == FILECOUNT
+
+    def test_walk(self):
+        self._dir.walk()
+        assert len(self._dir.files) == FILECOUNT
+
diff --git a/storage/yaml_fs/tests/test_yaml_fs.py b/storage/yaml_fs/tests/test_yaml_fs.py
new file mode 100644
index 0000000..d2cdf29
--- /dev/null
+++ b/storage/yaml_fs/tests/test_yaml_fs.py
@@ -0,0 +1,64 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+from storage.yaml_fs import ExternalNodeStorage
+
+import os
+
+PWD = os.path.dirname(__file__)
+HOSTS = ['red', 'blue', 'green']
+MEMBERSHIPS = {'apt_hosts': HOSTS,
+               'motd_hosts': HOSTS,
+               'firewall_hosts': HOSTS[:2],
+               'lighttpd_hosts': HOSTS[:2],
+               'postfix_hosts': HOSTS[1:],
+               'basenode': HOSTS,
+               'debiannode': HOSTS,
+               'debiannode@sid': HOSTS[0:1],
+               'debiannode@wheezy': HOSTS[2:3],
+               'debiannode@squeeze': HOSTS[1:2],
+               'hosted@munich': HOSTS[1:2],
+               'hosted@zurich': [HOSTS[0], HOSTS[2]],
+               'mailserver': HOSTS[1:],
+               'webserver': HOSTS[:2]
+              }
+
+class TestYamlFs:
+
+    def setUp(self):
+        self._storage = ExternalNodeStorage(os.path.join(PWD, 'nodes'),
+                                            os.path.join(PWD, 'classes'))
+        self._inventory = self._storage.inventory()
+
+    def test_inventory_setup(self):
+        assert isinstance(self._inventory, dict)
+        assert len(self._inventory) == len(MEMBERSHIPS)
+        for i in MEMBERSHIPS.iterkeys():
+            assert i in self._inventory
+
+    def test_inventory_memberships(self):
+        for app, members in self._inventory.iteritems():
+            for i in MEMBERSHIPS[app]:
+                print i
+                assert i in members
+        for app, members in MEMBERSHIPS.iteritems():
+            for i in self._inventory[app]:
+                print i
+                assert i in members
+
+    def test_host_meta(self):
+        for n in HOSTS:
+            node = self._storage.nodeinfo(n)
+            assert 'RECLASS' in node
+
+    def test_host_entity(self):
+        for n in HOSTS:
+            node = self._storage.nodeinfo(n)
+            assert 'applications' in node
+            assert 'classes' in node
+            assert 'parameters' in node
diff --git a/storage/yaml_fs/tests/test_yamlfile.py b/storage/yaml_fs/tests/test_yamlfile.py
new file mode 100644
index 0000000..d8302f1
--- /dev/null
+++ b/storage/yaml_fs/tests/test_yamlfile.py
@@ -0,0 +1,28 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import storage.yaml_fs.yamlfile as yamlfile
+import os, sys
+
+TESTFILE = os.path.join(sys.path[0], 'nodes', 'blue.yml')
+
+class TestYamlFile:
+
+    def setUp(self):
+        self._file = yamlfile.YamlFile(TESTFILE)
+
+    def test_data(self):
+        e = self._file.entity
+        c = e.classes
+        assert len(c) == 4
+        assert hasattr(c, 'merge')
+        p = e.parameters
+        assert len(p) == 2
+        assert 'motd' in p
+        assert 'colour' in p
+        assert hasattr(p, 'merge')
diff --git a/storage/yaml_fs/yamlfile.py b/storage/yaml_fs/yamlfile.py
new file mode 100644
index 0000000..0d1175a
--- /dev/null
+++ b/storage/yaml_fs/yamlfile.py
@@ -0,0 +1,37 @@
+#
+# -*- coding: utf-8 -*-
+#
+# This file is part of reclass (http://github.com/madduck/reclass)
+#
+# Copyright © 2007–13 martin f. krafft <madduck@madduck.net>
+# Released under the terms of the Artistic Licence 2.0
+#
+import datatypes
+import yaml
+
+class YamlFile(object):
+
+    def __init__(self, path):
+        ''' Initialise a yamlfile object '''
+        self._path = path
+        self._data = dict()
+        self._read()
+
+    def _read(self):
+        fp = file(self._path)
+        self._data = yaml.safe_load(fp)
+        self._name = self._data.get('name', self._path)
+        fp.close()
+
+    name = property(lambda self: self._name)
+
+    def _get_entity(self):
+        classes = datatypes.Classes(self._data.get('classes', []))
+        parameters = datatypes.Parameters(self._data.get('parameters', {}))
+        applications = datatypes.Applications(self._data.get('applications', []))
+        return datatypes.Entity(classes, parameters, applications)
+    entity = property(lambda self: self._get_entity())
+
+    def __repr__(self):
+        return '<{0} {1}, {2}>'.format(self.__class__.__name__, self._name,
+                                       self._data.keys())