From fc872d6cfab48d861c43c766d583edde73370836 2023-03-30 08:22:45 From: Matthias Bussonnier Date: 2023-03-30 08:22:45 Subject: [PATCH] Allow to dispatch getting documentation on objects Base for #13860, so that object can be queried for documentation on their fields/properties. Typically this allows the following, to extend the doc documentation when requesting information on a field. In [1]: class DictLike: ...: def __getitem__(self, k): ...: if k.startswith('f'): ...: return "documentation for k" ...: else: ...: raise KeyError ...: ...: class Bar: ...: __custom_documentations__ = DictLike() ...: ...: faz = 1 ...: ...: ...: @property ...: def foo(self): ...: return 1 ...: b = Bar() In [2]: b.faz? --- diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 9b0bc97..e4a1a4c 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -1609,10 +1609,19 @@ class InteractiveShell(SingletonConfigurable): def _ofind( self, oname: str, namespaces: Optional[Sequence[Tuple[str, AnyType]]] = None - ): + ) -> OInfo: """Find an object in the available namespaces. - self._ofind(oname) -> dict with keys: found,obj,ospace,ismagic + + Returns + ------- + OInfo with fields: + - ismagic + - isalias + - found + - obj + - namespac + - parent Has special code to detect magic functions. """ @@ -1771,11 +1780,11 @@ class InteractiveShell(SingletonConfigurable): This function is meant to be called by pdef, pdoc & friends. """ - info = self._object_find(oname, namespaces) + info: OInfo = self._object_find(oname, namespaces) docformat = ( sphinxify(self.object_inspect(oname)) if self.sphinxify_docstring else None ) - if info.found: + if info.found or hasattr(info.parent, oinspect.HOOK_NAME): pmethod = getattr(self.inspector, meth) # TODO: only apply format_screen to the plain/text repr of the mime # bundle. diff --git a/IPython/core/oinspect.py b/IPython/core/oinspect.py index 706c736..83aada8 100644 --- a/IPython/core/oinspect.py +++ b/IPython/core/oinspect.py @@ -13,18 +13,25 @@ reference the name under which an object is being read. __all__ = ['Inspector','InspectColors'] # stdlib modules -import ast -import inspect +from dataclasses import dataclass from inspect import signature +from textwrap import dedent +import ast import html +import inspect +import io as stdlib_io import linecache -import warnings import os -from textwrap import dedent +import sys import types -import io as stdlib_io +import warnings -from typing import Union +from typing import Any, Optional, Dict, Union, List, Tuple + +if sys.version_info <= (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias # IPython's own from IPython.core import page @@ -46,8 +53,11 @@ from pygments import highlight from pygments.lexers import PythonLexer from pygments.formatters import HtmlFormatter -from typing import Any, Optional -from dataclasses import dataclass +HOOK_NAME = "__custom_documentations__" + + +UnformattedBundle: TypeAlias = Dict[str, List[Tuple[str, str]]] # List of (title, body) +Bundle: TypeAlias = Dict[str, str] @dataclass @@ -564,34 +574,52 @@ class Inspector(Colorable): else: return dict(defaults, **formatted) - - def format_mime(self, bundle): + def format_mime(self, bundle: UnformattedBundle) -> Bundle: """Format a mimebundle being created by _make_info_unformatted into a real mimebundle""" # Format text/plain mimetype - if isinstance(bundle["text/plain"], (list, tuple)): - # bundle['text/plain'] is a list of (head, formatted body) pairs - lines = [] - _len = max(len(h) for h, _ in bundle["text/plain"]) - - for head, body in bundle["text/plain"]: - body = body.strip("\n") - delim = "\n" if "\n" in body else " " - lines.append( - f"{self.__head(head+':')}{(_len - len(head))*' '}{delim}{body}" - ) + assert isinstance(bundle["text/plain"], list) + for item in bundle["text/plain"]: + assert isinstance(item, tuple) - bundle["text/plain"] = "\n".join(lines) + new_b: Bundle = {} + lines = [] + _len = max(len(h) for h, _ in bundle["text/plain"]) - # Format the text/html mimetype - if isinstance(bundle["text/html"], (list, tuple)): - # bundle['text/html'] is a list of (head, formatted body) pairs - bundle["text/html"] = "\n".join( - (f"

{head}

\n{body}" for (head, body) in bundle["text/html"]) + for head, body in bundle["text/plain"]: + body = body.strip("\n") + delim = "\n" if "\n" in body else " " + lines.append( + f"{self.__head(head+':')}{(_len - len(head))*' '}{delim}{body}" ) - return bundle + + new_b["text/plain"] = "\n".join(lines) + + if "text/html" in bundle: + assert isinstance(bundle["text/html"], list) + for item in bundle["text/html"]: + assert isinstance(item, tuple) + # Format the text/html mimetype + if isinstance(bundle["text/html"], (list, tuple)): + # bundle['text/html'] is a list of (head, formatted body) pairs + new_b["text/html"] = "\n".join( + (f"

