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 functools
21import os
22import shlex
23import sys
24
25_path = os.path.realpath(__file__ + '/../..')
26if sys.path[0] != _path:
27    sys.path.insert(0, _path)
28del _path
29
30# pylint: disable=wrong-import-position
31import rh.hooks
32import rh.shell
33from rh.sixish import configparser
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    # pylint can't seem to grok our use of *args here.
56    # pylint: disable=arguments-differ
57
58    def options(self, section, *args):
59        """Return the options in |section| (with default |args|).
60
61        Args:
62          section: The section to look up.
63          args: What to return if |section| does not exist.
64        """
65        cnt = self._check_args('options', 2, 3, args)
66        try:
67            return configparser.RawConfigParser.options(self, section)
68        except configparser.NoSectionError:
69            if cnt == 1:
70                return args[0]
71            raise
72
73    def get(self, section, option, *args):
74        """Return the value for |option| in |section| (with default |args|)."""
75        cnt = self._check_args('get', 3, 4, args)
76        try:
77            return configparser.RawConfigParser.get(self, section, option)
78        except (configparser.NoSectionError, configparser.NoOptionError):
79            if cnt == 1:
80                return args[0]
81            raise
82
83    def items(self, section, *args):
84        """Return a list of (key, value) tuples for the options in |section|."""
85        cnt = self._check_args('items', 2, 3, args)
86        try:
87            return configparser.RawConfigParser.items(self, section)
88        except configparser.NoSectionError:
89            if cnt == 1:
90                return args[0]
91            raise
92
93
94class PreUploadConfig(object):
95    """Config file used for per-project `repo upload` hooks."""
96
97    FILENAME = 'PREUPLOAD.cfg'
98    GLOBAL_FILENAME = 'GLOBAL-PREUPLOAD.cfg'
99
100    CUSTOM_HOOKS_SECTION = 'Hook Scripts'
101    BUILTIN_HOOKS_SECTION = 'Builtin Hooks'
102    BUILTIN_HOOKS_OPTIONS_SECTION = 'Builtin Hooks Options'
103    TOOL_PATHS_SECTION = 'Tool Paths'
104    OPTIONS_SECTION = 'Options'
105
106    OPTION_IGNORE_MERGED_COMMITS = 'ignore_merged_commits'
107    VALID_OPTIONS = (OPTION_IGNORE_MERGED_COMMITS,)
108
109    def __init__(self, paths=('',), global_paths=()):
110        """Initialize.
111
112        All the config files found will be merged together in order.
113
114        Args:
115          paths: The directories to look for config files.
116          global_paths: The directories to look for global config files.
117        """
118        config = RawConfigParser()
119
120        def _search(paths, filename):
121            for path in paths:
122                path = os.path.join(path, filename)
123                if os.path.exists(path):
124                    self.paths.append(path)
125                    try:
126                        config.read(path)
127                    except configparser.ParsingError as e:
128                        raise ValidationError('%s: %s' % (path, e))
129
130        self.paths = []
131        _search(global_paths, self.GLOBAL_FILENAME)
132        _search(paths, self.FILENAME)
133
134        self.config = config
135
136        self._validate()
137
138    @property
139    def custom_hooks(self):
140        """List of custom hooks to run (their keys/names)."""
141        return self.config.options(self.CUSTOM_HOOKS_SECTION, [])
142
143    def custom_hook(self, hook):
144        """The command to execute for |hook|."""
145        return shlex.split(self.config.get(self.CUSTOM_HOOKS_SECTION, hook, ''))
146
147    @property
148    def builtin_hooks(self):
149        """List of all enabled builtin hooks (their keys/names)."""
150        return [k for k, v in self.config.items(self.BUILTIN_HOOKS_SECTION, ())
151                if rh.shell.boolean_shell_value(v, None)]
152
153    def builtin_hook_option(self, hook):
154        """The options to pass to |hook|."""
155        return shlex.split(self.config.get(self.BUILTIN_HOOKS_OPTIONS_SECTION,
156                                           hook, ''))
157
158    @property
159    def tool_paths(self):
160        """List of all tool paths."""
161        return dict(self.config.items(self.TOOL_PATHS_SECTION, ()))
162
163    def callable_hooks(self):
164        """Yield a name and callback for each hook to be executed."""
165        for hook in self.custom_hooks:
166            options = rh.hooks.HookOptions(hook,
167                                           self.custom_hook(hook),
168                                           self.tool_paths)
169            yield (hook, functools.partial(rh.hooks.check_custom,
170                                           options=options))
171
172        for hook in self.builtin_hooks:
173            options = rh.hooks.HookOptions(hook,
174                                           self.builtin_hook_option(hook),
175                                           self.tool_paths)
176            yield (hook, functools.partial(rh.hooks.BUILTIN_HOOKS[hook],
177                                           options=options))
178
179    @property
180    def ignore_merged_commits(self):
181        """Whether to skip hooks for merged commits."""
182        return rh.shell.boolean_shell_value(
183            self.config.get(self.OPTIONS_SECTION,
184                            self.OPTION_IGNORE_MERGED_COMMITS, None),
185            False)
186
187    def _validate(self):
188        """Run consistency checks on the config settings."""
189        config = self.config
190
191        # Reject unknown sections.
192        valid_sections = set((
193            self.CUSTOM_HOOKS_SECTION,
194            self.BUILTIN_HOOKS_SECTION,
195            self.BUILTIN_HOOKS_OPTIONS_SECTION,
196            self.TOOL_PATHS_SECTION,
197            self.OPTIONS_SECTION,
198        ))
199        bad_sections = set(config.sections()) - valid_sections
200        if bad_sections:
201            raise ValidationError('%s: unknown sections: %s' %
202                                  (self.paths, bad_sections))
203
204        # Reject blank custom hooks.
205        for hook in self.custom_hooks:
206            if not config.get(self.CUSTOM_HOOKS_SECTION, hook):
207                raise ValidationError('%s: custom hook "%s" cannot be blank' %
208                                      (self.paths, hook))
209
210        # Reject unknown builtin hooks.
211        valid_builtin_hooks = set(rh.hooks.BUILTIN_HOOKS.keys())
212        if config.has_section(self.BUILTIN_HOOKS_SECTION):
213            hooks = set(config.options(self.BUILTIN_HOOKS_SECTION))
214            bad_hooks = hooks - valid_builtin_hooks
215            if bad_hooks:
216                raise ValidationError('%s: unknown builtin hooks: %s' %
217                                      (self.paths, bad_hooks))
218        elif config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
219            raise ValidationError('Builtin hook options specified, but missing '
220                                  'builtin hook settings')
221
222        if config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
223            hooks = set(config.options(self.BUILTIN_HOOKS_OPTIONS_SECTION))
224            bad_hooks = hooks - valid_builtin_hooks
225            if bad_hooks:
226                raise ValidationError('%s: unknown builtin hook options: %s' %
227                                      (self.paths, bad_hooks))
228
229        # Verify hooks are valid shell strings.
230        for hook in self.custom_hooks:
231            try:
232                self.custom_hook(hook)
233            except ValueError as e:
234                raise ValidationError('%s: hook "%s" command line is invalid: '
235                                      '%s' % (self.paths, hook, e))
236
237        # Verify hook options are valid shell strings.
238        for hook in self.builtin_hooks:
239            try:
240                self.builtin_hook_option(hook)
241            except ValueError as e:
242                raise ValidationError('%s: hook options "%s" are invalid: %s' %
243                                      (self.paths, hook, e))
244
245        # Reject unknown tools.
246        valid_tools = set(rh.hooks.TOOL_PATHS.keys())
247        if config.has_section(self.TOOL_PATHS_SECTION):
248            tools = set(config.options(self.TOOL_PATHS_SECTION))
249            bad_tools = tools - valid_tools
250            if bad_tools:
251                raise ValidationError('%s: unknown tools: %s' %
252                                      (self.paths, bad_tools))
253
254        # Reject unknown options.
255        valid_options = set(self.VALID_OPTIONS)
256        if config.has_section(self.OPTIONS_SECTION):
257            options = set(config.options(self.OPTIONS_SECTION))
258            bad_options = options - valid_options
259            if bad_options:
260                raise ValidationError('%s: unknown options: %s' %
261                                      (self.paths, bad_options))
262