diff --git a/README.md b/README.md index 32e50267..14e9c7f9 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ pdoc's main feature is a focus on simplicity: pdoc aims to do one thing and do i * Understands numpydoc and Google-style docstrings. * Standalone HTML output without additional dependencies. -Under the hood... +Under the hood... * `pdoc` will automatically link identifiers in your docstrings to their corresponding documentation. * `pdoc` respects your `__all__` variable when present. 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.