#|default_exp showdoc
Display symbol documentation in notebook and website
#|export
from __future__ import annotations
from nbdev.doclinks import *
from nbdev.config import get_config
from fastcore.dispatch import TypeDispatch
from fastcore.docments import *
from fastcore.utils import *
from importlib import import_module
import inspect, sys
from inspect import Signature, Parameter
from collections import OrderedDict
from dataclasses import dataclass, is_dataclass
from textwrap import fill
from types import FunctionType
#|hide
from fastcore.test import *
Render nicely formatted tables that shows docments
for any function or method.
#|export
def _non_empty_keys(d:dict): return L([k for k,v in d.items() if v != inspect._empty])
def _bold(s): return f'**{s}**' if s.strip() else s
#|export
def _escape_pipe(s): return re.sub(r'(\\)?\|', '\|', s)
#|hide
test_eq(_escape_pipe('|'), '\|')
test_eq(_escape_pipe('\|'), '\|')
#|export
def _escape_fn(s): return re.sub(r'(?<!\\)\^\[', '\^[', s)
#|hide
test_eq(_escape_fn(' ^[_'), ' \^[_')
test_eq(_escape_fn('foo ^[_'), 'foo \^[_')
test_eq(_escape_fn(' \^[_'), ' \^[_') #if it is already escaped leave it alone
#|export
def _maybe_nm(o):
if (o == inspect._empty): return ''
else: return o.__name__ if hasattr(o, '__name__') else _escape_fn(_escape_pipe(str(o)))
#|hide
test_eq(_maybe_nm(list), 'list')
test_eq(_maybe_nm('fastai'), 'fastai')
#|export
def _list2row(l:list): return '| '+' | '.join([_maybe_nm(o) for o in l]) + ' |'
#|hide
test_eq(_list2row(['Hamel', 'Jeremy']), '| Hamel | Jeremy |')
test_eq(_list2row([inspect._empty, bool, 'foo']), '| | bool | foo |')
#|export
class DocmentTbl:
# this is the column order we want these items to appear
_map = OrderedDict({'anno':'Type', 'default':'Default', 'docment':'Details'})
def __init__(self, obj, verbose=True, returns=True):
"Compute the docment table string"
self.verbose = verbose
self.returns = False if isdataclass(obj) else returns
try: self.params = L(signature_ex(obj, eval_str=True).parameters.keys())
except (ValueError,TypeError): self.params=[]
try: _dm = docments(obj, full=True, returns=returns)
except: _dm = {}
if 'self' in _dm: del _dm['self']
for d in _dm.values(): d['docment'] = ifnone(d['docment'], inspect._empty)
self.dm = _dm
@property
def _columns(self):
"Compute the set of fields that have at least one non-empty value so we don't show tables empty columns"
cols = set(flatten(L(self.dm.values()).filter().map(_non_empty_keys)))
candidates = self._map if self.verbose else {'docment': 'Details'}
return OrderedDict({k:v for k,v in candidates.items() if k in cols})
@property
def has_docment(self): return 'docment' in self._columns and self._row_list
@property
def has_return(self): return self.returns and bool(_non_empty_keys(self.dm.get('return', {})))
def _row(self, nm, props):
"unpack data for single row to correspond with column names."
return [nm] + [props[c] for c in self._columns]
@property
def _row_list(self):
"unpack data for all rows."
ordered_params = [(p, self.dm[p]) for p in self.params if p != 'self' and p in self.dm]
return L([self._row(nm, props) for nm,props in ordered_params])
@property
def _hdr_list(self): return [' '] + [_bold(l) for l in L(self._columns.values())]
@property
def hdr_str(self):
"The markdown string for the header portion of the table"
md = _list2row(self._hdr_list)
return md + '\n' + _list2row(['-' * len(l) for l in self._hdr_list])
@property
def params_str(self):
"The markdown string for the parameters portion of the table."
return '\n'.join(self._row_list.map(_list2row))
@property
def return_str(self):
"The markdown string for the returns portion of the table."
return _list2row(['**Returns**']+[_bold(_maybe_nm(self.dm['return'][c])) for c in self._columns])
def _repr_markdown_(self):
if not self.has_docment: return ''
_tbl = [self.hdr_str, self.params_str]
if self.has_return: _tbl.append(self.return_str)
return '\n'.join(_tbl)
def __eq__(self,other): return self.__str__() == str(other).strip()
__str__ = _repr_markdown_
__repr__ = basic_repr()
DocmentTbl
can render a markdown table showing docments
if appropriate. This is an example of how a docments
table will render for a function:
def _f(a, # description of param a
b=True, # description of param b
c:str=None
) -> int: ...
_dm = DocmentTbl(_f)
_dm
Type | Default | Details | |
---|---|---|---|
a | description of param a | ||
b | bool | True | description of param b |
c | str | None | |
Returns | int |
#|hide
_exp_res="""
| | **Type** | **Default** | **Details** |
| -- | -------- | ----------- | ----------- |
| a | | | description of param a |
| b | bool | True | description of param b |
| c | str | None | |
| **Returns** | **int** | | |
"""
test_eq(_dm, _exp_res)
If one column in the table has no information, for example because there are no default values, that column will not be shown. In the below example, the Default column, will not be shown. Additionally, if the return of the function is not annotated the Returns row will not be rendered:
def _f(a,
b, #param b
c #param c
): ...
_dm2 = DocmentTbl(_f)
_dm2
Details | |
---|---|
a | |
b | param b |
c | param c |
#|hide
_exp_res2 = """
| | **Details** |
| -- | ----------- |
| a | |
| b | param b |
| c | param c |
"""
test_eq(_dm2, _exp_res2)
DocmentTbl
also works on classes. By default, the __init__
will be rendered:
class _Test:
def __init__(self,
a, # description of param a
b=True, # description of param b
c:str=None):
...
def foo(self,
c:int, # description of param c
d=True, # description of param d
):
...
DocmentTbl(_Test)
Type | Default | Details | |
---|---|---|---|
a | description of param a | ||
b | bool | True | description of param b |
c | str | None |
You can also pass a method to be rendered as well:
DocmentTbl(_Test.foo)
Type | Default | Details | |
---|---|---|---|
c | int | description of param c | |
d | bool | True | description of param d |
#|hide
_exp_res3 = """
| | **Type** | **Default** | **Details** |
| -- | -------- | ----------- | ----------- |
| c | int | | description of param c |
| d | bool | True | description of param d |
"""
test_eq(DocmentTbl(_Test.foo), _exp_res3)
Render the signature as well as the docments
to show complete documentation for an object.
#|export
def _fullname(o):
module,name = o.__module__,qual_name(o)
return name if module is None or module in ('__main__','builtins') else module + '.' + name
class ShowDocRenderer:
def __init__(self, sym, name:str|None=None, title_level:int=3):
"Show documentation for `sym`"
sym = getattr(sym, '__wrapped__', sym)
sym = getattr(sym, 'fget', None) or getattr(sym, 'fset', None) or sym
store_attr()
self.nm = name or qual_name(sym)
self.isfunc = inspect.isfunction(sym)
try: self.sig = signature_ex(sym, eval_str=True)
except (ValueError,TypeError): self.sig = None
self.docs = docstring(sym)
self.dm = DocmentTbl(sym)
self.fn = _fullname(sym)
__repr__ = basic_repr()
#|export
def _f_name(o): return f'<function {o.__name__}>' if isinstance(o, FunctionType) else None
def _fmt_anno(o): return inspect.formatannotation(o).strip("'").replace(' ','')
def _show_param(param):
"Like `Parameter.__str__` except removes: quotes in annos, spaces, ids in reprs"
kind,res,anno,default = param.kind,param._name,param._annotation,param._default
kind = '*' if kind==inspect._VAR_POSITIONAL else '**' if kind==inspect._VAR_KEYWORD else ''
res = kind+res
if anno is not inspect._empty: res += f':{_f_name(anno) or _fmt_anno(anno)}'
if default is not inspect._empty: res += f'={_f_name(default) or repr(default)}'
return res
#|hide
def _func(): pass
p = Parameter('foo', Parameter.POSITIONAL_OR_KEYWORD, default=_func, annotation='Callable')
test_eq(_show_param(p), 'foo:Callable=<function _func>')
p = p.replace(annotation=_func)
test_eq(_show_param(p), 'foo:<function _func>=<function _func>')
#|export
def _fmt_sig(sig):
if sig is None: return ''
p = {k:v for k,v in sig.parameters.items()}
_params = [_show_param(p[k]) for k in p.keys() if k != 'self']
return "(" + ', '.join(_params) + ")"
def _wrap_sig(s):
"wrap a signature to appear on multiple lines if necessary."
pad = '> ' + ' ' * 5
indent = pad + ' ' * (s.find('(') + 1)
return fill(s, width=80, initial_indent=pad, subsequent_indent=indent)
#|hide
def _long_f(a_param, b_param=True, c_param:str='Some quite long value', d:int=2, e:bool=False):
"A docstring"
...
_res = "> (a_param, b_param=True, c_param:str='Some quite long value', d:int=2,\n> e:bool=False)"
_sig = _fmt_sig(signature_ex(_long_f, eval_str=True))
test_eq(_wrap_sig(_sig), _res)
#|export
def _ext_link(url, txt, xtra=""): return f'[{txt}]({url}){{target="_blank" {xtra}}}'
class BasicMarkdownRenderer(ShowDocRenderer):
"Markdown renderer for `show_doc`"
def _repr_markdown_(self):
doc = '---\n\n'
src = NbdevLookup().code(self.fn)
if src: doc += _ext_link(src, 'source', 'style="float:right; font-size:smaller"') + '\n\n'
h = '#'*self.title_level
doc += f'{h} {self.nm}\n\n'
sig = _wrap_sig(f"{self.nm} {_fmt_sig(self.sig)}") if self.sig else ''
doc += f'{sig}'
if self.docs: doc += f"\n\n{self.docs}"
if self.dm.has_docment: doc += f"\n\n{self.dm}"
return doc
__repr__=__str__=_repr_markdown_
#|export
def show_doc(sym, # Symbol to document
renderer=None, # Optional renderer (defaults to markdown)
name:str|None=None, # Optionally override displayed name of `sym`
title_level:int=3): # Heading level to use for symbol name
"Show signature and docstring for `sym`"
if renderer is None: renderer = get_config().get('renderer', None)
if renderer is None: renderer=BasicMarkdownRenderer
elif isinstance(renderer,str):
p,m = renderer.rsplit('.', 1)
renderer = getattr(import_module(p), m)
if isinstance(sym, TypeDispatch): pass
else:return renderer(sym or show_doc, name=name, title_level=title_level)
You can use show_doc
to document apis of functions, classes or methods.
if you have numpy docstrings instead of docments
, show_doc
will attempt to parse and render those just like docments
.
#|hide
def f(x=1):
"""
func docstring in the numpy style.
This is another line of the docstring.
Parameters
----------
x : int
the parameter x
Returns
-------
None
this function doesn't return anything"""
...
show_doc(f)
:::{.callout-warning}
Numpy docstring formatting is very strict. If your docstrings do not strictly adhere to the numpy format, it will not be parsed properly and information about parameters and return values may not properly be rendered in the table below the signature. Where possible, we recommend using docments
to annonate your function instead.
:::
show_doc
works on Classes, too, including when you use @patch
.
#|hide
class Foo:
def __init__(self, d:str,e:int):
"This is the docstring for the `__init__` method"
...
@property
def some_prop(self):
"This is a class property."
return 'foo property'
show_doc(Foo)
You can define methods for the class Foo
with @patch
which is convenient in allowing you to break up code for documentation in notebooks.
#|hide
@patch
def a_method(self:Foo,
a:list, # param a
b:dict,c):
"This is a method"
...
_res = show_doc(Foo.a_method)
_res
Foo.a_method (a:list, b:dict, c)
This is a method
Type | Details | |
---|---|---|
a | list | param a |
b | dict | |
c |
#|hide
# signature and docment should show properly when using @patch
assert '(a:list, b:dict, c)' in str(_res)
assert 'param a' in str(_res)
Class properties also work with showdoc.
#|hide
show_doc(Foo.some_prop)
You can replace the default markdown show_doc renderer with custom renderers. For instance, nbdev comes with a simple example for rendering with raw HTML.
#|export
def _html_link(url, txt): return f'<a href="{url}" target="_blank" rel="noreferrer noopener">{txt}</a>'
class BasicHtmlRenderer(ShowDocRenderer):
"Simple HTML renderer for `show_doc`"
def _repr_html_(self):
doc = '<hr/>\n'
doc += f'<h{self.title_level}>{self.nm}</h{self.title_level}>\n'
doc += f'<blockquote><pre><code>{self.nm}{_fmt_sig(self.sig)}</code></pre></blockquote>'
if self.docs: doc += f"<p>{self.docs}</p>"
return doc
def doc(self):
"Show `show_doc` info along with link to docs"
from IPython.display import display,HTML
res = self._repr_html_()
docs = NbdevLookup().doc(self.fn)
if docs is not None: res += '\n<p>' +_html_link(docs, "Show in docs") + '</p>'
display(HTML(res))
#|export
def doc(elt):
"Show `show_doc` info along with link to docs"
BasicHtmlRenderer(elt).doc()
#|hide
doc(show_doc)
show_doc(sym, renderer=None, name:Optional[str]=None, title_level:int=3)
Show signature and docstring for `sym`
#|hide
class F:
"class docstring"
def __init__(self, x:int=1): ...
@classmethod
def class_method(cls,
foo:str, # docment for parameter foo
bar:int):
"This is a class method."
pass
def regular_method(self,
baz:bool=True): # docment for parameter baz
"This is a regular method"
pass
show_doc(F, renderer=BasicHtmlRenderer)
F(x:int=1)
class docstring
#|hide
_res = show_doc(F.class_method)
_res
F.class_method (foo:str, bar:int)
This is a class method.
Type | Details | |
---|---|---|
foo | str | docment for parameter foo |
bar | int |
#|hide
# There should be docments for a class method
assert 'docment for parameter foo' in str(_res), 'No docment found for class method'
#|hide
show_doc(F.regular_method)
F.regular_method (baz:bool=True)
This is a regular method
Type | Default | Details | |
---|---|---|---|
baz | bool | True | docment for parameter baz |
#|export
def showdoc_nm(tree):
"Get the fully qualified name for showdoc."
return ifnone(patch_name(tree), tree.name)
#|hide
import ast
#|hide
code="""
@bar
@patch
@foo
def a_method(self:Foo, a:list,b:dict,c):
"This is a method"
...
"""
code2="""
@bar
@foo
def a_method(self:Foo, a:list,b:dict,c):
"This is a method"
...
"""
_tree = ast.parse(code).body[0]
test_eq(showdoc_nm(_tree), 'Foo.a_method')
_tree2 = ast.parse(code2).body[0]
test_eq(showdoc_nm(_tree2), 'a_method')
#|export
def colab_link(path):
"Get a link to the notebook at `path` on Colab"
from IPython.display import Markdown
cfg = get_config()
pre = 'https://colab.research.google.com/github/'
res = f'{pre}{cfg.user}/{cfg.lib_name}/blob/{cfg.branch}/{cfg.path("nbs_path").name}/{path}.ipynb'
display(Markdown(f'[Open `{path}` in Colab]({res})'))
colab_link('index')
#|hide
e = enum.Enum('e', 'a b')
test_eq(str(show_doc(e)), '---\n\n### e\n\n> e (value, names=None, module=None, qualname=None, type=None, start=1)\n\nAn enumeration.')
#|hide
from fastcore.dispatch import typedispatch
#|hide
@typedispatch
def _typ_test(
a:list, # A list
b:str, # A second integer
) -> float:
"Perform op"
return a.extend(b)
@typedispatch
def _typ_test(
a:str, # An integer
b:str # A str
) -> float:
"Perform op"
return str(a) + b
test_eq(show_doc(_typ_test), None) # show_doc ignores typedispatch at the moment
#|hide
import nbdev; nbdev. nbdev_export()