config_parser.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. from __future__ import annotations
  2. import argparse
  3. import configparser
  4. import glob as fileglob
  5. import os
  6. import re
  7. import sys
  8. from io import StringIO
  9. from mypy.errorcodes import error_codes
  10. if sys.version_info >= (3, 11):
  11. import tomllib
  12. else:
  13. import tomli as tomllib
  14. from typing import (
  15. Any,
  16. Callable,
  17. Dict,
  18. Final,
  19. Iterable,
  20. List,
  21. Mapping,
  22. MutableMapping,
  23. Sequence,
  24. TextIO,
  25. Tuple,
  26. Union,
  27. )
  28. from typing_extensions import TypeAlias as _TypeAlias
  29. from mypy import defaults
  30. from mypy.options import PER_MODULE_OPTIONS, Options
  31. _CONFIG_VALUE_TYPES: _TypeAlias = Union[
  32. str, bool, int, float, Dict[str, str], List[str], Tuple[int, int]
  33. ]
  34. _INI_PARSER_CALLABLE: _TypeAlias = Callable[[Any], _CONFIG_VALUE_TYPES]
  35. def parse_version(v: str | float) -> tuple[int, int]:
  36. m = re.match(r"\A(\d)\.(\d+)\Z", str(v))
  37. if not m:
  38. raise argparse.ArgumentTypeError(f"Invalid python version '{v}' (expected format: 'x.y')")
  39. major, minor = int(m.group(1)), int(m.group(2))
  40. if major == 2 and minor == 7:
  41. pass # Error raised elsewhere
  42. elif major == 3:
  43. if minor < defaults.PYTHON3_VERSION_MIN[1]:
  44. msg = "Python 3.{} is not supported (must be {}.{} or higher)".format(
  45. minor, *defaults.PYTHON3_VERSION_MIN
  46. )
  47. if isinstance(v, float):
  48. msg += ". You may need to put quotes around your Python version"
  49. raise argparse.ArgumentTypeError(msg)
  50. else:
  51. raise argparse.ArgumentTypeError(
  52. f"Python major version '{major}' out of range (must be 3)"
  53. )
  54. return major, minor
  55. def try_split(v: str | Sequence[str], split_regex: str = "[,]") -> list[str]:
  56. """Split and trim a str or list of str into a list of str"""
  57. if isinstance(v, str):
  58. return [p.strip() for p in re.split(split_regex, v)]
  59. return [p.strip() for p in v]
  60. def validate_codes(codes: list[str]) -> list[str]:
  61. invalid_codes = set(codes) - set(error_codes.keys())
  62. if invalid_codes:
  63. raise argparse.ArgumentTypeError(
  64. f"Invalid error code(s): {', '.join(sorted(invalid_codes))}"
  65. )
  66. return codes
  67. def expand_path(path: str) -> str:
  68. """Expand the user home directory and any environment variables contained within
  69. the provided path.
  70. """
  71. return os.path.expandvars(os.path.expanduser(path))
  72. def str_or_array_as_list(v: str | Sequence[str]) -> list[str]:
  73. if isinstance(v, str):
  74. return [v.strip()] if v.strip() else []
  75. return [p.strip() for p in v if p.strip()]
  76. def split_and_match_files_list(paths: Sequence[str]) -> list[str]:
  77. """Take a list of files/directories (with support for globbing through the glob library).
  78. Where a path/glob matches no file, we still include the raw path in the resulting list.
  79. Returns a list of file paths
  80. """
  81. expanded_paths = []
  82. for path in paths:
  83. path = expand_path(path.strip())
  84. globbed_files = fileglob.glob(path, recursive=True)
  85. if globbed_files:
  86. expanded_paths.extend(globbed_files)
  87. else:
  88. expanded_paths.append(path)
  89. return expanded_paths
  90. def split_and_match_files(paths: str) -> list[str]:
  91. """Take a string representing a list of files/directories (with support for globbing
  92. through the glob library).
  93. Where a path/glob matches no file, we still include the raw path in the resulting list.
  94. Returns a list of file paths
  95. """
  96. return split_and_match_files_list(paths.split(","))
  97. def check_follow_imports(choice: str) -> str:
  98. choices = ["normal", "silent", "skip", "error"]
  99. if choice not in choices:
  100. raise argparse.ArgumentTypeError(
  101. "invalid choice '{}' (choose from {})".format(
  102. choice, ", ".join(f"'{x}'" for x in choices)
  103. )
  104. )
  105. return choice
  106. def split_commas(value: str) -> list[str]:
  107. # Uses a bit smarter technique to allow last trailing comma
  108. # and to remove last `""` item from the split.
  109. items = value.split(",")
  110. if items and items[-1] == "":
  111. items.pop(-1)
  112. return items
  113. # For most options, the type of the default value set in options.py is
  114. # sufficient, and we don't have to do anything here. This table
  115. # exists to specify types for values initialized to None or container
  116. # types.
  117. ini_config_types: Final[dict[str, _INI_PARSER_CALLABLE]] = {
  118. "python_version": parse_version,
  119. "custom_typing_module": str,
  120. "custom_typeshed_dir": expand_path,
  121. "mypy_path": lambda s: [expand_path(p.strip()) for p in re.split("[,:]", s)],
  122. "files": split_and_match_files,
  123. "quickstart_file": expand_path,
  124. "junit_xml": expand_path,
  125. "follow_imports": check_follow_imports,
  126. "no_site_packages": bool,
  127. "plugins": lambda s: [p.strip() for p in split_commas(s)],
  128. "always_true": lambda s: [p.strip() for p in split_commas(s)],
  129. "always_false": lambda s: [p.strip() for p in split_commas(s)],
  130. "enable_incomplete_feature": lambda s: [p.strip() for p in split_commas(s)],
  131. "disable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]),
  132. "enable_error_code": lambda s: validate_codes([p.strip() for p in split_commas(s)]),
  133. "package_root": lambda s: [p.strip() for p in split_commas(s)],
  134. "cache_dir": expand_path,
  135. "python_executable": expand_path,
  136. "strict": bool,
  137. "exclude": lambda s: [s.strip()],
  138. "packages": try_split,
  139. "modules": try_split,
  140. }
  141. # Reuse the ini_config_types and overwrite the diff
  142. toml_config_types: Final[dict[str, _INI_PARSER_CALLABLE]] = ini_config_types.copy()
  143. toml_config_types.update(
  144. {
  145. "python_version": parse_version,
  146. "mypy_path": lambda s: [expand_path(p) for p in try_split(s, "[,:]")],
  147. "files": lambda s: split_and_match_files_list(try_split(s)),
  148. "follow_imports": lambda s: check_follow_imports(str(s)),
  149. "plugins": try_split,
  150. "always_true": try_split,
  151. "always_false": try_split,
  152. "enable_incomplete_feature": try_split,
  153. "disable_error_code": lambda s: validate_codes(try_split(s)),
  154. "enable_error_code": lambda s: validate_codes(try_split(s)),
  155. "package_root": try_split,
  156. "exclude": str_or_array_as_list,
  157. "packages": try_split,
  158. "modules": try_split,
  159. }
  160. )
  161. def parse_config_file(
  162. options: Options,
  163. set_strict_flags: Callable[[], None],
  164. filename: str | None,
  165. stdout: TextIO | None = None,
  166. stderr: TextIO | None = None,
  167. ) -> None:
  168. """Parse a config file into an Options object.
  169. Errors are written to stderr but are not fatal.
  170. If filename is None, fall back to default config files.
  171. """
  172. stdout = stdout or sys.stdout
  173. stderr = stderr or sys.stderr
  174. if filename is not None:
  175. config_files: tuple[str, ...] = (filename,)
  176. else:
  177. config_files_iter: Iterable[str] = map(os.path.expanduser, defaults.CONFIG_FILES)
  178. config_files = tuple(config_files_iter)
  179. config_parser = configparser.RawConfigParser()
  180. for config_file in config_files:
  181. if not os.path.exists(config_file):
  182. continue
  183. try:
  184. if is_toml(config_file):
  185. with open(config_file, "rb") as f:
  186. toml_data = tomllib.load(f)
  187. # Filter down to just mypy relevant toml keys
  188. toml_data = toml_data.get("tool", {})
  189. if "mypy" not in toml_data:
  190. continue
  191. toml_data = {"mypy": toml_data["mypy"]}
  192. parser: MutableMapping[str, Any] = destructure_overrides(toml_data)
  193. config_types = toml_config_types
  194. else:
  195. config_parser.read(config_file)
  196. parser = config_parser
  197. config_types = ini_config_types
  198. except (tomllib.TOMLDecodeError, configparser.Error, ConfigTOMLValueError) as err:
  199. print(f"{config_file}: {err}", file=stderr)
  200. else:
  201. if config_file in defaults.SHARED_CONFIG_FILES and "mypy" not in parser:
  202. continue
  203. file_read = config_file
  204. options.config_file = file_read
  205. break
  206. else:
  207. return
  208. os.environ["MYPY_CONFIG_FILE_DIR"] = os.path.dirname(os.path.abspath(config_file))
  209. if "mypy" not in parser:
  210. if filename or file_read not in defaults.SHARED_CONFIG_FILES:
  211. print(f"{file_read}: No [mypy] section in config file", file=stderr)
  212. else:
  213. section = parser["mypy"]
  214. prefix = f"{file_read}: [mypy]: "
  215. updates, report_dirs = parse_section(
  216. prefix, options, set_strict_flags, section, config_types, stderr
  217. )
  218. for k, v in updates.items():
  219. setattr(options, k, v)
  220. options.report_dirs.update(report_dirs)
  221. for name, section in parser.items():
  222. if name.startswith("mypy-"):
  223. prefix = get_prefix(file_read, name)
  224. updates, report_dirs = parse_section(
  225. prefix, options, set_strict_flags, section, config_types, stderr
  226. )
  227. if report_dirs:
  228. print(
  229. "%sPer-module sections should not specify reports (%s)"
  230. % (prefix, ", ".join(s + "_report" for s in sorted(report_dirs))),
  231. file=stderr,
  232. )
  233. if set(updates) - PER_MODULE_OPTIONS:
  234. print(
  235. "%sPer-module sections should only specify per-module flags (%s)"
  236. % (prefix, ", ".join(sorted(set(updates) - PER_MODULE_OPTIONS))),
  237. file=stderr,
  238. )
  239. updates = {k: v for k, v in updates.items() if k in PER_MODULE_OPTIONS}
  240. globs = name[5:]
  241. for glob in globs.split(","):
  242. # For backwards compatibility, replace (back)slashes with dots.
  243. glob = glob.replace(os.sep, ".")
  244. if os.altsep:
  245. glob = glob.replace(os.altsep, ".")
  246. if any(c in glob for c in "?[]!") or any(
  247. "*" in x and x != "*" for x in glob.split(".")
  248. ):
  249. print(
  250. "%sPatterns must be fully-qualified module names, optionally "
  251. "with '*' in some components (e.g spam.*.eggs.*)" % prefix,
  252. file=stderr,
  253. )
  254. else:
  255. options.per_module_options[glob] = updates
  256. def get_prefix(file_read: str, name: str) -> str:
  257. if is_toml(file_read):
  258. module_name_str = 'module = "%s"' % "-".join(name.split("-")[1:])
  259. else:
  260. module_name_str = name
  261. return f"{file_read}: [{module_name_str}]: "
  262. def is_toml(filename: str) -> bool:
  263. return filename.lower().endswith(".toml")
  264. def destructure_overrides(toml_data: dict[str, Any]) -> dict[str, Any]:
  265. """Take the new [[tool.mypy.overrides]] section array in the pyproject.toml file,
  266. and convert it back to a flatter structure that the existing config_parser can handle.
  267. E.g. the following pyproject.toml file:
  268. [[tool.mypy.overrides]]
  269. module = [
  270. "a.b",
  271. "b.*"
  272. ]
  273. disallow_untyped_defs = true
  274. [[tool.mypy.overrides]]
  275. module = 'c'
  276. disallow_untyped_defs = false
  277. Would map to the following config dict that it would have gotten from parsing an equivalent
  278. ini file:
  279. {
  280. "mypy-a.b": {
  281. disallow_untyped_defs = true,
  282. },
  283. "mypy-b.*": {
  284. disallow_untyped_defs = true,
  285. },
  286. "mypy-c": {
  287. disallow_untyped_defs: false,
  288. },
  289. }
  290. """
  291. if "overrides" not in toml_data["mypy"]:
  292. return toml_data
  293. if not isinstance(toml_data["mypy"]["overrides"], list):
  294. raise ConfigTOMLValueError(
  295. "tool.mypy.overrides sections must be an array. Please make "
  296. "sure you are using double brackets like so: [[tool.mypy.overrides]]"
  297. )
  298. result = toml_data.copy()
  299. for override in result["mypy"]["overrides"]:
  300. if "module" not in override:
  301. raise ConfigTOMLValueError(
  302. "toml config file contains a [[tool.mypy.overrides]] "
  303. "section, but no module to override was specified."
  304. )
  305. if isinstance(override["module"], str):
  306. modules = [override["module"]]
  307. elif isinstance(override["module"], list):
  308. modules = override["module"]
  309. else:
  310. raise ConfigTOMLValueError(
  311. "toml config file contains a [[tool.mypy.overrides]] "
  312. "section with a module value that is not a string or a list of "
  313. "strings"
  314. )
  315. for module in modules:
  316. module_overrides = override.copy()
  317. del module_overrides["module"]
  318. old_config_name = f"mypy-{module}"
  319. if old_config_name not in result:
  320. result[old_config_name] = module_overrides
  321. else:
  322. for new_key, new_value in module_overrides.items():
  323. if (
  324. new_key in result[old_config_name]
  325. and result[old_config_name][new_key] != new_value
  326. ):
  327. raise ConfigTOMLValueError(
  328. "toml config file contains "
  329. "[[tool.mypy.overrides]] sections with conflicting "
  330. "values. Module '%s' has two different values for '%s'"
  331. % (module, new_key)
  332. )
  333. result[old_config_name][new_key] = new_value
  334. del result["mypy"]["overrides"]
  335. return result
  336. def parse_section(
  337. prefix: str,
  338. template: Options,
  339. set_strict_flags: Callable[[], None],
  340. section: Mapping[str, Any],
  341. config_types: dict[str, Any],
  342. stderr: TextIO = sys.stderr,
  343. ) -> tuple[dict[str, object], dict[str, str]]:
  344. """Parse one section of a config file.
  345. Returns a dict of option values encountered, and a dict of report directories.
  346. """
  347. results: dict[str, object] = {}
  348. report_dirs: dict[str, str] = {}
  349. for key in section:
  350. invert = False
  351. options_key = key
  352. if key in config_types:
  353. ct = config_types[key]
  354. else:
  355. dv = None
  356. # We have to keep new_semantic_analyzer in Options
  357. # for plugin compatibility but it is not a valid option anymore.
  358. assert hasattr(template, "new_semantic_analyzer")
  359. if key != "new_semantic_analyzer":
  360. dv = getattr(template, key, None)
  361. if dv is None:
  362. if key.endswith("_report"):
  363. report_type = key[:-7].replace("_", "-")
  364. if report_type in defaults.REPORTER_NAMES:
  365. report_dirs[report_type] = str(section[key])
  366. else:
  367. print(f"{prefix}Unrecognized report type: {key}", file=stderr)
  368. continue
  369. if key.startswith("x_"):
  370. pass # Don't complain about `x_blah` flags
  371. elif key.startswith("no_") and hasattr(template, key[3:]):
  372. options_key = key[3:]
  373. invert = True
  374. elif key.startswith("allow") and hasattr(template, "dis" + key):
  375. options_key = "dis" + key
  376. invert = True
  377. elif key.startswith("disallow") and hasattr(template, key[3:]):
  378. options_key = key[3:]
  379. invert = True
  380. elif key.startswith("show_") and hasattr(template, "hide_" + key[5:]):
  381. options_key = "hide_" + key[5:]
  382. invert = True
  383. elif key == "strict":
  384. pass # Special handling below
  385. else:
  386. print(f"{prefix}Unrecognized option: {key} = {section[key]}", file=stderr)
  387. if invert:
  388. dv = getattr(template, options_key, None)
  389. else:
  390. continue
  391. ct = type(dv)
  392. v: Any = None
  393. try:
  394. if ct is bool:
  395. if isinstance(section, dict):
  396. v = convert_to_boolean(section.get(key))
  397. else:
  398. v = section.getboolean(key) # type: ignore[attr-defined] # Until better stub
  399. if invert:
  400. v = not v
  401. elif callable(ct):
  402. if invert:
  403. print(f"{prefix}Can not invert non-boolean key {options_key}", file=stderr)
  404. continue
  405. try:
  406. v = ct(section.get(key))
  407. except argparse.ArgumentTypeError as err:
  408. print(f"{prefix}{key}: {err}", file=stderr)
  409. continue
  410. else:
  411. print(f"{prefix}Don't know what type {key} should have", file=stderr)
  412. continue
  413. except ValueError as err:
  414. print(f"{prefix}{key}: {err}", file=stderr)
  415. continue
  416. if key == "strict":
  417. if v:
  418. set_strict_flags()
  419. continue
  420. results[options_key] = v
  421. # These two flags act as per-module overrides, so store the empty defaults.
  422. if "disable_error_code" not in results:
  423. results["disable_error_code"] = []
  424. if "enable_error_code" not in results:
  425. results["enable_error_code"] = []
  426. return results, report_dirs
  427. def convert_to_boolean(value: Any | None) -> bool:
  428. """Return a boolean value translating from other types if necessary."""
  429. if isinstance(value, bool):
  430. return value
  431. if not isinstance(value, str):
  432. value = str(value)
  433. if value.lower() not in configparser.RawConfigParser.BOOLEAN_STATES:
  434. raise ValueError(f"Not a boolean: {value}")
  435. return configparser.RawConfigParser.BOOLEAN_STATES[value.lower()]
  436. def split_directive(s: str) -> tuple[list[str], list[str]]:
  437. """Split s on commas, except during quoted sections.
  438. Returns the parts and a list of error messages."""
  439. parts = []
  440. cur: list[str] = []
  441. errors = []
  442. i = 0
  443. while i < len(s):
  444. if s[i] == ",":
  445. parts.append("".join(cur).strip())
  446. cur = []
  447. elif s[i] == '"':
  448. i += 1
  449. while i < len(s) and s[i] != '"':
  450. cur.append(s[i])
  451. i += 1
  452. if i == len(s):
  453. errors.append("Unterminated quote in configuration comment")
  454. cur.clear()
  455. else:
  456. cur.append(s[i])
  457. i += 1
  458. if cur:
  459. parts.append("".join(cur).strip())
  460. return parts, errors
  461. def mypy_comments_to_config_map(line: str, template: Options) -> tuple[dict[str, str], list[str]]:
  462. """Rewrite the mypy comment syntax into ini file syntax."""
  463. options = {}
  464. entries, errors = split_directive(line)
  465. for entry in entries:
  466. if "=" not in entry:
  467. name = entry
  468. value = None
  469. else:
  470. name, value = (x.strip() for x in entry.split("=", 1))
  471. name = name.replace("-", "_")
  472. if value is None:
  473. value = "True"
  474. options[name] = value
  475. return options, errors
  476. def parse_mypy_comments(
  477. args: list[tuple[int, str]], template: Options
  478. ) -> tuple[dict[str, object], list[tuple[int, str]]]:
  479. """Parse a collection of inline mypy: configuration comments.
  480. Returns a dictionary of options to be applied and a list of error messages
  481. generated.
  482. """
  483. errors: list[tuple[int, str]] = []
  484. sections = {}
  485. for lineno, line in args:
  486. # In order to easily match the behavior for bools, we abuse configparser.
  487. # Oddly, the only way to get the SectionProxy object with the getboolean
  488. # method is to create a config parser.
  489. parser = configparser.RawConfigParser()
  490. options, parse_errors = mypy_comments_to_config_map(line, template)
  491. parser["dummy"] = options
  492. errors.extend((lineno, x) for x in parse_errors)
  493. stderr = StringIO()
  494. strict_found = False
  495. def set_strict_flags() -> None:
  496. nonlocal strict_found
  497. strict_found = True
  498. new_sections, reports = parse_section(
  499. "", template, set_strict_flags, parser["dummy"], ini_config_types, stderr=stderr
  500. )
  501. errors.extend((lineno, x) for x in stderr.getvalue().strip().split("\n") if x)
  502. if reports:
  503. errors.append((lineno, "Reports not supported in inline configuration"))
  504. if strict_found:
  505. errors.append(
  506. (
  507. lineno,
  508. 'Setting "strict" not supported in inline configuration: specify it in '
  509. "a configuration file instead, or set individual inline flags "
  510. '(see "mypy -h" for the list of flags enabled in strict mode)',
  511. )
  512. )
  513. sections.update(new_sections)
  514. return sections, errors
  515. def get_config_module_names(filename: str | None, modules: list[str]) -> str:
  516. if not filename or not modules:
  517. return ""
  518. if not is_toml(filename):
  519. return ", ".join(f"[mypy-{module}]" for module in modules)
  520. return "module = ['%s']" % ("', '".join(sorted(modules)))
  521. class ConfigTOMLValueError(ValueError):
  522. pass