1# -*- coding:utf-8 -*-
2# Copyright 2016 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Manage various config files."""
17
18from __future__ import print_function
19
20import ConfigParser
21import functools
22import os
23import shlex
24import sys
25
26_path = os.path.realpath(__file__ + '/../..')
27if sys.path[0] != _path:
28    sys.path.insert(0, _path)
29del _path
30
31# pylint: disable=wrong-import-position
32import rh.hooks
33import rh.shell
34
35
36class Error(Exception):
37    """Base exception class."""
38
39
40class ValidationError(Error):
41    """Config file has unknown sections/keys or other values."""
42
43
44class RawConfigParser(ConfigParser.RawConfigParser):
45    """Like RawConfigParser but with some default helpers."""
46
47    @staticmethod
48    def _check_args(name, cnt_min, cnt_max, args):
49        cnt = len(args)
50        if cnt not in (0, cnt_max - cnt_min):
51            raise TypeError('%s() takes %i or %i arguments (got %i)' %
52                            (name, cnt_min, cnt_max, cnt,))
53        return cnt
54
55    def options(self, section, *args):
56        """Return the options in |section| (with default |args|).
57
58        Args:
59          section: The section to look up.
60          args: What to return if |section| does not exist.
61        """
62        cnt = self._check_args('options', 2, 3, args)
63        try:
64            return ConfigParser.RawConfigParser.options(self, section)
65        except ConfigParser.NoSectionError:
66            if cnt == 1:
67                return args[0]
68            raise
69
70    def get(self, section, option, *args):
71        """Return the value for |option| in |section| (with default |args|)."""
72        cnt = self._check_args('get', 3, 4, args)
73        try:
74            return ConfigParser.RawConfigParser.get(self, section, option)
75        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
76            if cnt == 1:
77                return args[0]
78            raise
79
80    def items(self, section, *args):
81        """Return a list of (key, value) tuples for the options in |section|."""
82        cnt = self._check_args('items', 2, 3, args)
83        try:
84            return ConfigParser.RawConfigParser.items(self, section)
85        except ConfigParser.NoSectionError:
86            if cnt == 1:
87                return args[0]
88            raise
89
90
91class PreSubmitConfig(object):
92    """Config file used for per-project `repo upload` hooks."""
93
94    FILENAME = 'PREUPLOAD.cfg'
95    GLOBAL_FILENAME = 'GLOBAL-PREUPLOAD.cfg'
96
97    CUSTOM_HOOKS_SECTION = 'Hook Scripts'
98    BUILTIN_HOOKS_SECTION = 'Builtin Hooks'
99    BUILTIN_HOOKS_OPTIONS_SECTION = 'Builtin Hooks Options'
100    TOOL_PATHS_SECTION = 'Tool Paths'
101    OPTIONS_SECTION = 'Options'
102
103    OPTION_IGNORE_MERGED_COMMITS = 'ignore_merged_commits'
104    VALID_OPTIONS = (OPTION_IGNORE_MERGED_COMMITS,)
105
106    def __init__(self, paths=('',), global_paths=()):
107        """Initialize.
108
109        All the config files found will be merged together in order.
110
111        Args:
112          paths: The directories to look for config files.
113          global_paths: The directories to look for global config files.
114        """
115        config = RawConfigParser()
116
117        def _search(paths, filename):
118            for path in paths:
119                path = os.path.join(path, filename)
120                if os.path.exists(path):
121                    self.paths.append(path)
122                    try:
123                        config.read(path)
124                    except ConfigParser.ParsingError as e:
125                        raise ValidationError('%s: %s' % (path, e))
126
127        self.paths = []
128        _search(global_paths, self.GLOBAL_FILENAME)
129        _search(paths, self.FILENAME)
130
131        self.config = config
132
133        self._validate()
134
135    @property
136    def custom_hooks(self):
137        """List of custom hooks to run (their keys/names)."""
138        return self.config.options(self.CUSTOM_HOOKS_SECTION, [])
139
140    def custom_hook(self, hook):
141        """The command to execute for |hook|."""
142        return shlex.split(self.config.get(self.CUSTOM_HOOKS_SECTION, hook, ''))
143
144    @property
145    def builtin_hooks(self):
146        """List of all enabled builtin hooks (their keys/names)."""
147        return [k for k, v in self.config.items(self.BUILTIN_HOOKS_SECTION, ())
148                if rh.shell.boolean_shell_value(v, None)]
149
150    def builtin_hook_option(self, hook):
151        """The options to pass to |hook|."""
152        return shlex.split(self.config.get(self.BUILTIN_HOOKS_OPTIONS_SECTION,
153                                           hook, ''))
154
155    @property
156    def tool_paths(self):
157        """List of all tool paths."""
158        return dict(self.config.items(self.TOOL_PATHS_SECTION, ()))
159
160    def callable_hooks(self):
161        """Yield a name and callback for each hook to be executed."""
162        for hook in self.custom_hooks:
163            options = rh.hooks.HookOptions(hook,
164                                           self.custom_hook(hook),
165                                           self.tool_paths)
166            yield (hook, functools.partial(rh.hooks.check_custom,
167                                           options=options))
168
169        for hook in self.builtin_hooks:
170            options = rh.hooks.HookOptions(hook,
171                                           self.builtin_hook_option(hook),
172                                           self.tool_paths)
173            yield (hook, functools.partial(rh.hooks.BUILTIN_HOOKS[hook],
174                                           options=options))
175
176    @property
177    def ignore_merged_commits(self):
178        """Whether to skip hooks for merged commits."""
179        return rh.shell.boolean_shell_value(
180            self.config.get(self.OPTIONS_SECTION,
181                            self.OPTION_IGNORE_MERGED_COMMITS, None),
182            False)
183
184    def _validate(self):
185        """Run consistency checks on the config settings."""
186        config = self.config
187
188        # Reject unknown sections.
189        valid_sections = set((
190            self.CUSTOM_HOOKS_SECTION,
191            self.BUILTIN_HOOKS_SECTION,
192            self.BUILTIN_HOOKS_OPTIONS_SECTION,
193            self.TOOL_PATHS_SECTION,
194            self.OPTIONS_SECTION,
195        ))
196        bad_sections = set(config.sections()) - valid_sections
197        if bad_sections:
198            raise ValidationError('%s: unknown sections: %s' %
199                                  (self.paths, bad_sections))
200
201        # Reject blank custom hooks.
202        for hook in self.custom_hooks:
203            if not config.get(self.CUSTOM_HOOKS_SECTION, hook):
204                raise ValidationError('%s: custom hook "%s" cannot be blank' %
205                                      (self.paths, hook))
206
207        # Reject unknown builtin hooks.
208        valid_builtin_hooks = set(rh.hooks.BUILTIN_HOOKS.keys())
209        if config.has_section(self.BUILTIN_HOOKS_SECTION):
210            hooks = set(config.options(self.BUILTIN_HOOKS_SECTION))
211            bad_hooks = hooks - valid_builtin_hooks
212            if bad_hooks:
213                raise ValidationError('%s: unknown builtin hooks: %s' %
214                                      (self.paths, bad_hooks))
215        elif config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
216            raise ValidationError('Builtin hook options specified, but missing '
217                                  'builtin hook settings')
218
219        if config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
220            hooks = set(config.options(self.BUILTIN_HOOKS_OPTIONS_SECTION))
221            bad_hooks = hooks - valid_builtin_hooks
222            if bad_hooks:
223                raise ValidationError('%s: unknown builtin hook options: %s' %
224                                      (self.paths, bad_hooks))
225
226        # Verify hooks are valid shell strings.
227        for hook in self.custom_hooks:
228            try:
229                self.custom_hook(hook)
230            except ValueError as e:
231                raise ValidationError('%s: hook "%s" command line is invalid: '
232                                      '%s' % (self.paths, hook, e))
233
234        # Verify hook options are valid shell strings.
235        for hook in self.builtin_hooks:
236            try:
237                self.builtin_hook_option(hook)
238            except ValueError as e:
239                raise ValidationError('%s: hook options "%s" are invalid: %s' %
240                                      (self.paths, hook, e))
241
242        # Reject unknown tools.
243        valid_tools = set(rh.hooks.TOOL_PATHS.keys())
244        if config.has_section(self.TOOL_PATHS_SECTION):
245            tools = set(config.options(self.TOOL_PATHS_SECTION))
246            bad_tools = tools - valid_tools
247            if bad_tools:
248                raise ValidationError('%s: unknown tools: %s' %
249                                      (self.paths, bad_tools))
250
251        # Reject unknown options.
252        valid_options = set(self.VALID_OPTIONS)
253        if config.has_section(self.OPTIONS_SECTION):
254            options = set(config.options(self.OPTIONS_SECTION))
255            bad_options = options - valid_options
256            if bad_options:
257                raise ValidationError('%s: unknown options: %s' %
258                                      (self.paths, bad_options))
259