1# SPDX-License-Identifier: GPL-2.0+
2# Copyright (c) 2011 The Chromium OS Authors.
3# Copyright (c) 2022 Maxim Cournoyer <maxim.cournoyer@savoirfairelinux.com>
4#
5
6try:
7    import configparser as ConfigParser
8except Exception:
9    import ConfigParser
10
11import argparse
12from io import StringIO
13import os
14import re
15import sys
16
17from u_boot_pylib import gitutil
18
19"""Default settings per-project.
20
21These are used by _ProjectConfigParser.  Settings names should match
22the "dest" of the option parser from patman.py.
23"""
24_default_settings = {
25    "u-boot": {},
26    "linux": {
27        "process_tags": "False",
28        "check_patch_use_tree": "True",
29    },
30    "gcc": {
31        "process_tags": "False",
32        "add_signoff": "False",
33        "check_patch": "False",
34    },
35}
36
37
38class _ProjectConfigParser(ConfigParser.ConfigParser):
39    """ConfigParser that handles projects.
40
41    There are two main goals of this class:
42    - Load project-specific default settings.
43    - Merge general default settings/aliases with project-specific ones.
44
45    # Sample config used for tests below...
46    >>> from io import StringIO
47    >>> sample_config = '''
48    ... [alias]
49    ... me: Peter P. <likesspiders@example.com>
50    ... enemies: Evil <evil@example.com>
51    ...
52    ... [sm_alias]
53    ... enemies: Green G. <ugly@example.com>
54    ...
55    ... [sm2_alias]
56    ... enemies: Doc O. <pus@example.com>
57    ...
58    ... [settings]
59    ... am_hero: True
60    ... '''
61
62    # Check to make sure that bogus project gets general alias.
63    >>> config = _ProjectConfigParser("zzz")
64    >>> config.read_file(StringIO(sample_config))
65    >>> str(config.get("alias", "enemies"))
66    'Evil <evil@example.com>'
67
68    # Check to make sure that alias gets overridden by project.
69    >>> config = _ProjectConfigParser("sm")
70    >>> config.read_file(StringIO(sample_config))
71    >>> str(config.get("alias", "enemies"))
72    'Green G. <ugly@example.com>'
73
74    # Check to make sure that settings get merged with project.
75    >>> config = _ProjectConfigParser("linux")
76    >>> config.read_file(StringIO(sample_config))
77    >>> sorted((str(a), str(b)) for (a, b) in config.items("settings"))
78    [('am_hero', 'True'), ('check_patch_use_tree', 'True'), ('process_tags', 'False')]
79
80    # Check to make sure that settings works with unknown project.
81    >>> config = _ProjectConfigParser("unknown")
82    >>> config.read_file(StringIO(sample_config))
83    >>> sorted((str(a), str(b)) for (a, b) in config.items("settings"))
84    [('am_hero', 'True')]
85    """
86    def __init__(self, project_name):
87        """Construct _ProjectConfigParser.
88
89        In addition to standard ConfigParser initialization, this also
90        loads project defaults.
91
92        Args:
93            project_name: The name of the project.
94        """
95        self._project_name = project_name
96        ConfigParser.ConfigParser.__init__(self)
97
98        # Update the project settings in the config based on
99        # the _default_settings global.
100        project_settings = "%s_settings" % project_name
101        if not self.has_section(project_settings):
102            self.add_section(project_settings)
103        project_defaults = _default_settings.get(project_name, {})
104        for setting_name, setting_value in project_defaults.items():
105            self.set(project_settings, setting_name, setting_value)
106
107    def get(self, section, option, *args, **kwargs):
108        """Extend ConfigParser to try project_section before section.
109
110        Args:
111            See ConfigParser.
112        Returns:
113            See ConfigParser.
114        """
115        try:
116            val = ConfigParser.ConfigParser.get(
117                self, "%s_%s" % (self._project_name, section), option,
118                *args, **kwargs
119            )
120        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
121            val = ConfigParser.ConfigParser.get(
122                self, section, option, *args, **kwargs
123            )
124        return val
125
126    def items(self, section, *args, **kwargs):
127        """Extend ConfigParser to add project_section to section.
128
129        Args:
130            See ConfigParser.
131        Returns:
132            See ConfigParser.
133        """
134        project_items = []
135        has_project_section = False
136        top_items = []
137
138        # Get items from the project section
139        try:
140            project_items = ConfigParser.ConfigParser.items(
141                self, "%s_%s" % (self._project_name, section), *args, **kwargs
142            )
143            has_project_section = True
144        except ConfigParser.NoSectionError:
145            pass
146
147        # Get top-level items
148        try:
149            top_items = ConfigParser.ConfigParser.items(
150                self, section, *args, **kwargs
151            )
152        except ConfigParser.NoSectionError:
153            # If neither section exists raise the error on...
154            if not has_project_section:
155                raise
156
157        item_dict = dict(top_items)
158        item_dict.update(project_items)
159        return {(item, val) for item, val in item_dict.items()}
160
161
162def ReadGitAliases(fname):
163    """Read a git alias file. This is in the form used by git:
164
165    alias uboot  u-boot@lists.denx.de
166    alias wd     Wolfgang Denk <wd@denx.de>
167
168    Args:
169        fname: Filename to read
170    """
171    try:
172        fd = open(fname, 'r', encoding='utf-8')
173    except IOError:
174        print("Warning: Cannot find alias file '%s'" % fname)
175        return
176
177    re_line = re.compile(r'alias\s+(\S+)\s+(.*)')
178    for line in fd.readlines():
179        line = line.strip()
180        if not line or line[0] == '#':
181            continue
182
183        m = re_line.match(line)
184        if not m:
185            print("Warning: Alias file line '%s' not understood" % line)
186            continue
187
188        list = alias.get(m.group(1), [])
189        for item in m.group(2).split(','):
190            item = item.strip()
191            if item:
192                list.append(item)
193        alias[m.group(1)] = list
194
195    fd.close()
196
197
198def CreatePatmanConfigFile(config_fname):
199    """Creates a config file under $(HOME)/.patman if it can't find one.
200
201    Args:
202        config_fname: Default config filename i.e., $(HOME)/.patman
203
204    Returns:
205        None
206    """
207    name = gitutil.get_default_user_name()
208    if name is None:
209        name = input("Enter name: ")
210
211    email = gitutil.get_default_user_email()
212
213    if email is None:
214        email = input("Enter email: ")
215
216    try:
217        f = open(config_fname, 'w')
218    except IOError:
219        print("Couldn't create patman config file\n")
220        raise
221
222    print('''[alias]
223me: %s <%s>
224
225[bounces]
226nxp = Zhikang Zhang <zhikang.zhang@nxp.com>
227''' % (name, email), file=f)
228    f.close()
229
230
231def _UpdateDefaults(main_parser, config, argv):
232    """Update the given OptionParser defaults based on config.
233
234    We'll walk through all of the settings from all parsers.
235    For each setting we'll look for a default in the option parser.
236    If it's found we'll update the option parser default.
237
238    The idea here is that the .patman file should be able to update
239    defaults but that command line flags should still have the final
240    say.
241
242    Args:
243        parser: An instance of an ArgumentParser whose defaults will be
244            updated.
245        config: An instance of _ProjectConfigParser that we will query
246            for settings.
247        argv (list of str or None): Arguments to parse
248    """
249    # Find all the parsers and subparsers
250    parsers = [main_parser]
251    parsers += [subparser for action in main_parser._actions
252                if isinstance(action, argparse._SubParsersAction)
253                for _, subparser in action.choices.items()]
254
255    # Collect the defaults from each parser
256    defaults = {}
257    parser_defaults = []
258    argv = list(argv)
259    orig_argv = argv
260
261    bad = False
262    full_parser_list = []
263    for parser in parsers:
264        argv_list = [orig_argv]
265        special_cases = []
266        if hasattr(parser, 'defaults_cmds'):
267           special_cases = parser.defaults_cmds
268        for action in parser._actions:
269            if action.choices:
270                argv_list = []
271                for choice in action.choices:
272                    argv = None
273                    for case in special_cases:
274                        if case[0] == choice:
275                            argv = case
276                    argv_list.append(argv or [choice])
277
278        for argv in argv_list:
279            parser.message = None
280            old_val = parser.catch_error
281            try:
282                parser.catch_error = True
283                pdefs = parser.parse_known_args(argv)[0]
284            finally:
285                parser.catch_error = old_val
286
287            # if parser.message:
288                # print('bad', argv, parser.message)
289                # bad = True
290
291            parser_defaults.append(pdefs)
292            defaults.update(vars(pdefs))
293            full_parser_list.append(parser)
294    if bad:
295        print('Internal parsing error')
296        sys.exit(1)
297
298    # Go through the settings and collect defaults
299    for name, val in config.items('settings'):
300        if name in defaults:
301            default_val = defaults[name]
302            if isinstance(default_val, bool):
303                val = config.getboolean('settings', name)
304            elif isinstance(default_val, int):
305                val = config.getint('settings', name)
306            elif isinstance(default_val, str):
307                val = config.get('settings', name)
308            defaults[name] = val
309        else:
310            print("WARNING: Unknown setting %s" % name)
311    if 'cmd' in defaults:
312        del defaults['cmd']
313    if 'subcmd' in defaults:
314        del defaults['subcmd']
315
316    # Set all the defaults and manually propagate them to subparsers
317    main_parser.set_defaults(**defaults)
318    assert len(full_parser_list) == len(parser_defaults)
319    for parser, pdefs in zip(full_parser_list, parser_defaults):
320        parser.set_defaults(**{k: v for k, v in defaults.items()
321                               if k in pdefs})
322    return defaults
323
324
325def _ReadAliasFile(fname):
326    """Read in the U-Boot git alias file if it exists.
327
328    Args:
329        fname: Filename to read.
330    """
331    if os.path.exists(fname):
332        bad_line = None
333        with open(fname, encoding='utf-8') as fd:
334            linenum = 0
335            for line in fd:
336                linenum += 1
337                line = line.strip()
338                if not line or line.startswith('#'):
339                    continue
340                words = line.split(None, 2)
341                if len(words) < 3 or words[0] != 'alias':
342                    if not bad_line:
343                        bad_line = "%s:%d:Invalid line '%s'" % (fname, linenum,
344                                                                line)
345                    continue
346                alias[words[1]] = [s.strip() for s in words[2].split(',')]
347        if bad_line:
348            print(bad_line)
349
350
351def _ReadBouncesFile(fname):
352    """Read in the bounces file if it exists
353
354    Args:
355        fname: Filename to read.
356    """
357    if os.path.exists(fname):
358        with open(fname) as fd:
359            for line in fd:
360                if line.startswith('#'):
361                    continue
362                bounces.add(line.strip())
363
364
365def GetItems(config, section):
366    """Get the items from a section of the config.
367
368    Args:
369        config: _ProjectConfigParser object containing settings
370        section: name of section to retrieve
371
372    Returns:
373        List of (name, value) tuples for the section
374    """
375    try:
376        return config.items(section)
377    except ConfigParser.NoSectionError:
378        return []
379
380
381def Setup(parser, project_name, argv, config_fname=None):
382    """Set up the settings module by reading config files.
383
384    Unless `config_fname` is specified, a `.patman` config file local
385    to the git repository is consulted, followed by the global
386    `$HOME/.patman`. If none exists, the later is created. Values
387    defined in the local config file take precedence over those
388    defined in the global one.
389
390    Args:
391        parser:         The parser to update.
392        project_name:   Name of project that we're working on; we'll look
393            for sections named "project_section" as well.
394        config_fname:   Config filename to read, or None for default, or False
395            for an empty config.  An error is raised if it does not exist.
396        argv (list of str or None): Arguments to parse, or None for default
397    """
398    # First read the git alias file if available
399    _ReadAliasFile('doc/git-mailrc')
400    config = _ProjectConfigParser(project_name)
401
402    if config_fname and not os.path.exists(config_fname):
403        raise Exception(f'provided {config_fname} does not exist')
404
405    if config_fname is None:
406        config_fname = '%s/.patman' % os.getenv('HOME')
407    git_local_config_fname = os.path.join(gitutil.get_top_level() or '',
408                                          '.patman')
409
410    has_config = False
411    has_git_local_config = False
412    if config_fname is not False:
413        has_config = os.path.exists(config_fname)
414        has_git_local_config = os.path.exists(git_local_config_fname)
415
416    # Read the git local config last, so that its values override
417    # those of the global config, if any.
418    if has_config:
419        config.read(config_fname)
420    if has_git_local_config:
421        config.read(git_local_config_fname)
422
423    if config_fname is not False and not (has_config or has_git_local_config):
424        print("No config file found.\nCreating ~/.patman...\n")
425        CreatePatmanConfigFile(config_fname)
426
427    for name, value in GetItems(config, 'alias'):
428        alias[name] = value.split(',')
429
430    _ReadBouncesFile('doc/bounces')
431    for name, value in GetItems(config, 'bounces'):
432        bounces.add(value)
433
434    return _UpdateDefaults(parser, config, argv)
435
436
437# These are the aliases we understand, indexed by alias. Each member is a list.
438alias = {}
439bounces = set()
440
441if __name__ == "__main__":
442    import doctest
443
444    doctest.testmod()
445