{head}

\n{body}" for (head, body) in bundle["text/html"]) + ) + + for k in bundle.keys(): + if k in ("text/html", "text/plain"): + continue + else: + new_b = bundle[k] # type:ignore + return new_b def _append_info_field( - self, bundle, title: str, key: str, info, omit_sections, formatter + self, + bundle: UnformattedBundle, + title: str, + key: str, + info, + omit_sections, + formatter, ): """Append an info value to the unformatted mimebundle being constructed by _make_info_unformatted""" if title in omit_sections or key in omit_sections: @@ -602,15 +630,19 @@ class Inspector(Colorable): bundle["text/plain"].append((title, formatted_field["text/plain"])) bundle["text/html"].append((title, formatted_field["text/html"])) - def _make_info_unformatted(self, obj, info, formatter, detail_level, omit_sections): + def _make_info_unformatted( + self, obj, info, formatter, detail_level, omit_sections + ) -> UnformattedBundle: """Assemble the mimebundle as unformatted lists of information""" - bundle = { + bundle: UnformattedBundle = { "text/plain": [], "text/html": [], } # A convenience function to simplify calls below - def append_field(bundle, title: str, key: str, formatter=None): + def append_field( + bundle: UnformattedBundle, title: str, key: str, formatter=None + ): self._append_info_field( bundle, title=title, @@ -620,7 +652,7 @@ class Inspector(Colorable): formatter=formatter, ) - def code_formatter(text): + def code_formatter(text) -> Bundle: return { 'text/plain': self.format(text), 'text/html': pylight(text) @@ -678,8 +710,14 @@ class Inspector(Colorable): def _get_info( - self, obj, oname="", formatter=None, info=None, detail_level=0, omit_sections=() - ): + self, + obj: Any, + oname: str = "", + formatter=None, + info: Optional[OInfo] = None, + detail_level=0, + omit_sections=(), + ) -> Bundle: """Retrieve an info dict and format it. Parameters @@ -697,9 +735,13 @@ class Inspector(Colorable): Titles or keys to omit from output (can be set, tuple, etc., anything supporting `in`) """ - info = self.info(obj, oname=oname, info=info, detail_level=detail_level) + info_dict = self.info(obj, oname=oname, info=info, detail_level=detail_level) bundle = self._make_info_unformatted( - obj, info, formatter, detail_level=detail_level, omit_sections=omit_sections + obj, + info_dict, + formatter, + detail_level=detail_level, + omit_sections=omit_sections, ) return self.format_mime(bundle) @@ -708,7 +750,7 @@ class Inspector(Colorable): obj, oname="", formatter=None, - info=None, + info: Optional[OInfo] = None, detail_level=0, enable_html_pager=True, omit_sections=(), @@ -736,12 +778,13 @@ class Inspector(Colorable): - omit_sections: set of section keys and titles to omit """ - info = self._get_info( + assert info is not None + info_b: Bundle = self._get_info( obj, oname, formatter, info, detail_level, omit_sections=omit_sections ) if not enable_html_pager: - del info['text/html'] - page.page(info) + del info_b["text/html"] + page.page(info_b) def _info(self, obj, oname="", info=None, detail_level=0): """ @@ -758,7 +801,7 @@ class Inspector(Colorable): ) return self.info(obj, oname=oname, info=info, detail_level=detail_level) - def info(self, obj, oname="", info=None, detail_level=0) -> dict: + def info(self, obj, oname="", info=None, detail_level=0) -> Dict[str, Any]: """Compute a dict with detailed information about an object. Parameters @@ -789,7 +832,19 @@ class Inspector(Colorable): ospace = info.namespace # Get docstring, special-casing aliases: - if isalias: + att_name = oname.split(".")[-1] + parents_docs = None + prelude = "" + if info and info.parent and hasattr(info.parent, HOOK_NAME): + parents_docs_dict = getattr(info.parent, HOOK_NAME) + parents_docs = parents_docs_dict.get(att_name, None) + out = dict( + name=oname, found=True, isalias=isalias, ismagic=ismagic, subclasses=None + ) + + if parents_docs: + ds = parents_docs + elif isalias: if not callable(obj): try: ds = "Alias to the system command:\n %s" % obj[1] @@ -806,8 +861,9 @@ class Inspector(Colorable): else: ds = ds_or_None + ds = prelude + ds + # store output in a dict, we initialize it here and fill it as we go - out = dict(name=oname, found=True, isalias=isalias, ismagic=ismagic, subclasses=None) string_max = 200 # max size of strings to show (snipped if longer) shalf = int((string_max - 5) / 2) @@ -980,8 +1036,8 @@ class Inspector(Colorable): source already contains it, avoiding repetition of information. """ try: - def_node, = ast.parse(dedent(src)).body - return ast.get_docstring(def_node) == doc + (def_node,) = ast.parse(dedent(src)).body + return ast.get_docstring(def_node) == doc # type: ignore[arg-type] except Exception: # The source can become invalid or even non-existent (because it # is re-fetched from the source file) so the above code fail in diff --git a/IPython/core/tests/test_oinspect.py b/IPython/core/tests/test_oinspect.py index 8ae146f..6ccdb04 100644 --- a/IPython/core/tests/test_oinspect.py +++ b/IPython/core/tests/test_oinspect.py @@ -471,12 +471,49 @@ def test_pinfo_docstring_if_detail_and_no_source(): ip._inspect('pinfo', 'foo.bar', detail_level=1) +def test_pinfo_docstring_dynamic(): + obj_def = """class Bar: + __custom_documentations__ = { + "prop" : "cdoc for prop", + "non_exist" : "cdoc for non_exist", + } + @property + def prop(self): + ''' + Docstring for prop + ''' + return self._prop + + @prop.setter + def prop(self, v): + self._prop = v + """ + ip.run_cell(obj_def) + + ip.run_cell("b = Bar()") + + with AssertPrints("Docstring: cdoc for prop"): + ip.run_line_magic("pinfo", "b.prop") + + with AssertPrints("Docstring: cdoc for non_exist"): + ip.run_line_magic("pinfo", "b.non_exist") + + with AssertPrints("Docstring: cdoc for prop"): + ip.run_cell("b.prop?") + + with AssertPrints("Docstring: cdoc for non_exist"): + ip.run_cell("b.non_exist?") + + with AssertPrints("Docstring: "): + ip.run_cell("b.undefined?") + + def test_pinfo_magic(): - with AssertPrints('Docstring:'): - ip._inspect('pinfo', 'lsmagic', detail_level=0) + with AssertPrints("Docstring:"): + ip._inspect("pinfo", "lsmagic", detail_level=0) - with AssertPrints('Source:'): - ip._inspect('pinfo', 'lsmagic', detail_level=1) + with AssertPrints("Source:"): + ip._inspect("pinfo", "lsmagic", detail_level=1) def test_init_colors(): diff --git a/docs/source/whatsnew/version8.rst b/docs/source/whatsnew/version8.rst index 4c29674..02f7d42 100644 --- a/docs/source/whatsnew/version8.rst +++ b/docs/source/whatsnew/version8.rst @@ -2,6 +2,81 @@ 8.x Series ============ +.. _version 8.12.0: + + +Dynamic documentation dispatch +------------------------------ + + +We are experimenting with dynamic documentation dispatch for object attribute. +See :ghissue:`13860`. The goal is to allow object to define documentation for +their attributes, properties, even when those are dynamically defined with +`__getattr__`. + +In particular when those objects are base types it can be useful to show the +documentation + + +.. code:: + + In [1]: class User: + ...: + ...: __custom_documentations__ = { + ...: "first": "The first name of the user.", + ...: "last": "The last name of the user.", + ...: } + ...: + ...: first:str + ...: last:str + ...: + ...: def __init__(self, first, last): + ...: self.first = first + ...: self.last = last + ...: + ...: @property + ...: def full(self): + ...: """`self.first` and `self.last` joined by a space.""" + ...: return self.first + " " + self.last + ...: + ...: + ...: user = Person('Jane', 'Doe') + + In [2]: user.first? + Type: str + String form: Jane + Length: 4 + Docstring: the first name of a the person object, a str + Class docstring: + .... + + In [3]: user.last? + Type: str + String form: Doe + Length: 3 + Docstring: the last name, also a str + ... + + +We can see here the symmetry with IPython looking for the docstring on the +properties:: + + + In [4]: user.full? + HERE + Type: property + String form: + Docstring: first and last join by a space + + +Note that while in the above example we use a static dictionary, libraries may +decide to use a custom object that define ``__getitem__``, we caution against +using objects that would trigger computation to show documentation, but it is +sometime preferable for highly dynamic code that for example export ans API as +object. + + + .. _version 8.11.0: IPython 8.11 @@ -94,7 +169,7 @@ Thanks to the `D. E. Shaw group `__ for sponsoring work on IPython and related libraries. .. _version 8.10.0: - + IPython 8.10 ------------ @@ -107,7 +182,7 @@ This is a really low severity CVE that you most likely are not affected by unles valid shell commands. You can read more on `the advisory -`__. +`__. In addition to fixing this CVE we also fix a couple of outstanding bugs and issues. diff --git a/setup.cfg b/setup.cfg index 28d7f46..abdad78 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,7 @@ install_requires = pygments>=2.4.0 stack_data traitlets>=5 + typing_extensions ; python_version<'3.10' [options.extras_require] black =