Skip to content

Commit

Permalink
First attempt to implement relatively smart Expand/Collapse signature…
Browse files Browse the repository at this point in the history
…s when overloads are overwhelming... This probably requires some more tweaks but it's still better than showing everything at once.
  • Loading branch information
tristanlatr committed Oct 29, 2024
1 parent 6a4de9f commit c0f93dc
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 40 deletions.
36 changes: 14 additions & 22 deletions pydoctor/epydoc2stan.py
Original file line number Diff line number Diff line change
Expand Up @@ -1348,40 +1348,32 @@ def get_parsed_signature(func: Union[model.Function, model.FunctionOverload]) ->
func.parsed_signature = psig
return psig

LONG_FUNCTION_DEF = 80 # this doesn't acount for the 'def ' and the ending ':'
"""
Maximum size of a function definition to be rendered on a single line.
The multiline formatting is only applied at the CSS level to stay customizable.
We add a css class to the signature HTML to signify the signature could possibly
be better formatted on several lines.
"""

def is_long_function_def(func: model.Function | model.FunctionOverload) -> bool:
def function_signature_len(func: model.Function | model.FunctionOverload) -> int:
"""
Whether this function definition is considered as long.
The lenght of the a function def is defnied by the lenght of it's name plus the lenght of it's signature.
On top of that, a function or method that takes no argument (expect unannotated 'self' for methods, and 'cls' for classmethods)
is never considered as long.
@see: L{LONG_FUNCTION_DEF}
will always have a lenght equals to the function name len plus two for 'function()'.
"""
ctx = func.primary if isinstance(func, model.FunctionOverload) else func
name_len = len(ctx.name)

if (sig:=func.signature) is None or (
psig:=get_parsed_signature(func)) is None:
return False

return name_len + 2 # bogus function def

Check warning on line 1362 in pydoctor/epydoc2stan.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc2stan.py#L1362

Added line #L1362 was not covered by tests
nargs = len(sig.parameters)
if nargs == 0:
# no arguments at all -> never long
return False
ctx = func.primary if isinstance(func, model.FunctionOverload) else func
# no arguments at all
return name_len + 2

param1 = next(iter(sig.parameters.values()))
if _is_less_important_param(param1, ctx):
nargs -= 1
if nargs == 0:
# method with only unannotated self/cls parameter -> never long
return False
# method with only unannotated self/cls parameter
return name_len + 2

name_len = len(ctx.name)
signature_len = len(''.join(node2stan.gettext(psig.to_node())))
return LONG_FUNCTION_DEF - (name_len + signature_len) < 0
signature_len = len(psig.to_text())
return name_len + signature_len

98 changes: 86 additions & 12 deletions pydoctor/templatewriter/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from pydoctor.templatewriter.pages.functionchild import FunctionChild


