1# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import collections
6import json
7import os
8
9
10class ConfigJsonIteratorError(Exception):
11    """"Exception for config json iterator"""
12    pass
13
14
15class ConfigJsonIterator(object):
16    """Class to consolidate multiple config json files.
17
18    This class reads and combines input JSON instances into one based on the
19    following rules:
20    1. "_deps" value in the root config file contains a list of common config
21       file paths. Each path represents a RELATIVE path to
22       the root config file.
23       For example (common.config is in the same directory as root.config):
24       root.config:
25           { "a": "123",
26             "_deps": ["../common.config"]}
27       common.config:
28           { "b": "xxx" }
29       End output:
30           { "a": "123",
31             "b": "xxx" }
32    2. common config files defined in "_deps" MUST NOT contain identical keys
33       (otherwise an exception will be thrown), for example (invalid - common1
34       and common2.config are in the same directory as root.config):
35       root.config:
36           { "a": "123",
37             "_deps": ["../common1.config",
38                       "../common2.config"]}
39       common1.config:
40           { "b": "xxx" }
41       common2.config:
42           { "b": "yyy" }
43    3. values in the root config will override the ones in the common config
44       files. This logic applies to any dependency config file (imagine that
45       the common config also has "_deps"), thus is recursive.
46       For example (common.config is in the same directory as root.config):
47       root.config:
48           { "a": "123",
49             "_deps": ["../common.config"]}
50       common.config:
51           { "a": "456",
52             "b": "xxx" }
53       End output:
54           { "a": "123",
55             "b": "xxx" }
56    """
57    DEPS = '_deps'
58
59
60    def __init__(self, config_path=None):
61        """Constructor.
62
63        @param config_path: String of root config file path.
64        """
65        if config_path:
66            self.set_config_dir(config_path)
67
68
69    def set_config_dir(self, config_path):
70        """Sets config dictionary.
71
72        @param config_path: String of config file path.
73        @raises ConfigJsonIteratorError if config does not exist.
74        """
75        if not os.path.isfile(config_path):
76            raise ConfigJsonIteratorError('config file does not exist %s'
77                                          % config_path)
78        self._config_dir = os.path.abspath(os.path.dirname(config_path))
79
80
81    def _load_config(self, config_path):
82        """Iterate the base config file.
83
84        @param config_path: String of config file path.
85        @return Dictionary of the config file.
86        @raises ConfigJsonIteratorError: if config file is not found or invalid.
87        """
88        if not os.path.isfile(config_path):
89            raise ConfigJsonIteratorError('config file does not exist %s'
90                                          % config_path)
91        with open(config_path, 'r') as config_file:
92            try:
93                return json.load(config_file)
94            except ValueError:
95                raise ConfigJsonIteratorError(
96                        'invalid JSON file %s' % config_file)
97
98
99    def aggregated_config(self, config_path):
100        """Returns dictionary of aggregated config files.
101        The dependency list contains the RELATIVE path to the root config.
102
103        @param config_path: String of config file path.
104        @return Dictionary containing the aggregated config files.
105        @raises ConfigJsonIteratorError: if dependency config list
106            does not exist.
107        """
108        ret_dict = self._load_config(config_path)
109        if ConfigJsonIterator.DEPS not in ret_dict:
110            return ret_dict
111        else:
112            deps_list = ret_dict[ConfigJsonIterator.DEPS]
113            if not isinstance(deps_list, list):
114                raise ConfigJsonIteratorError('dependency must be a list %s'
115                                              % deps_list)
116            del ret_dict[ConfigJsonIterator.DEPS]
117            common_dict = {}
118            for dep in deps_list:
119                common_config_path = os.path.join(self._config_dir, dep)
120                dep_dict = self.aggregated_config(common_config_path)
121                common_dict = self._merge_dict(common_dict, dep_dict,
122                                               allow_override=False)
123            return self._merge_dict(common_dict, ret_dict, allow_override=True)
124
125
126    def _merge_dict(self, dict_one, dict_two, allow_override=True):
127        """Returns a merged dictionary.
128
129        @param dict_one: Dictionary to merge (first).
130        @param dict_two: Dictionary to merge (second).
131        @param allow_override: Boolean to allow override or not.
132        @return Dictionary containing merged result.
133        @raises ConfigJsonIteratorError: if no dictionary given.
134        """
135        if not isinstance(dict_one, dict) or not isinstance(dict_two, dict):
136            raise ConfigJsonIteratorError('Input is not a dictionary')
137        if allow_override:
138            return dict(dict_one.items() + dict_two.items())
139        else:
140            merge = collections.Counter(
141                    dict_one.keys() + dict_two.keys()).most_common()[0]
142            if merge[1] > 1:
143                raise ConfigJsonIteratorError(
144                    'Duplicate key %s found', merge[0])
145            return dict_one
146