diff --git a/platypush/backend/http/app/routes/index.py b/platypush/backend/http/app/routes/index.py
index 65ed04415..da6eccf51 100644
--- a/platypush/backend/http/app/routes/index.py
+++ b/platypush/backend/http/app/routes/index.py
@@ -67,6 +67,7 @@ def index():
websocket_port=get_websocket_port(),
template_folder=template_folder, static_folder=static_folder,
plugins=Config.get_plugins(), backends=Config.get_backends(),
+ procedures=Config.get_procedures(),
has_ssl=http_conf.get('ssl_cert') is not None)
diff --git a/platypush/backend/http/static/css/source/common/elements.scss b/platypush/backend/http/static/css/source/common/elements.scss
index 3433d461e..f07a9d043 100644
--- a/platypush/backend/http/static/css/source/common/elements.scss
+++ b/platypush/backend/http/static/css/source/common/elements.scss
@@ -33,4 +33,5 @@ select:-moz-focusring {
@import 'common/elements/slider';
@import 'common/elements/text';
@import 'common/elements/dropdown';
+@import 'common/elements/autocomplete';
diff --git a/platypush/backend/http/static/css/source/common/elements/autocomplete.scss b/platypush/backend/http/static/css/source/common/elements/autocomplete.scss
new file mode 100644
index 000000000..639867432
--- /dev/null
+++ b/platypush/backend/http/static/css/source/common/elements/autocomplete.scss
@@ -0,0 +1,33 @@
+.autocomplete {
+ /*the container must be positioned relative:*/
+ position: relative;
+ display: inline-block;
+}
+
+.autocomplete-items {
+ position: absolute;
+ border: $default-border-2;
+ border-bottom: none;
+ border-top: none;
+ z-index: 99;
+ /*position the autocomplete items to be the same width as the container:*/
+ top: 100%;
+ left: 0;
+ right: 0;
+}
+
+.autocomplete-items div {
+ padding: 1em;
+ cursor: pointer;
+ border-bottom: $default-border-2;
+ background-color: $autocomplete-bg;
+}
+
+.autocomplete-items div:hover {
+ background-color: $hover-bg;
+}
+
+.autocomplete-active {
+ background-color: $selected-bg !important;
+}
+
diff --git a/platypush/backend/http/static/css/source/common/elements/button.scss b/platypush/backend/http/static/css/source/common/elements/button.scss
index 1af86842e..22014cfd8 100644
--- a/platypush/backend/http/static/css/source/common/elements/button.scss
+++ b/platypush/backend/http/static/css/source/common/elements/button.scss
@@ -7,6 +7,6 @@ button[disabled],
.btn-primary {
background-color: #d8ffe0 !important;
- border: 1px solid #98efb0 !important;
+ border: 1px solid #c2f0cf !important;
}
diff --git a/platypush/backend/http/static/css/source/common/vars.scss b/platypush/backend/http/static/css/source/common/vars.scss
index b8f26dcab..1f29e3758 100644
--- a/platypush/backend/http/static/css/source/common/vars.scss
+++ b/platypush/backend/http/static/css/source/common/vars.scss
@@ -107,3 +107,6 @@ $modal-header-bg: #f0f0f0 !default;
$modal-header-border: 1px solid #ccc !default;
$modal-body-bg: white !default;
+//// Autocomplete element
+$autocomplete-bg: white !default;
+
diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/execute/index.scss b/platypush/backend/http/static/css/source/webpanel/plugins/execute/index.scss
new file mode 100644
index 000000000..ea376b2cb
--- /dev/null
+++ b/platypush/backend/http/static/css/source/webpanel/plugins/execute/index.scss
@@ -0,0 +1,173 @@
+@import 'common/vars';
+@import 'common/layout';
+@import 'webpanel/plugins/execute/vars';
+
+.execute-container {
+ height: 99%;
+ color: $default-fg-2;
+ font-weight: 400;
+ //line-height: 3.8rem;
+ //letter-spacing: .1rem;
+ border-bottom: $default-border-2;
+ border-radius: 0 0 1em 1em;
+
+ .title {
+ background: $title-bg;
+ padding: .2em;
+ border: $title-border;
+ box-shadow: $title-shadow;
+ font-size: 1.1em;
+ }
+
+ .request-type-container {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+ margin: 1em 0 0 1em;
+
+ label {
+ margin: 0 1em 0 .5em;
+ }
+ }
+
+ .request {
+ margin: 0 .5em;
+
+ form {
+ margin-bottom: 0 !important;
+ }
+
+ .autocomplete {
+ width: 80%;
+ max-width: 60em;
+ }
+
+ .action-name {
+ box-shadow: $action-name-shadow;
+ width: 100%;
+ }
+
+ [type=submit] {
+ margin-left: 2em;
+ }
+
+ .options {
+ display: flex;
+ margin-top: .5em;
+ margin-bottom: 1.5em;
+ padding-top: .5em;
+ }
+
+ .params {
+ margin-right: 1.5em;
+ max-height: 50vh;
+ overflow: auto;
+
+ .param {
+ margin-bottom: .25em;
+ }
+
+ .action-param-value {
+ width: 100%;
+ }
+ }
+
+ .add-param {
+ width: 100%;
+
+ button {
+ width: 100%;
+ background: $extra-params-btn-bg;
+ border: $title-border;
+ }
+ }
+
+ .extra-param {
+ display: flex;
+ margin-bottom: .5em;
+
+ .action-extra-param-del {
+ border: 0;
+ text-align: right;
+ padding: 0 .5em;
+ }
+ }
+
+ .output-container {
+ max-height: 50vh;
+ overflow: auto;
+
+ .response,
+ .error,
+ .doc {
+ padding: .5em .5em 0 .5em;
+ border-radius: 1em;
+ }
+
+ .response {
+ background: $response-bg;
+ border: $response-border;
+ }
+
+ .error {
+ background: $error-bg;
+ border: $error-border;
+ }
+
+ .doc {
+ background: $doc-bg;
+ border: $doc-border;
+ }
+ }
+
+ textarea {
+ width: 80%;
+ max-width: 60em;
+ height: 10em;
+ border-radius: 1em;
+ }
+ }
+
+ .raw-request {
+ .first-row {
+ display: flex;
+ flex-direction: row;
+ }
+ }
+
+ .procedures-container {
+ .procedure {
+ border-bottom: $default-border-2;
+ padding: 1.5em .5em;
+ cursor: pointer;
+
+ &:hover {
+ background: $hover-bg;
+ }
+
+ &.selected {
+ background: $selected-bg;
+ }
+
+ form {
+ display: flex;
+ margin-bottom: 0 !important;
+ flex-direction: column;
+ }
+
+ .head {
+ display: flex;
+ align-items: center;
+ }
+
+ .btn-container {
+ text-align: right;
+ }
+
+ button {
+ background: $procedure-submit-btn-bg;
+ }
+ }
+ }
+}
+
diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/execute/vars.scss b/platypush/backend/http/static/css/source/webpanel/plugins/execute/vars.scss
new file mode 100644
index 000000000..fd6fc9e0b
--- /dev/null
+++ b/platypush/backend/http/static/css/source/webpanel/plugins/execute/vars.scss
@@ -0,0 +1,14 @@
+$title-bg: #eee;
+$title-border: 1px solid #ddd;
+$title-shadow: 0 3px 3px 0 rgba(187,187,187,0.75);
+$action-name-shadow: 1px 1px 1px 1px #ddd;
+$extra-params-btn-bg: #eee;
+$response-bg: #edfff2;
+$response-border: 1px dashed #98ff98;
+$error-bg: #ffbcbc;
+$error-border: 1px dashed #ff5353;
+$doc-bg: #e8feff;
+$doc-border: 1px dashed #84f9ff;
+$procedure-submit-btn-bg: #ebffeb;
+
+
diff --git a/platypush/backend/http/static/js/autocomplete.js b/platypush/backend/http/static/js/autocomplete.js
new file mode 100644
index 000000000..a57de31eb
--- /dev/null
+++ b/platypush/backend/http/static/js/autocomplete.js
@@ -0,0 +1,110 @@
+function autocomplete(inp, arr, listener) {
+ /*the autocomplete function takes two arguments,
+ the text field element and an array of possible autocompleted values:*/
+ var currentFocus;
+ /*execute a function when someone writes in the text field:*/
+ inp.addEventListener("input", function(e) {
+ var a, b, i, val = this.value;
+ /*close any already open lists of autocompleted values*/
+ closeAllLists();
+ if (!val) { return false;}
+ currentFocus = -1;
+ /*create a DIV element that will contain the items (values):*/
+ a = document.createElement("DIV");
+ a.setAttribute("id", this.id + "autocomplete-list");
+ a.setAttribute("class", "autocomplete-items");
+ /*append the DIV element as a child of the autocomplete container:*/
+ this.parentNode.appendChild(a);
+ /*for each item in the array...*/
+ for (i = 0; i < arr.length; i++) {
+ /*check if the item starts with the same letters as the text field value:*/
+ if (arr[i].substr(0, val.length).toUpperCase() == val.toUpperCase()) {
+ /*create a DIV element for each matching element:*/
+ b = document.createElement("DIV");
+ /*make the matching letters bold:*/
+ b.innerHTML = "" + arr[i].substr(0, val.length) + "";
+ b.innerHTML += arr[i].substr(val.length);
+ /*insert a input field that will hold the current array item's value:*/
+ b.innerHTML += "";
+ /*execute a function when someone clicks on the item value (DIV element):*/
+ b.addEventListener("click", function(e) {
+ /*insert the value for the autocomplete text field:*/
+ inp.value = this.getElementsByTagName("input")[0].value;
+ /*trigger event listener if any:*/
+ if (listener) {
+ listener(e, inp.value);
+ }
+ /*close the list of autocompleted values,
+ (or any other open lists of autocompleted values:*/
+ closeAllLists();
+ });
+ a.appendChild(b);
+ }
+ }
+ });
+
+ inp.addEventListener("keydown", function(e) {
+ if (e.keyCode == 9) {
+ /*Reset the list if tab has been pressed*/
+ closeAllLists();
+ }
+ });
+
+ /*execute a function presses a key on the keyboard:*/
+ inp.addEventListener("keydown", function(e) {
+ var x = document.getElementById(this.id + "autocomplete-list");
+ if (x) x = x.getElementsByTagName("div");
+ if (e.keyCode == 40) {
+ /*If the arrow DOWN key is pressed,
+ increase the currentFocus variable:*/
+ currentFocus++;
+ /*and and make the current item more visible:*/
+ addActive(x);
+ } else if (e.keyCode == 38) { //up
+ /*If the arrow UP key is pressed,
+ decrease the currentFocus variable:*/
+ currentFocus--;
+ /*and and make the current item more visible:*/
+ addActive(x);
+ } else if (e.keyCode == 13) {
+ /*If the ENTER key is pressed, prevent the form from being submitted,*/
+ if (currentFocus > -1 && x && x.length) {
+ e.preventDefault();
+ /*and simulate a click on the "active" item:*/
+ x[currentFocus].click();
+ /*and restore the focus on the input element:*/
+ this.focus();
+ }
+ }
+ });
+ function addActive(x) {
+ /*a function to classify an item as "active":*/
+ if (!x) return false;
+ /*start by removing the "active" class on all items:*/
+ removeActive(x);
+ if (currentFocus >= x.length) currentFocus = 0;
+ if (currentFocus < 0) currentFocus = (x.length - 1);
+ /*add class "autocomplete-active":*/
+ x[currentFocus].classList.add("autocomplete-active");
+ }
+ function removeActive(x) {
+ /*a function to remove the "active" class from all autocomplete items:*/
+ for (var i = 0; i < x.length; i++) {
+ x[i].classList.remove("autocomplete-active");
+ }
+ }
+ function closeAllLists(elmnt) {
+ /*close all autocomplete lists in the document,
+ except the one passed as an argument:*/
+ var x = document.getElementsByClassName("autocomplete-items");
+ for (var i = 0; i < x.length; i++) {
+ if (elmnt != x[i] && elmnt != inp) {
+ x[i].parentNode.removeChild(x[i]);
+ }
+ }
+}
+/*execute a function when someone clicks in the document:*/
+document.addEventListener("click", function (e) {
+ closeAllLists(e.target);
+});
+}
diff --git a/platypush/backend/http/static/js/plugins/execute/index.js b/platypush/backend/http/static/js/plugins/execute/index.js
new file mode 100644
index 000000000..14e229590
--- /dev/null
+++ b/platypush/backend/http/static/js/plugins/execute/index.js
@@ -0,0 +1,215 @@
+Vue.component('execute', {
+ template: '#tmpl-execute',
+ props: ['config'],
+ data: function() {
+ return {
+ loading: false,
+ running: false,
+ structuredInput: true,
+ actionChanged: false,
+ selectedDoc: undefined,
+ selectedProcedure: {
+ name: undefined,
+ args: {},
+ },
+
+ response: undefined,
+ error: undefined,
+ htmlDoc: false,
+ rawRequest: undefined,
+ actions: {},
+ plugins: {},
+ procedures: {},
+ action: {
+ name: undefined,
+ args: {},
+ extraArgs: [],
+ supportsExtraArgs: false,
+ },
+ };
+ },
+
+ methods: {
+ refresh: async function() {
+ this.loading = true;
+ this.procedures = JSON.parse(this.config);
+ this.plugins = await request('inspect.get_all_plugins', {html_doc: true});
+
+ for (const plugin of Object.values(this.plugins)) {
+ if (plugin.html_doc)
+ this.htmlDoc = true;
+
+ for (const action of Object.values(plugin.actions)) {
+ action.name = plugin.name + '.' + action.name;
+ action.supportsExtraArgs = !!action.has_kwargs;
+ delete action.has_kwargs;
+ this.actions[action.name] = action;
+ }
+ }
+
+ const self = this;
+ autocomplete(this.$refs.actionName, Object.keys(this.actions).sort(), (evt, value) => {
+ this.action.name = value;
+ self.updateAction();
+ });
+
+ this.loading = false;
+ },
+
+ updateAction: function() {
+ if (!this.actionChanged || !(this.action.name in this.actions))
+ return;
+
+ this.loading = true;
+ this.action = {
+ ...this.actions[this.action.name],
+ args: Object.entries(this.actions[this.action.name].args).reduce((args, entry) => {
+ args[entry[0]] = {
+ ...entry[1],
+ value: entry[1].default,
+ };
+
+ return args;
+ }, {}),
+ extraArgs: [],
+ };
+
+ this.selectedDoc = this.action.doc;
+ this.actionChanged = false;
+ this.response = undefined;
+ this.error = undefined;
+ this.loading = false;
+ },
+
+ updateProcedure: function(name) {
+ if (event.target.getAttribute('type') === 'submit') {
+ return;
+ }
+
+ if (this.selectedProcedure.name === name) {
+ this.selectedProcedure = {
+ name: undefined,
+ args: {},
+ };
+
+ return;
+ }
+
+ if (!(name in this.procedures)) {
+ console.warn('Procedure not found: ' + name);
+ return;
+ }
+
+ this.selectedProcedure = {
+ name: name,
+ args: this.procedures[name].args.reduce((args, arg) => {
+ args[arg] = undefined;
+ return args;
+ }, {}),
+ };
+ },
+
+ addParameter: function() {
+ this.action.extraArgs.push({
+ name: undefined,
+ value: undefined,
+ })
+ },
+
+ removeParameter: function(i) {
+ this.action.extraArgs.pop(i);
+ },
+
+ selectAttrDoc: function(name) {
+ this.response = undefined;
+ this.error = undefined;
+ this.selectedDoc = this.action.args[name].doc;
+ },
+
+ resetDoc: function() {
+ this.response = undefined;
+ this.error = undefined;
+ this.selectedDoc = this.action.doc;
+ },
+
+ onInputTypeChange: function(structuredInput) {
+ this.structuredInput = structuredInput;
+ this.response = undefined;
+ this.error = undefined;
+ },
+
+ onResponse: function(response) {
+ this.response = '
' + JSON.stringify(response, null, 2) + '
';
+ this.error = undefined;
+ },
+
+ onError: function(error) {
+ this.response = undefined;
+ this.error = '' + error + '
';
+ },
+
+ onDone: function() {
+ this.running = false;
+ },
+
+ executeAction: function() {
+ if (!this.action.name && !this.rawRequest || this.running)
+ return;
+
+ this.running = true;
+ if (this.structuredInput) {
+ const args = {
+ ...Object.entries(this.action.args).reduce((args, param) => {
+ if (param[1].value != null) {
+ let value = param[1].value;
+ try {value = JSON.parse(value);}
+ catch (e) {}
+ args[param[0]] = value;
+ }
+ return args;
+ }, {}),
+
+ ...this.action.extraArgs.reduce((args, param) => {
+ let value = args[param.value];
+ try {value = JSON.parse(value);}
+ catch (e) {}
+
+ args[param.name] = value;
+ return args;
+ }, {})
+ };
+
+ request(this.action.name, args).then(this.onResponse).catch(this.onError).finally(this.onDone);
+ } else {
+ execute(JSON.parse(this.rawRequest)).then(this.onResponse).catch(this.onError).finally(this.onDone);
+ }
+ },
+
+ executeProcedure: function(event) {
+ if (!this.selectedProcedure.name || this.running)
+ return;
+
+ event.stopPropagation();
+ this.running = true;
+ const args = {
+ ...Object.entries(this.selectedProcedure.args).reduce((args, param) => {
+ if (param[1] != null) {
+ let value = param[1];
+ try {value = JSON.parse(value);}
+ catch (e) {}
+ args[param[0]] = value;
+ }
+ return args;
+ }, {}),
+ };
+
+ request('procedure.' + this.selectedProcedure.name, args)
+ .then(this.onResponse).catch(this.onError).finally(this.onDone);
+ },
+ },
+
+ created: function() {
+ this.refresh();
+ },
+});
+
diff --git a/platypush/backend/http/templates/index.html b/platypush/backend/http/templates/index.html
index f8f2f9265..910d8b7c7 100644
--- a/platypush/backend/http/templates/index.html
+++ b/platypush/backend/http/templates/index.html
@@ -8,6 +8,7 @@
+
+
{% for style in styles.values() %}
{% endfor %}
{% include 'elements.html' %}
+ {% include 'plugins/execute/index.html' %}
{% for plugin, conf in templates.items() %}
{% with configuration=templates[plugin] %}
@@ -56,6 +59,7 @@
{% endwith %}
{% endfor %}
+
{% for script in scripts.values() %}
{% endfor %}
@@ -69,6 +73,9 @@
+
+
+ -
+
+
+
+
+
{% for plugin in plugins|sort %}
-
diff --git a/platypush/backend/http/templates/plugins/execute/index.html b/platypush/backend/http/templates/plugins/execute/index.html
new file mode 100644
index 000000000..6ea560bbf
--- /dev/null
+++ b/platypush/backend/http/templates/plugins/execute/index.html
@@ -0,0 +1,112 @@
+
+
diff --git a/platypush/config/__init__.py b/platypush/config/__init__.py
index 52ef8a6a2..e6c5227f1 100644
--- a/platypush/config/__init__.py
+++ b/platypush/config/__init__.py
@@ -1,6 +1,7 @@
import datetime
import logging
import os
+import re
import socket
import sys
import yaml
@@ -157,7 +158,6 @@ class Config(object):
return config
-
def _init_components(self):
for key in self._config.keys():
if key.startswith('backend.'):
@@ -173,9 +173,21 @@ class Config(object):
tokens = key.split('.')
_async = True if len(tokens) > 2 and tokens[1] == 'async' else False
procedure_name = '.'.join(tokens[2:] if len(tokens) > 2 else tokens[1:])
+ args = []
+ m = re.match(r'^([^(]+)\(([^)]+)\)\s*', procedure_name)
+
+ if m:
+ procedure_name = m.group(1).strip()
+ args = [
+ arg.strip()
+ for arg in m.group(2).strip().split(',')
+ if arg.strip()
+ ]
+
self.procedures[procedure_name] = {
'_async': _async,
- 'actions': self._config[key]
+ 'actions': self._config[key],
+ 'args': args,
}
elif not self._is_special_token(key):
self.plugins[key] = self._config[key]
diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py
index 2e17a6c87..becaf298c 100644
--- a/platypush/message/request/__init__.py
+++ b/platypush/message/request/__init__.py
@@ -209,13 +209,16 @@ class Request(Message):
args = self.expand_value_from_context(args, **context)
response = plugin.run(method=method_name, **args)
- if response and response.is_error():
- logger.warning(('Response processed with errors from ' +
- 'action {}: {}').format(
- action, str(response)))
- elif not response.disable_logging:
- logger.info('Processed response from action {}: {}'.
- format(action, str(response)))
+ if not response:
+ logger.warning('Received null response from action {}'.format(action))
+ else:
+ if response.is_error():
+ logger.warning(('Response processed with errors from ' +
+ 'action {}: {}').format(
+ action, str(response)))
+ elif not response.disable_logging:
+ logger.info('Processed response from action {}: {}'.
+ format(action, str(response)))
except Exception as e:
# Retry mechanism
plugin.logger.exception(e)
diff --git a/platypush/plugins/inspect.py b/platypush/plugins/inspect.py
new file mode 100644
index 000000000..b539f8724
--- /dev/null
+++ b/platypush/plugins/inspect.py
@@ -0,0 +1,160 @@
+import importlib
+import inspect
+import json
+import pkgutil
+import re
+import threading
+
+import platypush.plugins
+
+from platypush.plugins import Plugin, action
+from platypush.utils import get_decorators
+
+
+# noinspection PyTypeChecker
+class Model:
+ def __str__(self):
+ return json.dumps(dict(self), indent=2, sort_keys=True)
+
+ def __repr__(self):
+ return json.dumps(dict(self))
+
+ @staticmethod
+ def to_html(doc):
+ try:
+ import docutils.core
+ except ImportError:
+ # docutils not found
+ return doc
+
+ return docutils.core.publish_parts(doc, writer_name='html')['html_body']
+
+
+class PluginModel(Model):
+ def __init__(self, plugin, prefix='', html_doc: bool = False):
+ self.name = plugin.__module__[len(prefix):]
+ self.html_doc = html_doc
+ self.doc = self.to_html(plugin.__doc__) if html_doc and plugin.__doc__ else plugin.__doc__
+ self.actions = {action_name: ActionModel(getattr(plugin, action_name), html_doc=html_doc)
+ for action_name in get_decorators(plugin, climb_class_hierarchy=True).get('action', [])}
+
+ def __iter__(self):
+ for attr in ['name', 'actions', 'doc', 'html_doc']:
+ if attr == 'actions':
+ # noinspection PyShadowingNames
+ yield attr, {name: dict(action) for name, action in self.actions.items()},
+ else:
+ yield attr, getattr(self, attr)
+
+
+class ActionModel(Model):
+ # noinspection PyShadowingNames
+ def __init__(self, action, html_doc: bool = False):
+ self.name = action.__name__
+ self.doc, argsdoc = self._parse_docstring(action.__doc__, html_doc=html_doc)
+ 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, html_doc: bool = False):
+ 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] = cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
+
+ cur_param = m.group(1)
+ cur_param_docstring = m.group(2)
+ elif re.match(r'^\s*:[^:]+:\s*.*', line):
+ continue
+ else:
+ if cur_param:
+ if not line.strip():
+ params[cur_param] = cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
+ cur_param = None
+ cur_param_docstring = ''
+ else:
+ cur_param_docstring += '\n' + line.strip()
+ else:
+ new_docstring += line.rstrip() + '\n'
+
+ if cur_param:
+ params[cur_param] = cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
+
+ return new_docstring.strip() if not html_doc else cls.to_html(new_docstring), params
+
+ def __iter__(self):
+ for attr in ['name', 'args', 'doc', 'has_kwargs']:
+ yield attr, getattr(self, attr)
+
+
+class InspectPlugin(Plugin):
+ """
+ This plugin can be used to inspect platypush plugins and backends
+
+ Requires:
+
+ * **docutils** (``pip install docutils``) - optional, for HTML doc generation
+
+ """
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._plugins = {}
+ self._plugins_lock = threading.RLock()
+ self._html_doc = False
+
+ def _init_plugins(self):
+ package = platypush.plugins
+ prefix = package.__name__ + '.'
+
+ for _, modname, _ in pkgutil.walk_packages(path=package.__path__,
+ prefix=prefix,
+ onerror=lambda x: None):
+ # noinspection PyBroadException
+ try:
+ module = importlib.import_module(modname)
+ except:
+ continue
+
+ for _, obj in inspect.getmembers(module):
+ if inspect.isclass(obj) and issubclass(obj, Plugin):
+ model = PluginModel(plugin=obj, prefix=prefix, html_doc=self._html_doc)
+ if model.name:
+ self._plugins[model.name] = model
+
+ @action
+ def get_all_plugins(self, html_doc: bool = None):
+ """
+ :param html_doc: If True then the docstring will be parsed into HTML (default: False)
+ """
+ with self._plugins_lock:
+ if not self._plugins or (html_doc is not None and html_doc != self._html_doc):
+ self._html_doc = html_doc
+ self._init_plugins()
+
+ return json.dumps({
+ name: dict(plugin)
+ for name, plugin in self._plugins.items()
+ })
+
+
+# vim:sw=4:ts=4:et:
diff --git a/requirements.txt b/requirements.txt
index 4d450977e..ce4b80c7b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -181,3 +181,6 @@ croniter
# Support for clipboard manipulation
# pyperclip
+
+# Support for RST->HTML docstring conversion
+# docutils
diff --git a/setup.py b/setup.py
index 7375a8cb1..292e07435 100755
--- a/setup.py
+++ b/setup.py
@@ -246,6 +246,8 @@ setup(
'mlx90640': ['Pillow'],
# Support for machine learning and CV plugin
'cv': ['cv2', 'numpy'],
+ # Support for the generation of HTML documentation from docstring
+ 'htmldoc': ['docutils'],
},
)