diff options
Diffstat (limited to 'docs/_ext/github_links.py')
| -rw-r--r-- | docs/_ext/github_links.py | 149 |
1 files changed, 149 insertions, 0 deletions
diff --git a/docs/_ext/github_links.py b/docs/_ext/github_links.py new file mode 100644 index 0000000000..32af97186d --- /dev/null +++ b/docs/_ext/github_links.py @@ -0,0 +1,149 @@ +import ast +import functools +import importlib.util +import pathlib + + +class CodeLocator(ast.NodeVisitor): + def __init__(self): + super().__init__() + self.current_path = [] + self.node_line_numbers = {} + self.import_locations = {} + + @classmethod + def from_code(cls, code): + tree = ast.parse(code) + locator = cls() + locator.visit(tree) + return locator + + def visit_node(self, node): + self.current_path.append(node.name) + self.node_line_numbers[".".join(self.current_path)] = node.lineno + self.generic_visit(node) + self.current_path.pop() + + def visit_FunctionDef(self, node): + self.visit_node(node) + + def visit_ClassDef(self, node): + self.visit_node(node) + + def visit_ImportFrom(self, node): + for alias in node.names: + if alias.asname: + # Exclude linking aliases (`import x as y`) to avoid confusion + # when clicking a source link to a differently named entity. + continue + if alias.name == "*": + # Resolve wildcard imports. + file = module_name_to_file_path(node.module) + file_contents = file.read_text(encoding="utf-8") + locator = CodeLocator.from_code(file_contents) + self.import_locations |= locator.import_locations + self.import_locations |= { + n: node.module for n in locator.node_line_numbers if "." not in n + } + else: + self.import_locations[alias.name] = ("." * node.level) + ( + node.module or "" + ) + + +@functools.lru_cache(maxsize=1024) +def get_locator(file): + file_contents = file.read_text(encoding="utf-8") + return CodeLocator.from_code(file_contents) + + +class CodeNotFound(Exception): + pass + + +def module_name_to_file_path(module_name): + # Avoid importlib machinery as locating a module involves importing its + # parent, which would trigger import side effects. + + for suffix in [".py", "/__init__.py"]: + file_path = pathlib.Path(__file__).parents[2] / ( + module_name.replace(".", "/") + suffix + ) + if file_path.exists(): + return file_path + + raise CodeNotFound + + +def get_path_and_line(module, fullname): + path = module_name_to_file_path(module_name=module) + + locator = get_locator(path) + + lineno = locator.node_line_numbers.get(fullname) + + if lineno is not None: + return path, lineno + + imported_object = fullname.split(".", maxsplit=1)[0] + try: + imported_path = locator.import_locations[imported_object] + except KeyError: + raise CodeNotFound + + # From a statement such as: + # from . import y.z + # - either y.z might be an object in the parent module + # - or y might be a module, and z be an object in y + # also: + # - either the current file is x/__init__.py, and z would be in x.y + # - or the current file is x/a.py, and z would be in x.a.y + if path.name != "__init__.py": + # Look in parent module + module = module.rsplit(".", maxsplit=1)[0] + try: + imported_module = importlib.util.resolve_name( + name=imported_path, package=module + ) + except ImportError as error: + raise ImportError( + f"Could not import '{imported_path}' in '{module}'." + ) from error + try: + return get_path_and_line(module=imported_module, fullname=fullname) + except CodeNotFound: + if "." not in fullname: + raise + + first_element, remainder = fullname.rsplit(".", maxsplit=1) + # Retrying, assuming the first element of the fullname is a module. + return get_path_and_line( + module=f"{imported_module}.{first_element}", fullname=remainder + ) + + +def get_branch(version, next_version): + if version == next_version: + return "main" + else: + return f"stable/{version}.x" + + +def github_linkcode_resolve(domain, info, *, version, next_version): + if domain != "py": + return None + + if not (module := info["module"]): + return None + + try: + path, lineno = get_path_and_line(module=module, fullname=info["fullname"]) + except CodeNotFound: + return None + + branch = get_branch(version=version, next_version=next_version) + relative_path = path.relative_to(pathlib.Path(__file__).parents[2]) + # Use "/" explicitely to join the path parts since str(file), on Windows, + # uses the Windows path separator which is incorrect for URLs. + url_path = "/".join(relative_path.parts) + return f"https://github.com/django/django/blob/{branch}/{url_path}#L{lineno}" |