def format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Iterator["Flattenable"]:
def _format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Iterator["Flattenable"]:
# Since we use this function to colorize the FunctionOverload decorators and it's not an actual Documentable subclass, we use the overload's
# primary function for parts that requires an interface to Documentable methods or attributes
documentable_obj = obj if not isinstance(obj, model.FunctionOverload) else obj.primary
Expand All @@ -49,7 +49,11 @@ def format_decorators(obj: Union[model.Function, model.Attribute, model.Function

# Report eventual warnings. It warns when we can't colorize the expression for some reason.
epydoc2stan.reportWarnings(documentable_obj, doc.warnings, section='colorize decorator')
yield '@', stan.children, tags.br()

yield tags.span('@', stan.children, tags.br(), class_='function-decorator')

def format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Tag:
return tags.span(*_format_decorators(obj), class_='function-decorators')

def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Flattenable":
"""
Expand Down Expand Up @@ -107,40 +111,110 @@ def format_class_signature(cls: model.Class) -> "Flattenable":
r.append(')')
return r

LONG_SIGNATURE = 80 # this doesn't acount for the 'def ' and the ending ':'
"""
Maximum size of a function definition to be rendered on a single line.
The multiline formatting is only applied at the CSS level to stay customizable.
We add a css class to the signature HTML to signify the signature could possibly
be better formatted on several lines.
"""

PRETTY_LONG_SIGNATURE = LONG_SIGNATURE * 2
"""
From that number of characters, a signature is considered pretty long.
"""

VERY_LONG_SIGNATURE = PRETTY_LONG_SIGNATURE * 3
"""
From that number of characters, a signature is considered very long.
"""

def _are_overloads_overwhelming(func: model.Function) -> bool:
# a manner to wrap long overloads like the ones from temporalio.client.Client.start_workflow
# Maybe when there are more than one long overload, we create a fake overload without any annotations
# expect the one that are the same accros all overloads, then this could be showed when clicking on the function name then all overloads
# could be showed on demand

# The goal here is to hide overwhelming informations and only display it on demand.
# The following code tries hard to determine if the overloads are overwhelming...
# First what is overwhelming overloads ?
# - If there are at least 2 very long signatures, it's overwhelming.
# - If there are at least 6 pretty long signatures, it's overwhelming.
# - If there are at least 10 long signatures, it's overwhelming.
# - If there are 16 or more signatures, it's overwhelming.

if len(func.overloads) >= 16:
return True

Check warning on line 147 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L147

Added line #L147 was not covered by tests

n_long, n_pretty_long, n_very_long = 0, 0, 0
for o in func.overloads:
siglen = epydoc2stan.function_signature_len(o)
if siglen > LONG_SIGNATURE:
n_long += 1

Check warning on line 153 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L153

Added line #L153 was not covered by tests
if siglen > PRETTY_LONG_SIGNATURE:
n_pretty_long += 1

Check warning on line 155 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L155

Added line #L155 was not covered by tests
if siglen > VERY_LONG_SIGNATURE:
n_very_long += 1

Check warning on line 157 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L157

Added line #L157 was not covered by tests
if n_very_long >= 3:
return True

Check warning on line 159 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L159

Added line #L159 was not covered by tests
elif n_pretty_long >= 6:
return True

Check warning on line 161 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L161

Added line #L161 was not covered by tests
elif n_long >= 10:
return True

Check warning on line 163 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L163

Added line #L163 was not covered by tests

return False

def _expand_overloads_link(ctx: model.Documentable) -> list[Tag]:
_id = f'{ctx.fullName()}-overload-expand-link'
return [

Check warning on line 169 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L168-L169

Added lines #L168 - L169 were not covered by tests
tags.input(type='checkbox', id=_id, style="display: none;", class_="overload-expand-checkbox"),
tags.label(for_=_id, class_="overload-expand-link btn btn-link"),
]

def format_overloads(func: model.Function) -> Iterator["Flattenable"]:
"""
Format a function overloads definitions as nice HTML signatures.
"""
# TODO: Find a manner to wrap long overloads like the ones from temporalio.client.Client.start_workflow
# Maybe when there are more than one long overload, we create a fake overload without any annotations
# expect the one that are the same accros all overloads, then this could be showed when clicking on the function name then all overloads
# could be showed on demand
# When the overloads are overwhelming, we only show the first and the last overloads.
# the overloads in between are only showed with def x(...) and no decorators.

are_overwhelming = _are_overloads_overwhelming(func)
overload_class = 'function-overload'

if are_overwhelming:
yield from _expand_overloads_link(func)
overload_class += ' collapse-overload'

Check warning on line 186 in pydoctor/templatewriter/pages/__init__.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/templatewriter/pages/__init__.py#L185-L186

Added lines #L185 - L186 were not covered by tests

for overload in func.overloads:
yield from format_decorators(overload)
yield tags.div(format_function_def(func.name, func.is_async, overload))
yield tags.div(format_decorators(overload),
tags.div(format_function_def(func.name, func.is_async, overload)),
class_=overload_class)

def format_function_def(func_name: str, is_async: bool,
func: Union[model.Function, model.FunctionOverload]) -> List["Flattenable"]:
"""
Format a function definition as nice HTML signature.
If the function is overloaded, it will return an empty list. We use L{format_overloads} for these.
If the function is overloaded, it will return an empty list.
We use L{format_overloads} for these.
"""
r:List["Flattenable"] = []
# If this is a function with overloads, we do not render the principal signature because the overloaded signatures will be shown instead.
# If this is a function with overloads, we do not render the principal
# signature because the overloaded signatures will be shown instead.
if isinstance(func, model.Function) and func.overloads:
return r
def_stmt = 'async def' if is_async else 'def'
if func_name.endswith('.setter') or func_name.endswith('.deleter'):
func_name = func_name[:func_name.rindex('.')]

func_signature_css_class = 'function-signature'
if epydoc2stan.is_long_function_def(func):
if epydoc2stan.function_signature_len(func) > LONG_SIGNATURE:
func_signature_css_class += ' expand-signature'
r.extend([
tags.span(def_stmt, class_='py-keyword'), ' ',
tags.span(func_name, class_='py-defname'),
tags.span(format_signature(func), class_=func_signature_css_class), ':',
tags.span(format_signature(func), ':',
class_=func_signature_css_class),
])
return r

Expand Down
2 changes: 1 addition & 1 deletion pydoctor/templatewriter/pages/attributechild.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def anchorHref(self, request: object, tag: Tag) -> str:

@renderer
def decorator(self, request: object, tag: Tag) -> "Flattenable":
return list(format_decorators(self.ob))
return format_decorators(self.ob)

@renderer
def attribute(self, request: object, tag: Tag) -> "Flattenable":
Expand Down
2 changes: 1 addition & 1 deletion pydoctor/templatewriter/pages/functionchild.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def overloads(self, request: object, tag: Tag) -> "Flattenable":

@renderer
def decorator(self, request: object, tag: Tag) -> "Flattenable":
return list(format_decorators(self.ob))
return format_decorators(self.ob)

@renderer
def functionDef(self, request: object, tag: Tag) -> "Flattenable":
Expand Down
10 changes: 6 additions & 4 deletions pydoctor/test/test_templatewriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,11 +576,13 @@ def test_format_decorators() -> None:
def func():
...
''')
stan = stanutils.flatten(list(pages.format_decorators(cast(model.Function, mod.contents['func']))))
assert stan == ("""@string_decorator(<wbr></wbr>set(<wbr></wbr><span class="rst-variable-quote">'</span>"""
stan = stanutils.flatten(pages.format_decorators(cast(model.Function, mod.contents['func'])))
assert stan == ("""<span class="function-decorators"><span class="function-decorator">"""
"""@string_decorator(<wbr></wbr>set(<wbr></wbr><span class="rst-variable-quote">'</span>"""
r"""<span class="rst-variable-string">\\/:*?"&lt;&gt;|\f\v\t\r\n</span>"""
"""<span class="rst-variable-quote">'</span>))<br />@simple_decorator"""
"""(<wbr></wbr>max_examples=700, <wbr></wbr>deadline=None, <wbr></wbr>option=range(<wbr></wbr>10))<br />""")
"""<span class="rst-variable-quote">'</span>))<br /></span><span class="function-decorator">@simple_decorator"""
"""(<wbr></wbr>max_examples=700, <wbr></wbr>deadline=None, <wbr></wbr>option=range(<wbr></wbr>10))<br />"""
"""</span></span>""")


def test_compact_module_summary() -> None:
Expand Down
41 changes: 41 additions & 0 deletions pydoctor/themes/base/apidocs.css
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,47 @@ table .private {
padding-left: 0;
}

/* Style for the "Expand/Collapse signtures" link */

input[type=checkbox].overload-expand-checkbox:not(:checked) ~ label.overload-expand-link::after {
content: "Expand signatures";
}

input[type=checkbox].overload-expand-checkbox:checked ~ label.overload-expand-link::after {
content: "Collapse signatures";
}

input[type=checkbox].overload-expand-checkbox:not(:checked) ~ .collapse-overload .function-signature *
{
display: inline-flex !important;
gap: 1px;
margin-left: 0 !important;
padding-left: 0 !important;
text-indent: 0 !important;
height: 1.2em;
}

input[type=checkbox].overload-expand-checkbox:not(:checked) ~ .collapse-overload .function-signature{
text-overflow: ellipsis;
word-wrap: break-word;
white-space: nowrap;
overflow: hidden;
display: inline-block;
width: -webkit-fill-available;
height: 1.3em;
}

.overload-expand-link {
float: right;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}

input[type=checkbox].overload-expand-checkbox:not(:checked) ~ .collapse-overload div:has(.function-signature) {
display: flex;
text-wrap-mode: nowrap;
gap: 8px;
}

/*
- Links to class/function/etc names are nested like this:
<code><a>label</a></code>
Expand Down

0 comments on commit c0f93dc

Please sign in to comment.