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 items(self, section=_UNSET, default=_UNSET):
67        """Return a list of (key, value) tuples for the options in |section|."""
68        if section is _UNSET:
69            return super().items()
70
71        try:
72            return configparser.RawConfigParser.items(self, section)
73        except configparser.NoSectionError:
74            if default is not _UNSET:
75                return default
76            raise
77
78
79class PreUploadConfig(object):
80    """A single (abstract) config used for `repo upload` hooks."""
81
82    CUSTOM_HOOKS_SECTION = 'Hook Scripts'
83    BUILTIN_HOOKS_SECTION = 'Builtin Hooks'
84    BUILTIN_HOOKS_OPTIONS_SECTION = 'Builtin Hooks Options'
85    BUILTIN_HOOKS_EXCLUDE_SECTION = 'Builtin Hooks Exclude Paths'
86    TOOL_PATHS_SECTION = 'Tool Paths'
87    OPTIONS_SECTION = 'Options'
88    VALID_SECTIONS = {
89        CUSTOM_HOOKS_SECTION,
90        BUILTIN_HOOKS_SECTION,
91        BUILTIN_HOOKS_OPTIONS_SECTION,
92        BUILTIN_HOOKS_EXCLUDE_SECTION,
93        TOOL_PATHS_SECTION,
94        OPTIONS_SECTION,
95    }
96
97    OPTION_IGNORE_MERGED_COMMITS = 'ignore_merged_commits'
98    VALID_OPTIONS = {OPTION_IGNORE_MERGED_COMMITS}
99
100    def __init__(self, config=None, source=None):
101        """Initialize.
102
103        Args:
104          config: A configparse.ConfigParser instance.
105          source: Where this config came from. This is used in error messages to
106              facilitate debugging. It is not necessarily a valid path.
107        """
108        self.config = config if config else RawConfigParser()
109        self.source = source
110        if config:
111            self._validate()
112
113    @property
114    def custom_hooks(self):
115        """List of custom hooks to run (their keys/names)."""
116        return self.config.options(self.CUSTOM_HOOKS_SECTION, [])
117
118    def custom_hook(self, hook):
119        """The command to execute for |hook|."""
120        return shlex.split(self.config.get(
121            self.CUSTOM_HOOKS_SECTION, hook, fallback=''))
122
123    @property
124    def builtin_hooks(self):
125        """List of all enabled builtin hooks (their keys/names)."""
126        return [k for k, v in self.config.items(self.BUILTIN_HOOKS_SECTION, ())
127                if rh.shell.boolean_shell_value(v, None)]
128
129    def builtin_hook_option(self, hook):
130        """The options to pass to |hook|."""
131        return shlex.split(self.config.get(
132            self.BUILTIN_HOOKS_OPTIONS_SECTION, hook, fallback=''))
133
134    def builtin_hook_exclude_paths(self, hook):
135        """List of paths for which |hook| should not be executed."""
136        return shlex.split(self.config.get(
137            self.BUILTIN_HOOKS_EXCLUDE_SECTION, hook, fallback=''))
138
139    @property
140    def tool_paths(self):
141        """List of all tool paths."""
142        return dict(self.config.items(self.TOOL_PATHS_SECTION, ()))
143
144    def callable_hooks(self):
145        """Yield a CallableHook for each hook to be executed."""
146        scope = rh.hooks.ExclusionScope([])
147        for hook in self.custom_hooks:
148            options = rh.hooks.HookOptions(hook,
149                                           self.custom_hook(hook),
150                                           self.tool_paths)
151            func = functools.partial(rh.hooks.check_custom, options=options)
152            yield rh.hooks.CallableHook(hook, func, scope)
153
154        for hook in self.builtin_hooks:
155            options = rh.hooks.HookOptions(hook,
156                                           self.builtin_hook_option(hook),
157                                           self.tool_paths)
158            func = functools.partial(rh.hooks.BUILTIN_HOOKS[hook],
159                                     options=options)
160            scope = rh.hooks.ExclusionScope(
161                self.builtin_hook_exclude_paths(hook))
162            yield rh.hooks.CallableHook(hook, func, scope)
163
164    @property
165    def ignore_merged_commits(self):
166        """Whether to skip hooks for merged commits."""
167        return rh.shell.boolean_shell_value(
168            self.config.get(self.OPTIONS_SECTION,
169                            self.OPTION_IGNORE_MERGED_COMMITS, fallback=None),
170            False)
171
172    def update(self, preupload_config):
173        """Merge settings from |preupload_config| into ourself."""
174        self.config.read_dict(preupload_config.config)
175
176    def _validate(self):
177        """Run consistency checks on the config settings."""
178        config = self.config
179
180        # Reject unknown sections.
181        bad_sections = set(config.sections()) - self.VALID_SECTIONS
182        if bad_sections:
183            raise ValidationError('%s: unknown sections: %s' %
184                                  (self.source, bad_sections))
185
186        # Reject blank custom hooks.
187        for hook in self.custom_hooks:
188            if not config.get(self.CUSTOM_HOOKS_SECTION, hook):
189                raise ValidationError('%s: custom hook "%s" cannot be blank' %
190                                      (self.source, hook))
191
192        # Reject unknown builtin hooks.
193        valid_builtin_hooks = set(rh.hooks.BUILTIN_HOOKS.keys())
194        if config.has_section(self.BUILTIN_HOOKS_SECTION):
195            hooks = set(config.options(self.BUILTIN_HOOKS_SECTION))
196            bad_hooks = hooks - valid_builtin_hooks
197            if bad_hooks:
198                raise ValidationError('%s: unknown builtin hooks: %s' %
199                                      (self.source, bad_hooks))
200        elif config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
201            raise ValidationError('Builtin hook options specified, but missing '
202                                  'builtin hook settings')
203
204        if config.has_section(self.BUILTIN_HOOKS_OPTIONS_SECTION):
205            hooks = set(config.options(self.BUILTIN_HOOKS_OPTIONS_SECTION))
206            bad_hooks = hooks - valid_builtin_hooks
207            if bad_hooks:
208                raise ValidationError('%s: unknown builtin hook options: %s' %
209                                      (self.source, bad_hooks))
210
211        # Verify hooks are valid shell strings.
212        for hook in self.custom_hooks:
213            try:
214                self.custom_hook(hook)
215            except ValueError as e:
216                raise ValidationError('%s: hook "%s" command line is invalid: '
217                                      '%s' % (self.source, hook, e)) from e
218
219        # Verify hook options are valid shell strings.
220        for hook in self.builtin_hooks:
221            try:
222                self.builtin_hook_option(hook)
223            except ValueError as e:
224                raise ValidationError('%s: hook options "%s" are invalid: %s' %
225                                      (self.source, hook, e)) from e
226
227        # Reject unknown tools.
228        valid_tools = set(rh.hooks.TOOL_PATHS.keys())
229        if config.has_section(self.TOOL_PATHS_SECTION):
230            tools = set(config.options(self.TOOL_PATHS_SECTION))
231            bad_tools = tools - valid_tools
232            if bad_tools:
233                raise ValidationError('%s: unknown tools: %s' %
234                                      (self.source, bad_tools))
235
236        # Reject unknown options.
237        if config.has_section(self.OPTIONS_SECTION):
238            options = set(config.options(self.OPTIONS_SECTION))
239            bad_options = options - self.VALID_OPTIONS
240            if bad_options:
241                raise ValidationError('%s: unknown options: %s' %
242                                      (self.source, bad_options))
243
244
245class PreUploadFile(PreUploadConfig):
246    """A single config (file) used for `repo upload` hooks.
247
248    This is an abstract class that requires subclasses to define the FILENAME
249    constant.
250
251    Attributes:
252      path: The path of the file.
253    """
254    FILENAME = None
255
256    def __init__(self, path):
257        """Initialize.
258
259        Args:
260          path: The config file to load.
261        """
262        super().__init__(source=path)
263
264        self.path = path
265        try:
266            self.config.read(path)
267        except configparser.ParsingError as e:
268            raise ValidationError('%s: %s' % (path, e)) from e
269
270        self._validate()
271
272    @classmethod
273    def from_paths(cls, paths):
274        """Search for files within paths that matches the class FILENAME.
275
276        Args:
277          paths: List of directories to look for config files.
278
279        Yields:
280          For each valid file found, an instance is created and returned.
281        """
282        for path in paths:
283            path = os.path.join(path, cls.FILENAME)
284            if os.path.exists(path):
285                yield cls(path)
286
287
288class LocalPreUploadFile(PreUploadFile):
289    """A single config file for a project (PREUPLOAD.cfg)."""
290    FILENAME = 'PREUPLOAD.cfg'
291
292    def _validate(self):
293        super()._validate()
294
295        # Reject Exclude Paths section for local config.
296        if self.config.has_section(self.BUILTIN_HOOKS_EXCLUDE_SECTION):
297            raise ValidationError('%s: [%s] is not valid in local files' %
298                                  (self.path,
299                                   self.BUILTIN_HOOKS_EXCLUDE_SECTION))
300
301
302class GlobalPreUploadFile(PreUploadFile):
303    """A single config file for a repo (GLOBAL-PREUPLOAD.cfg)."""
304    FILENAME = 'GLOBAL-PREUPLOAD.cfg'
305
306
307class PreUploadSettings(PreUploadConfig):
308    """Settings for `repo upload` hooks.
309
310    This encompasses multiple config files and provides the final (merged)
311    settings for a particular project.
312    """
313
314    def __init__(self, paths=('',), global_paths=()):
315        """Initialize.
316
317        All the config files found will be merged together in order.
318
319        Args:
320          paths: The directories to look for config files.
321          global_paths: The directories to look for global config files.
322        """
323        super().__init__()
324
325        self.paths = []
326        for config in itertools.chain(
327                GlobalPreUploadFile.from_paths(global_paths),
328                LocalPreUploadFile.from_paths(paths)):
329            self.paths.append(config.path)
330            self.update(config)
331
332
333        # We validated configs in isolation, now do one final pass altogether.
334        self.source = '{%s}' % '|'.join(self.paths)
335        self._validate()
336