<reponame>ilyalabun/pylama<filename>pylama/config.py
"""Parse arguments from command line and configuration files."""
import fnmatch
import logging
import os
import re
import sys
from argparse import ArgumentParser, Namespace
from pathlib import Path
from typing import Any, Collection, Dict, List, Optional, Set, Union

from pylama import LOGGER, __version__
from pylama.libs import inirama
from pylama.lint import LINTERS

#: A default checkers
DEFAULT_LINTERS = "pycodestyle", "pyflakes", "mccabe"

CURDIR = Path.cwd()
HOMECFG = Path.home() / ".pylama.ini"
CONFIG_FILES = "pylama.ini", "setup.cfg", "tox.ini", "pytest.ini"

# Setup a logger
LOGGER.propagate = False
STREAM = logging.StreamHandler(sys.stdout)
LOGGER.addHandler(STREAM)


class _Default:
    def __init__(self, value=None):
        self.value = value

    def __str__(self):
        return str(self.value)

    def __repr__(self):
        return f"<_Default [{self.value}]>"


def split_csp_str(val: Union[Collection[str], str]) -> Set[str]:
    """Split comma separated string into unique values, keeping their order."""
    if isinstance(val, str):
        val = val.strip().split(",")
    return set(x for x in val if x)


def prepare_sorter(val: Union[Collection[str], str]) -> Optional[Dict[str, int]]:
    """Parse sort value."""
    if val:
        types = split_csp_str(val)
        return dict((v, n) for n, v in enumerate(types, 1))

    return None


def parse_linters(linters: str) -> List[str]:
    """Initialize choosen linters."""
    return [name for name in split_csp_str(linters) if name in LINTERS]


def get_default_config_file(rootdir: Path = None) -> Optional[str]:
    """Search for configuration file."""
    if rootdir is None:
        return DEFAULT_CONFIG_FILE

    for filename in CONFIG_FILES:
        path = rootdir / filename
        if path.is_file() and os.access(path, os.R_OK):
            return path.as_posix()

    return None


DEFAULT_CONFIG_FILE = get_default_config_file(CURDIR)


def setup_parser() -> ArgumentParser:
    """Create and setup parser for command line."""
    parser = ArgumentParser(description="Code audit tool for python.")
    parser.add_argument(
        "paths",
        nargs="*",
        default=_Default([CURDIR.as_posix()]),
        help="Paths to files or directories for code check.",
    )
    parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
    parser.add_argument("--verbose", "-v", action="store_true", help="Verbose mode.")
    parser.add_argument(
        "--options",
        "-o",
        default=DEFAULT_CONFIG_FILE,
        metavar="FILE",
        help=(
            "Specify configuration file. "
            f"Looks for {', '.join(CONFIG_FILES[:-1])}, or {CONFIG_FILES[-1]}"
            f" in the current directory (default: {DEFAULT_CONFIG_FILE})"
        ),
    )
    parser.add_argument(
        "--linters",
        "-l",
        default=_Default(",".join(DEFAULT_LINTERS)),
        type=parse_linters,
        help=(f"Select linters. (comma-separated). Choices are {','.join(s for s in LINTERS)}."),
    )
    parser.add_argument(
        "--from-stdin",
        action="store_true",
        help="Interpret the stdin as a python script, "
        "whose filename needs to be passed as the path argument.",
    )
    parser.add_argument(
        "--concurrent",
        "--async",
        action="store_true",
        help="Enable async mode. Useful for checking a lot of files. ",
    )

    parser.add_argument(
        "--format",
        "-f",
        default=_Default("pycodestyle"),
        choices=["pydocstyle", "pycodestyle", "pylint", "parsable", "json"],
        help="Choose output format.",
    )
    parser.add_argument(
        "--abspath",
        "-a",
        action="store_true",
        default=_Default(False),
        help="Use absolute paths in output.",
    )
    parser.add_argument(
        "--max-line-length",
        "-m",
        default=_Default(100),
        type=int,
        help="Maximum allowed line length",
    )
    parser.add_argument(
        "--select",
        "-s",
        default=_Default(""),
        type=split_csp_str,
        help="Select errors and warnings. (comma-separated list)",
    )
    parser.add_argument(
        "--ignore",
        "-i",
        default=_Default(""),
        type=split_csp_str,
        help="Ignore errors and warnings. (comma-separated)",
    )
    parser.add_argument(
        "--skip",
        default=_Default(""),
        type=lambda s: [re.compile(fnmatch.translate(p)) for p in s.split(",") if p],
        help="Skip files by masks (comma-separated, Ex. */messages.py)",
    )
    parser.add_argument(
        "--sort",
        default=_Default(),
        type=prepare_sorter,
        help="Sort result by error types. Ex. E,W,D",
    )
    parser.add_argument("--report", "-r", help="Send report to file [REPORT]")
    parser.add_argument("--hook", action="store_true", help="Install Git (Mercurial) hook.")

    for linter_type in LINTERS.values():
        linter_type.add_args(parser)

    return parser


