Compare commits

...

10 Commits

Author SHA1 Message Date
Fabio Manganiello 795754f858
Added PWA support 2023-05-18 03:12:48 +02:00
Fabio Manganiello 27d4a20418
Use reflection to infer the arguments of a Python user procedure 2023-05-17 17:17:59 +02:00
Fabio Manganiello 0a1209fe6e
Updated webapp dist files 2023-05-17 10:56:37 +02:00
Fabio Manganiello 33e2879413
Various UI improvements for the execute tab. 2023-05-17 10:41:02 +02:00
Fabio Manganiello 91daec579d
Reverted to the previous style for entities on mobile.
Better to use screen width wisely and avoid unnecessary padding.
2023-05-17 01:13:09 +02:00
Fabio Manganiello 61ea3d79e4
Large refactor for the `inspect` plugin.
More common logic has been extracted and all the methods and classes
have been documented and black'd.
2023-05-17 00:05:22 +02:00
Fabio Manganiello 2cba504e3b
Improvements for the autocomplete component. 2023-05-14 15:07:54 +02:00
Fabio Manganiello 8447f9a854
Improved rendering of actions/arguments documentation.
The frontend now calls `utils.rst_to_html` to render the docstrings as
HTML instead of dumping them as raw text.

Also, actions and arguments are now cached to improve performance.
2023-05-14 15:06:34 +02:00
Fabio Manganiello 5f2d6dfeb5
Added `utils.rst_to_html` action. 2023-05-14 15:05:24 +02:00
Fabio Manganiello 3c83e7f412
A faster implementation for the `inspect.get_*` methods.
Plugin/backend lookup is now done by inspecting the manifest files
instead of searching all the subpackages.
2023-05-13 13:44:46 +02:00
74 changed files with 3509 additions and 585 deletions

View File

