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