config.py 8.9 KB

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