source.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. # mypy: allow-untyped-defs
  2. from __future__ import annotations
  3. import ast
  4. from bisect import bisect_right
  5. from collections.abc import Iterable
  6. from collections.abc import Iterator
  7. import inspect
  8. import textwrap
  9. import tokenize
  10. import types
  11. from typing import overload
  12. import warnings
  13. class Source:
  14. """An immutable object holding a source code fragment.
  15. When using Source(...), the source lines are deindented.
  16. """
  17. def __init__(self, obj: object = None) -> None:
  18. if not obj:
  19. self.lines: list[str] = []
  20. self.raw_lines: list[str] = []
  21. elif isinstance(obj, Source):
  22. self.lines = obj.lines
  23. self.raw_lines = obj.raw_lines
  24. elif isinstance(obj, (tuple, list)):
  25. self.lines = deindent(x.rstrip("\n") for x in obj)
  26. self.raw_lines = list(x.rstrip("\n") for x in obj)
  27. elif isinstance(obj, str):
  28. self.lines = deindent(obj.split("\n"))
  29. self.raw_lines = obj.split("\n")
  30. else:
  31. try:
  32. rawcode = getrawcode(obj)
  33. src = inspect.getsource(rawcode)
  34. except TypeError:
  35. src = inspect.getsource(obj) # type: ignore[arg-type]
  36. self.lines = deindent(src.split("\n"))
  37. self.raw_lines = src.split("\n")
  38. def __eq__(self, other: object) -> bool:
  39. if not isinstance(other, Source):
  40. return NotImplemented
  41. return self.lines == other.lines
  42. # Ignore type because of https://github.com/python/mypy/issues/4266.
  43. __hash__ = None # type: ignore
  44. @overload
  45. def __getitem__(self, key: int) -> str: ...
  46. @overload
  47. def __getitem__(self, key: slice) -> Source: ...
  48. def __getitem__(self, key: int | slice) -> str | Source:
  49. if isinstance(key, int):
  50. return self.lines[key]
  51. else:
  52. if key.step not in (None, 1):
  53. raise IndexError("cannot slice a Source with a step")
  54. newsource = Source()
  55. newsource.lines = self.lines[key.start : key.stop]
  56. newsource.raw_lines = self.raw_lines[key.start : key.stop]
  57. return newsource
  58. def __iter__(self) -> Iterator[str]:
  59. return iter(self.lines)
  60. def __len__(self) -> int:
  61. return len(self.lines)
  62. def strip(self) -> Source:
  63. """Return new Source object with trailing and leading blank lines removed."""
  64. start, end = 0, len(self)
  65. while start < end and not self.lines[start].strip():
  66. start += 1
  67. while end > start and not self.lines[end - 1].strip():
  68. end -= 1
  69. source = Source()
  70. source.raw_lines = self.raw_lines
  71. source.lines[:] = self.lines[start:end]
  72. return source
  73. def indent(self, indent: str = " " * 4) -> Source:
  74. """Return a copy of the source object with all lines indented by the
  75. given indent-string."""
  76. newsource = Source()
  77. newsource.raw_lines = self.raw_lines
  78. newsource.lines = [(indent + line) for line in self.lines]
  79. return newsource
  80. def getstatement(self, lineno: int) -> Source:
  81. """Return Source statement which contains the given linenumber
  82. (counted from 0)."""
  83. start, end = self.getstatementrange(lineno)
  84. return self[start:end]
  85. def getstatementrange(self, lineno: int) -> tuple[int, int]:
  86. """Return (start, end) tuple which spans the minimal statement region
  87. which containing the given lineno."""
  88. if not (0 <= lineno < len(self)):
  89. raise IndexError("lineno out of range")
  90. ast, start, end = getstatementrange_ast(lineno, self)
  91. return start, end
  92. def deindent(self) -> Source:
  93. """Return a new Source object deindented."""
  94. newsource = Source()
  95. newsource.lines[:] = deindent(self.lines)
  96. newsource.raw_lines = self.raw_lines
  97. return newsource
  98. def __str__(self) -> str:
  99. return "\n".join(self.lines)
  100. #
  101. # helper functions
  102. #
  103. def findsource(obj) -> tuple[Source | None, int]:
  104. try:
  105. sourcelines, lineno = inspect.findsource(obj)
  106. except Exception:
  107. return None, -1
  108. source = Source()
  109. source.lines = [line.rstrip() for line in sourcelines]
  110. source.raw_lines = sourcelines
  111. return source, lineno
  112. def getrawcode(obj: object, trycall: bool = True) -> types.CodeType:
  113. """Return code object for given function."""
  114. try:
  115. return obj.__code__ # type: ignore[attr-defined,no-any-return]
  116. except AttributeError:
  117. pass
  118. if trycall:
  119. call = getattr(obj, "__call__", None)
  120. if call and not isinstance(obj, type):
  121. return getrawcode(call, trycall=False)
  122. raise TypeError(f"could not get code object for {obj!r}")
  123. def deindent(lines: Iterable[str]) -> list[str]:
  124. return textwrap.dedent("\n".join(lines)).splitlines()
  125. def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
  126. # Flatten all statements and except handlers into one lineno-list.
  127. # AST's line numbers start indexing at 1.
  128. values: list[int] = []
  129. for x in ast.walk(node):
  130. if isinstance(x, (ast.stmt, ast.ExceptHandler)):
  131. # The lineno points to the class/def, so need to include the decorators.
  132. if isinstance(x, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
  133. for d in x.decorator_list:
  134. values.append(d.lineno - 1)
  135. values.append(x.lineno - 1)
  136. for name in ("finalbody", "orelse"):
  137. val: list[ast.stmt] | None = getattr(x, name, None)
  138. if val:
  139. # Treat the finally/orelse part as its own statement.
  140. values.append(val[0].lineno - 1 - 1)
  141. values.sort()
  142. insert_index = bisect_right(values, lineno)
  143. start = values[insert_index - 1]
  144. if insert_index >= len(values):
  145. end = None
  146. else:
  147. end = values[insert_index]
  148. return start, end
  149. def getstatementrange_ast(
  150. lineno: int,
  151. source: Source,
  152. assertion: bool = False,
  153. astnode: ast.AST | None = None,
  154. ) -> tuple[ast.AST, int, int]:
  155. if astnode is None:
  156. content = str(source)
  157. # See #4260:
  158. # Don't produce duplicate warnings when compiling source to find AST.
  159. with warnings.catch_warnings():
  160. warnings.simplefilter("ignore")
  161. astnode = ast.parse(content, "source", "exec")
  162. start, end = get_statement_startend2(lineno, astnode)
  163. # We need to correct the end:
  164. # - ast-parsing strips comments
  165. # - there might be empty lines
  166. # - we might have lesser indented code blocks at the end
  167. if end is None:
  168. end = len(source.lines)
  169. if end > start + 1:
  170. # Make sure we don't span differently indented code blocks
  171. # by using the BlockFinder helper used which inspect.getsource() uses itself.
  172. block_finder = inspect.BlockFinder()
  173. # If we start with an indented line, put blockfinder to "started" mode.
  174. block_finder.started = (
  175. bool(source.lines[start]) and source.lines[start][0].isspace()
  176. )
  177. it = ((x + "\n") for x in source.lines[start:end])
  178. try:
  179. for tok in tokenize.generate_tokens(lambda: next(it)):
  180. block_finder.tokeneater(*tok)
  181. except (inspect.EndOfBlock, IndentationError):
  182. end = block_finder.last + start
  183. except Exception:
  184. pass
  185. # The end might still point to a comment or empty line, correct it.
  186. while end:
  187. line = source.lines[end - 1].lstrip()
  188. if line.startswith("#") or not line:
  189. end -= 1
  190. else:
  191. break
  192. return astnode, start, end