1"""Parse arguments from command line and configuration files."""
2import fnmatch
3import os
4import sys
5import re
6
7import logging
8from argparse import ArgumentParser
9
10from . import __version__
11from .libs.inirama import Namespace
12from .lint.extensions import LINTERS
13
14#: A default checkers
15DEFAULT_LINTERS = 'pycodestyle', 'pyflakes', 'mccabe'
16
17CURDIR = os.getcwd()
18CONFIG_FILES = 'pylama.ini', 'setup.cfg', 'tox.ini', 'pytest.ini'
19
20#: The skip pattern
21SKIP_PATTERN = re.compile(r'# *noqa\b', re.I).search
22
23# Parse a modelines
24MODELINE_RE = re.compile(
25    r'^\s*#\s+(?:pylama:)\s*((?:[\w_]*=[^:\n\s]+:?)+)',
26    re.I | re.M)
27
28# Setup a logger
29LOGGER = logging.getLogger('pylama')
30LOGGER.propagate = False
31STREAM = logging.StreamHandler(sys.stdout)
32LOGGER.addHandler(STREAM)
33
34
35class _Default(object):  # pylint: disable=too-few-public-methods
36
37    def __init__(self, value=None):
38        self.value = value
39
40    def __str__(self):
41        return str(self.value)
42
43    def __repr__(self):
44        return "<_Default [%s]>" % self.value
45
46
47def split_csp_str(val):
48    """ Split comma separated string into unique values, keeping their order.
49
50    :returns: list of splitted values
51    """
52    seen = set()
53    values = val if isinstance(val, (list, tuple)) else val.strip().split(',')
54    return [x for x in values if x and not (x in seen or seen.add(x))]
55
56
57def parse_linters(linters):
58    """ Initialize choosen linters.
59
60    :returns: list of inited linters
61
62    """
63    result = list()
64    for name in split_csp_str(linters):
65        linter = LINTERS.get(name)
66        if linter:
67            result.append((name, linter))
68        else:
69            logging.warning("Linter `%s` not found.", name)
70    return result
71
72
73def get_default_config_file(rootdir=None):
74    """Search for configuration file."""
75    if rootdir is None:
76        return DEFAULT_CONFIG_FILE
77
78    for path in CONFIG_FILES:
79        path = os.path.join(rootdir, path)
80        if os.path.isfile(path) and os.access(path, os.R_OK):
81            return path
82
83
84DEFAULT_CONFIG_FILE = get_default_config_file(CURDIR)
85
86
87PARSER = ArgumentParser(description="Code audit tool for python.")
88PARSER.add_argument(
89    "paths", nargs='*', default=_Default([CURDIR]),
90    help="Paths to files or directories for code check.")
91
92PARSER.add_argument(
93    "--verbose", "-v", action='store_true', help="Verbose mode.")
94
95PARSER.add_argument('--version', action='version',
96                    version='%(prog)s ' + __version__)
97
98PARSER.add_argument(
99    "--format", "-f", default=_Default('pycodestyle'),
100    choices=['pep8', 'pycodestyle', 'pylint', 'parsable'],
101    help="Choose errors format (pycodestyle, pylint, parsable).")
102
103PARSER.add_argument(
104    "--select", "-s", default=_Default(''), type=split_csp_str,
105    help="Select errors and warnings. (comma-separated list)")
106
107PARSER.add_argument(
108    "--sort", default=_Default(''), type=split_csp_str,
109    help="Sort result by error types. Ex. E,W,D")
110
111PARSER.add_argument(
112    "--linters", "-l", default=_Default(','.join(DEFAULT_LINTERS)),
113    type=parse_linters, help=(
114        "Select linters. (comma-separated). Choices are %s."
115        % ','.join(s for s in LINTERS)
116    ))
117
118PARSER.add_argument(
119    "--ignore", "-i", default=_Default(''), type=split_csp_str,
120    help="Ignore errors and warnings. (comma-separated)")
121
122PARSER.add_argument(
123    "--skip", default=_Default(''),
124    type=lambda s: [re.compile(fnmatch.translate(p))
125                    for p in s.split(',') if p],
126    help="Skip files by masks (comma-separated, Ex. */messages.py)")
127
128PARSER.add_argument("--report", "-r", help="Send report to file [REPORT]")
129PARSER.add_argument(
130    "--hook", action="store_true", help="Install Git (Mercurial) hook.")
131
132PARSER.add_argument(
133    "--concurrent", "--async", action="store_true",
134    help="Enable async mode. Useful for checking a lot of files. "
135    "Unsupported with pylint.")
136
137PARSER.add_argument(
138    "--options", "-o", default=DEFAULT_CONFIG_FILE, metavar='FILE',
139    help="Specify configuration file. "
140    "Looks for {}, or {} in the current directory (default: {}).".format(
141        ", ".join(CONFIG_FILES[:-1]), CONFIG_FILES[-1],
142        DEFAULT_CONFIG_FILE))
143
144PARSER.add_argument(
145    "--force", "-F", action='store_true', default=_Default(False),
146    help="Force code checking (if linter doesn't allow)")
147
148PARSER.add_argument(
149    "--abspath", "-a", action='store_true', default=_Default(False),
150    help="Use absolute paths in output.")
151
152
153ACTIONS = dict((a.dest, a)
154               for a in PARSER._actions)  # pylint: disable=protected-access
155
156
157def parse_options(args=None, config=True, rootdir=CURDIR, **overrides):  # noqa
158    """ Parse options from command line and configuration files.
159
160    :return argparse.Namespace:
161
162    """
163    args = args or []
164
165    # Parse args from command string
166    options = PARSER.parse_args(args)
167    options.file_params = dict()
168    options.linters_params = dict()
169
170    # Compile options from ini
171    if config:
172        cfg = get_config(str(options.options), rootdir=rootdir)
173        for opt, val in cfg.default.items():
174            LOGGER.info('Find option %s (%s)', opt, val)
175            passed_value = getattr(options, opt, _Default())
176            if isinstance(passed_value, _Default):
177                if opt == 'paths':
178                    val = val.split()
179                if opt == 'skip':
180                    val = fix_pathname_sep(val)
181                setattr(options, opt, _Default(val))
182
183        # Parse file related options
184        for name, opts in cfg.sections.items():
185
186            if name == cfg.default_section:
187                continue
188
189            if name.startswith('pylama'):
190                name = name[7:]
191
192            if name in LINTERS:
193                options.linters_params[name] = dict(opts)
194                continue
195
196            mask = re.compile(fnmatch.translate(fix_pathname_sep(name)))
197            options.file_params[mask] = dict(opts)
198
199    # Override options
200    _override_options(options, **overrides)
201
202    # Postprocess options
203    for name in options.__dict__:
204        value = getattr(options, name)
205        if isinstance(value, _Default):
206            setattr(options, name, process_value(name, value.value))
207
208    if options.concurrent and 'pylint' in options.linters:
209        LOGGER.warning('Can\'t parse code asynchronously with pylint enabled.')
210        options.concurrent = False
211
212    return options
213
214
215def _override_options(options, **overrides):
216    """Override options."""
217    for opt, val in overrides.items():
218        passed_value = getattr(options, opt, _Default())
219        if opt in ('ignore', 'select') and passed_value:
220            value = process_value(opt, passed_value.value)
221            value += process_value(opt, val)
222            setattr(options, opt, value)
223        elif isinstance(passed_value, _Default):
224            setattr(options, opt, process_value(opt, val))
225
226
227def process_value(name, value):
228    """ Compile option value. """
229    action = ACTIONS.get(name)
230    if not action:
231        return value
232
233    if callable(action.type):
234        return action.type(value)
235
236    if action.const:
237        return bool(int(value))
238
239    return value
240
241
242def get_config(ini_path=None, rootdir=None):
243    """ Load configuration from INI.
244
245    :return Namespace:
246
247    """
248    config = Namespace()
249    config.default_section = 'pylama'
250
251    if not ini_path:
252        path = get_default_config_file(rootdir)
253        if path:
254            config.read(path)
255    else:
256        config.read(ini_path)
257
258    return config
259
260
261def setup_logger(options):
262    """Do the logger setup with options."""
263    LOGGER.setLevel(logging.INFO if options.verbose else logging.WARN)
264    if options.report:
265        LOGGER.removeHandler(STREAM)
266        LOGGER.addHandler(logging.FileHandler(options.report, mode='w'))
267
268    if options.options:
269        LOGGER.info('Try to read configuration from: %r', options.options)
270
271
272def fix_pathname_sep(val):
273    """Fix pathnames for Win."""
274    return val.replace(os.altsep or "\\", os.sep)
275
276# pylama:ignore=W0212,D210,F0001
277