diff --git a/TouchPortalAPI/sdk_spec.py b/TouchPortalAPI/sdk_spec.py new file mode 100644 index 0000000..08a969c --- /dev/null +++ b/TouchPortalAPI/sdk_spec.py @@ -0,0 +1,131 @@ +""" +Touch Portal API spec tables + +Each lookup table corresponds to a major "collection" in TP SDK, +which are all listed in the TP API Reference page (https://www.touch-portal.com/api/index.php?section=reference) +The tables can be used for generating and/or validating entry.tp files. +Some tables, like for Actions, may contain nested data structures (like Action Data). + +Table attributes: + `v`: minimum TP SDK version + `r`: required true/false + `t`: value type (default is str) + `d`: default value, if any + `c`: optional list of valid value(s) (choices) + `l`: lookup table for child data structures, if any + +TODO: List valid attribute values per SDK version? +""" + +__copyright__ = """ +This file is part of the TouchPortal-API project. +Copyright TouchPortal-API Developers +Copyright (c) 2021 Maxim Paperno +All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +TPSDK_DEFAULT_VERSION = 4 + +TPSDK_ATTRIBS_SETTINGS = { +# key name sdk V required [type(s)] [default value] [valid value list] + 'name': { 'v': 3, 'r': True, 't': str }, + 'type': { 'v': 3, 'r': True, 't': str, 'd': "text", 'c': ["text","number"] }, + 'default': { 'v': 3, 'r': False, 't': str }, + 'maxLength': { 'v': 3, 'r': False, 't': int }, + 'isPassword': { 'v': 3, 'r': False, 't': bool }, + 'minValue': { 'v': 3, 'r': False, 't': int }, + 'maxValue': { 'v': 3, 'r': False, 't': int }, + 'readOnly': { 'v': 3, 'r': False, 't': bool, 'd': False }, +} + +TPSDK_ATTRIBS_STATE = { +# key name sdk V required [type(s)] [default value] [valid value list] + 'id': { 'v': 1, 'r': True, 't': str }, + 'type': { 'v': 1, 'r': True, 't': str, 'd': "text", 'c': ["text","choice"]}, + 'desc': { 'v': 1, 'r': True, 't': str }, + 'default': { 'v': 1, 'r': True, 't': str, 'd': "" }, + 'valueChoices': { 'v': 1, 'r': False, 't': list }, +} + +TPSDK_ATTRIBS_EVENT = { +# key name sdk V required [type(s)] [default value] [valid value list] + 'id': { 'v': 1, 'r': True, 't': str }, + 'name': { 'v': 1, 'r': True, 't': str }, + 'format': { 'v': 1, 'r': True, 't': str }, + 'type': { 'v': 1, 'r': True, 't': str, 'd': "communicate", 'c': ["communicate"] }, + 'valueChoices': { 'v': 1, 'r': True, 't': list, 'd': [] }, + 'valueType': { 'v': 1, 'r': True, 't': str, 'd': "choice", 'c': ["choice"] }, + 'valueStateId': { 'v': 1, 'r': True, 't': str }, +} + +TPSDK_ATTRIBS_ACT_DATA = { +# key name sdk V required [type(s)] [default value] [valid value list] + 'id': { 'v': 1, 'r': True, 't': str }, + 'type': { 'v': 1, 'r': True, 't': str, 'd': "text", 'c': ["text","number","switch","choice","file","folder","color"] }, + 'label': { 'v': 1, 'r': True, 't': str }, + 'default': { 'v': 1, 'r': True, 't': (str,int,float,bool), 'd': "" }, + 'valueChoices': { 'v': 1, 'r': False, 't': list }, + 'extensions': { 'v': 2, 'r': False, 't': list }, + 'allowDecimals': { 'v': 2, 'r': False, 't': bool }, + 'minValue': { 'v': 3, 'r': False, 't': int }, + 'maxValue': { 'v': 3, 'r': False, 't': int } +} + +TPSDK_ATTRIBS_ACTION = { +# key name sdk V required [type(s)] [default value] [valid value list] [lookup table] + 'id': { 'v': 1, 'r': True, 't': str }, + 'name': { 'v': 1, 'r': True, 't': str }, + 'prefix': { 'v': 1, 'r': True, 't': str }, # dynamic default? based on category name? + 'type': { 'v': 1, 'r': True, 't': str, 'd': "communicate", 'c': ["communicate","execute"] }, + 'description': { 'v': 1, 'r': False, 't': str }, + 'format': { 'v': 1, 'r': False, 't': str }, + 'executionType': { 'v': 1, 'r': False, 't': str }, + 'execution_cmd': { 'v': 1, 'r': False, 't': str }, + 'tryInline': { 'v': 1, 'r': False, 't': bool }, + 'hasHoldFunctionality': { 'v': 3, 'r': False, 't': bool }, + 'data': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_ACT_DATA }, +} + +TPSDK_ATTRIBS_CONNECTOR = { +# key name sdk V required [type(s)] [default value] [valid value list] [lookup table] + 'id': { 'v': 4, 'r': True, 't': str }, + 'name': { 'v': 4, 'r': True, 't': str }, + 'format': { 'v': 4, 'r': False, 't': str }, + 'data': { 'v': 4, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_ACT_DATA }, # same data as Actions? TP API docs are still vague +} + +TPSDK_ATTRIBS_CATEGORY = { +# key name sdk V required [type(s)] [lookup table] + 'id': { 'v': 1, 'r': True, 't': str }, # dynamic default id based on plugin id? + 'name': { 'v': 1, 'r': True, 't': str }, # dynamic default based on plugin name? + 'imagepath': { 'v': 1, 'r': False, 't': str }, + 'actions': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_ACTION }, + 'connectors': { 'v': 4, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_CONNECTOR }, + 'states': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_STATE }, + 'events': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_EVENT }, +} + +TPSDK_ATTRIBS_ROOT = { +# key name sdk V required [type(s)] [default value] [valid value list] [lookup table] + 'sdk': { 'v': 1, 'r': True, 't': int, 'd': TPSDK_DEFAULT_VERSION, 'c': [1,2,3,4] }, + 'version': { 'v': 1, 'r': True, 't': int, 'd': 1 }, + 'name': { 'v': 1, 'r': True, 't': str }, + 'id': { 'v': 1, 'r': True, 't': str }, + 'configuration': { 'v': 1, 'r': False, 't': dict }, + 'plugin_start_cmd': { 'v': 1, 'r': False, 't': str }, + 'categories': { 'v': 1, 'r': True, 't': list, 'd': [], 'l': TPSDK_ATTRIBS_CATEGORY }, + 'settings': { 'v': 3, 'r': False, 't': list, 'd': [], 'l': TPSDK_ATTRIBS_SETTINGS }, +} diff --git a/TouchPortalAPI/sdk_tools.py b/TouchPortalAPI/sdk_tools.py new file mode 100644 index 0000000..b0faf44 --- /dev/null +++ b/TouchPortalAPI/sdk_tools.py @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +""" +Touch Portal Python SDK Tools + +Functions: + * Generates an entry.tp definition file for a Touch Portal plugin based + on variables specified in the plugin source code (`generateDefinitionFromScript()`), + from a loaded module (`generateDefinitionFromModule()`) or specified by value (`generateDefinitionFromDeclaration()`). + * Validate an entire plugin definition (entry.tp) file (`validateDefinitionFile()`), + string (`validateDefinitionString()`), or object (`validateDefinitionObject()`). + * Validate an entry.tp attribute value against the minimum + SDK version, value type, value content, etc. (`validateAttribValue()`). + * ... ? + +Command-line Usage: + sdk_tools.py [-h] [-g] [-v] [-o ] [-s] [-i ] [target] + + positional arguments: + target Either a plugin script for `generate` or an entry.tp file for `validate`. Paths are relative to current working directory. Defaults to './main.py' and + './entry.tp' respectively. Use 'stdin' (or '-') to read from input stream instead. + + optional arguments: + -h, --help show this help message and exit + -g, --generate Generate a definition file from plugin script data. This is the default action. + -v, --validate Validate a definition JSON file (entry.tp). If given with `generate` then will validate the generated JSON output. + + Generator arguments: + -o Output file for `generate` action. Default will be a file named 'entry.tp' in the same folder as the input script. Paths are relative to current working + directory. Use 'stdout' (or '-') to print the output to the console/stream instead. + -s, --skip-invalid Skip attributes with invalid values (they will not be included in generated output). Default behavior is to only warn about them. + -i , --indent Indent level (spaces) for generated JSON. Use 0 for only newlines, or -1 for the most compact representation. Default is 2 spaces. + + This script exits with status code -1 (error) if generation or validation produces warning messages about malformed data. + All progress and warning messages are printed to stderr stream. + +TODO/Ideas: + +* Dynamic default values, eg. for action prefix or category id/name (see notes in sdk_spec tables). +* Dynamic ID generation and write-back to plugin at runtime. +* Allow plugin author to set their own defaults. +""" + +__copyright__ = """ +This file is part of the TouchPortal-API project. +Copyright TouchPortal-API Developers +Copyright (c) 2021 Maxim Paperno +All rights reserved. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import sys +import os.path +import importlib.util +import json +from types import ModuleType +from typing import (Union, TextIO) +from re import compile as re_compile + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from sdk_spec import * + +## globals +g_messages = [] # validation reporting +g_seen_ids = {} # for validating unique IDs + + +## Utils + +def getMessages(): + """ Gets a list of messages which may have been generated during generation/validation. + """ + return g_messages + +def clearMessages(): + """ Clears the list of validation warning messages. + Do this before invoking `validateAttribValue()` directly (outside of the other functions provided here). + """ + global g_messages + g_messages.clear() + +def _printMessages(messages:list): + for msg in messages: + _printToErr(msg) + +def _addMessage(msg): + global g_messages + g_messages.append(msg) + + +def _seenIds(): + global g_seen_ids + return g_seen_ids.keys() + +def _addSeenId(id, path): + global g_seen_ids + g_seen_ids[id] = path + +def _clearSeenIds(): + global g_seen_ids + g_seen_ids.clear() + + +def _printToErr(msg): + sys.stderr.write(msg + "\n") + +def _normPath(path): + if not isinstance(path, str): + return path + return os.path.normpath(os.path.join(os.getcwd(), path)) + +def _keyPath(path, key): + return ":".join(filter(None, [path, key])) + +## Generator functions + +def _dictFromItem(item:dict, table:dict, sdk_v:int, path:str="", skip_invalid:bool=False): + ret = {} + if not isinstance(item, dict): + return ret + for k, data in table.items(): + # try get explicit value from item + if (v := item.get(k)) is None: + # try get default value + v = data.get('d') + # check if there is nested data, eg. in an Action + if isinstance(v, dict) and data.get('t') is list: + v = _arrayFromDict(v, data.get('l', {}), sdk_v, path=_keyPath(path, k), skip_invalid=skip_invalid) + # check that the value is valid and add it to the dict if it is + if validateAttribValue(k, v, data, sdk_v, path) or (not skip_invalid and v != None): + ret[k] = v + return ret + + +def _arrayFromDict(d:dict, table:dict, sdk_v:int, category:str=None, path:str="", skip_invalid:bool=False): + ret = [] + if not isinstance(d, dict): + return ret + for key, item in d.items(): + if not category or not (cat := item.get('category')) or cat == category: + ret.append(_dictFromItem(item, table, sdk_v, f"{path}[{key}]", skip_invalid)) + if path in ["actions","connectors"]: + _replaceFormatTokens(ret) + return ret + + +def _replaceFormatTokens(items:list): + for d in items: + if not isinstance(d, dict) or not 'format' in d.keys() or not 'data' in d.keys(): + continue + data_ids = {} + for data in d.get('data'): + if (did := data.get('id')): + data_ids[did.rsplit(".", 1)[-1]] = did + if not data_ids: + continue + fmt = d.get('format') + rx = re_compile(r'\$\[(\w+)\]') + begin = 0 + while (m := rx.search(fmt, begin)): + idx = m.group(1) + if idx in data_ids.keys(): + val = data_ids.get(idx) + elif idx.isdigit() and (i := int(idx) - 1) >= 0 and i < len(data_ids): + val = list(data_ids.values())[i] + else: + begin = m.end() + _addMessage(f"WARNING: Could not find replacement for token '{idx}' in 'format' attribute for element `{d.get('id')}`. The data arry does not contain this name/index.") + continue + # print(m.span(), val) + fmt = fmt[:m.start()] + "{$" + val + "$}" + fmt[m.end():] + begin = m.start() + len(val) + 4 + d['format'] = fmt + + +def generateDefinitionFromScript(script:Union[str, TextIO], skip_invalid:bool=False): + """ + Returns an "entry.tp" Python `dict` which is suitable for direct conversion to JSON format. + + `script` should be a valid python script, either a file path (ending in .py), string, or open file handle (like stdin). + The script should contain "SDK declaration variables" like `TP_PLUGIN_INFO`, `TP_PLUGIN_SETTINGS`, etc. + + Setting `skip_invalid` to `True` will skip attributes with invalid values (they will not be included in generated output). + Default behavior is to only warn about them. + + Note that the script is interpreted (executed), so any actual "business" logic (like connecting to TP) should be in "__main__". + Also note that when using input from a file handle or string, the script's "__file__" attribute is set to the current working + directory and the file name "tp_plugin.py". + + May raise an `ImportError` if the plugin script could not be loaded or is missing required variables. + Use `getMessages()` to check for any warnings/etc which may be generated (eg. from attribute validation). + """ + script_str = "" + if hasattr(script, "read"): + script_str = script.read() + elif not script.endswith(".py"): + script_str = script + + try: + if script_str: + # load from a string + spec = importlib.util.spec_from_loader("plugin", loader=None) + plugin = importlib.util.module_from_spec(spec) + setattr(plugin, "__file__", os.path.join(os.getcwd(), "tp_plugin.py")) + exec(script_str, plugin.__dict__) + else: + # load directly from a file path + spec = importlib.util.spec_from_file_location("plugin", script) + plugin = importlib.util.module_from_spec(spec) + spec.loader.exec_module(plugin) + # print(plugin.TP_PLUGIN_INFO) + except Exception as e: + input_name = "input stream" if script_str else script + raise ImportError(f"ERROR while trying to import plugin code from '{input_name}': {repr(e)}") + return generateDefinitionFromModule(plugin, skip_invalid) + + +def generateDefinitionFromModule(plugin:ModuleType, skip_invalid:bool=False): + """ + Returns an "entry.tp" Python `dict`, which is suitable for direct conversion to JSON format. + `plugin` should be a loaded Python "module" which contains "SDK declaration variables" like + `TP_PLUGIN_INFO`, `TP_PLUGIN_SETTINGS`, etc. From within a plugin script this could be called like: + `generateDefinitionFromModule(sys.modules[__name__])`. + + Setting `skip_invalid` to `True` will skip attributes with invalid values (they will not be included in generated output). + Default behavior is to only warn about them. + + May raise an `ImportError` if the plugin script is missing required variables TP_PLUGIN_INFO and TP_PLUGIN_CATEGORIES. + Use `getMessages()` to check for any warnings/etc which may be generated (eg. from attribute validation). + """ + # Load the "standard SDK declaration variables" from plugin script into local scope + # INFO and CATEGORY are required, rest are optional. + if not (info := getattr(plugin, "TP_PLUGIN_INFO", {})): + raise ImportError(f"ERROR: Could not import required TP_PLUGIN_INFO variable from plugin source.") + if not (cats := getattr(plugin, "TP_PLUGIN_CATEGORIES", {})): + raise ImportError(f"ERROR: Could not import required TP_PLUGIN_CATEGORIES variable from plugin source.") + return generateDefinitionFromDeclaration( + info, cats, + settings = getattr(plugin, "TP_PLUGIN_SETTINGS", {}), + actions = getattr(plugin, "TP_PLUGIN_ACTIONS", {}), + states = getattr(plugin, "TP_PLUGIN_STATES", {}), + events = getattr(plugin, "TP_PLUGIN_EVENTS", {}), + connectors = getattr(plugin, "TP_PLUGIN_CONNECTORS", {}), + skip_invalid = skip_invalid + ) + + +def generateDefinitionFromDeclaration(info:dict, categories:dict, skip_invalid:bool=False, **kwargs): + """ + Returns an "entry.tp" Python `dict` which is suitable for direct conversion to JSON format. + Arguments should contain SDK declaration dict values, for example as specified for `TP_PLUGIN_INFO`, etc. + + The `info` and `category` values are required, the rest are optional. + + Setting `skip_invalid` to `True` will skip attributes with invalid values (they will not be included in generated output). + Default behavior is to only warn about them. + + `kwargs` can be one or more of: + settings:dict={}, + actions:dict={}, + states:dict={}, + events:dict={}, + connectors:dict={} + + Use `getMessages()` to check for any warnings/etc which may be generated (eg. from attribute validation). + """ + _clearSeenIds() + clearMessages() + settings = kwargs.get('settings', {}) + actions = kwargs.get('actions', {}) + states = kwargs.get('states', {}) + events = kwargs.get('events', {}) + connectors = kwargs.get('connectors', {}) + # print(info, categories, settings, actions, states, events, connectors) + + # Start the root entry.tp object using basic plugin metadata + # This will also create an empty `categories` array in the root of the entry. + entry = _dictFromItem(info, TPSDK_ATTRIBS_ROOT, TPSDK_DEFAULT_VERSION, "info") + + # Get the target SDK version (was either specified in plugin or is TPSDK_DEFAULT_VERSION) + tgt_sdk_v = entry['sdk'] + + # Loop over each plugin category and set up actions, states, events, and connectors. + for cat, data in categories.items(): + path = f"category[{cat}]" + category = _dictFromItem(data, TPSDK_ATTRIBS_CATEGORY, tgt_sdk_v, path, skip_invalid) + category['actions'] = _arrayFromDict(actions, TPSDK_ATTRIBS_ACTION, tgt_sdk_v, cat, "actions", skip_invalid) + category['states'] = _arrayFromDict(states, TPSDK_ATTRIBS_STATE, tgt_sdk_v, cat, "states", skip_invalid) + category['events'] = _arrayFromDict(events, TPSDK_ATTRIBS_EVENT, tgt_sdk_v, cat, "events", skip_invalid) + if tgt_sdk_v >= 4: + category['connectors'] = _arrayFromDict(connectors, TPSDK_ATTRIBS_CONNECTOR, tgt_sdk_v, cat, "connectors", skip_invalid) + # add the category to entry's categories array + entry['categories'].append(category) + + # Add Settings to root + if tgt_sdk_v >= 3: + entry['settings'].extend(_arrayFromDict(settings, TPSDK_ATTRIBS_SETTINGS, tgt_sdk_v, path = "settings", skip_invalid = skip_invalid)) + + return entry + + +## Validation functions + +def validateAttribValue(key:str, value, attrib_data:dict, sdk_v:int, path:str=""): + """ + Validates one attribute's value based on provided lookup table and target SDK version. + Returns `False` if any validation fails or `value` is `None`, `True` otherwise. + Error description message(s) can be retrieved with `getMessages()` and cleared with `clearMessages()`. + + Args: + `key` is the attribute name; + `value` is what to validate; + `attrib_data` is the lookup table data for the given key (eg. `TPSDK_ATTRIBS_INFO[key]` ); + `sdk_v` is the TP SDK version being used (for validation). + `path` is just extra information to print before the key name in warning messages (to show where attribute is in the tree). + """ + global g_seen_ids + keypath = _keyPath(path, key) + if value is None: + if attrib_data.get('r'): + _addMessage(f"WARNING: Missing required attribute '{keypath}'.") + return False + if not isinstance(value, (exp_typ := attrib_data.get('t', str))): + _addMessage(f"WARNING: Wrong data type for attribute '{keypath}'. Expected {exp_typ} but got {type(value)}") + return False + if sdk_v < (min_sdk := attrib_data.get('v', sdk_v)): + _addMessage(f"WARNING: Wrong SDK version for attribute '{keypath}'. Minimum is v{min_sdk} but using v{sdk_v}") + return False + if (choices := attrib_data.get('c')) and value not in choices: + _addMessage(f"WARNING: Value error for attribute '{keypath}'. Got '{value}' but expected one of {choices}") + return False + if key == "id": + if not value in _seenIds(): + _addSeenId(value, keypath) + else: + _addMessage(f"WARNING: The ID '{value}' in '{keypath}' is not unique. It was previously seen in '{g_seen_ids.get(value)}'") + return False + return True + +def _validateDefinitionDict(d:dict, table:dict, sdk_v:int, path:str=""): + # iterate over existing attributes to validate them + for k, v in d.items(): + adata = table.get(k) + keypath = _keyPath(path, k) + if not adata: + _addMessage(f"WARNING: Attribute '{keypath}' is unknown.") + continue + if not validateAttribValue(k, v, adata, sdk_v, path): + continue + # print(k, v, type(v)) + if isinstance(v, list) and (ltable := adata.get('l')): + _validateDefinitionArray(v, ltable, sdk_v, keypath) + # iterate over table entries to check if all required attribs are present + for k, data in table.items(): + if data.get('r') and k not in d.keys(): + _addMessage(f"WARNING: Missing required attribute '{_keyPath(path, k)}'.") + +def _validateDefinitionArray(a:list, table:dict, sdk_v:int, path:str=""): + i = 0 + for item in a: + if isinstance(item, dict): + _validateDefinitionDict(item, table, sdk_v, f"{path}[{i:d}]") + else: + _addMessage(f"WARNING: Unable to handle array member '{item}' in '{path}'.") + i += 1 + + +def validateDefinitionObject(data:dict): + """ + Validates a TP plugin definition structure from a Python `dict` object. + `data` is a de-serialzed entry.tp JSON object (eg. json.load('entry.tp')) + Returns `True` if no problems were found, `False` otherwise. + Use `getMessages()` to check for any validation warnings which may be generated. + """ + _clearSeenIds() + clearMessages() + sdk_v = data.get('sdk', TPSDK_DEFAULT_VERSION) + _validateDefinitionDict(data, TPSDK_ATTRIBS_ROOT, sdk_v) + return len(g_messages) == 0 + +def validateDefinitionString(data:str): + """ + Validates a TP plugin definition structure from JSON string. + `data` is an entry.tp JSON string + Returns `True` if no problems were found, `False` otherwise. + Use `getMessages()` to check for any validation warnings which may be generated. + """ + return validateDefinitionObject(json.loads(data)) + +def validateDefinitionFile(file:Union[str, TextIO]): + """ + Validates a TP plugin definition structure from JSON file. + `file` is a valid system path to an entry.tp JSON file _or_ an already-opened file handle (eg. sys.stdin). + Returns `True` if no problems were found, `False` otherwise. + Use `getMessages()` to check for any validation warnings which may be generated. + """ + fh = file + if isinstance(fh, str): + fh = open(file, 'r') + ret = validateDefinitionObject(json.load(fh)) + if fh != file: + fh.close() + return ret + + +## CLI handlers + +def _generateDefinition(script, output_path, indent, skip_invalid:bool=False): + input_name = "input stream" + if isinstance(script, str): + if len(script.split(".")) < 2: + script = script + ".py" + input_name = script + indent = None if indent is None or int(indent) < 0 else indent + + _printToErr(f"Generating plugin definition JSON from '{input_name}'...\n") + entry = generateDefinitionFromScript(script, skip_invalid) + entry_str = json.dumps(entry, indent=indent) + "\n" + valid = True + if (messages := getMessages()): + valid = False + _printMessages(messages) + _printToErr("") + # output + if output_path: + # write it to a file + with open(output_path, "w") as entry_file: + entry_file.write(entry_str) + _printToErr(f"Saved generated JSON to '{output_path}'\n") + else: + # send to stdout + print(entry_str) + _printToErr(f"Finished generating plugin definition JSON from '{input_name}'.\n") + return entry_str, valid + + +def _validateDefinition(entry, as_str=False): + name = entry if isinstance(entry, str) and not as_str else "input stream" + _printToErr(f"Validating '{name}', any errors or warnings will be printed below...\n") + if as_str: + res = validateDefinitionString(entry) + else: + res = validateDefinitionFile(entry) + if res: + _printToErr("No problems found!") + else: + _printMessages(getMessages()) + _printToErr(f"\nFinished validating '{name}'.\n") + return res + + +def main(): + from argparse import ArgumentParser + + parser = ArgumentParser(epilog="This script exits with status code -1 (error) if generation or validation produces warning messages about malformed data. " + "All progress and warning messages are printed to stderr stream.") + parser.add_argument("-g", "--generate", action='store_true', + help="Generate a definition file from plugin script data. This is the default action.") + parser.add_argument("-v", "--validate", action='store_true', + help="Validate a definition JSON file (entry.tp). If given with `generate` then will validate the generated JSON output.") + parser.add_argument("target", metavar="target", nargs="?", default="", + help="Either a plugin script for `generate` or an entry.tp file for `validate`. Paths are relative to current working directory. " + "Defaults to './main.py' and './entry.tp' respectively. Use 'stdin' (or '-') to read from input stream instead. ") + gen_grp = parser.add_argument_group("Generator arguments") + gen_grp.add_argument("-o", metavar="", + help="Output file for `generate` action. Default will be a file named 'entry.tp' in the same folder as the input script. " + "Paths are relative to current working directory. Use 'stdout' (or '-') to print the output to the console/stream instead.") + gen_grp.add_argument("-s", "--skip-invalid", action='store_true', dest="skip_invalid", default=False, + help="Skip attributes with invalid values (they will not be included in generated output). Default behavior is to only warn about them.") + gen_grp.add_argument("-i", "--indent", metavar="", type=int, default=2, + help="Indent level (spaces) for generated JSON. Use 0 for only newlines, or -1 for the most compact representation. Default is %(default)s spaces.") + opts = parser.parse_args() + del parser + + # default action + opts.generate = opts.generate or not opts.validate + + _printToErr("") + + if opts.target in ("-","stdin"): + opts.target = sys.stdin + + valid = True + entry_str = "" + if opts.generate: + opts.target = _normPath(opts.target or "main.py") + output_path = None + if opts.o: + if opts.o not in ("-","stdout"): + output_path = opts.o + else: + out_dir = os.getcwd() if hasattr(opts.target, "read") else os.path.dirname(opts.target) + output_path = os.path.join(out_dir, "entry.tp") + entry_str, valid = _generateDefinition(opts.target, output_path, opts.indent, opts.skip_invalid) + if opts.validate and output_path: + opts.target = output_path + + if opts.validate: + if entry_str: + valid = _validateDefinition(entry_str, True) + else: + opts.target = _normPath(opts.target or "entry.tp") + valid = _validateDefinition(opts.target) + + return 0 if valid else -1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/plugin-example.py b/examples/plugin-example.py new file mode 100644 index 0000000..991e1ae --- /dev/null +++ b/examples/plugin-example.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +''' +Touch Portal Plugin Example +''' + +import sys + +# Load the TP Python API. Note that TouchPortalAPI must be installed (eg. with pip) +# _or_ be in a folder directly below this plugin file. +import TouchPortalAPI as TP + +# imports below are optional, to provide argument parsing and logging functionality +from argparse import ArgumentParser +from logging import (getLogger, Formatter, NullHandler, FileHandler, StreamHandler, DEBUG, INFO, WARNING) + + +# Version string of this plugin (in Python style). +__version__ = "1.0" + +# The unique plugin ID string is used in multiple places. +# It also forms the base for all other ID strings (for states, actions, etc). +PLUGIN_ID = "tp.plugin.example.python" + +## Start Python SDK declarations +# These will be used to generate the entry.tp file, +# and of course can also be used within this plugin's code. +# These could also live in a separate .py file which is then imported +# into your plugin's code, and be used directly to generate the entry.tp JSON. +# +# Some entries have default values (like "type" for a Setting), +# which are commented below and could technically be excluded from this code. +# +# Note that you may add any arbitrary keys/data to these dictionaries +# w/out breaking the generation routine. Only known TP SDK attributes +# (targeting the specified SDK version) will be used in the final entry.tp JSON. +## + +# Basic plugin metadata +TP_PLUGIN_INFO = { + 'sdk': 3, + 'version': int(float(__version__) * 100), # TP only recognizes integer version numbers + 'name': "Touch Portal Plugin Example", + 'id': PLUGIN_ID, + 'configuration': { + 'colorDark': "#25274c", + 'colorLight': "#707ab5" + } +} + +# Setting(s) for this plugin. These could be either for users to +# set, or to persist data between plugin runs (as read-only settings). +TP_PLUGIN_SETTINGS = { + 'example': { + 'name': "Example Setting", + # "text" is the default type and could be omitted here + 'type': "text", + 'default': "Example value", + 'readOnly': False, # this is also the default + 'value': None # we can optionally use the settings struct to hold the current value + }, +} + +# This example only uses one Category for actions/etc., but multiple categories are supported also. +TP_PLUGIN_CATEGORIES = { + "main": { + 'id': PLUGIN_ID + ".main", + 'name' : "Python Examples", + 'imagepath' : "icon-24.png" + } +} + +# Action(s) which this plugin supports. +TP_PLUGIN_ACTIONS = { + 'example': { + # 'category' is optional, if omitted then this action will be added to all, or the only, category(ies) + 'category': "main", + 'id': PLUGIN_ID + ".act.example", + 'name': "Set Example State", + 'prefix': TP_PLUGIN_CATEGORIES['main']['name'], + 'type': "communicate", + # 'format' tokens like $[1] will be replaced in the generated JSON with the corresponding data id wrapped with "{$...$}". + # Numeric token values correspond to the order in which the data items are listed here, while text tokens correspond + # to the last part of a dotted data ID (the part after the last period; letters, numbers, and underscore allowed). + 'format': "Set Example State Text to $[text] and Color to $[2]", + 'data': { + 'text': { + 'id': PLUGIN_ID + ".act.example.data.text", + # "text" is the default type and could be omitted here + 'type': "text", + 'label': "Text", + 'default': "Hello World!" + }, + 'color': { + 'id': PLUGIN_ID + ".act.example.data.color", + 'type': "color", + 'label': "Color", + 'default': "#818181FF" + }, + } + }, +} + +# Plugin static state(s). These are listed in the entry.tp file, +# vs. dynamic states which would be created/removed at runtime. +TP_PLUGIN_STATES = { + 'text': { + # 'category' is optional, if omitted then this state will be added to all, or the only, category(ies) + 'category': "main", + 'id': PLUGIN_ID + ".state.text", + # "text" is the default type and could be omitted here + 'type': "text", + 'desc': "Example State Text", + # we can conveniently use a value here which we already defined above + 'default': TP_PLUGIN_ACTIONS['example']['data']['text']['default'] + }, + 'color': { + 'id': PLUGIN_ID + ".state.color", + 'desc': "Example State Color", + 'default': TP_PLUGIN_ACTIONS['example']['data']['color']['default'] + }, +} + +# Plugin Event(s). +TP_PLUGIN_EVENTS = {} + +## +## End Python SDK declarations + + +# Create the Touch Portal API client. +try: + TPClient = TP.Client( + pluginId = PLUGIN_ID, # required ID of this plugin + sleepPeriod = 0.05, # allow more time than default for other processes + autoClose = True, # automatically disconnect when TP sends "closePlugin" message + checkPluginId = True, # validate destination of messages sent to this plugin + maxWorkers = 4, # run up to 4 event handler threads + updateStatesOnBroadcast = False, # do not spam TP with state updates on every page change + ) +except Exception as e: + sys.exit(f"Could not create TP Client, exiting. Error was:\n{repr(e)}") + +# Crate the (optional) global logger +g_log = getLogger() + +# Settings will be sent by TP upon initial connection to the plugin, +# as well as whenever they change at runtime. This example uses a +# shared function to handle both cases. See also onConnect() and onSettingUpdate() +def handleSettings(settings, on_connect=False): + # the settings array from TP can just be flattened to a single dict, + # from: + # [ {"Setting 1" : "value"}, {"Setting 2" : "value"} ] + # to: + # { "Setting 1" : "value", "Setting 2" : "value" } + settings = { list(settings[i])[0] : list(settings[i].values())[0] for i in range(len(settings)) } + # now we can just get settings, and their values, by name + if (value := settings.get(TP_PLUGIN_SETTINGS['example']['name'])) is not None: + # this example doesn't do anything useful with the setting, just saves it + TP_PLUGIN_SETTINGS['example']['value'] = value + + +## TP Client event handler callbacks + +# Initial connection handler +@TPClient.on(TP.TYPES.onConnect) +def onConnect(data): + g_log.info(f"Connected to TP v{data.get('tpVersionString', '?')}, plugin v{data.get('pluginVersion', '?')}.") + g_log.debug(f"Connection: {data}") + if settings := data.get('settings'): + handleSettings(settings, True) + +# Settings handler +@TPClient.on(TP.TYPES.onSettingUpdate) +def onSettingUpdate(data): + g_log.debug(f"Settings: {data}") + if (settings := data.get('values')): + handleSettings(settings, False) + +# Action handler +@TPClient.on(TP.TYPES.onAction) +def onAction(data): + g_log.debug(f"Action: {data}") + # check that `data` and `actionId` members exist and save them for later use + if not (action_data := data.get('data')) or not (aid := data.get('actionId')): + return + if aid == TP_PLUGIN_ACTIONS['example']['id']: + # set our example State text and color values with the data from this action + text = TP.getActionDataValue(action_data, TP_PLUGIN_ACTIONS['example']['data']['text']) + color = TP.getActionDataValue(action_data, TP_PLUGIN_ACTIONS['example']['data']['color']) + TPClient.stateUpdate(TP_PLUGIN_STATES['text']['id'], text) + TPClient.stateUpdate(TP_PLUGIN_STATES['color']['id'], color) + else: + g_log.warning("Got unknown action ID: " + aid) + +# Shutdown handler +@TPClient.on(TP.TYPES.onShutdown) +def onShutdown(data): + g_log.info('Received shutdown event from TP Client.') + # We do not need to disconnect manually because we used `autoClose = True` + # when constructing TPClient() + # TPClient.disconnect() + +# Error handler +@TPClient.on(TP.TYPES.onError) +def onError(exc): + g_log.error(f'Error in TP Client event handler: {repr(exc)}') + # ... do something ? + +## main + +def main(): + global TPClient, g_log + ret = 0 # sys.exit() value + + # handle CLI arguments + parser = ArgumentParser() + parser.add_argument("-d", action='store_true', + help="Use debug logging.") + parser.add_argument("-w", action='store_true', + help="Only log warnings and errors.") + parser.add_argument("-q", action='store_true', + help="Disable all logging (quiet).") + parser.add_argument("-l", metavar="", + help="Log to this file (default is stdout).") + parser.add_argument("-s", action='store_true', + help="If logging to file, also output to stdout.") + + opts = parser.parse_args() + del parser + + # set up logging + if opts.q: + # no logging at all + g_log.addHandler(NullHandler()) + else: + # set up pretty log formatting (similar to TP format) + fmt = Formatter( + fmt="{asctime:s}.{msecs:03.0f} [{levelname:.1s}] [{filename:s}:{lineno:d}] {message:s}", + datefmt="%H:%M:%S", style="{" + ) + # set the logging level + if opts.d: g_log.setLevel(DEBUG) + elif opts.w: g_log.setLevel(WARNING) + else: g_log.setLevel(INFO) + # set up log destination (file/stdout) + if opts.l: + try: + # note that this will keep appending to any existing log file + fh = FileHandler(str(opts.l)) + fh.setFormatter(fmt) + g_log.addHandler(fh) + except Exception as e: + opts.s = True + print(f"Error while creating file logger, falling back to stdout. {repr(e)}") + if not opts.l or opts.s: + sh = StreamHandler(sys.stdout) + sh.setFormatter(fmt) + g_log.addHandler(sh) + + # ready to go + g_log.info(f"Starting {TP_PLUGIN_INFO['name']} v{__version__} on {sys.platform}.") + + try: + # Connect to Touch Portal desktop application. + # If connection succeeds, this method will not return (blocks) until the client is disconnected. + TPClient.connect() + g_log.info('TP Client closed.') + except KeyboardInterrupt: + g_log.warning("Caught keyboard interrupt, exiting.") + except Exception: + # This will catch and report any critical exceptions in the base TPClient code, + # _not_ exceptions in this plugin's event handlers (use onError(), above, for that). + from traceback import format_exc + g_log.error(f"Exception in TP Client:\n{format_exc()}") + ret = -1 + finally: + # Make sure TP Client is stopped, this will do nothing if it is already disconnected. + TPClient.disconnect() + + # TP disconnected, clean up. + del TPClient + + g_log.info(f"{TP_PLUGIN_INFO['name']} stopped.") + return ret + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.py b/setup.py index 1ae974e..d11e907 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( name='TouchPortal API', - version='1.4', + version='1.5', description='Touch Portal API for Python', long_description=open('README.md').read(), long_description_content_type="text/markdown", @@ -22,11 +22,14 @@ author_email='DamienWeiFen@gmail.com', license='MIT', classifiers=classifiers, - keywords='TouchPortal, API, Plugin', + keywords='TouchPortal, API, Plugin, SDK', packages=['TouchPortalAPI'], python_requires='>=3.8', install_requires=[ 'pyee', 'requests' - ] + ], + entry_points = { + 'console_scripts': ['tppsdk=TouchPortalAPI.sdk_tools:main'] + } )