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 adbf1d4..d37a405 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 =