aboutsummaryrefslogtreecommitdiff
path: root/rh/config.py
blob: 61f2ef041c76298f9c468e4aa6c50a532e021e6e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# -*- coding:utf-8 -*-
# Copyright 2016 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Manage various config files."""

from __future__ import print_function

import ConfigParser
import functools
import os
import shlex
import sys

_path = os.path.realpath(__file__ + '/../..')
if sys.path[0] != _path:
    sys.path.insert(0, _path)
del _path

# pylint: disable=wrong-import-position
import rh.hooks
import rh.shell


class Error(Exception):
    """Base exception class."""


class ValidationError(Error):
    """Config file has unknown sections/keys or other values."""


class RawConfigParser(ConfigParser.RawConfigParser):
    """Like RawConfigParser but with some default helpers."""

    @staticmethod
    def _check_args(name, cnt_min, cnt_max, args):
        cnt = len(args)
        if cnt not in (0, cnt_max - cnt_min):
            raise TypeError('%s() takes %i or %i arguments (got %i)' %
                            (name, cnt_min, cnt_max, cnt,))
        return cnt

    def options(self, section, *args):
        """Return the options in |section| (with default |args|).

        Args:
          section: The section to look up.
          args: What to return if |section| does not exist.
        """
        cnt = self._check_args('options', 2, 3, args)
        try:
            return ConfigParser.RawConfigParser.options(self, section)
        except ConfigParser.NoSectionError:
            if cnt == 1:
                return args[0]
            raise

    def get(self, section, option, *args):
        """Return the value for |option| in |section| (with default |args|)."""
        cnt = self._check_args('get', 3, 4, args)
        try:
            return ConfigParser.RawConfigParser.get(self, section, option)
        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
            if cnt == 1:
                return args[0]
            raise

    def items(self, section, *args):
        """Return a list of (key, value) tuples for the options in |section|."""
        cnt = self._check_args('items', 2, 3, args)
        try:
            return ConfigParser.RawConfigParser.items(self, section)
        except ConfigParser.NoSectionError:
            if cnt == 1:
                return args[0]
            raise


