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