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..c6599d9f 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..786e7beb 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,13 @@ 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 or fullname}' + ) + if standard_link := get_stdlib_doc_link(text or fullname): + return Markup(f'{text or fullname}') return Markup.escape(text or fullname) @@ -572,3 +587,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 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/enums.html b/test/testdata/enums.html index bd30fca0..6b494d2a 100644 --- a/test/testdata/enums.html +++ b/test/testdata/enums.html @@ -134,7 +134,7 @@

class - EnumDemo(enum.Enum): + EnumDemo(enum.Enum): @@ -207,7 +207,7 @@

class - EnumWithoutDocstrings(enum.Enum): + EnumWithoutDocstrings(enum.Enum): @@ -251,7 +251,7 @@

class - IntEnum(enum.IntEnum): + IntEnum(enum.IntEnum): @@ -295,7 +295,7 @@

class - StrEnum(enum.StrEnum): + StrEnum(enum.StrEnum): diff --git a/test/testdata/flavors_google.html b/test/testdata/flavors_google.html index 8b0425a6..61d0f401 100644 --- a/test/testdata/flavors_google.html +++ b/test/testdata/flavors_google.html @@ -974,7 +974,7 @@
Examples:
class - ExampleError(builtins.Exception): + ExampleError(builtins.Exception): diff --git a/test/testdata/flavors_numpy.html b/test/testdata/flavors_numpy.html index 096e2bb7..21bf7001 100644 --- a/test/testdata/flavors_numpy.html +++ b/test/testdata/flavors_numpy.html @@ -1023,7 +1023,7 @@
Examples
class - ExampleError(builtins.Exception): + ExampleError(builtins.Exception): diff --git a/test/testdata/misc.html b/test/testdata/misc.html index b9fad715..d845e1db 100644 --- a/test/testdata/misc.html +++ b/test/testdata/misc.html @@ -1016,7 +1016,7 @@

class - GenericParent(typing.Generic[~T]): + GenericParent(typing.Generic[~T]): @@ -2256,7 +2256,7 @@
Heading 6
class - scheduler(sched.scheduler): + scheduler(sched.scheduler): @@ -2532,7 +2532,7 @@
Parameters
@dataclass(init=False)
class - DataclassStructure(_ctypes.Structure): + DataclassStructure(_ctypes.Structure): @@ -2544,7 +2544,7 @@
Parameters
-

DataclassStructure raises for inspect.signature.

+

DataclassStructure raises for inspect.signature.

diff --git a/test/testdata/misc_py310.html b/test/testdata/misc_py310.html index bbf149b2..1cbb2522 100644 --- a/test/testdata/misc_py310.html +++ b/test/testdata/misc_py310.html @@ -136,7 +136,7 @@

OldStyleDict = -typing.Dict[str, str] +typing.Dict[str, str]
diff --git a/test/testdata/misc_py312.html b/test/testdata/misc_py312.html index 7bfabfe7..df2574d9 100755 --- a/test/testdata/misc_py312.html +++ b/test/testdata/misc_py312.html @@ -162,7 +162,7 @@

-

A "classic" typing.TypeAlias.

+

A "classic" typing.TypeAlias.

@@ -172,7 +172,7 @@

class - NamedTupleExample(typing.NamedTuple): + NamedTupleExample(typing.NamedTuple): @@ -189,7 +189,7 @@

-

An example for a typing.NamedTuple.

+

An example for a typing.NamedTuple.

diff --git a/test/testdata/misc_py313.html b/test/testdata/misc_py313.html index df5779dd..0f11a2f7 100644 --- a/test/testdata/misc_py313.html +++ b/test/testdata/misc_py313.html @@ -82,7 +82,7 @@

class - MyDict(builtins.dict): + MyDict(builtins.dict): @@ -101,7 +101,7 @@

class - CustomException(builtins.RuntimeError): + CustomException(builtins.RuntimeError): diff --git a/test/testdata/type_checking_imports.html b/test/testdata/type_checking_imports.html index 2383600c..82ec1de5 100755 --- a/test/testdata/type_checking_imports.html +++ b/test/testdata/type_checking_imports.html @@ -162,7 +162,7 @@

A variable with a type annotation that's imported from another file's TYPE_CHECKING block.

-

In this case, the module is not in sys.modules outside of TYPE_CHECKING.

+

In this case, the module is not in sys.modules outside of TYPE_CHECKING.

diff --git a/test/testdata/typed_dict.html b/test/testdata/typed_dict.html index 1c6716a5..07e6c6ee 100644 --- a/test/testdata/typed_dict.html +++ b/test/testdata/typed_dict.html @@ -106,7 +106,7 @@

class - Foo(typing.TypedDict): + Foo(typing.TypedDict):