| #!/usr/bin/env python3 | |
| """ | |
| This module is meant to be run as a script (see the docstring of main | |
| below) and passed the filename of any Python file in this repo, to | |
| typecheck that file using only the subset of our mypy configs that apply | |
| to it. | |
| Since editors (e.g. VS Code) can be configured to use this wrapper | |
| script in lieu of mypy itself, the idea is that this can be used to get | |
| inline mypy results while developing, and have at least some degree of | |
| assurance that those inline results match up with what you would get | |
| from running the mypy lint from the .github/workflows/lint.yml file. | |
| See also these wiki pages: | |
| - https://github.com/pytorch/pytorch/wiki/Guide-for-adding-type-annotations-to-PyTorch | |
| - https://github.com/pytorch/pytorch/wiki/Lint-as-you-type | |
| """ | |
| import sys | |
| from collections import defaultdict | |
| from configparser import ConfigParser | |
| from pathlib import Path, PurePath, PurePosixPath | |
| from typing import Any, Dict, List, Optional, Set, Tuple | |
| import mypy.api | |
| # not part of the public API, but this is the easiest way to ensure that | |
| # we agree with what mypy actually does | |
| import mypy.config_parser | |
| def read_config(config_path: Path) -> Set[str]: | |
| """ | |
| Return the set of `files` in the `mypy` ini file at config_path. | |
| """ | |
| config = ConfigParser() | |
| config.read(config_path) | |
| # hopefully on Windows this gives posix paths | |
| return set(mypy.config_parser.split_and_match_files( | |
| config['mypy']['files'], | |
| )) | |
| # see tools/test/test_mypy_wrapper.py for examples of many of the | |
| # following functions | |
| def config_files() -> Dict[str, Set[str]]: | |
| """ | |
| Return a dict from all our `mypy` ini filenames to their `files`. | |
| """ | |
| return {str(ini): read_config(ini) for ini in Path().glob('mypy*.ini')} | |
| def split_path(path: str) -> List[str]: | |
| """ | |
| Split a relative (not absolute) POSIX path into its segments. | |
| """ | |
| pure = PurePosixPath(path) | |
| return [str(p.name) for p in list(reversed(pure.parents))[1:] + [pure]] | |
| # mypy doesn't support recursive types yet | |
| # https://github.com/python/mypy/issues/731 | |
| # but if it did, the `Any` here would be `Union[Set[str], 'Trie']`, | |
| # although that is not completely accurate: specifically, every `None` | |
| # key must map to a `Set[str]`, and every `str` key must map to a `Trie` | |
| Trie = Dict[Optional[str], Any] | |
| def make_trie(configs: Dict[str, Set[str]]) -> Trie: | |
| """ | |
| Return a trie from path prefixes to their `mypy` configs. | |
| Specifically, each layer of the trie represents a segment of a POSIX | |
| path relative to the root of this repo. If you follow a path down | |
| the trie and reach a `None` key, that `None` maps to the (nonempty) | |
| set of keys in `configs` which explicitly include that path. | |
| """ | |
| trie: Trie = {} | |
| for ini, files in configs.items(): | |
| for f in files: | |
| inner = trie | |
| for segment in split_path(f): | |
| inner = inner.setdefault(segment, {}) | |
| inner.setdefault(None, set()).add(ini) | |
| return trie | |
| def lookup(trie: Trie, filename: str) -> Set[str]: | |
| """ | |
| Return the configs in `trie` that include a prefix of `filename`. | |
| A path is included by a config if any of its ancestors are included | |
| by the wildcard-expanded version of that config's `files`. Thus, | |
| this function follows `filename`'s path down the `trie` and | |
| accumulates all the configs it finds along the way. | |
| """ | |
| configs = set() | |
| inner = trie | |
| for segment in split_path(filename): | |
| inner = inner.get(segment, {}) | |
| configs |= inner.get(None, set()) | |
| return configs | |
| def make_plan( | |
| *, | |
| configs: Dict[str, Set[str]], | |
| files: List[str] | |
| ) -> Dict[str, List[str]]: | |
| """ | |
| Return a dict from config names to the files to run them with. | |
| The keys of the returned dict are a subset of the keys of `configs`. | |
| The list of files in each value of returned dict should contain a | |
| nonempty subset of the given `files`, in the same order as `files`. | |
| """ | |
| trie = make_trie(configs) | |
| plan = defaultdict(list) | |
| for filename in files: | |
| for config in lookup(trie, filename): | |
| plan[config].append(filename) | |
| return plan | |
| def run( | |
| *, | |
| args: List[str], | |
| files: List[str], | |
| ) -> Tuple[int, List[str], List[str]]: | |
| """ | |
| Return the exit code and list of output lines from running `mypy`. | |
| The given `args` are passed verbatim to `mypy`. The `files` (each of | |
| which must be an absolute path) are converted to relative paths | |
| (that is, relative to the root of this repo) and then classified | |
| according to which ones need to be run with each `mypy` config. | |
| Thus, `mypy` may be run zero, one, or multiple times, but it will be | |
| run at most once for each `mypy` config used by this repo. | |
| """ | |
| repo_root = Path.cwd() | |
| plan = make_plan(configs=config_files(), files=[ | |
| PurePath(f).relative_to(repo_root).as_posix() for f in files | |
| ]) | |
| mypy_results = [ | |
| mypy.api.run( | |
| # insert custom flags after args to avoid being overridden | |
| # by existing flags in args | |
| args + [ | |
| # don't special-case the last line | |
| '--no-error-summary', | |
| f'--config-file={config}', | |
| ] + filtered | |
| ) | |
| # by construction, filtered must be nonempty | |
| for config, filtered in plan.items() | |
| ] | |
| return ( | |
| # assume all mypy exit codes are nonnegative | |
| # https://github.com/python/mypy/issues/6003 | |
| max( | |
| [exit_code for _, _, exit_code in mypy_results], | |
| default=0, | |
| ), | |
| list(dict.fromkeys( # remove duplicates, retain order | |
| item | |
| for stdout, _, _ in mypy_results | |
| for item in stdout.splitlines() | |
| )), | |
| [stderr for _, stderr, _ in mypy_results], | |
| ) | |
| def main(args: List[str]) -> None: | |
| """ | |
| Run mypy on one Python file using the correct config file(s). | |
| This function assumes the following preconditions hold: | |
| - the cwd is set to the root of this cloned repo | |
| - args is a valid list of CLI arguments that could be passed to mypy | |
| - some of args are absolute paths to files to typecheck | |
| - all the other args are config flags for mypy, rather than files | |
| These assumptions hold, for instance, when mypy is run automatically | |
| by VS Code's Python extension, so in your clone of this repository, | |
| you could modify your .vscode/settings.json to look something like | |
| this (assuming you use a conda environment named "pytorch"): | |
| { | |
| "python.linting.enabled": true, | |
| "python.linting.mypyEnabled": true, | |
| "python.linting.mypyPath": | |
| "${env:HOME}/miniconda3/envs/pytorch/bin/python", | |
| "python.linting.mypyArgs": [ | |
| "${workspaceFolder}/tools/linter/mypy_wrapper.py" | |
| ] | |
| } | |
| More generally, this should work for any editor sets the cwd to the | |
| repo root, runs mypy on individual files via their absolute paths, | |
| and allows you to set the path to the mypy executable. | |
| """ | |
| repo_root = str(Path.cwd()) | |
| exit_code, mypy_issues, stderrs = run( | |
| args=[arg for arg in args if not arg.startswith(repo_root)], | |
| files=[arg for arg in args if arg.startswith(repo_root)], | |
| ) | |
| for issue in mypy_issues: | |
| print(issue) | |
| for stderr in stderrs: | |
| print(stderr, end='', file=sys.stderr) | |
| sys.exit(exit_code) | |
| if __name__ == '__main__': | |
| main(sys.argv[1:]) |