From de1cb497ff11a1ab51b2d8f6c7c35f991aa8ed93 Mon Sep 17 00:00:00 2001 From: wolfgang <> Date: Fri, 29 Aug 2025 03:06:39 -0300 Subject: [PATCH 1/5] Add hyperlink to type annotations of standard and third-party libraries --- pdoc/__main__.py | 12 ++++ pdoc/render.py | 16 ++++++ pdoc/render_helpers.py | 122 +++++++++++++++++++++++++++++------------ 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/pdoc/__main__.py b/pdoc/__main__.py index fe5802b3..44a039e5 100644 --- a/pdoc/__main__.py +++ b/pdoc/__main__.py @@ -71,6 +71,17 @@ "May be passed multiple times. " "Example: pdoc=https://github.com/mitmproxy/pdoc/blob/main/pdoc/", ) +renderopts.add_argument( + "-l", + "--link-library", + action="append", + type=str, + default=[], + metavar="module=url", + help="A mapping between module names and URL prefixes, used to link modules to external documentation. " + "May be passed multiple times. " + "Example: pdoc=https://pdoc.dev/docs/pdoc.html", +) renderopts.add_argument( "--favicon", type=str, @@ -185,6 +196,7 @@ def cli(args: list[str] | None = None) -> None: edit_url_map=dict(x.split("=", 1) for x in opts.edit_url), favicon=opts.favicon, footer_text=opts.footer_text, + link_library=dict(x.split("=", 1) for x in opts.link_library), logo=opts.logo, logo_link=opts.logo_link, math=opts.math, diff --git a/pdoc/render.py b/pdoc/render.py index 3fc524ea..4393b974 100644 --- a/pdoc/render.py +++ b/pdoc/render.py @@ -38,6 +38,7 @@ def configure( edit_url_map: Mapping[str, str] | None = None, favicon: str | None = None, footer_text: str = "", + link_library: Mapping[str, str] | None = None, logo: str | None = None, logo_link: str | None = None, math: bool = False, @@ -61,6 +62,20 @@ def configure( renders the "Edit on GitHub" button on this page. The URL prefix can be modified to pin a particular version. - `favicon` is an optional path/URL for a favicon image - `footer_text` is additional text that should appear in the navigation footer. + - `link_library` is a mapping from module names to URL documentation. For example, + + ```json + {"pdoc": "https://pdoc.dev/docs/pdoc.html"} + ``` + + will make all "pdoc" type annotation to link to the provided URL, but will not link if it is "pdoc.doc.Module". + It accept wildcard for such cases, for instance: + + ```json + {"pdoc*": "https://pdoc.dev/docs/pdoc.html"} + ``` + + will also match "pdoc.doc.Module". - `logo` is an optional URL to the project's logo image - `logo_link` is an optional URL the logo should point to - `math` enables math rendering by including MathJax into the rendered documentation. @@ -83,6 +98,7 @@ def configure( env.globals["mermaid"] = mermaid env.globals["show_source"] = show_source env.globals["favicon"] = favicon + env.globals["link_library"] = link_library or {} env.globals["logo"] = logo env.globals["logo_link"] = logo_link env.globals["footer_text"] = footer_text diff --git a/pdoc/render_helpers.py b/pdoc/render_helpers.py index c96194b0..0f7a1b4c 100644 --- a/pdoc/render_helpers.py +++ b/pdoc/render_helpers.py @@ -4,11 +4,15 @@ from collections.abc import Iterable from collections.abc import Mapping from contextlib import contextmanager +import fnmatch from functools import cache import html +import importlib import inspect import os import re +import sys +import sysconfig from unittest.mock import patch import warnings @@ -359,43 +363,47 @@ def linkify_repl(m: re.Match): try: sources = list(possible_sources(context["all_modules"], identifier)) - except ValueError: - # possible_sources did not find a parent module. - return text - - # Try to find the actual target object so that we can then later verify - # that objects exposed at a parent module with the same name point to it. - target_object = None - for module_name, qualname in sources: - if doc := context["all_modules"].get(module_name, {}).get(qualname): - target_object = doc.obj - break - - # Look at the different modules where our target object may be exposed. - for module_name in module_candidates(identifier, mod.modulename): - module: pdoc.doc.Module | None = context["all_modules"].get(module_name) - if not module: - continue - - for _, qualname in sources: - doc = module.get(qualname) - # Check if they have an object with the same name, - # and verify that it's pointing to the right thing and is public. - if ( - doc - and (target_object is doc.obj or target_object is None) - and context["is_public"](doc).strip() - ): - if shorten: - if module == mod: - url_text = qualname + + # Try to find the actual target object so that we can then later verify + # that objects exposed at a parent module with the same name point to it. + target_object = None + for module_name, qualname in sources: + if doc := context["all_modules"].get(module_name, {}).get(qualname): + target_object = doc.obj + break + + # Look at the different modules where our target object may be exposed. + for module_name in module_candidates(identifier, mod.modulename): + module: pdoc.doc.Module | None = context["all_modules"].get(module_name) + if not module: + continue + + for _, qualname in sources: + doc = module.get(qualname) + # Check if they have an object with the same name, + # and verify that it's pointing to the right thing and is public. + if ( + doc + and (target_object is doc.obj or target_object is None) + and context["is_public"](doc).strip() + ): + if shorten: + if module == mod: + url_text = qualname + else: + url_text = doc.fullname + if plain_text.endswith("()"): + url_text += "()" else: - url_text = doc.fullname - if plain_text.endswith("()"): - url_text += "()" - else: - url_text = plain_text - return f'{url_text}' + url_text = plain_text + return f'{url_text}' + except ValueError: + pass + for lib in context["link_library"]: + if fnmatch.fnmatch(plain_text, lib): + return f'{plain_text}' + if standard_link := get_stdlib_doc_link(plain_text): + return f'{plain_text}' # No matches found. return text @@ -469,6 +477,11 @@ def link(context: Context, spec: tuple[str, str], text: str | None = None) -> Ma return Markup( f'{text or fullname}' ) + for lib in context["link_library"]: + if fnmatch.fnmatch(text, lib): + return Markup(f'{text}') + if standard_link := get_stdlib_doc_link(text): + return Markup(f'{text}') return Markup.escape(text or fullname) @@ -572,3 +585,40 @@ def parse(self, parser): [], ) return [m, if_stmt] + + +def get_stdlib_doc_link(path: str) -> str | None: + """ + If the object is from the standard library, return a hyperlink + to the official Python documentation (approximate). + + Args: + path (str): e.g. 'math.sqrt' or 'datetime.datetime' + + Returns: + str | None: URL to documentation or None if not valid/standard + """ + try: + module_path, obj_name = path.rsplit('.', 1) + module = importlib.import_module(module_path) + + # Check if it's a standard library module + module_file: str | None = getattr(module, '__file__', None) + if module_file is None: + if module_path in sys.builtin_module_names: + base_url = f"https://docs.python.org/3/library/{module_path}.html" + return f"{base_url}#{module_path}.{obj_name}" + else: + return None + + stdlib_path = sysconfig.get_paths()['stdlib'] + module_file = os.path.realpath(module_file) + + if not module_file.startswith(os.path.realpath(stdlib_path)): + return None + + base_module = module_path.split('.')[0] # Top-level module name + base_url = f"https://docs.python.org/3/library/{base_module}.html" + return f"{base_url}#{module_path}.{obj_name}" + except Exception: + return None \ No newline at end of file From 3cdb28bf9b9134aaf0557feda04387f1e97a62ed Mon Sep 17 00:00:00 2001 From: wolfgang <> Date: Fri, 29 Aug 2025 04:27:51 -0300 Subject: [PATCH 2/5] Updated testcases --- test/testdata/collections_abc.html | 4 ++-- test/testdata/demo_long.html | 14 +++++++------- test/testdata/demopackage_dir.html | 2 +- test/testdata/enums.html | 8 ++++---- test/testdata/enums.txt | 2 +- test/testdata/flavors_google.html | 2 +- test/testdata/flavors_numpy.html | 2 +- test/testdata/misc.html | 8 ++++---- test/testdata/misc_py310.html | 2 +- test/testdata/misc_py312.html | 6 +++--- test/testdata/misc_py313.html | 4 ++-- test/testdata/type_checking_imports.html | 2 +- test/testdata/typed_dict.html | 2 +- 13 files changed, 29 insertions(+), 29 deletions(-) diff --git a/test/testdata/collections_abc.html b/test/testdata/collections_abc.html index cad4c68c..63a0197c 100644 --- a/test/testdata/collections_abc.html +++ b/test/testdata/collections_abc.html @@ -51,7 +51,7 @@

API Documentation

collections_abc

-

Test that we remove 'collections.abc' from type signatures.

+

Test that we remove 'collections.abc' from type signatures.

@@ -107,7 +107,7 @@

class - Class(collections.abc.Container[str]): + Class(collections.abc.Container[str]): diff --git a/test/testdata/demo_long.html b/test/testdata/demo_long.html index b3dc0084..a0c74b50 100644 --- a/test/testdata/demo_long.html +++ b/test/testdata/demo_long.html @@ -623,7 +623,7 @@

A Second Section

This is a function with a fairly complex signature, -involving type annotations with typing.Union, a typing.TypeVar (~T), +involving type annotations with typing.Union, a typing.TypeVar (~T), as well as a keyword-only arguments (*).

@@ -845,7 +845,7 @@

A Second Section

-

This is a @functools.cached_property attribute. pdoc will display it as a variable as well.

+

This is a @functools.cached_property attribute. pdoc will display it as a variable as well.

@@ -1153,7 +1153,7 @@
Inherited Members
def - security(test=os.environ): + security(test=os.environ): @@ -1169,7 +1169,7 @@
Inherited Members

Default values are generally rendered using repr(), -but some special cases -- like os.environ -- are overridden to avoid leaking sensitive data.

+but some special cases -- like os.environ -- are overridden to avoid leaking sensitive data.

@@ -1179,7 +1179,7 @@
Inherited Members
class - DoubleInherit(Foo, Bar.Baz, abc.ABC): + DoubleInherit(Foo, Bar.Baz, abc.ABC): @@ -1353,7 +1353,7 @@
Inherited Members
-

This property is assigned to dataclasses.field(), which works just as well.

+

This property is assigned to dataclasses.field(), which works just as well.

@@ -1425,7 +1425,7 @@
Inherited Members
class - EnumDemo(enum.Enum): + EnumDemo(enum.Enum): diff --git a/test/testdata/demopackage_dir.html b/test/testdata/demopackage_dir.html index 34b0af5f..3046ed37 100644 --- a/test/testdata/demopackage_dir.html +++ b/test/testdata/demopackage_dir.html @@ -3,7 +3,7 @@

search.js