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(
184                f'{self.source}: unknown sections: {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(
190                    f'{self.source}: custom hook "{hook}" cannot be blank')
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(
199                    f'{self.source}: unknown builtin hooks: {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(
209                    f'{self.source}: unknown builtin hook options: {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(
217                    f'{self.source}: hook "{hook}" command line is invalid: {e}'
218                ) from e
219
220        # Verify hook options are valid shell strings.
221        for hook in self.builtin_hooks:
222            try:
223                self.builtin_hook_option(hook)
224            except ValueError as e:
225                raise ValidationError(
226                    f'{self.source}: hook options "{hook}" are invalid: {e}'
227                ) from e
228
229        # Reject unknown tools.
230        valid_tools = set(rh.hooks.TOOL_PATHS.keys())
231        if config.has_section(self.TOOL_PATHS_SECTION):
232            tools = set(config.options(self.TOOL_PATHS_SECTION))
233            bad_tools = tools - valid_tools
234            if bad_tools:
235                raise ValidationError(
236                    f'{self.source}: unknown tools: {bad_tools}')
237
238        # Reject unknown options.
239        if config.has_section(self.OPTIONS_SECTION):
240            options = set(config.options(self.OPTIONS_SECTION))
241            bad_options = options - self.VALID_OPTIONS
242            if bad_options:
243                raise ValidationError(
244                    f'{self.source}: unknown options: {bad_options}')
245
246
247class PreUploadFile(PreUploadConfig):
248    """A single config (file) used for `repo upload` hooks.
249
250    This is an abstract class that requires subclasses to define the FILENAME
251    constant.
252
253    Attributes:
254      path: The path of the file.
255    """
256    FILENAME = None
257
258    def __init__(self, path):
259        """Initialize.
260
261        Args:
262          path: The config file to load.
263        """
264        super().__init__(source=path)
265
266        self.path = path
267        try:
268            self.config.read(path)
269        except configparser.ParsingError as e:
270            raise ValidationError(f'{path}: {e}') from e
271
272        self._validate()
273
274    @classmethod
275    def from_paths(cls, paths):
276        """Search for files within paths that matches the class FILENAME.
277
278        Args:
279          paths: List of directories to look for config files.
280
281        Yields:
282          For each valid file found, an instance is created and returned.
283        """
284        for path in paths:
285            path = os.path.join(path, cls.FILENAME)
286            if os.path.exists(path):
287                yield cls(path)
288
289
290class LocalPreUploadFile(PreUploadFile):
291    """A single config file for a project (PREUPLOAD.cfg)."""
292    FILENAME = 'PREUPLOAD.cfg'
293
294    def _validate(self):
295        super()._validate()
296
297        # Reject Exclude Paths section for local config.
298        if self.config.has_section(self.BUILTIN_HOOKS_EXCLUDE_SECTION):
299            raise ValidationError(
300                f'{self.path}: [{self.BUILTIN_HOOKS_EXCLUDE_SECTION}] is not '
301                'valid in local files')
302
303
304class GlobalPreUploadFile(PreUploadFile):
305    """A single config file for a repo (GLOBAL-PREUPLOAD.cfg)."""
306    FILENAME = 'GLOBAL-PREUPLOAD.cfg'
307
308
309class PreUploadSettings(PreUploadConfig):
310    """Settings for `repo upload` hooks.
311
312    This encompasses multiple config files and provides the final (merged)
313    settings for a particular project.
314    """
315
316    def __init__(self, paths=('',), global_paths=()):
317        """Initialize.
318
319        All the config files found will be merged together in order.
320
321        Args:
322          paths: The directories to look for config files.
323          global_paths: The directories to look for global config files.
324        """
325        super().__init__()
326
327        self.paths = []
328        for config in itertools.chain(
329                GlobalPreUploadFile.from_paths(global_paths),
330                LocalPreUploadFile.from_paths(paths)):
331            self.paths.append(config.path)
332            self.update(config)
333
334
335        # We validated configs in isolation, now do one final pass altogether.
336        self.source = '{' + '|'.join(self.paths) + '}'
337        self._validate()
338