1"""Config handling logic for Flake8."""
2import collections
3import configparser
4import logging
5import os.path
6from typing import List
7from typing import Optional
8from typing import Tuple
9
10from flake8 import utils
11
12LOG = logging.getLogger(__name__)
13
14__all__ = ("ConfigFileFinder", "ConfigParser")
15
16
17class ConfigFileFinder:
18    """Encapsulate the logic for finding and reading config files."""
19
20    def __init__(
21        self,
22        program_name: str,
23        extra_config_files: Optional[List[str]] = None,
24        config_file: Optional[str] = None,
25        ignore_config_files: bool = False,
26    ) -> None:
27        """Initialize object to find config files.
28
29        :param str program_name:
30            Name of the current program (e.g., flake8).
31        :param list extra_config_files:
32            Extra configuration files specified by the user to read.
33        :param str config_file:
34            Configuration file override to only read configuration from.
35        :param bool ignore_config_files:
36            Determine whether to ignore configuration files or not.
37        """
38        # The values of --append-config from the CLI
39        if extra_config_files is None:
40            extra_config_files = []
41        self.extra_config_files = utils.normalize_paths(extra_config_files)
42
43        # The value of --config from the CLI.
44        self.config_file = config_file
45
46        # The value of --isolated from the CLI.
47        self.ignore_config_files = ignore_config_files
48
49        # User configuration file.
50        self.program_name = program_name
51
52        # List of filenames to find in the local/project directory
53        self.project_filenames = ("setup.cfg", "tox.ini", f".{program_name}")
54
55        self.local_directory = os.path.abspath(os.curdir)
56
57    @staticmethod
58    def _read_config(
59        *files: str,
60    ) -> Tuple[configparser.RawConfigParser, List[str]]:
61        config = configparser.RawConfigParser()
62
63        found_files = []
64        for filename in files:
65            try:
66                found_files.extend(config.read(filename))
67            except UnicodeDecodeError:
68                LOG.exception(
69                    "There was an error decoding a config file."
70                    "The file with a problem was %s.",
71                    filename,
72                )
73            except configparser.ParsingError:
74                LOG.exception(
75                    "There was an error trying to parse a config "
76                    "file. The file with a problem was %s.",
77                    filename,
78                )
79        return (config, found_files)
80
81    def cli_config(self, files: str) -> configparser.RawConfigParser:
82        """Read and parse the config file specified on the command-line."""
83        config, found_files = self._read_config(files)
84        if found_files:
85            LOG.debug("Found cli configuration files: %s", found_files)
86        return config
87
88    def generate_possible_local_files(self):
89        """Find and generate all local config files."""
90        parent = tail = os.getcwd()
91        found_config_files = False
92        while tail and not found_config_files:
93            for project_filename in self.project_filenames:
94                filename = os.path.abspath(
95                    os.path.join(parent, project_filename)
96                )
97                if os.path.exists(filename):
98                    yield filename
99                    found_config_files = True
100                    self.local_directory = parent
101            (parent, tail) = os.path.split(parent)
102
103    def local_config_files(self):
104        """Find all local config files which actually exist.
105
106        Filter results from
107        :meth:`~ConfigFileFinder.generate_possible_local_files` based
108        on whether the filename exists or not.
109
110        :returns:
111            List of files that exist that are local project config files with
112            extra config files appended to that list (which also exist).
113        :rtype:
114            [str]
115        """
116        exists = os.path.exists
117        return [
118            filename for filename in self.generate_possible_local_files()
119        ] + [f for f in self.extra_config_files if exists(f)]
120
121    def local_configs_with_files(self):
122        """Parse all local config files into one config object.
123
124        Return (config, found_config_files) tuple.
125        """
126        config, found_files = self._read_config(*self.local_config_files())
127        if found_files:
128            LOG.debug("Found local configuration files: %s", found_files)
129        return (config, found_files)
130
131    def local_configs(self):
132        """Parse all local config files into one config object."""
133        return self.local_configs_with_files()[0]
134
135
136class ConfigParser:
137    """Encapsulate merging different types of configuration files.
138
139    This parses out the options registered that were specified in the
140    configuration files, handles extra configuration files, and returns
141    dictionaries with the parsed values.
142    """
143
144    #: Set of actions that should use the
145    #: :meth:`~configparser.RawConfigParser.getbool` method.
146    GETBOOL_ACTIONS = {"store_true", "store_false"}
147
148    def __init__(self, option_manager, config_finder):
149        """Initialize the ConfigParser instance.
150
151        :param flake8.options.manager.OptionManager option_manager:
152            Initialized OptionManager.
153        :param flake8.options.config.ConfigFileFinder config_finder:
154            Initialized ConfigFileFinder.
155        """
156        #: Our instance of flake8.options.manager.OptionManager
157        self.option_manager = option_manager
158        #: The prog value for the cli parser
159        self.program_name = option_manager.program_name
160        #: Mapping of configuration option names to
161        #: :class:`~flake8.options.manager.Option` instances
162        self.config_options = option_manager.config_options_dict
163        #: Our instance of our :class:`~ConfigFileFinder`
164        self.config_finder = config_finder
165
166    def _normalize_value(self, option, value, parent=None):
167        if parent is None:
168            parent = self.config_finder.local_directory
169
170        final_value = option.normalize(value, parent)
171        LOG.debug(
172            '%r has been normalized to %r for option "%s"',
173            value,
174            final_value,
175            option.config_name,
176        )
177        return final_value
178
179    def _parse_config(self, config_parser, parent=None):
180        config_dict = {}
181        for option_name in config_parser.options(self.program_name):
182            if option_name not in self.config_options:
183                LOG.debug(
184                    'Option "%s" is not registered. Ignoring.', option_name
185                )
186                continue
187            option = self.config_options[option_name]
188
189            # Use the appropriate method to parse the config value
190            method = config_parser.get
191            if option.type is int or option.action == "count":
192                method = config_parser.getint
193            elif option.action in self.GETBOOL_ACTIONS:
194                method = config_parser.getboolean
195
196            value = method(self.program_name, option_name)
197            LOG.debug('Option "%s" returned value: %r', option_name, value)
198
199            final_value = self._normalize_value(option, value, parent)
200            config_dict[option.config_name] = final_value
201
202        return config_dict
203
204    def is_configured_by(self, config):
205        """Check if the specified config parser has an appropriate section."""
206        return config.has_section(self.program_name)
207
208    def parse_local_config(self):
209        """Parse and return the local configuration files."""
210        config = self.config_finder.local_configs()
211        if not self.is_configured_by(config):
212            LOG.debug(
213                "Local configuration files have no %s section",
214                self.program_name,
215            )
216            return {}
217
218        LOG.debug("Parsing local configuration files.")
219        return self._parse_config(config)
220
221    def parse_cli_config(self, config_path):
222        """Parse and return the file specified by --config."""
223        config = self.config_finder.cli_config(config_path)
224        if not self.is_configured_by(config):
225            LOG.debug(
226                "CLI configuration files have no %s section",
227                self.program_name,
228            )
229            return {}
230
231        LOG.debug("Parsing CLI configuration files.")
232        return self._parse_config(config, os.path.dirname(config_path))
233
234    def parse(self):
235        """Parse and return the local config files.
236
237        :returns:
238            Dictionary of parsed configuration options
239        :rtype:
240            dict
241        """
242        if self.config_finder.ignore_config_files:
243            LOG.debug(
244                "Refusing to parse configuration files due to user-"
245                "requested isolation"
246            )
247            return {}
248
249        if self.config_finder.config_file:
250            LOG.debug(
251                "Ignoring user and locally found configuration files. "
252                'Reading only configuration from "%s" specified via '
253                "--config by the user",
254                self.config_finder.config_file,
255            )
256            return self.parse_cli_config(self.config_finder.config_file)
257
258        return self.parse_local_config()
259
260
261def get_local_plugins(config_finder):
262    """Get local plugins lists from config files.
263
264    :param flake8.options.config.ConfigFileFinder config_finder:
265        The config file finder to use.
266    :returns:
267        LocalPlugins namedtuple containing two lists of plugin strings,
268        one for extension (checker) plugins and one for report plugins.
269    :rtype:
270        flake8.options.config.LocalPlugins
271    """
272    local_plugins = LocalPlugins(extension=[], report=[], paths=[])
273    if config_finder.ignore_config_files:
274        LOG.debug(
275            "Refusing to look for local plugins in configuration"
276            "files due to user-requested isolation"
277        )
278        return local_plugins
279
280    if config_finder.config_file:
281        LOG.debug(
282            'Reading local plugins only from "%s" specified via '
283            "--config by the user",
284            config_finder.config_file,
285        )
286        config = config_finder.cli_config(config_finder.config_file)
287        config_files = [config_finder.config_file]
288    else:
289        config, config_files = config_finder.local_configs_with_files()
290
291    base_dirs = {os.path.dirname(cf) for cf in config_files}
292
293    section = f"{config_finder.program_name}:local-plugins"
294    for plugin_type in ["extension", "report"]:
295        if config.has_option(section, plugin_type):
296            local_plugins_string = config.get(section, plugin_type).strip()
297            plugin_type_list = getattr(local_plugins, plugin_type)
298            plugin_type_list.extend(
299                utils.parse_comma_separated_list(
300                    local_plugins_string, regexp=utils.LOCAL_PLUGIN_LIST_RE
301                )
302            )
303    if config.has_option(section, "paths"):
304        raw_paths = utils.parse_comma_separated_list(
305            config.get(section, "paths").strip()
306        )
307        norm_paths: List[str] = []
308        for base_dir in base_dirs:
309            norm_paths.extend(
310                path
311                for path in utils.normalize_paths(raw_paths, parent=base_dir)
312                if os.path.exists(path)
313            )
314        local_plugins.paths.extend(norm_paths)
315    return local_plugins
316
317
318LocalPlugins = collections.namedtuple("LocalPlugins", "extension report paths")
319