1# Copyright 2016 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Manage various config files.""" 16 17import configparser 18import functools 19import itertools 20import os 21import shlex 22import sys 23 24_path = os.path.realpath(__file__ + '/../..') 25if sys.path[0] != _path: 26 sys.path.insert(0, _path) 27del _path 28 29# pylint: disable=wrong-import-position 30import rh.hooks 31import rh.shell 32 33 34class Error(Exception): 35 """Base exception class.""" 36 37 38class ValidationError(Error): 39 """Config file has unknown sections/keys or other values.""" 40 41 42# Sentinel so we can handle None-vs-unspecified. 43_UNSET = object() 44 45 46class RawConfigParser(configparser.RawConfigParser): 47 """Like RawConfigParser but with some default helpers.""" 48 49 # pylint doesn't like it when we extend the API. 50 # pylint: disable=arguments-differ 51 52 def options(self, section, default=_UNSET): 53 """Return the options in |section|. 54 55 Args: 56 section: The section to look up. 57 default: What to return if |section| does not exist. 58 """ 59 try: 60 return configparser.RawConfigParser.options(self, section) 61 except configparser.NoSectionError: 62 if default is not _UNSET: 63 return default 64 raise 65 66 def get(self, section, option, default=_UNSET): 67 """Return the value for |option| in |section| (with |default|).""" 68 try: 69 return configparser.RawConfigParser.get(self, section, option) 70 except (configparser.NoSectionError, configparser.NoOptionError): 71 if default is not _UNSET: 72 return default 73 raise 74 75 def items(self, section=_UNSET, default=_UNSET): 76 """Return a list of (key, value) tuples for the options in |section|.""" 77 if section is _UNSET: 78 # Python 3 compat logic. Return a dict of section-to-options. 79 if sys.version_info.major < 3: 80 return [(x, self.items(x)) for x in self.sections()] 81 return super(RawConfigParser, self).items() 82 83 try: 84 return configparser.RawConfigParser.items(self, section) 85 except configparser.NoSectionError: 86 if default is not _UNSET: 87 return default 88 raise 89 90 if sys.version_info.major < 3: 91 def read_dict(self, dictionary): 92 """Store |dictionary| into ourselves.""" 93 for section, settings in dictionary.items(): 94 for option, value in settings: 95 if not self.has_section(section): 96 self.add_section(section) 97 self.set(section, option, value) 98 99 100class PreUploadConfig(object): 101 """A single (abstract) config used for `repo upload` hooks.""" 102 103 CUSTOM_HOOKS_SECTION = 'Hook Scripts' 104 BUILTIN_HOOKS_SECTION = 'Builtin Hooks' 105 BUILTIN_HOOKS_OPTIONS_SECTION = 'Builtin Hooks Options' 106 BUILTIN_HOOKS_EXCLUDE_SECTION = 'Builtin Hooks Exclude Paths' 107 TOOL_PATHS_SECTION = 'Tool Paths' 108 OPTIONS_SECTION = 'Options' 109 VALID_SECTIONS = { 110 CUSTOM_HOOKS_SECTION, 111 BUILTIN_HOOKS_SECTION, 112 BUILTIN_HOOKS_OPTIONS_SECTION, 113 BUILTIN_HOOKS_EXCLUDE_SECTION, 114 TOOL_PATHS_SECTION, 115 OPTIONS_SECTION, 116 } 117 118 OPTION_IGNORE_MERGED_COMMITS = 'ignore_merged_commits' 119 VALID_OPTIONS = {OPTION_IGNORE_MERGED_COMMITS} 120 121 def __init__(self, config=None, source=None): 122 """Initialize. 123 124 Args: 125 config: A configparse.ConfigParser instance. 126 source: Where this config came from. This is used in error messages to 127 facilitate debugging. It is not necessarily a valid path. 128 """ 129 self.config = config if config else RawConfigParser() 130 self.source = source 131 if config: 132 self._validate() 133 134 @property 135 def custom_hooks(self): 136 """List of custom hooks to run (their keys/names).""" 137 return self.config.options(self.CUSTOM_HOOKS_SECTION, []) 138 139 def custom_hook(self, hook): 140 """The command to execute for |hook|.""" 141 return shlex.split(self.config.get(self.CUSTOM_HOOKS_SECTION, hook, '')) 142 143 @property 144 def builtin_hooks(self): 145 """List of all enabled builtin hooks (their keys/names).""" 146 return [k for k, v in self.config.items(self.BUILTIN_HOOKS_SECTION, ()) 147 if rh.shell.boolean_shell_value(v, None)] 148 149 def builtin_hook_option(self, hook): 150 """The options to pass to |hook|.""" 151 return shlex.split(self.config.get(self.BUILTIN_HOOKS_OPTIONS_SECTION, 152 hook, '')) 153 154 def builtin_hook_exclude_paths(self, hook): 155 """List of paths for which |hook| should not be executed.""" 156 return shlex.split(self.config.get(self.BUILTIN_HOOKS_EXCLUDE_SECTION, 157 hook, '')) 158 159 @property 160 def tool_paths(self): 161 """List of all tool paths.""" 162 return dict(self.config.items(self.TOOL_PATHS_SECTION, ())) 163 164 def callable_hooks(self): 165 """Yield a CallableHook for each hook to be executed.""" 166 scope = rh.hooks.ExclusionScope([]) 167 for hook in self.custom_hooks: 168 options = rh.hooks.HookOptions(hook, 169 self.custom_hook(hook), 170 self.tool_paths) 171 func = functools.partial(rh.hooks.check_custom, options=options) 172 yield rh.hooks.CallableHook(hook, func, scope) 173 174 for hook in self.builtin_hooks: 175 options = rh.hooks.HookOptions(hook, 176 self.builtin_hook_option(hook), 177 self.tool_paths) 178 func = functools.partial(rh.hooks.BUILTIN_HOOKS[hook], 179 options=options) 180 scope = rh.hooks.ExclusionScope( 181 self.builtin_hook_exclude_paths(hook)) 182 yield rh.hooks.CallableHook(hook, func, scope) 183 184 @property 185 def ignore_merged_commits(self): 186 """Whether to skip hooks for merged commits.""" 187 return rh.shell.boolean_shell_value( 188 self.config.get(self.OPTIONS_SECTION, 189 self.OPTION_IGNORE_MERGED_COMMITS, None), 190 False) 191 192 def update(self, preupload_config): 193 """Merge settings from |preupload_config| into ourself.""" 194 self.config.read_dict(preupload_config.config) 195 196 def _validate(self): 197 """Run consistency checks on the config settings.""" 198 config = self.config 199 200 # Reject unknown sections. 201 bad_sections = set(config.sections()) - self.VALID_SECTIONS 202 if bad_sections: 203 raise ValidationError('%s: unknown sections: %s' % 204 (self.source, bad_sections)) 205 206 # Reject blank custom hooks. 207 for hook in self.custom_hooks: 208 if not config.get(self.CUSTOM_HOOKS_SECTION, hook): 209 raise ValidationError('%s: custom hook "%s" cannot be blank' % 210 (self.source, hook)) 211 212 # Reject unknown builtin hooks. 213 valid_builtin_hooks = set(rh.hooks.BUILTIN_HOOKS.keys()) 214 if config.has_section(self.BUILTIN_HOOKS_SECTION): 215 hooks = set(config.options(self.BUILTIN_HOOKS_SECTION)) 216 bad_hooks = hooks - valid_builtin_hooks 217 if bad_hooks: 218 raise ValidationError('%s: unknown builtin hooks: %s' % 219 (self.source, bad_hooks)) 220 elif config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION): 221 raise ValidationError('Builtin hook options specified, but missing ' 222 'builtin hook settings') 223 224 if config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION): 225 hooks = set(config.options(self.BUILTIN_HOOKS_OPTIONS_SECTION)) 226 bad_hooks = hooks - valid_builtin_hooks 227 if bad_hooks: 228 raise ValidationError('%s: unknown builtin hook options: %s' % 229 (self.source, bad_hooks)) 230 231 # Verify hooks are valid shell strings. 232 for hook in self.custom_hooks: 233 try: 234 self.custom_hook(hook) 235 except ValueError as e: 236 raise ValidationError('%s: hook "%s" command line is invalid: ' 237 '%s' % (self.source, hook, e)) 238 239 # Verify hook options are valid shell strings. 240 for hook in self.builtin_hooks: 241 try: 242 self.builtin_hook_option(hook) 243 except ValueError as e: 244 raise ValidationError('%s: hook options "%s" are invalid: %s' % 245 (self.source, hook, e)) 246 247 # Reject unknown tools. 248 valid_tools = set(rh.hooks.TOOL_PATHS.keys()) 249 if config.has_section(self.TOOL_PATHS_SECTION): 250 tools = set(config.options(self.TOOL_PATHS_SECTION)) 251 bad_tools = tools - valid_tools 252 if bad_tools: 253 raise ValidationError('%s: unknown tools: %s' % 254 (self.source, bad_tools)) 255 256 # Reject unknown options. 257 if config.has_section(self.OPTIONS_SECTION): 258 options = set(config.options(self.OPTIONS_SECTION)) 259 bad_options = options - self.VALID_OPTIONS 260 if bad_options: 261 raise ValidationError('%s: unknown options: %s' % 262 (self.source, bad_options)) 263 264 265class PreUploadFile(PreUploadConfig): 266 """A single config (file) used for `repo upload` hooks. 267 268 This is an abstract class that requires subclasses to define the FILENAME 269 constant. 270 271 Attributes: 272 path: The path of the file. 273 """ 274 FILENAME = None 275 276 def __init__(self, path): 277 """Initialize. 278 279 Args: 280 path: The config file to load. 281 """ 282 super(PreUploadFile, self).__init__(source=path) 283 284 self.path = path 285 try: 286 self.config.read(path) 287 except configparser.ParsingError as e: 288 raise ValidationError('%s: %s' % (path, e)) 289 290 self._validate() 291 292 @classmethod 293 def from_paths(cls, paths): 294 """Search for files within paths that matches the class FILENAME. 295 296 Args: 297 paths: List of directories to look for config files. 298 299 Yields: 300 For each valid file found, an instance is created and returned. 301 """ 302 for path in paths: 303 path = os.path.join(path, cls.FILENAME) 304 if os.path.exists(path): 305 yield cls(path) 306 307 308class LocalPreUploadFile(PreUploadFile): 309 """A single config file for a project (PREUPLOAD.cfg).""" 310 FILENAME = 'PREUPLOAD.cfg' 311 312 def _validate(self): 313 super(LocalPreUploadFile, self)._validate() 314 315 # Reject Exclude Paths section for local config. 316 if self.config.has_section(self.BUILTIN_HOOKS_EXCLUDE_SECTION): 317 raise ValidationError('%s: [%s] is not valid in local files' % 318 (self.path, 319 self.BUILTIN_HOOKS_EXCLUDE_SECTION)) 320 321 322class GlobalPreUploadFile(PreUploadFile): 323 """A single config file for a repo (GLOBAL-PREUPLOAD.cfg).""" 324 FILENAME = 'GLOBAL-PREUPLOAD.cfg' 325 326 327class PreUploadSettings(PreUploadConfig): 328 """Settings for `repo upload` hooks. 329 330 This encompasses multiple config files and provides the final (merged) 331 settings for a particular project. 332 """ 333 334 def __init__(self, paths=('',), global_paths=()): 335 """Initialize. 336 337 All the config files found will be merged together in order. 338 339 Args: 340 paths: The directories to look for config files. 341 global_paths: The directories to look for global config files. 342 """ 343 super(PreUploadSettings, self).__init__() 344 345 self.paths = [] 346 for config in itertools.chain( 347 GlobalPreUploadFile.from_paths(global_paths), 348 LocalPreUploadFile.from_paths(paths)): 349 self.paths.append(config.path) 350 self.update(config) 351 352 353 # We validated configs in isolation, now do one final pass altogether. 354 self.source = '{%s}' % '|'.join(self.paths) 355 self._validate() 356