@ -641,11 +641,7 @@ of Platypush to your fingertips.
## Tests ## Tests
To run the tests simply run `pytest` either from the project root folder or the To run the tests simply run `pytest` either from the project root folder or the
`tests/` folder. Or run the following command from the project root folder: `tests/` folder.
```shell
python -m tests
```
--- ---

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" type="module" src="/static/js/chunk-vendors.95bedba1.js"></script><script defer="defer" type="module" src="/static/js/app.5b73356c.js"></script><link href="/static/css/chunk-vendors.0fcd36f0.css" rel="stylesheet"><link href="/static/css/app.7cb6eac2.css" rel="stylesheet"><script defer="defer" src="/static/js/chunk-vendors-legacy.79dede0c.js" nomodule></script><script defer="defer" src="/static/js/app-legacy.d7eee501.js" nomodule></script></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" type="module" src="/static/js/chunk-vendors.95bedba1.js"></script><script defer="defer" type="module" src="/static/js/app.8fd4b02d.js"></script><link href="/static/css/chunk-vendors.0fcd36f0.css" rel="stylesheet"><link href="/static/css/app.7cb6eac2.css" rel="stylesheet"><script defer="defer" src="/static/js/chunk-vendors-legacy.79dede0c.js" nomodule></script><script defer="defer" src="/static/js/app-legacy.2bd8b862.js" nomodule></script></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"core-js": "^3.23.4", "core-js": "^3.23.4",
"lato-font": "^3.0.0", "lato-font": "^3.0.0",
"mitt": "^2.1.0", "mitt": "^2.1.0",
"register-service-worker": "^1.7.2",
"sass": "^1.53.0", "sass": "^1.53.0",
"sass-loader": "^10.3.1", "sass-loader": "^10.3.1",
"vue": "^3.2.13", "vue": "^3.2.13",
@ -25,6 +26,7 @@
"@babel/eslint-parser": "^7.12.16", "@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-pwa": "~5.0.0",
"@vue/cli-service": "~5.0.0", "@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3" "eslint-plugin-vue": "^8.0.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@ -2,19 +2,26 @@ function autocomplete(inp, arr, listener) {
/*the autocomplete function takes two arguments, /*the autocomplete function takes two arguments,
the text field element and an array of possible autocompleted values:*/ the text field element and an array of possible autocompleted values:*/
let currentFocus; let currentFocus;
/*execute a function when someone writes in the text field:*/ /*execute a function when someone writes in the text field:*/
inp.addEventListener("input", function() { inp.addEventListener("input", function() {
let a, b, i, val = this.value; let a, b, i, val = this.value;
/*close any already open lists of autocompleted values*/ /*close any already open lists of autocompleted values*/
closeAllLists(); closeAllLists();
if (!val) { return false;} if (!val) {
return false;
}
currentFocus = -1; currentFocus = -1;
/*create a DIV element that will contain the items (values):*/ /*create a DIV element that will contain the items (values):*/
a = document.createElement("DIV"); a = document.createElement("DIV");
a.setAttribute("id", this.id + "autocomplete-list"); a.setAttribute("id", this.id + "autocomplete-list");
a.setAttribute("class", "autocomplete-items"); a.setAttribute("class", "autocomplete-items");
/*append the DIV element as a child of the autocomplete container:*/ /*append the DIV element as a child of the autocomplete container:*/
this.parentNode.appendChild(a); this.parentNode.appendChild(a);
/*for each item in the array...*/ /*for each item in the array...*/
for (i = 0; i < arr.length; i++) { for (i = 0; i < arr.length; i++) {
/*check if the item starts with the same letters as the text field value:*/ /*check if the item starts with the same letters as the text field value:*/
@ -43,10 +50,13 @@ function autocomplete(inp, arr, listener) {
} }
}); });
inp.addEventListener("keydown", function(e) { inp.addEventListener("keyup", function(e) {
if (e.keyCode === 9) { if (["ArrowUp", "ArrowDown", "Tab", "Enter"].indexOf(e.key) >= 0) {
/*Reset the list if tab has been pressed*/ e.stopPropagation();
closeAllLists(); }
if (e.key === "Enter") {
this.blur();
} }
}); });
@ -54,19 +64,21 @@ function autocomplete(inp, arr, listener) {
inp.addEventListener("keydown", function(e) { inp.addEventListener("keydown", function(e) {
let x = document.getElementById(this.id + "autocomplete-list"); let x = document.getElementById(this.id + "autocomplete-list");
if (x) x = x.getElementsByTagName("div"); if (x) x = x.getElementsByTagName("div");
if (e.keyCode === 40) { if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
/*If the arrow DOWN key is pressed, /*If the arrow DOWN key is pressed,
increase the currentFocus variable:*/ increase the currentFocus variable:*/
currentFocus++; currentFocus++;
/*and and make the current item more visible:*/ /*and and make the current item more visible:*/
addActive(x); addActive(x);
} else if (e.keyCode === 38) { //up e.preventDefault();
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { //up
/*If the arrow UP key is pressed, /*If the arrow UP key is pressed,
decrease the currentFocus variable:*/ decrease the currentFocus variable:*/
currentFocus--; currentFocus--;
/*and and make the current item more visible:*/ /*and and make the current item more visible:*/
addActive(x); addActive(x);
} else if (e.keyCode === 13) { e.preventDefault();
} else if (e.key === 'Enter') {
/*If the ENTER key is pressed, prevent the form from being submitted,*/ /*If the ENTER key is pressed, prevent the form from being submitted,*/
if (currentFocus > -1 && x && x.length) { if (currentFocus > -1 && x && x.length) {
e.preventDefault(); e.preventDefault();
@ -77,6 +89,7 @@ function autocomplete(inp, arr, listener) {
} }
} }
}); });
function addActive(x) { function addActive(x) {
/*a function to classify an item as "active":*/ /*a function to classify an item as "active":*/
if (!x) return false; if (!x) return false;
@ -87,12 +100,14 @@ function autocomplete(inp, arr, listener) {
/*add class "autocomplete-active":*/ /*add class "autocomplete-active":*/
x[currentFocus].classList.add("autocomplete-active"); x[currentFocus].classList.add("autocomplete-active");
} }
function removeActive(x) { function removeActive(x) {
/*a function to remove the "active" class from all autocomplete items:*/ /*a function to remove the "active" class from all autocomplete items:*/
for (let i = 0; i < x.length; i++) { for (let i = 0; i < x.length; i++) {
x[i].classList.remove("autocomplete-active"); x[i].classList.remove("autocomplete-active");
} }
} }
function closeAllLists(elmnt) { function closeAllLists(elmnt) {
/*close all autocomplete lists in the document, /*close all autocomplete lists in the document,
except the one passed as an argument:*/ except the one passed as an argument:*/
@ -103,6 +118,7 @@ function autocomplete(inp, arr, listener) {
} }
} }
} }
/*execute a function when someone clicks in the document:*/ /*execute a function when someone clicks in the document:*/
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
closeAllLists(e.target); closeAllLists(e.target);

View File

@ -509,6 +509,7 @@ export default {
@include until(#{$tablet - 1}) { @include until(#{$tablet - 1}) {
padding: 0; padding: 0;
margin-bottom: $main-margin;
} }
@include from($tablet) { @include from($tablet) {
@ -524,11 +525,8 @@ export default {
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;
border-radius: 1em;
@include from($tablet) { box-shadow: $group-shadow;
border-radius: 1em;
box-shadow: $group-shadow;
}
} }
.header { .header {
@ -579,10 +577,6 @@ export default {
overflow: auto; overflow: auto;
flex-grow: 1; flex-grow: 1;
@include until(#{$tablet - 1}) {
background: $default-bg-4;
}
@include from($tablet) { @include from($tablet) {
background: $default-bg-2; background: $default-bg-2;
} }
@ -590,16 +584,8 @@ export default {
.entity-frame { .entity-frame {
background: $background-color; background: $background-color;
@include until(#{$tablet - 1}) { &:last-child {
margin: 0.75em 0.25em; border-radius: 0 0 1em 1em;
border: $default-border-2;
border-radius: 1em;
}
@include from($tablet) {
&:last-child {
border-radius: 0 0 1em 1em;
}
} }
} }
} }

View File

@ -6,10 +6,6 @@ $collapse-toggler-width: 2em;
background: $selected-bg; background: $selected-bg;
font-weight: bold; font-weight: bold;
box-shadow: 0 0 3px 2px $default-shadow-color; box-shadow: 0 0 3px 2px $default-shadow-color;
@include until(#{$tablet - 1}) {
border-radius: 1em;
}
} }
.entity-container { .entity-container {
@ -18,18 +14,7 @@ $collapse-toggler-width: 2em;
align-items: center; align-items: center;
position: relative; position: relative;
padding: 0 !important; padding: 0 !important;
border-bottom: $default-border-3;
@include until(#{$tablet - 1}) {
border-radius: 1em;
&:not(:last-child) {
border-bottom: $default-border-3;
}
}
@include from($tablet) {
border-bottom: $default-border-3;
}
&.with-children:not(.collapsed) { &.with-children:not(.collapsed) {
@include expanded-entity(); @include expanded-entity();
@ -50,12 +35,6 @@ $collapse-toggler-width: 2em;
} }
@include until(#{$tablet - 1}) { @include until(#{$tablet - 1}) {
.entity-container {
.children {
border-radius: 0 0 1em 1em;
}
}
.child { .child {
&:not(:last-child) { &:not(:last-child) {
.entity-container { .entity-container {
@ -239,38 +218,8 @@ $collapse-toggler-width: 2em;
&.with-children:not(.collapsed) { &.with-children:not(.collapsed) {
box-shadow: 0 3px 4px 0 $default-shadow-color; box-shadow: 0 3px 4px 0 $default-shadow-color;
@include until(#{$tablet - 1}) { .children .child:last-child {
border-radius: 1em; box-shadow: 0 3px 4px 0 $default-shadow-color;
}
@include from($tablet) {
.children .child:last-child {
box-shadow: 0 3px 4px 0 $default-shadow-color;
}
}
}
@include until(#{$tablet - 1}) {
box-shadow: 0 3px 4px 0 $default-shadow-color;
&.collapsed {
border-radius: 1em;
}
.children {
.entity-container-wrapper {
border-radius: 0;
box-shadow: none;
&.with-children:not(.collapsed) {
border-radius: 1em;
box-shadow: 0 3px 4px 0 $default-shadow-color;
}
&:last-child {
border-radius: 0 0 1em 1em;
}
}
} }
} }
} }

View File

@ -21,7 +21,8 @@
@change="actionChanged=true" @blur="updateAction"> @change="actionChanged=true" @blur="updateAction">
</label> </label>
</div> </div>
<button type="submit" class="run-btn btn-primary" :disabled="running" title="Run"> <button type="submit" class="run-btn btn-primary"
:disabled="running || !action?.name?.length" title="Run">
<i class="fas fa-play" /> <i class="fas fa-play" />
</button> </button>
@ -30,8 +31,10 @@
Action documentation Action documentation
</div> </div>
<div class="doc html" v-html="selectedDoc" v-if="htmlDoc" /> <div class="doc html">
<div class="doc raw" v-text="selectedDoc" v-else /> <Loading v-if="docLoading" />
<span v-html="selectedDoc" v-else />
</div>
</div> </div>
<div class="options" v-if="action.name in actions && (Object.keys(action.args).length || <div class="options" v-if="action.name in actions && (Object.keys(action.args).length ||
@ -51,8 +54,10 @@
Attribute: <div class="attr-name" v-text="selectedAttr" /> Attribute: <div class="attr-name" v-text="selectedAttr" />
</div> </div>
<div class="doc html" v-html="selectedAttrDoc" v-if="htmlDoc" /> <div class="doc html">
<div class="doc raw" v-text="selectedAttrDoc" v-else /> <Loading v-if="docLoading" />
<span v-html="selectedAttrDoc" v-else />
</div>
</div> </div>
</div> </div>
@ -62,11 +67,11 @@
<input type="text" class="action-extra-param-name" :disabled="running" <input type="text" class="action-extra-param-name" :disabled="running"
placeholder="Name" v-model="action.extraArgs[i].name"> placeholder="Name" v-model="action.extraArgs[i].name">
</label> </label>
<label class="col-5"> <label class="col-6">
<input type="text" class="action-extra-param-value" :disabled="running" <input type="text" class="action-extra-param-value" :disabled="running"
placeholder="Value" v-model="action.extraArgs[i].value"> placeholder="Value" v-model="action.extraArgs[i].value">
</label> </label>
<label class="col-2 buttons"> <label class="col-1 buttons">
<button type="button" class="action-extra-param-del" title="Remove parameter" <button type="button" class="action-extra-param-del" title="Remove parameter"
@click="removeParameter(i)"> @click="removeParameter(i)">
<i class="fas fa-trash" /> <i class="fas fa-trash" />
@ -87,22 +92,24 @@
Attribute: <div class="attr-name" v-text="selectedAttr" /> Attribute: <div class="attr-name" v-text="selectedAttr" />
</div> </div>
<div class="doc html" v-html="selectedAttrDoc" v-if="htmlDoc" /> <div class="doc html">
<div class="doc raw" v-text="selectedAttrDoc" v-else /> <Loading v-if="docLoading" />
<span v-html="selectedAttrDoc" v-else />
</div>
</div> </div>
</div>
<div class="output-container"> <div class="output-container">
<div class="title" v-text="error != null ? 'Error' : 'Output'" v-if="error != null || response != null" /> <div class="title" v-text="error != null ? 'Error' : 'Output'" v-if="error != null || response != null" />
<div class="response" v-html="response" v-if="response != null" /> <div class="response" v-html="response" v-if="response != null" />
<div class="error" v-html="error" v-else-if="error != null" /> <div class="error" v-html="error" v-else-if="error != null" />
</div>
</div> </div>
</div> </div>
<div class="request raw-request" :class="structuredInput ? 'hidden' : ''"> <div class="request raw-request" :class="structuredInput ? 'hidden' : ''">
<div class="first-row"> <div class="first-row">
<label> <label>
<textarea v-model="rawRequest" placeholder="Raw JSON request" /> <textarea v-model="rawRequest" ref="rawAction" :placeholder="rawRequestPlaceholder" />
</label> </label>
<button type="submit" :disabled="running" class="run-btn btn-primary" title="Run"> <button type="submit" :disabled="running" class="run-btn btn-primary" title="Run">
<i class="fas fa-play" /> <i class="fas fa-play" />
@ -163,6 +170,7 @@ export default {
return { return {
loading: false, loading: false,
running: false, running: false,
docLoading: false,
structuredInput: true, structuredInput: true,
actionChanged: false, actionChanged: false,
selectedDoc: undefined, selectedDoc: undefined,
@ -175,11 +183,13 @@ export default {
response: undefined, response: undefined,
error: undefined, error: undefined,
htmlDoc: false,
rawRequest: undefined, rawRequest: undefined,
rawRequestPlaceholder: 'Raw JSON request. Example:\n\n' +
'{"type": "request", "action": "file.list", "args": {"path": "/"}}',
actions: {}, actions: {},
plugins: {}, plugins: {},
procedures: {}, procedures: {},
actionDocsCache: {},
action: { action: {
name: undefined, name: undefined,
args: {}, args: {},
@ -195,15 +205,12 @@ export default {
try { try {
this.procedures = await this.request('inspect.get_procedures') this.procedures = await this.request('inspect.get_procedures')
this.plugins = await this.request('inspect.get_all_plugins', {html_doc: false}) this.plugins = await this.request('inspect.get_all_plugins')
} finally { } finally {
this.loading = false this.loading = false
} }
for (const plugin of Object.values(this.plugins)) { for (const plugin of Object.values(this.plugins)) {
if (plugin.html_doc)
this.htmlDoc = true
for (const action of Object.values(plugin.actions)) { for (const action of Object.values(plugin.actions)) {
action.name = plugin.name + '.' + action.name action.name = plugin.name + '.' + action.name
action.supportsExtraArgs = !!action.has_kwargs action.supportsExtraArgs = !!action.has_kwargs
@ -213,20 +220,20 @@ export default {
} }
const self = this const self = this
autocomplete(this.$refs.actionName, Object.keys(this.actions).sort(), (evt, value) => { autocomplete(this.$refs.actionName, Object.keys(this.actions).sort(), (_, value) => {
this.action.name = value this.action.name = value
self.updateAction() self.updateAction()
}) })
}, },
updateAction() { async updateAction() {
if (!(this.action.name in this.actions)) if (!(this.action.name in this.actions))
this.selectedDoc = undefined this.selectedDoc = undefined
if (!this.actionChanged || !(this.action.name in this.actions)) if (!this.actionChanged || !(this.action.name in this.actions))
return return
this.loading = true this.docLoading = true
try { try {
this.action = { this.action = {
...this.actions[this.action.name], ...this.actions[this.action.name],
@ -241,32 +248,27 @@ export default {
extraArgs: [], extraArgs: [],
} }
} finally { } finally {
this.loading = false this.docLoading = false
} }
this.selectedDoc = this.parseDoc(this.action.doc) this.selectedDoc =
this.actionDocsCache[this.action.name]?.html ||
await this.parseDoc(this.action.doc)
if (!this.actionDocsCache[this.action.name])
this.actionDocsCache[this.action.name] = {}
this.actionDocsCache[this.action.name].html = this.selectedDoc
this.actionChanged = false this.actionChanged = false
this.response = undefined this.response = undefined
this.error = undefined this.error = undefined
}, },
parseDoc(docString) { async parseDoc(docString) {
if (!docString?.length || this.htmlDoc) if (!docString?.length)
return docString return docString
let lineNo = 0 return await this.request('utils.rst_to_html', {text: docString})
let trailingSpaces = 0
return docString.split('\n').reduce((doc, line) => {
if (++lineNo === 2)
trailingSpaces = line.match(/^(\s*)/)[1].length
if (line.trim().startsWith('.. code-block'))
return doc
doc += line.slice(trailingSpaces).replaceAll('``', '') + '\n'
return doc
}, '')
}, },
updateProcedure(name, event) { updateProcedure(name, event) {
@ -308,11 +310,16 @@ export default {
this.action.extraArgs.pop(i) this.action.extraArgs.pop(i)
}, },
selectAttrDoc(name) { async selectAttrDoc(name) {
this.response = undefined
this.error = undefined
this.selectedAttr = name this.selectedAttr = name
this.selectedAttrDoc = this.parseDoc(this.action.args[name].doc) this.selectedAttrDoc =
this.actionDocsCache[this.action.name]?.[name]?.html ||
await this.parseDoc(this.action.args[name].doc)
if (!this.actionDocsCache[this.action.name])
this.actionDocsCache[this.action.name] = {}
this.actionDocsCache[this.action.name][name] = {html: this.selectedAttrDoc}
}, },
resetAttrDoc() { resetAttrDoc() {
@ -326,6 +333,13 @@ export default {
this.structuredInput = structuredInput this.structuredInput = structuredInput
this.response = undefined this.response = undefined
this.error = undefined this.error = undefined
this.$nextTick(() => {
if (structuredInput) {
this.$refs.actionName.focus()
} else {
this.$refs.rawAction.focus()
}
})
}, },
onResponse(response) { onResponse(response) {
@ -422,6 +436,10 @@ export default {
}, },
mounted() { mounted() {
this.$nextTick(() => {
this.$refs.actionName.focus()
})
this.refresh() this.refresh()
}, },
} }
@ -450,11 +468,12 @@ $params-tablet-width: 20em;
} }
.action-form { .action-form {
background: $default-bg-2;
padding: 1em .5em; padding: 1em .5em;
} }
.title { .title {
background: $title-bg; background: $header-bg;
padding: .5em; padding: .5em;
border: $title-border; border: $title-border;
box-shadow: $title-shadow; box-shadow: $title-shadow;
@ -544,12 +563,26 @@ $params-tablet-width: 20em;
display: flex; display: flex;
margin-bottom: .5em; margin-bottom: .5em;
label {
margin-left: 0.25em;
}
.buttons {
display: flex;
flex-grow: 1;
justify-content: right;
}
.action-extra-param-del { .action-extra-param-del {
border: 0; border: 0;
text-align: right; text-align: right;
padding: 0 .5em; padding: 0 .5em;
} }
input[type=text] {
width: 100%;
}
.buttons { .buttons {
display: flex; display: flex;
align-items: center; align-items: center;
@ -569,11 +602,6 @@ $params-tablet-width: 20em;
.doc-container, .doc-container,
.output-container { .output-container {
margin-top: .5em; margin-top: .5em;
.doc {
&.raw {
white-space: pre;
}
}
} }
.output-container { .output-container {
@ -590,7 +618,6 @@ $params-tablet-width: 20em;
} }
.doc { .doc {
white-space: pre-line;
width: 100%; width: 100%;
overflow: auto; overflow: auto;
} }
@ -613,11 +640,6 @@ $params-tablet-width: 20em;
.attr-doc-container { .attr-doc-container {
.doc { .doc {
padding: 1em !important; padding: 1em !important;
&.raw {
font-family: monospace;
font-size: .8em;
}
} }
} }
@ -747,11 +769,30 @@ $params-tablet-width: 20em;
} }
.run-btn { .run-btn {
border-radius: 2em; background: $background-color;
padding: .5em .75em; border-radius: .25em;
padding: .5em 1.5em;
box-shadow: $primary-btn-shadow;
cursor: pointer;
&:hover { &:hover {
opacity: .8; background: $hover-bg;
box-shadow: none;
}
&:disabled {
opacity: 0.7;
color: $default-fg-2;
cursor: initial;
box-shadow: none;
&:hover {
background: $background-color;
box-shadow: none;
}
}
&:not(disabled) {
} }
} }
} }

View File

@ -7,7 +7,7 @@ $response-bg: #edfff2;
$response-border: 1px dashed #98ff98; $response-border: 1px dashed #98ff98;
$error-bg: #ffbcbc; $error-bg: #ffbcbc;
$error-border: 1px dashed #ff5353; $error-border: 1px dashed #ff5353;
$doc-bg: #e8feff; $doc-bg: $background-color;
$doc-border: 1px dashed #84f9ff; $doc-border: 1px dashed $border-color-2;
$procedure-submit-btn-bg: #ebffeb; $procedure-submit-btn-bg: #ebffeb;
$section-title-bg: rgba(0, 0, 0, .04); $section-title-bg: rgba(0, 0, 0, .04);

View File

@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from '@/App.vue' import App from '@/App.vue'
import router from '@/router' import router from '@/router'
import './registerServiceWorker'
const app = createApp(App) const app = createApp(App)
app.config.globalProperties._config = window.config app.config.globalProperties._config = window.config

View File

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready () {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered () {
console.log('Service worker has been registered.')
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated () {
console.log('New content is available; please refresh.')
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

View File

@ -22,11 +22,11 @@
background-color: $background-color; background-color: $background-color;
&:hover { &:hover {
background-color: $hover-bg; background-color: $hover-bg-2;
} }
} }
} }
.autocomplete-active { .autocomplete-active {
background-color: $selected-bg !important; background-color: $hover-bg-2 !important;
} }

View File

@ -56,6 +56,7 @@ $border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
$border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color; $border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
$header-shadow: 0px 1px 3px 1px #bbb !default; $header-shadow: 0px 1px 3px 1px #bbb !default;
$group-shadow: 3px -2px 6px 1px #98b0a0; $group-shadow: 3px -2px 6px 1px #98b0a0;
$primary-btn-shadow: 1px 1px 0.5px 0.75px #32b64640 !default;
//// Modals //// Modals
$modal-header-bg: #e0e0e0 !default; $modal-header-bg: #e0e0e0 !default;
@ -81,6 +82,7 @@ $default-hover-fg: #35b870 !default;
$default-hover-fg-2: #38cf80 !default; $default-hover-fg-2: #38cf80 !default;
$hover-fg: $default-hover-fg !default; $hover-fg: $default-hover-fg !default;
$hover-bg: linear-gradient(90deg, rgba(190,246,218,1) 0%, rgba(229,251,240,1) 100%) !default; $hover-bg: linear-gradient(90deg, rgba(190,246,218,1) 0%, rgba(229,251,240,1) 100%) !default;
$hover-bg-2: rgb(190,246,218) !default;
$active-bg: #8fefb7 !default; $active-bg: #8fefb7 !default;
/// Disabled /// Disabled

View File

@ -1,28 +1,36 @@
from collections import defaultdict
import importlib import importlib
import inspect import inspect
import json import json
import os
import pathlib
import pickle
import pkgutil import pkgutil
import threading from types import ModuleType
from typing import Optional from typing import Callable, Dict, Generator, Optional, Type, Union
import platypush.backend # lgtm [py/import-and-import-from]
import platypush.plugins # lgtm [py/import-and-import-from]
import platypush.message.event # lgtm [py/import-and-import-from]
import platypush.message.response # lgtm [py/import-and-import-from]
from platypush.backend import Backend from platypush.backend import Backend
from platypush.config import Config from platypush.config import Config
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
from platypush.message.event import Event from platypush.message.event import Event
from platypush.message.response import Response from platypush.message.response import Response
from platypush.utils import (
get_backend_class_by_name,
get_backend_name_by_class,
get_plugin_class_by_name,
get_plugin_name_by_class,
)
from platypush.utils.manifest import Manifest, scan_manifests
from ._context import ComponentContext
from ._model import ( from ._model import (
BackendModel, BackendModel,
EventModel, EventModel,
Model,
PluginModel, PluginModel,
ProcedureEncoder,
ResponseModel, ResponseModel,
) )
from ._serialize import ProcedureEncoder
class InspectPlugin(Plugin): class InspectPlugin(Plugin):
@ -32,183 +40,326 @@ class InspectPlugin(Plugin):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._plugins = {} self._components_cache_file = os.path.join(
self._backends = {} Config.get('workdir'), # type: ignore
self._events = {} 'components.cache', # type: ignore
self._responses = {} )
self._plugins_lock = threading.RLock() self._components_context: Dict[type, ComponentContext] = defaultdict(
self._backends_lock = threading.RLock() ComponentContext
self._events_lock = threading.RLock() )
self._responses_lock = threading.RLock() self._components_cache: Dict[type, dict] = defaultdict(dict)
self._html_doc = False self._load_components_cache()
def _load_components_cache(self):
"""
Loads the components cache from disk.
"""
try:
with open(self._components_cache_file, 'rb') as f:
self._components_cache = pickle.load(f)
except OSError:
return
def _flush_components_cache(self):
"""
Flush the current components cache to disk.
"""
with open(self._components_cache_file, 'wb') as f:
pickle.dump(self._components_cache, f)
def _get_cached_component(
self, base_type: type, comp_type: type
) -> Optional[Model]:
"""
Retrieve a cached component's ``Model``.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param comp_type: The specific type of the component (e.g.
``MusicMpdPlugin`` or ``HttpBackend``).
:return: The cached component's ``Model`` if it exists, otherwise null.
"""
return self._components_cache.get(base_type, {}).get(comp_type)
def _cache_component(
self,
base_type: type,
comp_type: type,
model: Model,
index_by_module: bool = False,
):
"""
Cache the ``Model`` object for a component.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param comp_type: The specific type of the component (e.g.
``MusicMpdPlugin`` or ``HttpBackend``).
:param model: The ``Model`` object to cache.
:param index_by_module: If ``True``, the ``Model`` object will be
indexed according to the ``base_type -> module -> comp_type``
mapping, otherwise ``base_type -> comp_type``.
"""
if index_by_module:
if not self._components_cache.get(base_type, {}).get(model.package):
self._components_cache[base_type][model.package] = {}
self._components_cache[base_type][model.package][comp_type] = model
else:
self._components_cache[base_type][comp_type] = model
def _scan_integrations(self, base_type: type):
"""
A generator that scans the manifest files given a ``base_type``
(``Plugin`` or ``Backend``) and yields the parsed submodules.
"""
for mf_file in scan_manifests(base_type):
manifest = Manifest.from_file(mf_file)
try:
yield importlib.import_module(manifest.package)
except Exception as e:
self.logger.debug(
'Could not import module %s: %s',
manifest.package,
e,
)
continue
def _scan_modules(self, base_type: type) -> Generator[ModuleType, None, None]:
"""
A generator that scan the modules given a ``base_type`` (e.g. ``Event``).
Unlike :meth:`._scan_integrations`, this method recursively scans the
modules using ``pkgutil`` instead of using the information provided in
the integrations' manifest files.
"""
prefix = base_type.__module__ + '.'
path = str(pathlib.Path(inspect.getfile(base_type)).parent)
for _, modname, _ in pkgutil.walk_packages(
path=[path], prefix=prefix, onerror=lambda _: None
):
try:
yield importlib.import_module(modname)
except Exception as e:
self.logger.debug('Could not import module %s: %s', modname, e)
continue
def _init_component(
self,
base_type: type,
comp_type: type,
model_type: Type[Model],
index_by_module: bool = False,
) -> Model:
"""
Initialize a component's ``Model`` object and cache it.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param comp_type: The specific type of the component (e.g.
``MusicMpdPlugin`` or ``HttpBackend``).
:param model_type: The type of the ``Model`` object that should be
created.
:param index_by_module: If ``True``, the ``Model`` object will be
indexed according to the ``base_type -> module -> comp_type``
mapping, otherwise ``base_type -> comp_type``.
:return: The initialized component's ``Model`` object.
"""
prefix = base_type.__module__ + '.'
comp_file = inspect.getsourcefile(comp_type)
model = None
mtime = None
if comp_file:
mtime = os.stat(comp_file).st_mtime
cached_model = self._get_cached_component(base_type, comp_type)
# Only update the component model if its source file was
# modified since the last time it was scanned
if (
cached_model
and cached_model.last_modified
and mtime <= cached_model.last_modified
):
model = cached_model
if not model:
self.logger.info('Scanning component %s', comp_type.__name__)
model = model_type(comp_type, prefix=prefix, last_modified=mtime)
self._cache_component(
base_type, comp_type, model, index_by_module=index_by_module
)
return model
def _init_modules(
self,
base_type: type,
model_type: Type[Model],
):
"""
Initializes, parses and caches all the components of a given type.
Unlike :meth:`._scan_integrations`, this method inspects all the
members of a ``module`` for those that match the given ``base_type``
instead of relying on the information provided in the manifest.
It is a bit more inefficient, but it works fine for simple components
(like entities and messages) that don't require extra recursive parsing
logic for their docs (unlike plugins).
"""
for module in self._scan_modules(base_type):
for _, obj_type in inspect.getmembers(module):
if (
inspect.isclass(obj_type)
and issubclass(obj_type, base_type)
# Exclude the base_type itself
and obj_type != base_type
):
self._init_component(
base_type=base_type,
comp_type=obj_type,
model_type=model_type,
index_by_module=True,
)
def _init_integrations(
self,
base_type: Type[Union[Plugin, Backend]],
model_type: Type[Union[PluginModel, BackendModel]],
class_by_name: Callable[[str], Optional[type]],
):
"""
Initializes, parses and caches all the integrations of a given type.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param model_type: The type of the ``Model`` objects that should be
created.
:param class_by_name: A function that returns the class of a given
integration given its qualified name.
"""
for module in self._scan_integrations(base_type):
comp_name = '.'.join(module.__name__.split('.')[2:])
comp_type = class_by_name(comp_name)
if not comp_type:
continue
self._init_component(
base_type=base_type,
comp_type=comp_type,
model_type=model_type,
)
self._flush_components_cache()
def _init_plugins(self): def _init_plugins(self):
package = platypush.plugins """
prefix = package.__name__ + '.' Initializes and caches all the available plugins.
"""
for _, modname, _ in pkgutil.walk_packages( self._init_integrations(
path=package.__path__, prefix=prefix, onerror=lambda _: None base_type=Plugin,
): model_type=PluginModel,
try: class_by_name=get_plugin_class_by_name,
module = importlib.import_module(modname) )
except Exception as e:
self.logger.warning('Could not import module %s: %s', modname, e)
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
def _init_backends(self): def _init_backends(self):
package = platypush.backend """
prefix = package.__name__ + '.' Initializes and caches all the available backends.
"""
for _, modname, _ in pkgutil.walk_packages( self._init_integrations(
path=package.__path__, prefix=prefix, onerror=lambda _: None base_type=Backend,
): model_type=BackendModel,
try: class_by_name=get_backend_class_by_name,
module = importlib.import_module(modname) )
except Exception as e:
self.logger.debug('Could not import module %s: %s', modname, e)
continue
for _, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, Backend):
model = BackendModel(
backend=obj, prefix=prefix, html_doc=self._html_doc
)
if model.name:
self._backends[model.name] = model
def _init_events(self): def _init_events(self):
package = platypush.message.event """
prefix = package.__name__ + '.' Initializes and caches all the available events.
"""
for _, modname, _ in pkgutil.walk_packages( self._init_modules(
path=package.__path__, prefix=prefix, onerror=lambda _: None base_type=Event,
): model_type=EventModel,
try: )
module = importlib.import_module(modname)
except Exception as e:
self.logger.debug('Could not import module %s: %s', modname, e)
continue
for _, obj in inspect.getmembers(module):
if type(obj) == Event: # pylint: disable=unidiomatic-typecheck
continue
if inspect.isclass(obj) and issubclass(obj, Event) and obj != Event:
event = EventModel(
event=obj, html_doc=self._html_doc, prefix=prefix
)
if event.package not in self._events:
self._events[event.package] = {event.name: event}
else:
self._events[event.package][event.name] = event
def _init_responses(self): def _init_responses(self):
package = platypush.message.response """
prefix = package.__name__ + '.' Initializes and caches all the available responses.
"""
self._init_modules(
base_type=Response,
model_type=ResponseModel,
)
for _, modname, _ in pkgutil.walk_packages( def _init_components(self, base_type: type, initializer: Callable[[], None]):
path=package.__path__, prefix=prefix, onerror=lambda _: None """
): Context manager boilerplate for the other ``_init_*`` methods.
try: """
module = importlib.import_module(modname) ctx = self._components_context[base_type]
except Exception as e: with ctx.init_lock:
self.logger.debug('Could not import module %s: %s', modname, e) if not ctx.refreshed.is_set():
continue initializer()
ctx.refreshed.set()
for _, obj in inspect.getmembers(module):
if type(obj) == Response: # pylint: disable=unidiomatic-typecheck
continue
if (
inspect.isclass(obj)
and issubclass(obj, Response)
and obj != Response
):
response = ResponseModel(
response=obj, html_doc=self._html_doc, prefix=prefix
)
if response.package not in self._responses:
self._responses[response.package] = {response.name: response}
else:
self._responses[response.package][response.name] = response
@action @action
def get_all_plugins(self, html_doc: Optional[bool] = None): def get_all_plugins(self):
""" """
:param html_doc: If True then the docstring will be parsed into HTML (default: False) Get information about all the available plugins.
""" """
with self._plugins_lock: self._init_components(Plugin, self._init_plugins)
if not self._plugins or ( return json.dumps(
html_doc is not None and html_doc != self._html_doc {
): get_plugin_name_by_class(cls): dict(plugin)
self._html_doc = html_doc for cls, plugin in self._components_cache.get(Plugin, {}).items()
self._init_plugins() }
)
return json.dumps(
{name: dict(plugin) for name, plugin in self._plugins.items()}
)
@action @action
def get_all_backends(self, html_doc: Optional[bool] = None): def get_all_backends(self):
""" """
:param html_doc: If True then the docstring will be parsed into HTML (default: False) Get information about all the available backends.
""" """
with self._backends_lock: self._init_components(Backend, self._init_backends)
if not self._backends or ( return json.dumps(
html_doc is not None and html_doc != self._html_doc {
): get_backend_name_by_class(cls): dict(backend)
self._html_doc = html_doc for cls, backend in self._components_cache.get(Backend, {}).items()
self._init_backends() }
)
return json.dumps(
{name: dict(backend) for name, backend in self._backends.items()}
)
@action @action
def get_all_events(self, html_doc: Optional[bool] = None): def get_all_events(self):
""" """
:param html_doc: If True then the docstring will be parsed into HTML (default: False) Get information about all the available events.
""" """
with self._events_lock: self._init_components(Event, self._init_events)
if not self._events or ( return json.dumps(
html_doc is not None and html_doc != self._html_doc {
): package: {
self._html_doc = html_doc obj_type.__name__: dict(event_model)
self._init_events() for obj_type, event_model in events.items()
return json.dumps(
{
package: {name: dict(event) for name, event in events.items()}
for package, events in self._events.items()
} }
) for package, events in self._components_cache.get(Event, {}).items()
}
)
@action @action
def get_all_responses(self, html_doc: Optional[bool] = None): def get_all_responses(self):
""" """
:param html_doc: If True then the docstring will be parsed into HTML (default: False) Get information about all the available responses.
""" """
with self._responses_lock: self._init_components(Response, self._init_responses)
if not self._responses or ( return json.dumps(
html_doc is not None and html_doc != self._html_doc {
): package: {
self._html_doc = html_doc obj_type.__name__: dict(response_model)
self._init_responses() for obj_type, response_model in responses.items()
return json.dumps(
{
package: {name: dict(event) for name, event in responses.items()}
for package, responses in self._responses.items()
} }
) for package, responses in self._components_cache.get(
Response, {}
).items()
}
)
@action @action
def get_procedures(self) -> dict: def get_procedures(self) -> dict:
@ -228,8 +379,7 @@ class InspectPlugin(Plugin):
if entry: if entry:
return Config.get(entry) return Config.get(entry)
cfg = Config.get() return Config.get()
return cfg
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -0,0 +1,12 @@
from dataclasses import dataclass, field
import threading
@dataclass
class ComponentContext:
"""
This class is used to store the context of a component type.
"""
init_lock: threading.RLock = field(default_factory=threading.RLock)
refreshed: threading.Event = field(default_factory=threading.Event)

View File

@ -1,123 +1,131 @@
from abc import ABC, abstractmethod
import inspect import inspect
import json import json
import re import re
from typing import Optional from typing import Optional, Type
from platypush.backend import Backend
from platypush.message.event import Event
from platypush.message.response import Response
from platypush.plugins import Plugin
from platypush.utils import get_decorators from platypush.utils import get_decorators
class Model(ABC): class Model:
"""
Base class for component models.
"""
def __init__(
self,
obj_type: type,
name: Optional[str] = None,
doc: Optional[str] = None,
prefix: str = '',
last_modified: Optional[float] = None,
) -> None:
"""
:param obj_type: Type of the component.
:param name: Name of the component.
:param doc: Documentation of the component.
:param last_modified: Last modified timestamp of the component.
"""
self._obj_type = obj_type
self.package = obj_type.__module__[len(prefix) :]
self.name = name or self.package
self.doc = doc or obj_type.__doc__
self.last_modified = last_modified
def __str__(self): def __str__(self):
"""
:return: JSON string representation of the model.
"""
return json.dumps(dict(self), indent=2, sort_keys=True) return json.dumps(dict(self), indent=2, sort_keys=True)
def __repr__(self): def __repr__(self):
"""
:return: JSON string representation of the model.
"""
return json.dumps(dict(self)) return json.dumps(dict(self))
@staticmethod
def to_html(doc):
try:
import docutils.core # type: ignore
except ImportError:
# docutils not found
return doc
return docutils.core.publish_parts(doc, writer_name='html')['html_body']
@abstractmethod
def __iter__(self): def __iter__(self):
raise NotImplementedError() """
Iterator for the model public attributes/values pairs.
"""
class ProcedureEncoder(json.JSONEncoder): for attr in ['name', 'doc']:
def default(self, o):
if callable(o):
return {
'type': 'native_function',
}
return super().default(o)
class BackendModel(Model):
def __init__(self, backend, prefix='', html_doc: Optional[bool] = False):
self.name = backend.__module__[len(prefix) :]
self.html_doc = html_doc
self.doc = (
self.to_html(backend.__doc__)
if html_doc and backend.__doc__
else backend.__doc__
)
def __iter__(self):
for attr in ['name', 'doc', 'html_doc']:
yield attr, getattr(self, attr) yield attr, getattr(self, attr)
# pylint: disable=too-few-public-methods
class BackendModel(Model):
"""
Model for backend components.
"""
def __init__(self, obj_type: Type[Backend], *args, **kwargs):
super().__init__(obj_type, *args, **kwargs)
# pylint: disable=too-few-public-methods
class PluginModel(Model): class PluginModel(Model):
def __init__(self, plugin, prefix='', html_doc: Optional[bool] = False): """
self.name = plugin.__module__[len(prefix) :] Model for plugin components.
self.html_doc = html_doc """
self.doc = (
self.to_html(plugin.__doc__) def __init__(self, obj_type: Type[Plugin], prefix: str = '', **kwargs):
if html_doc and plugin.__doc__ super().__init__(
else plugin.__doc__ obj_type,
name=re.sub(r'\._plugin$', '', obj_type.__module__[len(prefix) :]),
**kwargs,
) )
self.actions = { self.actions = {
action_name: ActionModel( action_name: ActionModel(getattr(obj_type, action_name))
getattr(plugin, action_name), html_doc=html_doc or False for action_name in get_decorators(obj_type, climb_class_hierarchy=True).get(
)
for action_name in get_decorators(plugin, climb_class_hierarchy=True).get(
'action', [] 'action', []
) )
} }
def __iter__(self): def __iter__(self):
for attr in ['name', 'actions', 'doc', 'html_doc']: """
Overrides the default implementation of ``__iter__`` to also include
plugin actions.
"""
for attr in ['name', 'actions', 'doc']:
if attr == 'actions': if attr == 'actions':
# noinspection PyShadowingNames
yield attr, { yield attr, {
name: dict(action) for name, action in self.actions.items() name: dict(action) for name, action in self.actions.items()
}, }
else: else:
yield attr, getattr(self, attr) yield attr, getattr(self, attr)
class EventModel(Model): class EventModel(Model):
def __init__(self, event, prefix='', html_doc: Optional[bool] = False): """
self.package = event.__module__[len(prefix) :] Model for event components.
self.name = event.__name__ """
self.html_doc = html_doc
self.doc = (
self.to_html(event.__doc__) if html_doc and event.__doc__ else event.__doc__
)
def __iter__(self): def __init__(self, obj_type: Type[Event], **kwargs):
for attr in ['name', 'doc', 'html_doc']: super().__init__(obj_type, **kwargs)
yield attr, getattr(self, attr)
class ResponseModel(Model): class ResponseModel(Model):
def __init__(self, response, prefix='', html_doc: Optional[bool] = False): """
self.package = response.__module__[len(prefix) :] Model for response components.
self.name = response.__name__ """
self.html_doc = html_doc
self.doc = (
self.to_html(response.__doc__)
if html_doc and response.__doc__
else response.__doc__
)
def __iter__(self): def __init__(self, obj_type: Type[Response], **kwargs):
for attr in ['name', 'doc', 'html_doc']: super().__init__(obj_type, **kwargs)
yield attr, getattr(self, attr)
class ActionModel(Model): class ActionModel(Model):
# noinspection PyShadowingNames """
def __init__(self, action, html_doc: bool = False): Model for plugin action components.
self.name = action.__name__ """
self.doc, argsdoc = self._parse_docstring(action.__doc__, html_doc=html_doc)
def __init__(self, action, **kwargs):
doc, argsdoc = self._parse_docstring(action.__doc__)
super().__init__(action, name=action.__name__, doc=doc, **kwargs)
self.args = {} self.args = {}
self.has_kwargs = False self.has_kwargs = False
@ -134,7 +142,7 @@ class ActionModel(Model):
} }
@classmethod @classmethod
def _parse_docstring(cls, docstring: str, html_doc: bool = False): def _parse_docstring(cls, docstring: str):
new_docstring = '' new_docstring = ''
params = {} params = {}
cur_param = None cur_param = None
@ -147,11 +155,7 @@ class ActionModel(Model):
m = re.match(r'^\s*:param ([^:]+):\s*(.*)', line) m = re.match(r'^\s*:param ([^:]+):\s*(.*)', line)
if m: if m:
if cur_param: if cur_param:
params[cur_param] = ( params[cur_param] = cur_param_docstring
cls.to_html(cur_param_docstring)
if html_doc
else cur_param_docstring
)
cur_param = m.group(1) cur_param = m.group(1)
cur_param_docstring = m.group(2) cur_param_docstring = m.group(2)
@ -160,11 +164,7 @@ class ActionModel(Model):
else: else:
if cur_param: if cur_param:
if not line.strip(): if not line.strip():
params[cur_param] = ( params[cur_param] = cur_param_docstring
cls.to_html(cur_param_docstring)
if html_doc
else cur_param_docstring
)
cur_param = None cur_param = None
cur_param_docstring = '' cur_param_docstring = ''
else: else:
@ -173,14 +173,9 @@ class ActionModel(Model):
new_docstring += line.rstrip() + '\n' new_docstring += line.rstrip() + '\n'
if cur_param: if cur_param:
params[cur_param] = ( params[cur_param] = cur_param_docstring
cls.to_html(cur_param_docstring) if html_doc else cur_param_docstring
)
return ( return new_docstring.strip(), params
new_docstring.strip() if not html_doc else cls.to_html(new_docstring),
params,
)
def __iter__(self): def __iter__(self):
for attr in ['name', 'args', 'doc', 'has_kwargs']: for attr in ['name', 'args', 'doc', 'has_kwargs']:

View File

@ -0,0 +1,23 @@
import inspect
import json
class ProcedureEncoder(json.JSONEncoder):
"""
Encoder for the Procedure model.
"""
def default(self, o):
if callable(o):
return {
'type': 'native_function',
'module': o.__module__,
'source': inspect.getsourcefile(o),
'args': [
name
for name, arg in inspect.signature(o).parameters.items()
if arg.kind != arg.VAR_KEYWORD
],
}
return super().default(o)

View File

@ -344,5 +344,24 @@ class UtilsPlugin(Plugin):
return plugins return plugins
@action
def rst_to_html(self, text: str):
"""
Utility action to convert RST to HTML.
It is mostly used by the frontend to render the docstring of the
available plugins and actions.
"""
try:
import docutils.core # type: ignore
except ImportError:
self.logger.warning(
"docutils is not installed. "
"Please install docutils to convert RST to HTML."
)
return text
return docutils.core.publish_parts(text, writer_name='html')['html_body']
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -73,6 +73,17 @@ def get_plugin_module_by_name(plugin_name):
return None return None
def get_backend_module_by_name(backend_name):
"""Gets the module of a backend by name (e.g. "backend.http" or "backend.mqtt")"""
module_name = 'platypush.backend.' + backend_name
try:
return importlib.import_module('platypush.backend.' + backend_name)
except ImportError as e:
logger.error('Cannot import %s: %s', module_name, e)
return None
def get_plugin_class_by_name(plugin_name): def get_plugin_class_by_name(plugin_name):
"""Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")""" """Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")"""
@ -110,6 +121,34 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
return '.'.join(class_tokens) return '.'.join(class_tokens)
def get_backend_class_by_name(backend_name: str):
"""Gets the class of a backend by name (e.g. "backend.http" or "backend.mqtt")"""
module = get_backend_module_by_name(backend_name)
if not module:
return
class_name = getattr(
module,
''.join(
[
token.capitalize()
for i, token in enumerate(backend_name.split('.'))
if not (i == 0 and token == 'backend')
]
)
+ 'Backend',
)
try:
return getattr(
module,
''.join([_.capitalize() for _ in backend_name.split('.')]) + 'Backend',
)
except Exception as e:
logger.error('Cannot import class %s: %s', class_name, e)
return None
def get_backend_name_by_class(backend) -> Optional[str]: def get_backend_name_by_class(backend) -> Optional[str]:
"""Gets the common name of a backend (e.g. "http" or "mqtt") given its class.""" """Gets the common name of a backend (e.g. "http" or "mqtt") given its class."""