1#!/usr/bin/env python3
2
3'''
4Phosphor Fan Presence (PFP) YAML parser and code generator.
5
6Parse the provided PFP configuration file and generate C++ code.
7
8The parser workflow is broken down as follows:
9  1 - Import the YAML configuration file as native python type(s)
10        instance(s).
11  2 - Create an instance of the Everything class from the
12        native python type instance(s) with the Everything.load
13        method.
14  3 - The Everything class constructor orchestrates conversion of the
15        native python type(s) instances(s) to render helper types.
16        Each render helper type constructor imports its attributes
17        from the native python type(s) instances(s).
18  4 - Present the converted YAML to the command processing method
19        requested by the script user.
20'''
21
22import os
23import sys
24import yaml
25from argparse import ArgumentParser
26import mako.lookup
27from sdbusplus.renderer import Renderer
28from sdbusplus.namedelement import NamedElement
29
30
31class InvalidConfigError(BaseException):
32    '''General purpose config file parsing error.'''
33
34    def __init__(self, path, msg):
35        '''Display configuration file with the syntax
36        error and the error message.'''
37
38        self.config = path
39        self.msg = msg
40
41
42class NotUniqueError(InvalidConfigError):
43    '''Within a config file names must be unique.
44    Display the duplicate item.'''
45
46    def __init__(self, path, cls, *names):
47        fmt = 'Duplicate {0}: "{1}"'
48        super(NotUniqueError, self).__init__(
49            path, fmt.format(cls, ' '.join(names)))
50
51
52def get_index(objs, cls, name):
53    '''Items are usually rendered as C++ arrays and as
54    such are stored in python lists.  Given an item name
55    its class, find the item index.'''
56
57    for i, x in enumerate(objs.get(cls, [])):
58        if x.name != name:
59            continue
60
61        return i
62    raise InvalidConfigError('Could not find name: "{0}"'.format(name))
63
64
65def exists(objs, cls, name):
66    '''Check to see if an item already exists in a list given
67    the item name.'''
68
69    try:
70        get_index(objs, cls, name)
71    except:
72        return False
73
74    return True
75
76
77def add_unique(obj, *a, **kw):
78    '''Add an item to one or more lists unless already present.'''
79
80    for container in a:
81        if not exists(container, obj.cls, obj.name):
82            container.setdefault(obj.cls, []).append(obj)
83
84
85class Indent(object):
86    '''Help templates be depth agnostic.'''
87
88    def __init__(self, depth=0):
89        self.depth = depth
90
91    def __add__(self, depth):
92        return Indent(self.depth + depth)
93
94    def __call__(self, depth):
95        '''Render an indent at the current depth plus depth.'''
96        return 4*' '*(depth + self.depth)
97
98
99class ConfigEntry(NamedElement):
100    '''Base interface for rendered items.'''
101
102    def __init__(self, *a, **kw):
103        '''Pop the class keyword.'''
104
105        self.cls = kw.pop('class')
106        super(ConfigEntry, self).__init__(**kw)
107
108    def factory(self, objs):
109        ''' Optional factory interface for subclasses to add
110        additional items to be rendered.'''
111
112        pass
113
114    def setup(self, objs):
115        ''' Optional setup interface for subclasses, invoked
116        after all factory methods have been run.'''
117
118        pass
119
120
121class Sensor(ConfigEntry):
122    '''Convenience type for config file method:type handlers.'''
123
124    def __init__(self, *a, **kw):
125        kw['class'] = 'sensor'
126        kw.pop('type')
127        self.policy = kw.pop('policy')
128        super(Sensor, self).__init__(**kw)
129
130    def setup(self, objs):
131        '''All sensors have an associated policy.  Get the policy index.'''
132
133        self.policy = get_index(objs, 'policy', self.policy)
134
135
136class Gpio(Sensor, Renderer):
137    '''Handler for method:type:gpio.'''
138
139    def __init__(self, *a, **kw):
140        self.key = kw.pop('key')
141        self.physpath = kw.pop('physpath')
142        self.devpath = kw.pop('devpath')
143        kw['name'] = 'gpio-{}'.format(self.key)
144        super(Gpio, self).__init__(**kw)
145
146    def construct(self, loader, indent):
147        return self.render(
148            loader,
149            'gpio.mako.hpp',
150            g=self,
151            indent=indent)
152
153    def setup(self, objs):
154        super(Gpio, self).setup(objs)
155
156
157class Tach(Sensor, Renderer):
158    '''Handler for method:type:tach.'''
159
160    def __init__(self, *a, **kw):
161        self.sensors = kw.pop('sensors')
162        kw['name'] = 'tach-{}'.format('-'.join(self.sensors))
163        super(Tach, self).__init__(**kw)
164
165    def construct(self, loader, indent):
166        return self.render(
167            loader,
168            'tach.mako.hpp',
169            t=self,
170            indent=indent)
171
172    def setup(self, objs):
173        super(Tach, self).setup(objs)
174
175
176class Rpolicy(ConfigEntry):
177    '''Convenience type for config file rpolicy:type handlers.'''
178
179    def __init__(self, *a, **kw):
180        kw.pop('type', None)
181        self.fan = kw.pop('fan')
182        self.sensors = []
183        kw['class'] = 'policy'
184        super(Rpolicy, self).__init__(**kw)
185
186    def setup(self, objs):
187        '''All policies have an associated fan and methods.
188        Resolve the indices.'''
189
190        sensors = []
191        for s in self.sensors:
192            sensors.append(get_index(objs, 'sensor', s))
193
194        self.sensors = sensors
195        self.fan = get_index(objs, 'fan', self.fan)
196
197
198class AnyOf(Rpolicy, Renderer):
199    '''Default policy handler (policy:type:anyof).'''
200
201    def __init__(self, *a, **kw):
202        kw['name'] = 'anyof-{}'.format(kw['fan'])
203        super(AnyOf, self).__init__(**kw)
204
205    def setup(self, objs):
206        super(AnyOf, self).setup(objs)
207
208    def construct(self, loader, indent):
209        return self.render(
210            loader,
211            'anyof.mako.hpp',
212            f=self,
213            indent=indent)
214
215
216class Fallback(Rpolicy, Renderer):
217    '''Fallback policy handler (policy:type:fallback).'''
218
219    def __init__(self, *a, **kw):
220        kw['name'] = 'fallback-{}'.format(kw['fan'])
221        super(Fallback, self).__init__(**kw)
222
223    def setup(self, objs):
224        super(Fallback, self).setup(objs)
225
226    def construct(self, loader, indent):
227        return self.render(
228            loader,
229            'fallback.mako.hpp',
230            f=self,
231            indent=indent)
232
233
234class Fan(ConfigEntry):
235    '''Fan directive handler.  Fans entries consist of an inventory path,
236    optional redundancy policy and associated sensors.'''
237
238    def __init__(self, *a, **kw):
239        self.path = kw.pop('path')
240        self.methods = kw.pop('methods')
241        self.rpolicy = kw.pop('rpolicy', None)
242        super(Fan, self).__init__(**kw)
243
244    def factory(self, objs):
245        ''' Create rpolicy and sensor(s) objects.'''
246
247        if self.rpolicy:
248            self.rpolicy['fan'] = self.name
249            factory = Everything.classmap(self.rpolicy['type'])
250            rpolicy = factory(**self.rpolicy)
251        else:
252            rpolicy = AnyOf(fan=self.name)
253
254        for m in self.methods:
255            m['policy'] = rpolicy.name
256            factory = Everything.classmap(m['type'])
257            sensor = factory(**m)
258            rpolicy.sensors.append(sensor.name)
259            add_unique(sensor, objs)
260
261        add_unique(rpolicy, objs)
262        super(Fan, self).factory(objs)
263
264
265class Everything(Renderer):
266    '''Parse/render entry point.'''
267
268    @staticmethod
269    def classmap(cls):
270        '''Map render item class entries to the appropriate
271        handler methods.'''
272
273        class_map = {
274            'anyof': AnyOf,
275            'fan': Fan,
276            'fallback': Fallback,
277            'gpio': Gpio,
278            'tach': Tach,
279        }
280
281        if cls not in class_map:
282            raise NotImplementedError('Unknown class: "{0}"'.format(cls))
283
284        return class_map[cls]
285
286    @staticmethod
287    def load(args):
288        '''Load the configuration file.  Parsing occurs in three phases.
289        In the first phase a factory method associated with each
290        configuration file directive is invoked.  These factory
291        methods generate more factory methods.  In the second
292        phase the factory methods created in the first phase
293        are invoked.  In the last phase a callback is invoked on
294        each object created in phase two.  Typically the callback
295        resolves references to other configuration file directives.'''
296
297        factory_objs = {}
298        objs = {}
299        with open(args.input, 'r') as fd:
300            for x in yaml.safe_load(fd.read()) or {}:
301
302                # The top level elements all represent fans.
303                x['class'] = 'fan'
304                # Create factory object for this config file directive.
305                factory = Everything.classmap(x['class'])
306                obj = factory(**x)
307
308                # For a given class of directive, validate the file
309                # doesn't have any duplicate names.
310                if exists(factory_objs, obj.cls, obj.name):
311                    raise NotUniqueError(args.input, 'fan', obj.name)
312
313                factory_objs.setdefault('fan', []).append(obj)
314                objs.setdefault('fan', []).append(obj)
315
316            for cls, items in list(factory_objs.items()):
317                for obj in items:
318                    # Add objects for template consumption.
319                    obj.factory(objs)
320
321            # Configuration file directives reference each other via
322            # the name attribute; however, when rendered the reference
323            # is just an array index.
324            #
325            # At this point all objects have been created but references
326            # have not been resolved to array indices.  Instruct objects
327            # to do that now.
328            for cls, items in list(objs.items()):
329                for obj in items:
330                    obj.setup(objs)
331
332        return Everything(**objs)
333
334    def __init__(self, *a, **kw):
335        self.fans = kw.pop('fan', [])
336        self.policies = kw.pop('policy', [])
337        self.sensors = kw.pop('sensor', [])
338        super(Everything, self).__init__(**kw)
339
340    def generate_cpp(self, loader):
341        '''Render the template with the provided data.'''
342        sys.stdout.write(
343            self.render(
344                loader,
345                args.template,
346                fans=self.fans,
347                sensors=self.sensors,
348                policies=self.policies,
349                indent=Indent()))
350
351if __name__ == '__main__':
352    script_dir = os.path.dirname(os.path.realpath(__file__))
353    valid_commands = {
354        'generate-cpp': 'generate_cpp',
355    }
356
357    parser = ArgumentParser(
358        description='Phosphor Fan Presence (PFP) YAML '
359        'scanner and code generator.')
360
361    parser.add_argument(
362        '-i', '--input', dest='input',
363        default=os.path.join(script_dir, 'example', 'example.yaml'),
364        help='Location of config file to process.')
365    parser.add_argument(
366        '-t', '--template', dest='template',
367        default='generated.mako.hpp',
368        help='The top level template to render.')
369    parser.add_argument(
370        '-p', '--template-path', dest='template_search',
371        default=os.path.join(script_dir, 'templates'),
372        help='The space delimited mako template search path.')
373    parser.add_argument(
374        'command', metavar='COMMAND', type=str,
375        choices=list(valid_commands.keys()),
376        help='%s.' % ' | '.join(list(valid_commands.keys())))
377
378    args = parser.parse_args()
379
380    if sys.version_info < (3, 0):
381        lookup = mako.lookup.TemplateLookup(
382            directories=args.template_search.split(),
383            disable_unicode=True)
384    else:
385        lookup = mako.lookup.TemplateLookup(
386            directories=args.template_search.split())
387    try:
388        function = getattr(
389            Everything.load(args),
390            valid_commands[args.command])
391        function(lookup)
392    except InvalidConfigError as e:
393        sys.stderr.write('{0}: {1}\n\n'.format(e.config, e.msg))
394        raise
395