def parse_options(  # noqa
    args: List[str] = None, config: bool = True, rootdir: Path = CURDIR, **overrides
) -> Namespace:
    """Parse options from command line and configuration files."""
    # Parse args from command string
    parser = setup_parser()
    actions = dict((a.dest, a) for a in parser._actions)  # pylint: disable=protected-access

    options = parser.parse_args(args or [])
    options.file_params = {}
    options.linters_params = {}

    # Compile options from ini
    if config:
        cfg = get_config(options.options, rootdir=rootdir)
        for opt, val in cfg.default.items():
            LOGGER.info("Find option %s (%s)", opt, val)
            passed_value = getattr(options, opt, _Default())
            if isinstance(passed_value, _Default):
                if opt == "paths":
                    val = val.split()
                if opt == "skip":
                    val = fix_pathname_sep(val)
                setattr(options, opt, _Default(val))

        # Parse file related options
        for name, opts in cfg.sections.items():

            if name == cfg.default_section:
                continue

            if name.startswith("pylama"):
                name = name[7:]

            if name in LINTERS:
                options.linters_params[name] = dict(opts)
                continue

            mask = re.compile(fnmatch.translate(fix_pathname_sep(name)))
            options.file_params[mask] = dict(opts)

    # Override options
    for opt, val in overrides.items():
        setattr(options, opt, process_value(actions, opt, val))

    # Postprocess options
    for name in options.__dict__:
        value = getattr(options, name)
        if isinstance(value, _Default):
            setattr(options, name, process_value(actions, name, value.value))

    if options.concurrent and "pylint" in options.linters:
        LOGGER.warning("Can't parse code asynchronously with pylint enabled.")
        options.concurrent = False

    return options


def process_value(actions: Dict, name: str, value: Any) -> Any:
    """Compile option value."""
    action = actions.get(name)
    if not action:
        return value

    if callable(action.type):
        return action.type(value)

    if action.const:
        return bool(int(value))

    return value


def get_config(ini_path: str = None, rootdir: Path = None) -> inirama.Namespace:
    """Load configuration from INI."""
    config = inirama.Namespace()
    config.default_section = "pylama"

    cfg_path = ini_path or get_default_config_file(rootdir)
    if not cfg_path and HOMECFG.exists():
        cfg_path = HOMECFG.as_posix()

    if cfg_path:
        LOGGER.info("Read config: %s", cfg_path)
        config.read(cfg_path)

    return config


def setup_logger(options: Namespace):
    """Do the logger setup with options."""
    LOGGER.setLevel(logging.INFO if options.verbose else logging.WARN)
    if options.report:
        LOGGER.removeHandler(STREAM)
        LOGGER.addHandler(logging.FileHandler(options.report, mode="w"))

    if options.options:
        LOGGER.info("Try to read configuration from: %r", options.options)


def fix_pathname_sep(val: str) -> str:
    """Fix pathnames for Win."""
    return val.replace(os.altsep or "\\", os.sep)


# pylama:ignore=W0212,D210,F0001

"""Parse arguments from command line and configuration files."""
import fnmatch
import logging
import os
import re
import sys
from argparse import ArgumentParser, Namespace
from pathlib import Path
from typing import Any, Collection, Dict, List, Optional, Set, Union

