Added multiple parsers for the entities referenced in docstrings.

The `inspect` plugin can now detect references to plugins, backends,
events, responses and schemas in docstrings and replace them either with
links to the documentation or auto-generated examples.
This commit is contained in:
Fabio Manganiello 2023-05-22 02:20:58 +02:00
parent 4f11d7cf74
commit d7405ad05d
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
12 changed files with 428 additions and 70 deletions

View file

@ -156,6 +156,8 @@ nav {
display: block; display: block;
color: $nav-fg; color: $nav-fg;
padding: 1em 0.5em; padding: 1em 0.5em;
text-decoration: none;
&:hover { &:hover {
color: $nav-fg; color: $nav-fg;
} }

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="row plugin execute-container"> <div class="row plugin execute-container" @click="onClick">
<Loading v-if="loading" /> <Loading v-if="loading" />
<div class="section command-container"> <div class="section command-container">
<div class="section-title">Execute Action</div> <div class="section-title">Execute Action</div>
@ -32,7 +32,7 @@
<div class="doc-container" v-if="selectedDoc"> <div class="doc-container" v-if="selectedDoc">
<div class="title"> <div class="title">
Action documentation <a :href="currentActionDocURL">Action documentation</a>
</div> </div>
<div class="doc html"> <div class="doc html">
@ -217,6 +217,24 @@ export default {
} }
}, },
computed: {
currentActionDocURL() {
if (!this.action?.name?.length)
return undefined
const plugin = this.action.name.split('.').slice(0, -1).join('.')
const actionName = this.action.name.split('.').slice(-1)
const actionClass = this.action.name
.split('.')
.slice(0, -1)
.map((token) => token.slice(0, 1).toUpperCase() + token.slice(1))
.join('') + 'Plugin'
return 'https://docs.platypush.tech/platypush/plugins/' +
`${plugin}.html#platypush.plugins.${plugin}.${actionClass}.${actionName}`
},
},
methods: { methods: {
async refresh() { async refresh() {
this.loading = true this.loading = true
@ -457,6 +475,15 @@ export default {
this.request('procedure.' + this.selectedProcedure.name, args) this.request('procedure.' + this.selectedProcedure.name, args)
.then(this.onResponse).catch(this.onError).finally(this.onDone) .then(this.onResponse).catch(this.onError).finally(this.onDone)
}, },
onClick(event) {
// Intercept any clicks from RST rendered links and open them in a new tab
if (event.target.tagName.toLowerCase() === 'a') {
event.stopPropagation()
event.preventDefault()
window.open(event.target.getAttribute('href', '_blank'))
}
},
}, },
mounted() { mounted() {
@ -857,6 +884,10 @@ $request-headers-btn-width: 7.5em;
background: $procedure-submit-btn-bg; background: $procedure-submit-btn-bg;
} }
} }
.action-param-value {
margin: 0.25em 0;
}
} }
pre { pre {

View file

@ -42,8 +42,13 @@ ul {
} }
a { a {
color: $default-link-fg;
text-decoration: underline dotted;
cursor: pointer; cursor: pointer;
text-decoration: none;
&:hover {
color: $default-hover-fg;
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {

View file

@ -1,7 +1,7 @@
import inspect import inspect
import json import json
import re import re
from typing import Optional, Type from typing import Callable, Optional, Type
from platypush.backend import Backend from platypush.backend import Backend
from platypush.message.event import Event from platypush.message.event import Event
@ -9,12 +9,30 @@ from platypush.message.response import Response
from platypush.plugins import Plugin from platypush.plugins import Plugin
from platypush.utils import get_decorators from platypush.utils import get_decorators
from ._parsers import (
BackendParser,
EventParser,
MethodParser,
PluginParser,
ResponseParser,
SchemaParser,
)
class Model: class Model:
""" """
Base class for component models. Base class for component models.
""" """
_parsers = [
BackendParser,
EventParser,
MethodParser,
PluginParser,
ResponseParser,
SchemaParser,
]
def __init__( def __init__(
self, self,
obj_type: type, obj_type: type,
@ -32,8 +50,22 @@ class Model:
self._obj_type = obj_type self._obj_type = obj_type
self.package = obj_type.__module__[len(prefix) :] self.package = obj_type.__module__[len(prefix) :]
self.name = name or self.package self.name = name or self.package
self.doc = doc or obj_type.__doc__
self.last_modified = last_modified self.last_modified = last_modified
self.doc, argsdoc = self._parse_docstring(doc or obj_type.__doc__ or '')
self.args = {}
self.has_kwargs = False
for arg in list(inspect.signature(obj_type).parameters.values())[1:]:
if arg.kind == arg.VAR_KEYWORD:
self.has_kwargs = True
continue
self.args[arg.name] = {
'default': arg.default
if not issubclass(arg.default.__class__, type)
else None,
'doc': argsdoc.get(arg.name),
}
def __str__(self): def __str__(self):
""" """
@ -51,9 +83,62 @@ class Model:
""" """
Iterator for the model public attributes/values pairs. Iterator for the model public attributes/values pairs.
""" """
for attr in ['name', 'doc']: for attr in ['name', 'args', 'doc', 'has_kwargs']:
yield attr, getattr(self, attr) yield attr, getattr(self, attr)
@classmethod
def _parse_docstring(cls, docstring: str):
new_docstring = ''
params = {}
cur_param = None
cur_param_docstring = ''
if not docstring:
return None, {}
for line in docstring.split('\n'):
m = re.match(r'^\s*:param ([^:]+):\s*(.*)', line)
if m:
if cur_param:
params[cur_param] = cur_param_docstring
cur_param = m.group(1)
cur_param_docstring = m.group(2)
continue
m = re.match(r'^\s*:return:\s+(.*)', line)
if m:
if cur_param:
params[cur_param] = cur_param_docstring
new_docstring += '\n\n**Returns:**\n\n' + m.group(1).strip() + ' '
cur_param = None
continue
if cur_param:
if not line.strip():
params[cur_param] = cur_param_docstring
cur_param = None
cur_param_docstring = ''
else:
cur_param_docstring += '\n' + line.strip() + ' '
else:
new_docstring += line + '\n'
if cur_param:
params[cur_param] = cur_param_docstring
for param, doc in params.items():
params[param] = cls._post_process_docstring(doc)
return cls._post_process_docstring(new_docstring), params
@classmethod
def _post_process_docstring(cls, docstring: str) -> str:
for parsers in cls._parsers:
docstring = parsers.parse(docstring)
return docstring.strip()
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class BackendModel(Model): class BackendModel(Model):
@ -90,7 +175,7 @@ class PluginModel(Model):
Overrides the default implementation of ``__iter__`` to also include Overrides the default implementation of ``__iter__`` to also include
plugin actions. plugin actions.
""" """
for attr in ['name', 'actions', 'doc']: for attr in ['name', 'args', 'actions', 'doc', 'has_kwargs']:
if attr == 'actions': if attr == 'actions':
yield attr, { yield attr, {
name: dict(action) for name, action in self.actions.items() name: dict(action) for name, action in self.actions.items()
@ -122,66 +207,5 @@ class ActionModel(Model):
Model for plugin action components. Model for plugin action components.
""" """
def __init__(self, action, **kwargs): def __init__(self, obj_type: Type[Callable], *args, **kwargs):
doc, argsdoc = self._parse_docstring(action.__doc__) super().__init__(obj_type, name=obj_type.__name__, *args, **kwargs)
super().__init__(action, name=action.__name__, doc=doc, **kwargs)
self.args = {}
self.has_kwargs = False
for arg in list(inspect.signature(action).parameters.values())[1:]:
if arg.kind == arg.VAR_KEYWORD:
self.has_kwargs = True
continue
self.args[arg.name] = {
'default': arg.default
if not issubclass(arg.default.__class__, type)
else None,
'doc': argsdoc.get(arg.name),
}
@classmethod
def _parse_docstring(cls, docstring: str):
new_docstring = ''
params = {}
cur_param = None
cur_param_docstring = ''
if not docstring:
return None, {}
for line in docstring.split('\n'):
if m := re.match(r'^\s*:param ([^:]+):\s*(.*)', line):
if cur_param:
params[cur_param] = cur_param_docstring
cur_param = m.group(1)
cur_param_docstring = m.group(2)
elif m := re.match(r'^\s*:return:\s+(.*)', line):
if cur_param:
params[cur_param] = cur_param_docstring
new_docstring += '\n\n**Returns:**\n\n' + m.group(1).strip() + ' '
cur_param = None
elif re.match(r'^\s*:[^:]+:\s*.*', line):
continue
else:
if cur_param:
if not line.strip():
params[cur_param] = cur_param_docstring
cur_param = None
cur_param_docstring = ''
else:
cur_param_docstring += '\n' + line.strip() + ' '
else:
new_docstring += line.strip() + ' '
if cur_param:
params[cur_param] = cur_param_docstring
return new_docstring.strip(), params
def __iter__(self):
for attr in ['name', 'args', 'doc', 'has_kwargs']:
yield attr, getattr(self, attr)

View file

@ -0,0 +1,16 @@
from ._backend import BackendParser
from ._event import EventParser
from ._method import MethodParser
from ._plugin import PluginParser
from ._response import ResponseParser
from ._schema import SchemaParser
__all__ = [
'BackendParser',
'EventParser',
'MethodParser',
'PluginParser',
'ResponseParser',
'SchemaParser',
]

View file

@ -0,0 +1,34 @@
import re
from typing_extensions import override
from ._base import Parser
class BackendParser(Parser):
"""
Parse backend references in the docstrings with rendered links to their
respective documentation.
"""
_backend_regex = re.compile(
r'(\s*):class:`(platypush\.backend\.(.+?))`', re.MULTILINE
)
@override
@classmethod
def parse(cls, docstring: str) -> str:
while True:
m = cls._backend_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._backend_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/backend/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View file

@ -0,0 +1,12 @@
from abc import ABC, abstractmethod
class Parser(ABC):
"""
Base class for parsers.
"""
@classmethod
@abstractmethod
def parse(cls, docstring: str) -> str:
raise NotImplementedError()

View file

@ -0,0 +1,34 @@
import re
from typing_extensions import override
from ._base import Parser
class EventParser(Parser):
"""
Parse event references in the docstrings with rendered links to their
respective documentation.
"""
_event_regex = re.compile(
r'(\s*):class:`(platypush\.message\.event\.(.+?))`', re.MULTILINE
)
@override
@classmethod
def parse(cls, docstring: str) -> str:
while True:
m = cls._event_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._event_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/events/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View file

@ -0,0 +1,35 @@
import re
from typing_extensions import override
from ._base import Parser
class MethodParser(Parser):
"""
Parse method references in the docstrings with rendered links to their
respective documentation.
"""
_method_regex = re.compile(
r'(\s*):meth:`(platypush\.plugins\.(.+?))`', re.MULTILINE
)
@override
@classmethod
def parse(cls, docstring: str) -> str:
while True:
m = cls._method_regex.search(docstring)
if not m:
break
tokens = m.group(3).split('.')
method = tokens[-1]
package = '.'.join(tokens[:-2])
docstring = cls._method_regex.sub(
f'{m.group(1)}`{package}.{method} '
f'<https://docs.platypush.tech/platypush/plugins/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View file

@ -0,0 +1,34 @@
import re
from typing_extensions import override
from ._base import Parser
class PluginParser(Parser):
"""
Parse plugin references in the docstrings with rendered links to their
respective documentation.
"""
_plugin_regex = re.compile(
r'(\s*):class:`(platypush\.plugins\.(.+?))`', re.MULTILINE
)
@override
@classmethod
def parse(cls, docstring: str) -> str:
while True:
m = cls._plugin_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._plugin_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/plugins/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View file

@ -0,0 +1,34 @@
import re
from typing_extensions import override
from ._base import Parser
class ResponseParser(Parser):
"""
Parse response references in the docstrings with rendered links to their
respective documentation.
"""
_response_regex = re.compile(
r'(\s*):class:`(platypush\.message\.response\.(.+?))`', re.MULTILINE
)
@override
@classmethod
def parse(cls, docstring: str) -> str:
while True:
m = cls._response_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._response_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/responses/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View file

@ -0,0 +1,97 @@
import importlib
import inspect
import json
import os
from random import randint
import re
import textwrap
from typing_extensions import override
from marshmallow import fields
import platypush.schemas
from ._base import Parser
class SchemaParser(Parser):
"""
Support for response/message schemas in the docs. Format: ``.. schema:: rel_path.SchemaClass(arg1=value1, ...)``,
where ``rel_path`` is the path of the schema relative to ``platypush/schemas``.
"""
_schemas_path = os.path.dirname(inspect.getfile(platypush.schemas))
_schema_regex = re.compile(
r'^(\s*)\.\.\s+schema::\s*([a-zA-Z0-9._]+)\s*(\((.+?)\))?', re.MULTILINE
)
@classmethod
def _get_field_value(cls, field):
metadata = getattr(field, 'metadata', {})
if metadata.get('example'):
return metadata['example']
if metadata.get('description'):
return metadata['description']
if isinstance(field, fields.Number):
return randint(1, 99)
if isinstance(field, fields.Boolean):
return bool(randint(0, 1))
if isinstance(field, fields.URL):
return 'https://example.org'
if isinstance(field, fields.List):
return [cls._get_field_value(field.inner)]
if isinstance(field, fields.Dict):
return {
cls._get_field_value(field.key_field)
if field.key_field
else 'key': cls._get_field_value(field.value_field)
if field.value_field
else 'value'
}
if isinstance(field, fields.Nested):
ret = {
name: cls._get_field_value(f)
for name, f in field.nested().fields.items()
}
return [ret] if field.many else ret
return str(field.__class__.__name__).lower()
@override
@classmethod
def parse(cls, docstring: str) -> str:
while True:
m = cls._schema_regex.search(docstring)
if not m:
break
schema_module_name = '.'.join(
['platypush.schemas', *(m.group(2).split('.')[:-1])]
)
schema_module = importlib.import_module(schema_module_name)
schema_class = getattr(schema_module, m.group(2).split('.')[-1])
schema_args = eval(f'dict({m.group(4)})') if m.group(4) else {}
schema = schema_class(**schema_args)
parsed_schema = {
name: cls._get_field_value(field)
for name, field in schema.fields.items()
if not field.load_only
}
if schema.many:
parsed_schema = [parsed_schema]
padding = m.group(1)
docstring = cls._schema_regex.sub(
textwrap.indent('\n\n.. code-block:: json\n\n', padding)
+ textwrap.indent(
json.dumps(parsed_schema, sort_keys=True, indent=2),
padding + ' ',
).replace('\n\n', '\n')
+ '\n\n',
docstring,
)
return docstring