diff --git a/docs/source/_linkcode.py b/docs/source/_linkcode.py new file mode 100644 index 0000000000..43f49aeacd --- /dev/null +++ b/docs/source/_linkcode.py @@ -0,0 +1,41 @@ +"""Resolve API source links without importing Sphinx or the Snowpark runtime.""" + +import inspect +import sys +from pathlib import Path + + +def resolve_linkcode(domain, info, release, repository_root): + if domain != "py" or not info.get("module") or not info.get("fullname"): + return None + + obj = sys.modules.get(info["module"]) + if obj is None: + return None + + try: + for part in info["fullname"].split("."): + obj = getattr(obj, part) + if isinstance(obj, property): + obj = obj.fget + obj = inspect.unwrap(obj) + filename = inspect.getsourcefile(obj) + if filename is None: + return None + # External dependencies have no source in the Snowpark repository. + source_path = ( + Path(filename).resolve().relative_to(Path(repository_root).resolve()) + ) + except (AttributeError, OSError, TypeError, ValueError): + return None + + try: + source, first_line = inspect.getsourcelines(obj) + linespec = f"#L{first_line}-L{first_line + len(source) - 1}" + except (OSError, TypeError): + linespec = "" + + return ( + "https://github.com/snowflakedb/snowpark-python/blob/" + f"v{release}/{source_path.as_posix()}{linespec}" + ) diff --git a/docs/source/conf.py b/docs/source/conf.py index 983d2136d8..8d7f80e0e8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,6 +13,12 @@ import os import sys +DOCS_SOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) +REPOSITORY_ROOT = os.path.abspath(os.path.join(DOCS_SOURCE_DIR, "../..")) +sys.path.insert(0, DOCS_SOURCE_DIR) + +from _linkcode import resolve_linkcode + # -- Project information ----------------------------------------------------- @@ -21,7 +27,7 @@ author = "Snowflake Inc." # The full version, including alpha/beta/rc tags -SRC_DIR = "../../src" +SRC_DIR = os.path.join(REPOSITORY_ROOT, "src") sys.path.insert(0, os.path.abspath(SRC_DIR)) SNOWPARK_SRC_DIR = os.path.join(SRC_DIR, "snowflake", "snowpark") VERSION = (1, 1, 1, None) # Default, needed so code will compile @@ -333,42 +339,4 @@ def setup(app): # Construct URL to the corresponding section in the snowpark-python repo def linkcode_resolve(domain, info): - import inspect - - if domain != "py": - return None - - mod_name = info["module"] - full_name = info["fullname"] - - obj = sys.modules.get(mod_name) - if obj is None: - return None - - for part in full_name.split("."): - try: - obj = getattr(obj, part) - except AttributeError: - return None - - try: - if isinstance(obj, property): - fn = inspect.getsourcefile(inspect.unwrap(obj.fget)) - else: - fn = inspect.getsourcefile(inspect.unwrap(obj)) - except TypeError as e: - return None - - try: - if isinstance(obj, property): - source, lineno = inspect.getsourcelines(obj.fget) - else: - source, lineno = inspect.getsourcelines(obj) - linespec = f"#L{lineno}-L{lineno + len(source) - 1}" - except TypeError: - linespec = "" - return ( - f"https://github.com/snowflakedb/snowpark-python/blob/" - f"v{release}/{os.path.relpath(fn)}{linespec}" - ) - + return resolve_linkcode(domain, info, release, REPOSITORY_ROOT) diff --git a/tests/unit/test_doc_linkcode.py b/tests/unit/test_doc_linkcode.py new file mode 100644 index 0000000000..8832e877c7 --- /dev/null +++ b/tests/unit/test_doc_linkcode.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. +# + +"""Source-link tests that don't import Sphinx or its configuration.""" + +import functools +import importlib.util +import inspect +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "_linkcode", REPOSITORY_ROOT / "docs/source/_linkcode.py" +) +LINKCODE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LINKCODE) + + +def decorated(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + return function(*args, **kwargs) + + return wrapper + + +class Example: + @decorated + def method(self): + return "example" + + @property + @decorated + def value(self): + return "example" + + +class LinkcodeTests(unittest.TestCase): + def resolve(self, name, **kwargs): + return LINKCODE.resolve_linkcode( + "py", + {"module": __name__, "fullname": name}, + "1.55.0", + kwargs.get("repository_root", REPOSITORY_ROOT), + ) + + def expected(self, obj): + source, line = inspect.getsourcelines(inspect.unwrap(obj)) + return ( + "https://github.com/snowflakedb/snowpark-python/blob/" + f"v1.55.0/tests/unit/test_doc_linkcode.py#L{line}-L{line + len(source) - 1}" + ) + + def test_decorated_method(self): + self.assertEqual(self.resolve("Example.method"), self.expected(Example.method)) + + def test_decorated_property(self): + self.assertEqual( + self.resolve("Example.value"), self.expected(Example.value.fget) + ) + + def test_independent_of_working_directory(self): + original = Path.cwd() + try: + with tempfile.TemporaryDirectory() as directory: + for cwd in ( + REPOSITORY_ROOT, + REPOSITORY_ROOT / "docs/source", + directory, + ): + with self.subTest(cwd=str(cwd)): + os.chdir(cwd) + self.assertEqual( + self.resolve("Example.method"), + self.expected(Example.method), + ) + finally: + os.chdir(original) + + def test_missing_object(self): + self.assertIsNone(self.resolve("Example.missing")) + + def test_unsupported_domain_and_missing_metadata(self): + for domain, info in ( + ("js", {"module": __name__, "fullname": "Example"}), + ("py", {}), + ("py", {"module": "not_a_loaded_module", "fullname": "Example"}), + ): + with self.subTest(domain=domain, info=info): + self.assertIsNone( + LINKCODE.resolve_linkcode(domain, info, "1.55.0", REPOSITORY_ROOT) + ) + + def test_builtin(self): + self.assertIsNone( + LINKCODE.resolve_linkcode( + "py", + {"module": "builtins", "fullname": "len"}, + "1.55.0", + REPOSITORY_ROOT, + ) + ) + + def test_source_outside_repository(self): + with tempfile.TemporaryDirectory() as directory: + self.assertIsNone(self.resolve("Example.method", repository_root=directory)) + + def test_missing_source_file(self): + with patch.object(LINKCODE.inspect, "getsourcefile", return_value=None): + self.assertIsNone(self.resolve("Example.method")) + + def test_missing_source_lines(self): + with patch.object(LINKCODE.inspect, "getsourcelines", side_effect=OSError): + self.assertEqual( + self.resolve("Example.method"), + "https://github.com/snowflakedb/snowpark-python/blob/" + "v1.55.0/tests/unit/test_doc_linkcode.py", + ) + + +if __name__ == "__main__": + unittest.main()