github_links.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import ast
  2. import functools
  3. import importlib.util
  4. import pathlib
  5. class CodeLocator(ast.NodeVisitor):
  6. def __init__(self):
  7. super().__init__()
  8. self.current_path = []
  9. self.node_line_numbers = {}
  10. self.import_locations = {}
  11. @classmethod
  12. def from_code(cls, code):
  13. tree = ast.parse(code)
  14. locator = cls()
  15. locator.visit(tree)
  16. return locator
  17. def visit_node(self, node):
  18. self.current_path.append(node.name)
  19. self.node_line_numbers[".".join(self.current_path)] = node.lineno
  20. self.generic_visit(node)
  21. self.current_path.pop()
  22. def visit_FunctionDef(self, node):
  23. self.visit_node(node)
  24. def visit_ClassDef(self, node):
  25. self.visit_node(node)
  26. def visit_ImportFrom(self, node):
  27. for alias in node.names:
  28. if alias.asname:
  29. # Exclude linking aliases (`import x as y`) to avoid confusion
  30. # when clicking a source link to a differently named entity.
  31. continue
  32. if alias.name == "*":
  33. # Resolve wildcard imports.
  34. file = module_name_to_file_path(node.module)
  35. file_contents = file.read_text(encoding="utf-8")
  36. locator = CodeLocator.from_code(file_contents)
  37. self.import_locations.update(locator.import_locations)
  38. self.import_locations.update(
  39. {n: node.module for n in locator.node_line_numbers if "." not in n}
  40. )
  41. else:
  42. self.import_locations[alias.name] = ("." * node.level) + (
  43. node.module or ""
  44. )
  45. @functools.lru_cache(maxsize=1024)
  46. def get_locator(file):
  47. file_contents = file.read_text(encoding="utf-8")
  48. return CodeLocator.from_code(file_contents)
  49. class CodeNotFound(Exception):
  50. pass
  51. def module_name_to_file_path(module_name):
  52. # Avoid importlib machinery as locating a module involves importing its
  53. # parent, which would trigger import side effects.
  54. for suffix in [".py", "/__init__.py"]:
  55. file_path = pathlib.Path(__file__).parents[2] / (
  56. module_name.replace(".", "/") + suffix
  57. )
  58. if file_path.exists():
  59. return file_path
  60. raise CodeNotFound
  61. def get_path_and_line(module, fullname):
  62. path = module_name_to_file_path(module_name=module)
  63. locator = get_locator(path)
  64. lineno = locator.node_line_numbers.get(fullname)
  65. if lineno is not None:
  66. return path, lineno
  67. imported_object = fullname.split(".", maxsplit=1)[0]
  68. try:
  69. imported_path = locator.import_locations[imported_object]
  70. except KeyError:
  71. raise CodeNotFound
  72. # From a statement such as:
  73. # from . import y.z
  74. # - either y.z might be an object in the parent module
  75. # - or y might be a module, and z be an object in y
  76. # also:
  77. # - either the current file is x/__init__.py, and z would be in x.y
  78. # - or the current file is x/a.py, and z would be in x.a.y
  79. if path.name != "__init__.py":
  80. # Look in parent module
  81. module = module.rsplit(".", maxsplit=1)[0]
  82. try:
  83. imported_module = importlib.util.resolve_name(
  84. name=imported_path, package=module
  85. )
  86. except ImportError as error:
  87. raise ImportError(
  88. f"Could not import '{imported_path}' in '{module}'."
  89. ) from error
  90. try:
  91. return get_path_and_line(module=imported_module, fullname=fullname)
  92. except CodeNotFound:
  93. if "." not in fullname:
  94. raise
  95. first_element, remainder = fullname.rsplit(".", maxsplit=1)
  96. # Retrying, assuming the first element of the fullname is a module.
  97. return get_path_and_line(
  98. module=f"{imported_module}.{first_element}", fullname=remainder
  99. )
  100. def get_branch(version, next_version):
  101. if version == next_version:
  102. return "main"
  103. else:
  104. return f"stable/{version}.x"
  105. def github_linkcode_resolve(domain, info, *, version, next_version):
  106. if domain != "py":
  107. return None
  108. if not (module := info["module"]):
  109. return None
  110. try:
  111. path, lineno = get_path_and_line(module=module, fullname=info["fullname"])
  112. except CodeNotFound:
  113. return None
  114. branch = get_branch(version=version, next_version=next_version)
  115. relative_path = path.relative_to(pathlib.Path(__file__).parents[2])
  116. # Use "/" explicitly to join the path parts since str(file), on Windows,
  117. # uses the Windows path separator which is incorrect for URLs.
  118. url_path = "/".join(relative_path.parts)
  119. return f"https://github.com/django/django/blob/{branch}/{url_path}#L{lineno}"