• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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