1#!/usr/bin/env python
2# This Source Code Form is subject to the terms of the Mozilla Public
3# License, v. 2.0. If a copy of the MPL was not distributed with this file,
4# You can obtain one at http://mozilla.org/MPL/2.0/.
5
6import json
7import pytoml
8import re
9import sys
10
11import six
12import voluptuous
13import voluptuous.humanize
14from voluptuous import Schema, Optional, Any, All, Required, Length, Range, Msg, Match
15
16
17Text = Any(six.text_type, six.binary_type)
18
19
20id_regex = re.compile(r"^[a-z0-9-]+$")
21feature_schema = Schema(
22    {
23        Match(id_regex): {
24            Required("title"): All(Text, Length(min=1)),
25            Required("description"): All(Text, Length(min=1)),
26            Required("bug-numbers"): All(Length(min=1), [All(int, Range(min=1))]),
27            Required("restart-required"): bool,
28            Required("type"): "boolean",  # In the future this may include other types
29            Optional("preference"): Text,
30            Optional("default-value"): Any(
31                bool, dict
32            ),  # the types of the keys here should match the value of `type`
33            Optional("is-public"): Any(bool, dict),
34            Optional("description-links"): dict,
35        },
36    }
37)
38
39
40EXIT_OK = 0
41EXIT_ERROR = 1
42
43
44def main(output, *filenames):
45    features = {}
46    errors = False
47    try:
48        features = process_files(filenames)
49        json.dump(features, output, sort_keys=True)
50    except ExceptionGroup as error_group:
51        print(str(error_group))
52        return EXIT_ERROR
53    return EXIT_OK
54
55
56class ExceptionGroup(Exception):
57    def __init__(self, errors):
58        self.errors = errors
59
60    def __str__(self):
61        rv = ["There were errors while processing feature definitions:"]
62        for error in self.errors:
63            # indent the message
64            s = "\n".join("    " + line for line in str(error).split("\n"))
65            # add a * at the beginning of the first line
66            s = "  * " + s[4:]
67            rv.append(s)
68        return "\n".join(rv)
69
70
71class FeatureGateException(Exception):
72    def __init__(self, message, filename=None):
73        super(FeatureGateException, self).__init__(message)
74        self.filename = filename
75
76    def __str__(self):
77        message = super(FeatureGateException, self).__str__()
78        rv = ["In"]
79        if self.filename is None:
80            rv.append("unknown file:")
81        else:
82            rv.append('file "{}":'.format(self.filename))
83        rv.append(message)
84        return " ".join(rv)
85
86    def __repr__(self):
87        # Turn "FeatureGateExcept(<message>,)" into "FeatureGateException(<message>, filename=<filename>)"
88        original = super(FeatureGateException, self).__repr__()
89        with_comma = original[:-1]
90        # python 2 adds a trailing comma and python 3 does not, so we need to conditionally reinclude it
91        if len(with_comma) > 0 and with_comma[-1] != ",":
92            with_comma = with_comma + ","
93        return with_comma + " filename={!r})".format(self.filename)
94
95
96def process_files(filenames):
97    features = {}
98    errors = []
99
100    for filename in filenames:
101        try:
102            with open(filename, "r") as f:
103                feature_data = pytoml.load(f)
104
105            voluptuous.humanize.validate_with_humanized_errors(
106                feature_data, feature_schema
107            )
108
109            for feature_id, feature in feature_data.items():
110                feature["id"] = feature_id
111                features[feature_id] = expand_feature(feature)
112        except (voluptuous.error.Error, IOError, FeatureGateException) as e:
113            # Wrap errors in enough information to know which file they came from
114            errors.append(FeatureGateException(e, filename))
115        except pytoml.TomlError as e:
116            # Toml errors have file information already
117            errors.append(e)
118
119    if errors:
120        raise ExceptionGroup(errors)
121
122    return features
123
124
125def hyphens_to_camel_case(s):
126    """Convert names-with-hyphens to namesInCamelCase"""
127    rv = ""
128    for part in s.split("-"):
129        if rv == "":
130            rv = part.lower()
131        else:
132            rv += part[0].upper() + part[1:].lower()
133    return rv
134
135
136def expand_feature(feature):
137    """Fill in default values for optional fields"""
138
139    # convert all names-with-hyphens to namesInCamelCase
140    key_changes = []
141    for key in feature.keys():
142        if "-" in key:
143            new_key = hyphens_to_camel_case(key)
144            key_changes.append((key, new_key))
145
146    for (old_key, new_key) in key_changes:
147        feature[new_key] = feature[old_key]
148        del feature[old_key]
149
150    if feature["type"] == "boolean":
151        feature.setdefault("preference", "features.{}.enabled".format(feature["id"]))
152        # set default value to None so that we can test for perferences where we forgot to set the default value
153        feature.setdefault("defaultValue", None)
154    elif "preference" not in feature:
155        raise FeatureGateException(
156            "Features of type {} must specify an explicit preference name".format(
157                feature["type"]
158            )
159        )
160
161    feature.setdefault("isPublic", False)
162
163    try:
164        for key in ["defaultValue", "isPublic"]:
165            feature[key] = process_configured_value(key, feature[key])
166    except FeatureGateException as e:
167        raise FeatureGateException(
168            "Error when processing feature {}: {}".format(feature["id"], e)
169        )
170
171    return feature
172
173
174def process_configured_value(name, value):
175    if not isinstance(value, dict):
176        return {"default": value}
177
178    if "default" not in value:
179        raise FeatureGateException(
180            "Config for {} has no default: {}".format(name, value)
181        )
182
183    expected_keys = set(
184        {
185            "default",
186            "win",
187            "mac",
188            "linux",
189            "android",
190            "nightly",
191            "early_beta_or_earlier",
192            "beta",
193            "release",
194            "dev-edition",
195            "esr",
196        }
197    )
198
199    for key in value.keys():
200        parts = [p.strip() for p in key.split(",")]
201        for part in parts:
202            if part not in expected_keys:
203                raise FeatureGateException(
204                    "Unexpected target {}, expected any of {}".format(
205                        part, expected_keys
206                    )
207                )
208
209    # TODO Compute values at build time, so that it always returns only a single value.
210
211    return value
212
213
214if __name__ == "__main__":
215    sys.exit(main(sys.stdout, *sys.argv[1:]))
216