from pylama import LOGGER, __version__
from pylama.libs import inirama
from pylama.lint import LINTERS

#: A default checkers
DEFAULT_LINTERS = "pycodestyle", "pyflakes", "mccabe"

CURDIR = Path.cwd()
HOMECFG = Path.home() / ".pylama.ini"
CONFIG_FILES = "pylama.ini", "setup.cfg", "tox.ini", "pytest.ini"

# Setup a logger
LOGGER.propagate = False
STREAM = logging.StreamHandler(sys.stdout)
LOGGER.addHandler(STREAM)


class _Default:
    def __init__(self, value=None):
        self.value = value

    def __str__(self):
        return str(self.value)

    def __repr__(self):
        return f"<_Default [{self.value}]>"


def split_csp_str(val: Union[Collection[str], str]) -> Set[str]:
    """Split comma separated string into unique values, keeping their order."""
    if isinstance(val, str):
        val = val.strip().split(",")
    return set(x for x in val if x)


def prepare_sorter(val: Union[Collection[str], str]) -> Optional[Dict[str, int]]:
    """Parse sort value."""
    if val:
        types = split_csp_str(val)
        return dict((v, n) for n, v in enumerate(types, 1))

    return None


def parse_linters(linters: str) -> List[str]:
    """Initialize choosen linters."""
    return [name for name in split_csp_str(linters) if name in LINTERS]


def get_default_config_file(rootdir: Path = None) -> Optional[str]:
    """Search for configuration file."""
    if rootdir is None:
        return DEFAULT_CONFIG_FILE

    for filename in CONFIG_FILES:
        path = rootdir / filename
        if path.is_file() and os.access(path, os.R_OK):
            return path.as_posix()

    return None


DEFAULT_CONFIG_FILE = get_default_config_file(CURDIR)


def setup_parser() -> ArgumentParser:
    """Create and setup parser for command line."""
    parser = ArgumentParser(description="Code audit tool for python.")
    parser.add_argument(
        "paths",
        nargs="*",
        default=_Default([CURDIR.as_posix()]),
        help="Paths to files or directories for code check.",
    )
    parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
    parser.add_argument("--verbose", "-v", action="store_true", help="Verbose mode.")
    parser.add_argument(
        "--options",
        "-o",
        default=DEFAULT_CONFIG_FILE,
        metavar="FILE",
        help=(
            "Specify configuration file. "
            f"Looks for {', '.join(CONFIG_FILES[:-1])}, or {CONFIG_FILES[-1]}"
            f" in the current directory (default: {DEFAULT_CONFIG_FILE})"
        ),
    )
    parser.add_argument(
        "--linters",
        "-l",
        default=_Default(",".join(DEFAULT_LINTERS)),
        type=parse_linters,
        help=(f"Select linters. (comma-separated). Choices are {','.join(s for s in LINTERS)}."),
    )
    parser.add_argument(
        "--from-stdin",
        action="store_true",
        help="Interpret the stdin as a python script, "
        "whose filename needs to be passed as the path argument.",
    )
    parser.add_argument(
        "--concurrent",
        "--async",
        action="store_true",
        help="Enable async mode. Useful for checking a lot of files. ",
    )

    parser.add_argument(
        "--format",
        "-f",
        default=_Default("pycodestyle"),
        choices=["pydocstyle", "pycodestyle", "pylint", "parsable", "json"],
        help="Choose output format.",
    )
    parser.add_argument(
        "--abspath",
        "-a",
        action="store_true",
        default=_Default(False),
        help="Use absolute paths in output.",
    )
    parser.add_argument(
        "--max-line-length",
        "-m",
        default=_Default(100),
        type=int,
        help="Maximum allowed line length",
    )
    parser.add_argument(
        "--select",
        "-s",
        default=_Default(""),
        type=split_csp_str,
        help="Select errors and warnings. (comma-separated list)",
    )
    parser.add_argument(
        "--ignore",
        "-i",
        default=_Default(""),
        type=split_csp_str,
        help="Ignore errors and warnings. (comma-separated)",
    )
    parser.add_argument(
        "--skip",
        default=_Default(""),
        type=lambda s: [re.compile(fnmatch.translate(p)) for p in s.split(",") if p],
        help="Skip files by masks (comma-separated, Ex. */messages.py)",
    )
    parser.add_argument(
        "--sort",
        default=_Default(),
        type=prepare_sorter,
        help="Sort result by error types. Ex. E,W,D",
    )
    parser.add_argument("--report", "-r", help="Send report to file [REPORT]")
    parser.add_argument("--hook", action="store_true", help="Install Git (Mercurial) hook.")

    for linter_type in LINTERS.values():
        linter_type.add_args(parser)

    return parser


