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 @@
Test that we remove 'collections.abc' from type signatures.
+Test that we remove 'collections.abc' from type signatures.
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 (*).
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.
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.This property is assigned to dataclasses.field()
, which works just as well.
This property is assigned to dataclasses.field()
, which works just as well.
DataclassStructure raises for inspect.signature
.
DataclassStructure raises for inspect.signature
.
A "classic" typing.TypeAlias.
+A "classic" typing.TypeAlias.
An example for a typing.NamedTuple.
+An example for a typing.NamedTuple.
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.