dmypy_server.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  1. """Server for mypy daemon mode.
  2. This implements a daemon process which keeps useful state in memory
  3. to enable fine-grained incremental reprocessing of changes.
  4. """
  5. from __future__ import annotations
  6. import argparse
  7. import base64
  8. import io
  9. import json
  10. import os
  11. import pickle
  12. import subprocess
  13. import sys
  14. import time
  15. import traceback
  16. from contextlib import redirect_stderr, redirect_stdout
  17. from typing import AbstractSet, Any, Callable, List, Sequence, Tuple
  18. from typing_extensions import Final, TypeAlias as _TypeAlias
  19. import mypy.build
  20. import mypy.errors
  21. import mypy.main
  22. from mypy.dmypy_util import receive
  23. from mypy.find_sources import InvalidSourceList, create_source_list
  24. from mypy.fscache import FileSystemCache
  25. from mypy.fswatcher import FileData, FileSystemWatcher
  26. from mypy.inspections import InspectionEngine
  27. from mypy.ipc import IPCServer
  28. from mypy.modulefinder import BuildSource, FindModuleCache, SearchPaths, compute_search_paths
  29. from mypy.options import Options
  30. from mypy.server.update import FineGrainedBuildManager, refresh_suppressed_submodules
  31. from mypy.suggestions import SuggestionEngine, SuggestionFailure
  32. from mypy.typestate import reset_global_state
  33. from mypy.util import FancyFormatter, count_stats
  34. from mypy.version import __version__
  35. MEM_PROFILE: Final = False # If True, dump memory profile after initialization
  36. if sys.platform == "win32":
  37. from subprocess import STARTUPINFO
  38. def daemonize(
  39. options: Options, status_file: str, timeout: int | None = None, log_file: str | None = None
  40. ) -> int:
  41. """Create the daemon process via "dmypy daemon" and pass options via command line
  42. When creating the daemon grandchild, we create it in a new console, which is
  43. started hidden. We cannot use DETACHED_PROCESS since it will cause console windows
  44. to pop up when starting. See
  45. https://github.com/python/cpython/pull/4150#issuecomment-340215696
  46. for more on why we can't have nice things.
  47. It also pickles the options to be unpickled by mypy.
  48. """
  49. command = [sys.executable, "-m", "mypy.dmypy", "--status-file", status_file, "daemon"]
  50. pickled_options = pickle.dumps(options.snapshot())
  51. command.append(f'--options-data="{base64.b64encode(pickled_options).decode()}"')
  52. if timeout:
  53. command.append(f"--timeout={timeout}")
  54. if log_file:
  55. command.append(f"--log-file={log_file}")
  56. info = STARTUPINFO()
  57. info.dwFlags = 0x1 # STARTF_USESHOWWINDOW aka use wShowWindow's value
  58. info.wShowWindow = 0 # SW_HIDE aka make the window invisible
  59. try:
  60. subprocess.Popen(command, creationflags=0x10, startupinfo=info) # CREATE_NEW_CONSOLE
  61. return 0
  62. except subprocess.CalledProcessError as e:
  63. return e.returncode
  64. else:
  65. def _daemonize_cb(func: Callable[[], None], log_file: str | None = None) -> int:
  66. """Arrange to call func() in a grandchild of the current process.
  67. Return 0 for success, exit status for failure, negative if
  68. subprocess killed by signal.
  69. """
  70. # See https://stackoverflow.com/questions/473620/how-do-you-create-a-daemon-in-python
  71. sys.stdout.flush()
  72. sys.stderr.flush()
  73. pid = os.fork()
  74. if pid:
  75. # Parent process: wait for child in case things go bad there.
  76. npid, sts = os.waitpid(pid, 0)
  77. sig = sts & 0xFF
  78. if sig:
  79. print("Child killed by signal", sig)
  80. return -sig
  81. sts = sts >> 8
  82. if sts:
  83. print("Child exit status", sts)
  84. return sts
  85. # Child process: do a bunch of UNIX stuff and then fork a grandchild.
  86. try:
  87. os.setsid() # Detach controlling terminal
  88. os.umask(0o27)
  89. devnull = os.open("/dev/null", os.O_RDWR)
  90. os.dup2(devnull, 0)
  91. os.dup2(devnull, 1)
  92. os.dup2(devnull, 2)
  93. os.close(devnull)
  94. pid = os.fork()
  95. if pid:
  96. # Child is done, exit to parent.
  97. os._exit(0)
  98. # Grandchild: run the server.
  99. if log_file:
  100. sys.stdout = sys.stderr = open(log_file, "a", buffering=1)
  101. fd = sys.stdout.fileno()
  102. os.dup2(fd, 2)
  103. os.dup2(fd, 1)
  104. func()
  105. finally:
  106. # Make sure we never get back into the caller.
  107. os._exit(1)
  108. def daemonize(
  109. options: Options, status_file: str, timeout: int | None = None, log_file: str | None = None
  110. ) -> int:
  111. """Run the mypy daemon in a grandchild of the current process
  112. Return 0 for success, exit status for failure, negative if
  113. subprocess killed by signal.
  114. """
  115. return _daemonize_cb(Server(options, status_file, timeout).serve, log_file)
  116. # Server code.
  117. CONNECTION_NAME: Final = "dmypy"
  118. def process_start_options(flags: list[str], allow_sources: bool) -> Options:
  119. _, options = mypy.main.process_options(
  120. ["-i"] + flags, require_targets=False, server_options=True
  121. )
  122. if options.report_dirs:
  123. print("dmypy: Ignoring report generation settings. Start/restart cannot generate reports.")
  124. if options.junit_xml:
  125. print(
  126. "dmypy: Ignoring report generation settings. "
  127. "Start/restart does not support --junit-xml. Pass it to check/recheck instead"
  128. )
  129. options.junit_xml = None
  130. if not options.incremental:
  131. sys.exit("dmypy: start/restart should not disable incremental mode")
  132. if options.follow_imports not in ("skip", "error", "normal"):
  133. sys.exit("dmypy: follow-imports=silent not supported")
  134. return options
  135. def ignore_suppressed_imports(module: str) -> bool:
  136. """Can we skip looking for newly unsuppressed imports to module?"""
  137. # Various submodules of 'encodings' can be suppressed, since it
  138. # uses module-level '__getattr__'. Skip them since there are many
  139. # of them, and following imports to them is kind of pointless.
  140. return module.startswith("encodings.")
  141. ModulePathPair: _TypeAlias = Tuple[str, str]
  142. ModulePathPairs: _TypeAlias = List[ModulePathPair]
  143. ChangesAndRemovals: _TypeAlias = Tuple[ModulePathPairs, ModulePathPairs]
  144. class Server:
  145. # NOTE: the instance is constructed in the parent process but
  146. # serve() is called in the grandchild (by daemonize()).
  147. def __init__(self, options: Options, status_file: str, timeout: int | None = None) -> None:
  148. """Initialize the server with the desired mypy flags."""
  149. self.options = options
  150. # Snapshot the options info before we muck with it, to detect changes
  151. self.options_snapshot = options.snapshot()
  152. self.timeout = timeout
  153. self.fine_grained_manager: FineGrainedBuildManager | None = None
  154. if os.path.isfile(status_file):
  155. os.unlink(status_file)
  156. self.fscache = FileSystemCache()
  157. options.raise_exceptions = True
  158. options.incremental = True
  159. options.fine_grained_incremental = True
  160. options.show_traceback = True
  161. if options.use_fine_grained_cache:
  162. # Using fine_grained_cache implies generating and caring
  163. # about the fine grained cache
  164. options.cache_fine_grained = True
  165. else:
  166. options.cache_dir = os.devnull
  167. # Fine-grained incremental doesn't support general partial types
  168. # (details in https://github.com/python/mypy/issues/4492)
  169. options.local_partial_types = True
  170. self.status_file = status_file
  171. # Since the object is created in the parent process we can check
  172. # the output terminal options here.
  173. self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.hide_error_codes)
  174. def _response_metadata(self) -> dict[str, str]:
  175. py_version = f"{self.options.python_version[0]}_{self.options.python_version[1]}"
  176. return {"platform": self.options.platform, "python_version": py_version}
  177. def serve(self) -> None:
  178. """Serve requests, synchronously (no thread or fork)."""
  179. command = None
  180. server = IPCServer(CONNECTION_NAME, self.timeout)
  181. try:
  182. with open(self.status_file, "w") as f:
  183. json.dump({"pid": os.getpid(), "connection_name": server.connection_name}, f)
  184. f.write("\n") # I like my JSON with a trailing newline
  185. while True:
  186. with server:
  187. data = receive(server)
  188. debug_stdout = io.StringIO()
  189. debug_stderr = io.StringIO()
  190. sys.stdout = debug_stdout
  191. sys.stderr = debug_stderr
  192. resp: dict[str, Any] = {}
  193. if "command" not in data:
  194. resp = {"error": "No command found in request"}
  195. else:
  196. command = data["command"]
  197. if not isinstance(command, str):
  198. resp = {"error": "Command is not a string"}
  199. else:
  200. command = data.pop("command")
  201. try:
  202. resp = self.run_command(command, data)
  203. except Exception:
  204. # If we are crashing, report the crash to the client
  205. tb = traceback.format_exception(*sys.exc_info())
  206. resp = {"error": "Daemon crashed!\n" + "".join(tb)}
  207. resp.update(self._response_metadata())
  208. resp["stdout"] = debug_stdout.getvalue()
  209. resp["stderr"] = debug_stderr.getvalue()
  210. server.write(json.dumps(resp).encode("utf8"))
  211. raise
  212. resp["stdout"] = debug_stdout.getvalue()
  213. resp["stderr"] = debug_stderr.getvalue()
  214. try:
  215. resp.update(self._response_metadata())
  216. server.write(json.dumps(resp).encode("utf8"))
  217. except OSError:
  218. pass # Maybe the client hung up
  219. if command == "stop":
  220. reset_global_state()
  221. sys.exit(0)
  222. finally:
  223. # If the final command is something other than a clean
  224. # stop, remove the status file. (We can't just
  225. # simplify the logic and always remove the file, since
  226. # that could cause us to remove a future server's
  227. # status file.)
  228. if command != "stop":
  229. os.unlink(self.status_file)
  230. try:
  231. server.cleanup() # try to remove the socket dir on Linux
  232. except OSError:
  233. pass
  234. exc_info = sys.exc_info()
  235. if exc_info[0] and exc_info[0] is not SystemExit:
  236. traceback.print_exception(*exc_info)
  237. def run_command(self, command: str, data: dict[str, object]) -> dict[str, object]:
  238. """Run a specific command from the registry."""
  239. key = "cmd_" + command
  240. method = getattr(self.__class__, key, None)
  241. if method is None:
  242. return {"error": f"Unrecognized command '{command}'"}
  243. else:
  244. if command not in {"check", "recheck", "run"}:
  245. # Only the above commands use some error formatting.
  246. del data["is_tty"]
  247. del data["terminal_width"]
  248. ret = method(self, **data)
  249. assert isinstance(ret, dict)
  250. return ret
  251. # Command functions (run in the server via RPC).
  252. def cmd_status(self, fswatcher_dump_file: str | None = None) -> dict[str, object]:
  253. """Return daemon status."""
  254. res: dict[str, object] = {}
  255. res.update(get_meminfo())
  256. if fswatcher_dump_file:
  257. data = self.fswatcher.dump_file_data() if hasattr(self, "fswatcher") else {}
  258. # Using .dumps and then writing was noticeably faster than using dump
  259. s = json.dumps(data)
  260. with open(fswatcher_dump_file, "w") as f:
  261. f.write(s)
  262. return res
  263. def cmd_stop(self) -> dict[str, object]:
  264. """Stop daemon."""
  265. # We need to remove the status file *before* we complete the
  266. # RPC. Otherwise a race condition exists where a subsequent
  267. # command can see a status file from a dying server and think
  268. # it is a live one.
  269. os.unlink(self.status_file)
  270. return {}
  271. def cmd_run(
  272. self,
  273. version: str,
  274. args: Sequence[str],
  275. export_types: bool,
  276. is_tty: bool,
  277. terminal_width: int,
  278. ) -> dict[str, object]:
  279. """Check a list of files, triggering a restart if needed."""
  280. stderr = io.StringIO()
  281. stdout = io.StringIO()
  282. try:
  283. # Process options can exit on improper arguments, so we need to catch that and
  284. # capture stderr so the client can report it
  285. with redirect_stderr(stderr):
  286. with redirect_stdout(stdout):
  287. sources, options = mypy.main.process_options(
  288. ["-i"] + list(args),
  289. require_targets=True,
  290. server_options=True,
  291. fscache=self.fscache,
  292. program="mypy-daemon",
  293. header=argparse.SUPPRESS,
  294. )
  295. # Signal that we need to restart if the options have changed
  296. if self.options_snapshot != options.snapshot():
  297. return {"restart": "configuration changed"}
  298. if __version__ != version:
  299. return {"restart": "mypy version changed"}
  300. if self.fine_grained_manager:
  301. manager = self.fine_grained_manager.manager
  302. start_plugins_snapshot = manager.plugins_snapshot
  303. _, current_plugins_snapshot = mypy.build.load_plugins(
  304. options, manager.errors, sys.stdout, extra_plugins=()
  305. )
  306. if current_plugins_snapshot != start_plugins_snapshot:
  307. return {"restart": "plugins changed"}
  308. except InvalidSourceList as err:
  309. return {"out": "", "err": str(err), "status": 2}
  310. except SystemExit as e:
  311. return {"out": stdout.getvalue(), "err": stderr.getvalue(), "status": e.code}
  312. return self.check(sources, export_types, is_tty, terminal_width)
  313. def cmd_check(
  314. self, files: Sequence[str], export_types: bool, is_tty: bool, terminal_width: int
  315. ) -> dict[str, object]:
  316. """Check a list of files."""
  317. try:
  318. sources = create_source_list(files, self.options, self.fscache)
  319. except InvalidSourceList as err:
  320. return {"out": "", "err": str(err), "status": 2}
  321. return self.check(sources, export_types, is_tty, terminal_width)
  322. def cmd_recheck(
  323. self,
  324. is_tty: bool,
  325. terminal_width: int,
  326. export_types: bool,
  327. remove: list[str] | None = None,
  328. update: list[str] | None = None,
  329. ) -> dict[str, object]:
  330. """Check the same list of files we checked most recently.
  331. If remove/update is given, they modify the previous list;
  332. if all are None, stat() is called for each file in the previous list.
  333. """
  334. t0 = time.time()
  335. if not self.fine_grained_manager:
  336. return {"error": "Command 'recheck' is only valid after a 'check' command"}
  337. sources = self.previous_sources
  338. if remove:
  339. removals = set(remove)
  340. sources = [s for s in sources if s.path and s.path not in removals]
  341. if update:
  342. known = {s.path for s in sources if s.path}
  343. added = [p for p in update if p not in known]
  344. try:
  345. added_sources = create_source_list(added, self.options, self.fscache)
  346. except InvalidSourceList as err:
  347. return {"out": "", "err": str(err), "status": 2}
  348. sources = sources + added_sources # Make a copy!
  349. t1 = time.time()
  350. manager = self.fine_grained_manager.manager
  351. manager.log(f"fine-grained increment: cmd_recheck: {t1 - t0:.3f}s")
  352. self.options.export_types = export_types
  353. if not self.following_imports():
  354. messages = self.fine_grained_increment(sources, remove, update)
  355. else:
  356. assert remove is None and update is None
  357. messages = self.fine_grained_increment_follow_imports(sources)
  358. res = self.increment_output(messages, sources, is_tty, terminal_width)
  359. self.flush_caches()
  360. self.update_stats(res)
  361. return res
  362. def check(
  363. self, sources: list[BuildSource], export_types: bool, is_tty: bool, terminal_width: int
  364. ) -> dict[str, Any]:
  365. """Check using fine-grained incremental mode.
  366. If is_tty is True format the output nicely with colors and summary line
  367. (unless disabled in self.options). Also pass the terminal_width to formatter.
  368. """
  369. self.options.export_types = export_types
  370. if not self.fine_grained_manager:
  371. res = self.initialize_fine_grained(sources, is_tty, terminal_width)
  372. else:
  373. if not self.following_imports():
  374. messages = self.fine_grained_increment(sources)
  375. else:
  376. messages = self.fine_grained_increment_follow_imports(sources)
  377. res = self.increment_output(messages, sources, is_tty, terminal_width)
  378. self.flush_caches()
  379. self.update_stats(res)
  380. return res
  381. def flush_caches(self) -> None:
  382. self.fscache.flush()
  383. if self.fine_grained_manager:
  384. self.fine_grained_manager.flush_cache()
  385. def update_stats(self, res: dict[str, Any]) -> None:
  386. if self.fine_grained_manager:
  387. manager = self.fine_grained_manager.manager
  388. manager.dump_stats()
  389. res["stats"] = manager.stats
  390. manager.stats = {}
  391. def following_imports(self) -> bool:
  392. """Are we following imports?"""
  393. # TODO: What about silent?
  394. return self.options.follow_imports == "normal"
  395. def initialize_fine_grained(
  396. self, sources: list[BuildSource], is_tty: bool, terminal_width: int
  397. ) -> dict[str, Any]:
  398. self.fswatcher = FileSystemWatcher(self.fscache)
  399. t0 = time.time()
  400. self.update_sources(sources)
  401. t1 = time.time()
  402. try:
  403. result = mypy.build.build(sources=sources, options=self.options, fscache=self.fscache)
  404. except mypy.errors.CompileError as e:
  405. output = "".join(s + "\n" for s in e.messages)
  406. if e.use_stdout:
  407. out, err = output, ""
  408. else:
  409. out, err = "", output
  410. return {"out": out, "err": err, "status": 2}
  411. messages = result.errors
  412. self.fine_grained_manager = FineGrainedBuildManager(result)
  413. if self.following_imports():
  414. sources = find_all_sources_in_build(self.fine_grained_manager.graph, sources)
  415. self.update_sources(sources)
  416. self.previous_sources = sources
  417. # If we are using the fine-grained cache, build hasn't actually done
  418. # the typechecking on the updated files yet.
  419. # Run a fine-grained update starting from the cached data
  420. if result.used_cache:
  421. t2 = time.time()
  422. # Pull times and hashes out of the saved_cache and stick them into
  423. # the fswatcher, so we pick up the changes.
  424. for state in self.fine_grained_manager.graph.values():
  425. meta = state.meta
  426. if meta is None:
  427. continue
  428. assert state.path is not None
  429. self.fswatcher.set_file_data(
  430. state.path,
  431. FileData(st_mtime=float(meta.mtime), st_size=meta.size, hash=meta.hash),
  432. )
  433. changed, removed = self.find_changed(sources)
  434. changed += self.find_added_suppressed(
  435. self.fine_grained_manager.graph,
  436. set(),
  437. self.fine_grained_manager.manager.search_paths,
  438. )
  439. # Find anything that has had its dependency list change
  440. for state in self.fine_grained_manager.graph.values():
  441. if not state.is_fresh():
  442. assert state.path is not None
  443. changed.append((state.id, state.path))
  444. t3 = time.time()
  445. # Run an update
  446. messages = self.fine_grained_manager.update(changed, removed)
  447. if self.following_imports():
  448. # We need to do another update to any new files found by following imports.
  449. messages = self.fine_grained_increment_follow_imports(sources)
  450. t4 = time.time()
  451. self.fine_grained_manager.manager.add_stats(
  452. update_sources_time=t1 - t0,
  453. build_time=t2 - t1,
  454. find_changes_time=t3 - t2,
  455. fg_update_time=t4 - t3,
  456. files_changed=len(removed) + len(changed),
  457. )
  458. else:
  459. # Stores the initial state of sources as a side effect.
  460. self.fswatcher.find_changed()
  461. if MEM_PROFILE:
  462. from mypy.memprofile import print_memory_profile
  463. print_memory_profile(run_gc=False)
  464. __, n_notes, __ = count_stats(messages)
  465. status = 1 if messages and n_notes < len(messages) else 0
  466. messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width)
  467. return {"out": "".join(s + "\n" for s in messages), "err": "", "status": status}
  468. def fine_grained_increment(
  469. self,
  470. sources: list[BuildSource],
  471. remove: list[str] | None = None,
  472. update: list[str] | None = None,
  473. ) -> list[str]:
  474. """Perform a fine-grained type checking increment.
  475. If remove and update are None, determine changed paths by using
  476. fswatcher. Otherwise, assume that only these files have changes.
  477. Args:
  478. sources: sources passed on the command line
  479. remove: paths of files that have been removed
  480. update: paths of files that have been changed or created
  481. """
  482. assert self.fine_grained_manager is not None
  483. manager = self.fine_grained_manager.manager
  484. t0 = time.time()
  485. if remove is None and update is None:
  486. # Use the fswatcher to determine which files were changed
  487. # (updated or added) or removed.
  488. self.update_sources(sources)
  489. changed, removed = self.find_changed(sources)
  490. else:
  491. # Use the remove/update lists to update fswatcher.
  492. # This avoids calling stat() for unchanged files.
  493. changed, removed = self.update_changed(sources, remove or [], update or [])
  494. changed += self.find_added_suppressed(
  495. self.fine_grained_manager.graph, set(), manager.search_paths
  496. )
  497. manager.search_paths = compute_search_paths(sources, manager.options, manager.data_dir)
  498. t1 = time.time()
  499. manager.log(f"fine-grained increment: find_changed: {t1 - t0:.3f}s")
  500. messages = self.fine_grained_manager.update(changed, removed)
  501. t2 = time.time()
  502. manager.log(f"fine-grained increment: update: {t2 - t1:.3f}s")
  503. manager.add_stats(
  504. find_changes_time=t1 - t0,
  505. fg_update_time=t2 - t1,
  506. files_changed=len(removed) + len(changed),
  507. )
  508. self.previous_sources = sources
  509. return messages
  510. def fine_grained_increment_follow_imports(self, sources: list[BuildSource]) -> list[str]:
  511. """Like fine_grained_increment, but follow imports."""
  512. t0 = time.time()
  513. # TODO: Support file events
  514. assert self.fine_grained_manager is not None
  515. fine_grained_manager = self.fine_grained_manager
  516. graph = fine_grained_manager.graph
  517. manager = fine_grained_manager.manager
  518. orig_modules = list(graph.keys())
  519. self.update_sources(sources)
  520. changed_paths = self.fswatcher.find_changed()
  521. manager.search_paths = compute_search_paths(sources, manager.options, manager.data_dir)
  522. t1 = time.time()
  523. manager.log(f"fine-grained increment: find_changed: {t1 - t0:.3f}s")
  524. seen = {source.module for source in sources}
  525. # Find changed modules reachable from roots (or in roots) already in graph.
  526. changed, new_files = self.find_reachable_changed_modules(
  527. sources, graph, seen, changed_paths
  528. )
  529. sources.extend(new_files)
  530. # Process changes directly reachable from roots.
  531. messages = fine_grained_manager.update(changed, [], followed=True)
  532. # Follow deps from changed modules (still within graph).
  533. worklist = changed.copy()
  534. while worklist:
  535. module = worklist.pop()
  536. if module[0] not in graph:
  537. continue
  538. sources2 = self.direct_imports(module, graph)
  539. # Filter anything already seen before. This prevents
  540. # infinite looping if there are any self edges. (Self
  541. # edges are maybe a bug, but...)
  542. sources2 = [source for source in sources2 if source.module not in seen]
  543. changed, new_files = self.find_reachable_changed_modules(
  544. sources2, graph, seen, changed_paths
  545. )
  546. self.update_sources(new_files)
  547. messages = fine_grained_manager.update(changed, [], followed=True)
  548. worklist.extend(changed)
  549. t2 = time.time()
  550. def refresh_file(module: str, path: str) -> list[str]:
  551. return fine_grained_manager.update([(module, path)], [], followed=True)
  552. for module_id, state in list(graph.items()):
  553. new_messages = refresh_suppressed_submodules(
  554. module_id, state.path, fine_grained_manager.deps, graph, self.fscache, refresh_file
  555. )
  556. if new_messages is not None:
  557. messages = new_messages
  558. t3 = time.time()
  559. # There may be new files that became available, currently treated as
  560. # suppressed imports. Process them.
  561. while True:
  562. new_unsuppressed = self.find_added_suppressed(graph, seen, manager.search_paths)
  563. if not new_unsuppressed:
  564. break
  565. new_files = [BuildSource(mod[1], mod[0], followed=True) for mod in new_unsuppressed]
  566. sources.extend(new_files)
  567. self.update_sources(new_files)
  568. messages = fine_grained_manager.update(new_unsuppressed, [], followed=True)
  569. for module_id, path in new_unsuppressed:
  570. new_messages = refresh_suppressed_submodules(
  571. module_id, path, fine_grained_manager.deps, graph, self.fscache, refresh_file
  572. )
  573. if new_messages is not None:
  574. messages = new_messages
  575. t4 = time.time()
  576. # Find all original modules in graph that were not reached -- they are deleted.
  577. to_delete = []
  578. for module_id in orig_modules:
  579. if module_id not in graph:
  580. continue
  581. if module_id not in seen:
  582. module_path = graph[module_id].path
  583. assert module_path is not None
  584. to_delete.append((module_id, module_path))
  585. if to_delete:
  586. messages = fine_grained_manager.update([], to_delete)
  587. fix_module_deps(graph)
  588. self.previous_sources = find_all_sources_in_build(graph)
  589. self.update_sources(self.previous_sources)
  590. # Store current file state as side effect
  591. self.fswatcher.find_changed()
  592. t5 = time.time()
  593. manager.log(f"fine-grained increment: update: {t5 - t1:.3f}s")
  594. manager.add_stats(
  595. find_changes_time=t1 - t0,
  596. fg_update_time=t2 - t1,
  597. refresh_suppressed_time=t3 - t2,
  598. find_added_supressed_time=t4 - t3,
  599. cleanup_time=t5 - t4,
  600. )
  601. return messages
  602. def find_reachable_changed_modules(
  603. self,
  604. roots: list[BuildSource],
  605. graph: mypy.build.Graph,
  606. seen: set[str],
  607. changed_paths: AbstractSet[str],
  608. ) -> tuple[list[tuple[str, str]], list[BuildSource]]:
  609. """Follow imports within graph from given sources until hitting changed modules.
  610. If we find a changed module, we can't continue following imports as the imports
  611. may have changed.
  612. Args:
  613. roots: modules where to start search from
  614. graph: module graph to use for the search
  615. seen: modules we've seen before that won't be visited (mutated here!!)
  616. changed_paths: which paths have changed (stop search here and return any found)
  617. Return (encountered reachable changed modules,
  618. unchanged files not in sources_set traversed).
  619. """
  620. changed = []
  621. new_files = []
  622. worklist = roots.copy()
  623. seen.update(source.module for source in worklist)
  624. while worklist:
  625. nxt = worklist.pop()
  626. if nxt.module not in seen:
  627. seen.add(nxt.module)
  628. new_files.append(nxt)
  629. if nxt.path in changed_paths:
  630. assert nxt.path is not None # TODO
  631. changed.append((nxt.module, nxt.path))
  632. elif nxt.module in graph:
  633. state = graph[nxt.module]
  634. for dep in state.dependencies:
  635. if dep not in seen:
  636. seen.add(dep)
  637. worklist.append(BuildSource(graph[dep].path, graph[dep].id, followed=True))
  638. return changed, new_files
  639. def direct_imports(
  640. self, module: tuple[str, str], graph: mypy.build.Graph
  641. ) -> list[BuildSource]:
  642. """Return the direct imports of module not included in seen."""
  643. state = graph[module[0]]
  644. return [BuildSource(graph[dep].path, dep, followed=True) for dep in state.dependencies]
  645. def find_added_suppressed(
  646. self, graph: mypy.build.Graph, seen: set[str], search_paths: SearchPaths
  647. ) -> list[tuple[str, str]]:
  648. """Find suppressed modules that have been added (and not included in seen).
  649. Args:
  650. seen: reachable modules we've seen before (mutated here!!)
  651. Return suppressed, added modules.
  652. """
  653. all_suppressed = set()
  654. for state in graph.values():
  655. all_suppressed |= state.suppressed_set
  656. # Filter out things that shouldn't actually be considered suppressed.
  657. #
  658. # TODO: Figure out why these are treated as suppressed
  659. all_suppressed = {
  660. module
  661. for module in all_suppressed
  662. if module not in graph and not ignore_suppressed_imports(module)
  663. }
  664. # Optimization: skip top-level packages that are obviously not
  665. # there, to avoid calling the relatively slow find_module()
  666. # below too many times.
  667. packages = {module.split(".", 1)[0] for module in all_suppressed}
  668. packages = filter_out_missing_top_level_packages(packages, search_paths, self.fscache)
  669. # TODO: Namespace packages
  670. finder = FindModuleCache(search_paths, self.fscache, self.options)
  671. found = []
  672. for module in all_suppressed:
  673. top_level_pkg = module.split(".", 1)[0]
  674. if top_level_pkg not in packages:
  675. # Fast path: non-existent top-level package
  676. continue
  677. result = finder.find_module(module, fast_path=True)
  678. if isinstance(result, str) and module not in seen:
  679. # When not following imports, we only follow imports to .pyi files.
  680. if not self.following_imports() and not result.endswith(".pyi"):
  681. continue
  682. found.append((module, result))
  683. seen.add(module)
  684. return found
  685. def increment_output(
  686. self, messages: list[str], sources: list[BuildSource], is_tty: bool, terminal_width: int
  687. ) -> dict[str, Any]:
  688. status = 1 if messages else 0
  689. messages = self.pretty_messages(messages, len(sources), is_tty, terminal_width)
  690. return {"out": "".join(s + "\n" for s in messages), "err": "", "status": status}
  691. def pretty_messages(
  692. self,
  693. messages: list[str],
  694. n_sources: int,
  695. is_tty: bool = False,
  696. terminal_width: int | None = None,
  697. ) -> list[str]:
  698. use_color = self.options.color_output and is_tty
  699. fit_width = self.options.pretty and is_tty
  700. if fit_width:
  701. messages = self.formatter.fit_in_terminal(
  702. messages, fixed_terminal_width=terminal_width
  703. )
  704. if self.options.error_summary:
  705. summary: str | None = None
  706. n_errors, n_notes, n_files = count_stats(messages)
  707. if n_errors:
  708. summary = self.formatter.format_error(
  709. n_errors, n_files, n_sources, use_color=use_color
  710. )
  711. elif not messages or n_notes == len(messages):
  712. summary = self.formatter.format_success(n_sources, use_color)
  713. if summary:
  714. # Create new list to avoid appending multiple summaries on successive runs.
  715. messages = messages + [summary]
  716. if use_color:
  717. messages = [self.formatter.colorize(m) for m in messages]
  718. return messages
  719. def update_sources(self, sources: list[BuildSource]) -> None:
  720. paths = [source.path for source in sources if source.path is not None]
  721. if self.following_imports():
  722. # Filter out directories (used for namespace packages).
  723. paths = [path for path in paths if self.fscache.isfile(path)]
  724. self.fswatcher.add_watched_paths(paths)
  725. def update_changed(
  726. self, sources: list[BuildSource], remove: list[str], update: list[str]
  727. ) -> ChangesAndRemovals:
  728. changed_paths = self.fswatcher.update_changed(remove, update)
  729. return self._find_changed(sources, changed_paths)
  730. def find_changed(self, sources: list[BuildSource]) -> ChangesAndRemovals:
  731. changed_paths = self.fswatcher.find_changed()
  732. return self._find_changed(sources, changed_paths)
  733. def _find_changed(
  734. self, sources: list[BuildSource], changed_paths: AbstractSet[str]
  735. ) -> ChangesAndRemovals:
  736. # Find anything that has been added or modified
  737. changed = [
  738. (source.module, source.path)
  739. for source in sources
  740. if source.path and source.path in changed_paths
  741. ]
  742. # Now find anything that has been removed from the build
  743. modules = {source.module for source in sources}
  744. omitted = [source for source in self.previous_sources if source.module not in modules]
  745. removed = []
  746. for source in omitted:
  747. path = source.path
  748. assert path
  749. removed.append((source.module, path))
  750. # Find anything that has had its module path change because of added or removed __init__s
  751. last = {s.path: s.module for s in self.previous_sources}
  752. for s in sources:
  753. assert s.path
  754. if s.path in last and last[s.path] != s.module:
  755. # Mark it as removed from its old name and changed at its new name
  756. removed.append((last[s.path], s.path))
  757. changed.append((s.module, s.path))
  758. return changed, removed
  759. def cmd_inspect(
  760. self,
  761. show: str,
  762. location: str,
  763. verbosity: int = 0,
  764. limit: int = 0,
  765. include_span: bool = False,
  766. include_kind: bool = False,
  767. include_object_attrs: bool = False,
  768. union_attrs: bool = False,
  769. force_reload: bool = False,
  770. ) -> dict[str, object]:
  771. """Locate and inspect expression(s)."""
  772. if sys.version_info < (3, 8):
  773. return {"error": 'Python 3.8 required for "inspect" command'}
  774. if not self.fine_grained_manager:
  775. return {
  776. "error": 'Command "inspect" is only valid after a "check" command'
  777. " (that produces no parse errors)"
  778. }
  779. engine = InspectionEngine(
  780. self.fine_grained_manager,
  781. verbosity=verbosity,
  782. limit=limit,
  783. include_span=include_span,
  784. include_kind=include_kind,
  785. include_object_attrs=include_object_attrs,
  786. union_attrs=union_attrs,
  787. force_reload=force_reload,
  788. )
  789. old_inspections = self.options.inspections
  790. self.options.inspections = True
  791. try:
  792. if show == "type":
  793. result = engine.get_type(location)
  794. elif show == "attrs":
  795. result = engine.get_attrs(location)
  796. elif show == "definition":
  797. result = engine.get_definition(location)
  798. else:
  799. assert False, "Unknown inspection kind"
  800. finally:
  801. self.options.inspections = old_inspections
  802. if "out" in result:
  803. assert isinstance(result["out"], str)
  804. result["out"] += "\n"
  805. return result
  806. def cmd_suggest(self, function: str, callsites: bool, **kwargs: Any) -> dict[str, object]:
  807. """Suggest a signature for a function."""
  808. if not self.fine_grained_manager:
  809. return {
  810. "error": "Command 'suggest' is only valid after a 'check' command"
  811. " (that produces no parse errors)"
  812. }
  813. engine = SuggestionEngine(self.fine_grained_manager, **kwargs)
  814. try:
  815. if callsites:
  816. out = engine.suggest_callsites(function)
  817. else:
  818. out = engine.suggest(function)
  819. except SuggestionFailure as err:
  820. return {"error": str(err)}
  821. else:
  822. if not out:
  823. out = "No suggestions\n"
  824. elif not out.endswith("\n"):
  825. out += "\n"
  826. return {"out": out, "err": "", "status": 0}
  827. finally:
  828. self.flush_caches()
  829. def cmd_hang(self) -> dict[str, object]:
  830. """Hang for 100 seconds, as a debug hack."""
  831. time.sleep(100)
  832. return {}
  833. # Misc utilities.
  834. MiB: Final = 2**20
  835. def get_meminfo() -> dict[str, Any]:
  836. res: dict[str, Any] = {}
  837. try:
  838. import psutil
  839. except ImportError:
  840. res["memory_psutil_missing"] = (
  841. "psutil not found, run pip install mypy[dmypy] "
  842. "to install the needed components for dmypy"
  843. )
  844. else:
  845. process = psutil.Process()
  846. meminfo = process.memory_info()
  847. res["memory_rss_mib"] = meminfo.rss / MiB
  848. res["memory_vms_mib"] = meminfo.vms / MiB
  849. if sys.platform == "win32":
  850. res["memory_maxrss_mib"] = meminfo.peak_wset / MiB
  851. else:
  852. # See https://stackoverflow.com/questions/938733/total-memory-used-by-python-process
  853. import resource # Since it doesn't exist on Windows.
  854. rusage = resource.getrusage(resource.RUSAGE_SELF)
  855. if sys.platform == "darwin":
  856. factor = 1
  857. else:
  858. factor = 1024 # Linux
  859. res["memory_maxrss_mib"] = rusage.ru_maxrss * factor / MiB
  860. return res
  861. def find_all_sources_in_build(
  862. graph: mypy.build.Graph, extra: Sequence[BuildSource] = ()
  863. ) -> list[BuildSource]:
  864. result = list(extra)
  865. seen = {source.module for source in result}
  866. for module, state in graph.items():
  867. if module not in seen:
  868. result.append(BuildSource(state.path, module))
  869. return result
  870. def fix_module_deps(graph: mypy.build.Graph) -> None:
  871. """After an incremental update, update module dependencies to reflect the new state.
  872. This can make some suppressed dependencies non-suppressed, and vice versa (if modules
  873. have been added to or removed from the build).
  874. """
  875. for module, state in graph.items():
  876. new_suppressed = []
  877. new_dependencies = []
  878. for dep in state.dependencies + state.suppressed:
  879. if dep in graph:
  880. new_dependencies.append(dep)
  881. else:
  882. new_suppressed.append(dep)
  883. state.dependencies = new_dependencies
  884. state.dependencies_set = set(new_dependencies)
  885. state.suppressed = new_suppressed
  886. state.suppressed_set = set(new_suppressed)
  887. def filter_out_missing_top_level_packages(
  888. packages: set[str], search_paths: SearchPaths, fscache: FileSystemCache
  889. ) -> set[str]:
  890. """Quickly filter out obviously missing top-level packages.
  891. Return packages with entries that can't be found removed.
  892. This is approximate: some packages that aren't actually valid may be
  893. included. However, all potentially valid packages must be returned.
  894. """
  895. # Start with a empty set and add all potential top-level packages.
  896. found = set()
  897. paths = (
  898. search_paths.python_path
  899. + search_paths.mypy_path
  900. + search_paths.package_path
  901. + search_paths.typeshed_path
  902. )
  903. for p in paths:
  904. try:
  905. entries = fscache.listdir(p)
  906. except Exception:
  907. entries = []
  908. for entry in entries:
  909. # The code is hand-optimized for mypyc since this may be somewhat
  910. # performance-critical.
  911. if entry.endswith(".py"):
  912. entry = entry[:-3]
  913. elif entry.endswith(".pyi"):
  914. entry = entry[:-4]
  915. elif entry.endswith("-stubs"):
  916. # Possible PEP 561 stub package
  917. entry = entry[:-6]
  918. if entry in packages:
  919. found.add(entry)
  920. return found