class PreSubmitConfig(object):
    """Config file used for per-project `repo upload` hooks."""

    FILENAME = 'PREUPLOAD.cfg'
    GLOBAL_FILENAME = 'GLOBAL-PREUPLOAD.cfg'

    CUSTOM_HOOKS_SECTION = 'Hook Scripts'
    BUILTIN_HOOKS_SECTION = 'Builtin Hooks'
    BUILTIN_HOOKS_OPTIONS_SECTION = 'Builtin Hooks Options'
    TOOL_PATHS_SECTION = 'Tool Paths'
    OPTIONS_SECTION = 'Options'

    OPTION_IGNORE_MERGED_COMMITS = 'ignore_merged_commits'
    VALID_OPTIONS = (OPTION_IGNORE_MERGED_COMMITS,)

    def __init__(self, paths=('',), global_paths=()):
        """Initialize.

        All the config files found will be merged together in order.

        Args:
          paths: The directories to look for config files.
          global_paths: The directories to look for global config files.
        """
        config = RawConfigParser()

        def _search(paths, filename):
            for path in paths:
                path = os.path.join(path, filename)
                if os.path.exists(path):
                    self.paths.append(path)
                    try:
                        config.read(path)
                    except ConfigParser.ParsingError as e:
                        raise ValidationError('%s: %s' % (path, e))

        self.paths = []
        _search(global_paths, self.GLOBAL_FILENAME)
        _search(paths, self.FILENAME)

        self.config = config

        self._validate()

    @property
    def custom_hooks(self):
        """List of custom hooks to run (their keys/names)."""
        return self.config.options(self.CUSTOM_HOOKS_SECTION, [])

    def custom_hook(self, hook):
        """The command to execute for |hook|."""
        return shlex.split(self.config.get(self.CUSTOM_HOOKS_SECTION, hook, ''))

    @property
    def builtin_hooks(self):
        """List of all enabled builtin hooks (their keys/names)."""
        return [k for k, v in self.config.items(self.BUILTIN_HOOKS_SECTION, ())
                if rh.shell.boolean_shell_value(v, None)]

    def builtin_hook_option(self, hook):
        """The options to pass to |hook|."""
        return shlex.split(self.config.get(self.BUILTIN_HOOKS_OPTIONS_SECTION,
                                           hook, ''))

    @property
    def tool_paths(self):
        """List of all tool paths."""
        return dict(self.config.items(self.TOOL_PATHS_SECTION, ()))

    def callable_hooks(self):
        """Yield a name and callback for each hook to be executed."""
        for hook in self.custom_hooks:
            options = rh.hooks.HookOptions(hook,
                                           self.custom_hook(hook),
                                           self.tool_paths)
            yield (hook, functools.partial(rh.hooks.check_custom,
                                           options=options))

        for hook in self.builtin_hooks:
            options = rh.hooks.HookOptions(hook,
                                           self.builtin_hook_option(hook),
                                           self.tool_paths)
            yield (hook, functools.partial(rh.hooks.BUILTIN_HOOKS[hook],
                                           options=options))

    @property
    def ignore_merged_commits(self):
        """Whether to skip hooks for merged commits."""
        return rh.shell.boolean_shell_value(
            self.config.get(self.OPTIONS_SECTION,
                            self.OPTION_IGNORE_MERGED_COMMITS, None),
            False)

    def _validate(self):
        """Run consistency checks on the config settings."""
        config = self.config

        # Reject unknown sections.
        valid_sections = set((
            self.CUSTOM_HOOKS_SECTION,
            self.BUILTIN_HOOKS_SECTION,
            self.BUILTIN_HOOKS_OPTIONS_SECTION,
            self.TOOL_PATHS_SECTION,
            self.OPTIONS_SECTION,
        ))
        bad_sections = set(config.sections()) - valid_sections
        if bad_sections:
            raise ValidationError('%s: unknown sections: %s' %
                                  (self.paths, bad_sections))

        # Reject blank custom hooks.
        for hook in self.custom_hooks:
            if not config.get(self.CUSTOM_HOOKS_SECTION, hook):
                raise ValidationError('%s: custom hook "%s" cannot be blank' %
                                      (self.paths, hook))

        # Reject unknown builtin hooks.
        valid_builtin_hooks = set(rh.hooks.BUILTIN_HOOKS.keys())
        if config.has_section(self.BUILTIN_HOOKS_SECTION):
            hooks = set(config.options(self.BUILTIN_HOOKS_SECTION))
            bad_hooks = hooks - valid_builtin_hooks
            if bad_hooks:
                raise ValidationError('%s: unknown builtin hooks: %s' %
                                      (self.paths, bad_hooks))
        elif config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
            raise ValidationError('Builtin hook options specified, but missing '
                                  'builtin hook settings')

        if config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
            hooks = set(config.options(self.BUILTIN_HOOKS_OPTIONS_SECTION))
            bad_hooks = hooks - valid_builtin_hooks
            if bad_hooks:
                raise ValidationError('%s: unknown builtin hook options: %s' %
                                      (self.paths, bad_hooks))

        # Verify hooks are valid shell strings.
        for hook in self.custom_hooks:
            try:
                self.custom_hook(hook)
            except ValueError as e:
                raise ValidationError('%s: hook "%s" command line is invalid: '
                                      '%s' % (self.paths, hook, e))

        # Verify hook options are valid shell strings.
        for hook in self.builtin_hooks:
            try:
                self.builtin_hook_option(hook)
            except ValueError as e:
                raise ValidationError('%s: hook options "%s" are invalid: %s' %
                                      (self.paths, hook, e))

        # Reject unknown tools.
        valid_tools = set(rh.hooks.TOOL_PATHS.keys())
        if config.has_section(self.TOOL_PATHS_SECTION):
            tools = set(config.options(self.TOOL_PATHS_SECTION))
            bad_tools = tools - valid_tools
            if bad_tools:
                raise ValidationError('%s: unknown tools: %s' %
                                      (self.paths, bad_tools))

        # Reject unknown options.
        valid_options = set(self.VALID_OPTIONS)
        if config.has_section(self.OPTIONS_SECTION):
            options = set(config.options(self.OPTIONS_SECTION))
            bad_options = options - valid_options
            if bad_options:
                raise ValidationError('%s: unknown options: %s' %
                                      (self.paths, bad_options))