stubutil.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """Utilities for mypy.stubgen, mypy.stubgenc, and mypy.stubdoc modules."""
  2. from __future__ import annotations
  3. import os.path
  4. import re
  5. import sys
  6. from contextlib import contextmanager
  7. from typing import Iterator
  8. from typing_extensions import overload
  9. from mypy.modulefinder import ModuleNotFoundReason
  10. from mypy.moduleinspect import InspectError, ModuleInspect
  11. # Modules that may fail when imported, or that may have side effects (fully qualified).
  12. NOT_IMPORTABLE_MODULES = ()
  13. class CantImport(Exception):
  14. def __init__(self, module: str, message: str):
  15. self.module = module
  16. self.message = message
  17. def walk_packages(
  18. inspect: ModuleInspect, packages: list[str], verbose: bool = False
  19. ) -> Iterator[str]:
  20. """Iterates through all packages and sub-packages in the given list.
  21. This uses runtime imports (in another process) to find both Python and C modules.
  22. For Python packages we simply pass the __path__ attribute to pkgutil.walk_packages() to
  23. get the content of the package (all subpackages and modules). However, packages in C
  24. extensions do not have this attribute, so we have to roll out our own logic: recursively
  25. find all modules imported in the package that have matching names.
  26. """
  27. for package_name in packages:
  28. if package_name in NOT_IMPORTABLE_MODULES:
  29. print(f"{package_name}: Skipped (blacklisted)")
  30. continue
  31. if verbose:
  32. print(f"Trying to import {package_name!r} for runtime introspection")
  33. try:
  34. prop = inspect.get_package_properties(package_name)
  35. except InspectError:
  36. report_missing(package_name)
  37. continue
  38. yield prop.name
  39. if prop.is_c_module:
  40. # Recursively iterate through the subpackages
  41. yield from walk_packages(inspect, prop.subpackages, verbose)
  42. else:
  43. yield from prop.subpackages
  44. def find_module_path_using_sys_path(module: str, sys_path: list[str]) -> str | None:
  45. relative_candidates = (
  46. module.replace(".", "/") + ".py",
  47. os.path.join(module.replace(".", "/"), "__init__.py"),
  48. )
  49. for base in sys_path:
  50. for relative_path in relative_candidates:
  51. path = os.path.join(base, relative_path)
  52. if os.path.isfile(path):
  53. return path
  54. return None
  55. def find_module_path_and_all_py3(
  56. inspect: ModuleInspect, module: str, verbose: bool
  57. ) -> tuple[str | None, list[str] | None] | None:
  58. """Find module and determine __all__ for a Python 3 module.
  59. Return None if the module is a C module. Return (module_path, __all__) if
  60. it is a Python module. Raise CantImport if import failed.
  61. """
  62. if module in NOT_IMPORTABLE_MODULES:
  63. raise CantImport(module, "")
  64. # TODO: Support custom interpreters.
  65. if verbose:
  66. print(f"Trying to import {module!r} for runtime introspection")
  67. try:
  68. mod = inspect.get_package_properties(module)
  69. except InspectError as e:
  70. # Fall back to finding the module using sys.path.
  71. path = find_module_path_using_sys_path(module, sys.path)
  72. if path is None:
  73. raise CantImport(module, str(e)) from e
  74. return path, None
  75. if mod.is_c_module:
  76. return None
  77. return mod.file, mod.all
  78. @contextmanager
  79. def generate_guarded(
  80. mod: str, target: str, ignore_errors: bool = True, verbose: bool = False
  81. ) -> Iterator[None]:
  82. """Ignore or report errors during stub generation.
  83. Optionally report success.
  84. """
  85. if verbose:
  86. print(f"Processing {mod}")
  87. try:
  88. yield
  89. except Exception as e:
  90. if not ignore_errors:
  91. raise e
  92. else:
  93. # --ignore-errors was passed
  94. print("Stub generation failed for", mod, file=sys.stderr)
  95. else:
  96. if verbose:
  97. print(f"Created {target}")
  98. def report_missing(mod: str, message: str | None = "", traceback: str = "") -> None:
  99. if message:
  100. message = " with error: " + message
  101. print(f"{mod}: Failed to import, skipping{message}")
  102. def fail_missing(mod: str, reason: ModuleNotFoundReason) -> None:
  103. if reason is ModuleNotFoundReason.NOT_FOUND:
  104. clarification = "(consider using --search-path)"
  105. elif reason is ModuleNotFoundReason.FOUND_WITHOUT_TYPE_HINTS:
  106. clarification = "(module likely exists, but is not PEP 561 compatible)"
  107. else:
  108. clarification = f"(unknown reason '{reason}')"
  109. raise SystemExit(f"Can't find module '{mod}' {clarification}")
  110. @overload
  111. def remove_misplaced_type_comments(source: bytes) -> bytes:
  112. ...
  113. @overload
  114. def remove_misplaced_type_comments(source: str) -> str:
  115. ...
  116. def remove_misplaced_type_comments(source: str | bytes) -> str | bytes:
  117. """Remove comments from source that could be understood as misplaced type comments.
  118. Normal comments may look like misplaced type comments, and since they cause blocking
  119. parse errors, we want to avoid them.
  120. """
  121. if isinstance(source, bytes):
  122. # This gives us a 1-1 character code mapping, so it's roundtrippable.
  123. text = source.decode("latin1")
  124. else:
  125. text = source
  126. # Remove something that looks like a variable type comment but that's by itself
  127. # on a line, as it will often generate a parse error (unless it's # type: ignore).
  128. text = re.sub(r'^[ \t]*# +type: +["\'a-zA-Z_].*$', "", text, flags=re.MULTILINE)
  129. # Remove something that looks like a function type comment after docstring,
  130. # which will result in a parse error.
  131. text = re.sub(r'""" *\n[ \t\n]*# +type: +\(.*$', '"""\n', text, flags=re.MULTILINE)
  132. text = re.sub(r"''' *\n[ \t\n]*# +type: +\(.*$", "'''\n", text, flags=re.MULTILINE)
  133. # Remove something that looks like a badly formed function type comment.
  134. text = re.sub(r"^[ \t]*# +type: +\([^()]+(\)[ \t]*)?$", "", text, flags=re.MULTILINE)
  135. if isinstance(source, bytes):
  136. return text.encode("latin1")
  137. else:
  138. return text
  139. def common_dir_prefix(paths: list[str]) -> str:
  140. if not paths:
  141. return "."
  142. cur = os.path.dirname(os.path.normpath(paths[0]))
  143. for path in paths[1:]:
  144. while True:
  145. path = os.path.dirname(os.path.normpath(path))
  146. if (cur + os.sep).startswith(path + os.sep):
  147. cur = path
  148. break
  149. return cur or "."