def parse_options(  # noqa
    args: List[str] = None, config: bool = True, rootdir: Path = CURDIR, **overrides
) -> Namespace:
    """Parse options from command line and configuration files."""
    # Parse args from command string
    parser = setup_parser()
    actions = dict((a.dest, a) for a in parser._actions)  # pylint: disable=protected-access

    options = parser.parse_args(args or [])
    options.file_params = {}
    options.linters_params = {}

    # Compile options from ini
    if config:
        cfg = get_config(options.options, rootdir=rootdir)
        for opt, val in cfg.default.items():
            LOGGER.info("Find option %s (%s)", opt, val)
            passed_value = getattr(options, opt, _Default())
            if isinstance(passed_value, _Default):
                if opt == "paths":
                    val = val.split()
                if opt == "skip":
                    val = fix_pathname_sep(val)
                setattr(options, opt, _Default(val))

        # Parse file related options
        for name, opts in cfg.sections.items():

            if name == cfg.default_section:
                continue

            if name.startswith("pylama"):
                name = name[7:]

            if name in LINTERS:
                options.linters_params[name] = dict(opts)
                continue

            mask = re.compile(fnmatch.translate(fix_pathname_sep(name)))
            options.file_params[mask] = dict(opts)

    # Override options
    for opt, val in overrides.items():
        setattr(options, opt, process_value(actions, opt, val))

    # Postprocess options
    for name in options.__dict__:
        value = getattr(options, name)
        if isinstance(value, _Default):
            setattr(options, name, process_value(actions, name, value.value))

    if options.concurrent and "pylint" in options.linters:
        LOGGER.warning("Can't parse code asynchronously with pylint enabled.")
        options.concurrent = False

    return options


def process_value(actions: Dict, name: str, value: Any) -> Any:
    """Compile option value."""
    action = actions.get(name)
    if not action:
        return value

    if callable(action.type):
        return action.type(value)

    if action.const:
        return bool(int(value))

    return value


def get_config(ini_path: str = None, rootdir: Path = None) -> inirama.Namespace:
    """Load configuration from INI."""
    config = inirama.Namespace()
    config.default_section = "pylama"

    cfg_path = ini_path or get_default_config_file(rootdir)
    if not cfg_path and HOMECFG.exists():
        cfg_path = HOMECFG.as_posix()

    if cfg_path:
        LOGGER.info("Read config: %s", cfg_path)
        config.read(cfg_path)

    return config


def setup_logger(options: Namespace):
    """Do the logger setup with options."""
    LOGGER.setLevel(logging.INFO if options.verbose else logging.WARN)
    if options.report:
        LOGGER.removeHandler(STREAM)
        LOGGER.addHandler(logging.FileHandler(options.report, mode="w"))

    if options.options:
        LOGGER.info("Try to read configuration from: %r", options.options)


def fix_pathname_sep(val: str) -> str:
    """Fix pathnames for Win."""
    return val.replace(os.altsep or "\\", os.sep)


# pylama:ignore=W0212,D210,F0001
Parse arguments from command line and configuration files. #: A default checkers # Setup a logger Split comma separated string into unique values, keeping their order. Parse sort value. Initialize choosen linters. Search for configuration file. Create and setup parser for command line. # noqa Parse options from command line and configuration files. # Parse args from command string # pylint: disable=protected-access # Compile options from ini # Parse file related options # Override options # Postprocess options Compile option value. Load configuration from INI. Do the logger setup with options. Fix pathnames for Win. # pylama:ignore=W0212,D210,F0001