From 2214b2ac42bb70de4a761ea62c7f40b54445e25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 4 Nov 2015 18:54:51 +0100 Subject: [PATCH 1/5] First steps towards API based CLI commands - "octoprint client" --- src/octoprint/cli/__init__.py | 3 +- src/octoprint/cli/client.py | 172 +++++++++++++++++++++++++++++++ src/octoprint_client/__init__.py | 73 +++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/octoprint/cli/client.py create mode 100644 src/octoprint_client/__init__.py diff --git a/src/octoprint/cli/__init__.py b/src/octoprint/cli/__init__.py index 547a9e59..113f737b 100644 --- a/src/octoprint/cli/__init__.py +++ b/src/octoprint/cli/__init__.py @@ -119,9 +119,10 @@ legacy_options = bulk_options([ from .server import server_commands from .plugins import plugin_commands from .dev import dev_commands +from .client import client_commands @click.group(name="octoprint", invoke_without_command=True, cls=click.CommandCollection, - sources=[server_commands, plugin_commands, dev_commands]) + sources=[server_commands, plugin_commands, dev_commands, client_commands]) @standard_options() @legacy_options @click.version_option(version=octoprint.__version__) diff --git a/src/octoprint/cli/client.py b/src/octoprint/cli/client.py new file mode 100644 index 00000000..c1ce9df0 --- /dev/null +++ b/src/octoprint/cli/client.py @@ -0,0 +1,172 @@ +# coding=utf-8 +from __future__ import absolute_import + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import click +import json + +import octoprint_client + +from octoprint.cli import pass_octoprint_ctx, bulk_options, standard_options +from octoprint import init_settings + + +class JsonStringParamType(click.ParamType): + name = "json" + + def convert(self, value, param, ctx): + try: + return json.loads(value) + except: + self.fail("%s is not a valid json string" % value, param, ctx) + + +@click.group() +def client_commands(): + pass + + +@client_commands.group("client", context_settings=dict(ignore_unknown_options=True)) +@click.option("--host", "-h", type=click.STRING) +@click.option("--port", "-p", type=click.INT) +@click.option("--httpuser", type=click.STRING) +@click.option("--httppass", type=click.STRING) +@click.option("--https", is_flag=True) +@click.option("--prefix", type=click.STRING) +@pass_octoprint_ctx +def client(obj, host, port, httpuser, httppass, https, prefix): + """Basic API client.""" + obj.settings = init_settings(obj.basedir, obj.configfile) + + settings_host = obj.settings.get(["server", "host"]) + settings_port = obj.settings.getInt(["server", "port"]) + settings_apikey = obj.settings.get(["api", "key"]) + + octoprint_client.apikey = settings_apikey + octoprint_client.baseurl = build_base_url(https=https, + httpuser=httpuser, + httppass=httppass, + host=host or settings_host if settings_host != "0.0.0.0" else "127.0.0.1", + port=port or settings_port, + prefix=prefix) + + +def build_base_url(https=False, httpuser=None, httppass=None, host=None, port=None, prefix=None): + protocol = "https" if https else "http" + httpauth = "{}:{}@".format(httpuser, httppass) if httpuser and httppass else "" + host = host if host else "127.0.0.1" + port = ":{}".format(port) if port else ":5000" + prefix = prefix if prefix else "" + + return "{}://{}{}{}{}".format(protocol, httpauth, host, port, prefix) + + +def build_url(obj, path): + return "{}/{}".format(build_base_url(obj), path) + + +def log_response(response, status_code=True, body=True, headers=False): + if status_code: + click.echo("Status Code: {}".format(response.status_code)) + if headers: + for header, value in response.headers.items(): + click.echo("{}: {}".format(header, value)) + click.echo() + if body: + click.echo(response.text) + + +@client.command("get") +@click.argument("path") +def get(path): + """Performs a GET request against the specified server path.""" + r = octoprint_client.get(path) + log_response(r) + + +@client.command("post_json") +@click.argument("path") +@click.argument("data", type=JsonStringParamType()) +def post_json(path, data): + """POSTs JSON data to the specified server path.""" + r = octoprint_client.post_json(path, data) + log_response(r) + + +@client.command("patch_json") +@click.argument("path") +@click.argument("data", type=JsonStringParamType()) +def patch_json(path, data): + """PATCHes JSON data to the specified server path.""" + r = octoprint_client.patch(path, data, encoding="json") + log_response(r) + + +@client.command("post_from_file") +@click.argument("path") +@click.argument("file_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) +@click.option("--json", is_flag=True) +@click.option("--yaml", is_flag=True) +def post_from_file(path, file_path, json_flag, yaml_flag): + """POSTs JSON data to the specified server path.""" + if json_flag or yaml_flag: + if json_flag: + with open(file_path, "rb") as fp: + data = json.load(fp) + else: + import yaml + with open(file_path, "rb") as fp: + data = yaml.safe_load(fp) + + r = octoprint_client.post_json(path, data) + else: + with open(file_path, "rb") as fp: + data = fp.read() + + r = octoprint_client.post(path, data) + + log_response(r) + + +@client.command("command") +@click.argument("path") +@click.argument("command") +@click.option("--str", "-s", "str_params", multiple=True, nargs=2, type=click.Tuple([unicode, unicode])) +@click.option("--int", "-i", "int_params", multiple=True, nargs=2, type=click.Tuple([unicode, int])) +@click.option("--float", "-f", "float_params", multiple=True, nargs=2, type=click.Tuple([unicode, float])) +@click.option("--bool", "-b", "bool_params", multiple=True, nargs=2, type=click.Tuple([unicode, bool])) +def command(path, command, str_params, int_params, float_params, bool_params): + """Sends a JSON command to the specified server path.""" + data = dict() + params = str_params + int_params + float_params + bool_params + for param in params: + data[param[0]] = param[1] + r = octoprint_client.post_command(path, command, parameters=data) + log_response(r, body=False) + + +@client.command("upload") +@click.argument("path") +@click.argument("file_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) +@click.option("--parameter", "-P", "params", multiple=True, nargs=2, type=click.Tuple([unicode, unicode])) +@click.option("--file-name", type=click.STRING) +@click.option("--content-type", type=click.STRING) +def upload(path, file_path, params, file_name, content_type): + """Uploads the specified file to the specified server path.""" + data = dict() + for param in params: + data[param[0]] = param[1] + + r = octoprint_client.upload(path, file_path, parameters=data, file_name=file_name, content_type=content_type) + log_response(r) + + +@client.command("delete") +@click.argument("path") +def delete(path): + """Sends a DELETE request to the specified server path.""" + r = octoprint_client.delete(path) + log_response(r) + diff --git a/src/octoprint_client/__init__.py b/src/octoprint_client/__init__.py new file mode 100644 index 00000000..b91116ed --- /dev/null +++ b/src/octoprint_client/__init__.py @@ -0,0 +1,73 @@ +# coding=utf-8 +from __future__ import absolute_import + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import requests + +apikey = None +baseurl = None + +def prepare_request(method=None, path=None): + url = None + if baseurl: + while path.startswith("/"): + path = path[1:] + url = baseurl + "/" + path + return requests.Request(method=method, url=url, headers={"X-Api-Key": apikey}).prepare() + +def request(method, path, data=None, files=None, encoding=None): + s = requests.Session() + request = prepare_request(method, path) + if data or files: + if encoding == "json": + request.prepare_body(None, None, json=data) + else: + request.prepare_body(data, files=files) + response = s.send(request) + return response + +def get(path): + return request("GET", path) + +def post(path, data, encoding=None): + return request("POST", path, data=data, encoding=encoding) + +def post_json(path, data): + return post(path, data, encoding="json") + +def post_command(path, command, parameters=None): + data = dict(command=command) + if parameters: + data.update(parameters) + return post_json(path, data) + +def upload(path, file_path, parameters=None, file_name=None, content_type=None): + import os + + if not os.path.isfile(file_path): + raise ValueError("{} cannot be uploaded since it is not a file".format(file_path)) + + if file_name is None: + file_name = os.path.basename(file_path) + + with open(file_path, "rb") as fp: + if content_type: + files = dict(file=(file_name, fp, content_type)) + else: + files = dict(file=(file_name, fp)) + + response = request("POST", path, data=parameters, files=files) + + return response + +def delete(path): + return request("DELETE", path) + +def patch(path, data, encoding=None): + return request("PATCH", path, data=data, encoding=encoding) + +def put(path, data, encoding=None): + return request("PUT", path, data=data, encoding=encoding) From affb062c1db41bd62abde7301f7a89a212356550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 5 Nov 2015 17:05:08 +0100 Subject: [PATCH 2/5] Refactored command line client to also include sockjs client Also changed the calling parameters a bit to also allow for GET request parameters. --- src/octoprint/cli/client.py | 31 +-------- src/octoprint_client/__init__.py | 116 +++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 50 deletions(-) diff --git a/src/octoprint/cli/client.py b/src/octoprint/cli/client.py index c1ce9df0..7fe824d2 100644 --- a/src/octoprint/cli/client.py +++ b/src/octoprint/cli/client.py @@ -39,32 +39,7 @@ def client_commands(): def client(obj, host, port, httpuser, httppass, https, prefix): """Basic API client.""" obj.settings = init_settings(obj.basedir, obj.configfile) - - settings_host = obj.settings.get(["server", "host"]) - settings_port = obj.settings.getInt(["server", "port"]) - settings_apikey = obj.settings.get(["api", "key"]) - - octoprint_client.apikey = settings_apikey - octoprint_client.baseurl = build_base_url(https=https, - httpuser=httpuser, - httppass=httppass, - host=host or settings_host if settings_host != "0.0.0.0" else "127.0.0.1", - port=port or settings_port, - prefix=prefix) - - -def build_base_url(https=False, httpuser=None, httppass=None, host=None, port=None, prefix=None): - protocol = "https" if https else "http" - httpauth = "{}:{}@".format(httpuser, httppass) if httpuser and httppass else "" - host = host if host else "127.0.0.1" - port = ":{}".format(port) if port else ":5000" - prefix = prefix if prefix else "" - - return "{}://{}{}{}{}".format(protocol, httpauth, host, port, prefix) - - -def build_url(obj, path): - return "{}/{}".format(build_base_url(obj), path) + octoprint_client.init_client(obj.settings, https=https, httpuser=httpuser, httppass=httppass, host=host, port=port, prefix=prefix) def log_response(response, status_code=True, body=True, headers=False): @@ -143,7 +118,7 @@ def command(path, command, str_params, int_params, float_params, bool_params): params = str_params + int_params + float_params + bool_params for param in params: data[param[0]] = param[1] - r = octoprint_client.post_command(path, command, parameters=data) + r = octoprint_client.post_command(path, command, additional=data) log_response(r, body=False) @@ -159,7 +134,7 @@ def upload(path, file_path, params, file_name, content_type): for param in params: data[param[0]] = param[1] - r = octoprint_client.upload(path, file_path, parameters=data, file_name=file_name, content_type=content_type) + r = octoprint_client.upload(path, file_path, additional=data, file_name=file_name, content_type=content_type) log_response(r) diff --git a/src/octoprint_client/__init__.py b/src/octoprint_client/__init__.py index b91116ed..7134d115 100644 --- a/src/octoprint_client/__init__.py +++ b/src/octoprint_client/__init__.py @@ -10,17 +10,41 @@ import requests apikey = None baseurl = None -def prepare_request(method=None, path=None): +def build_base_url(https=False, httpuser=None, httppass=None, host=None, port=None, prefix=None): + protocol = "https" if https else "http" + httpauth = "{}:{}@".format(httpuser, httppass) if httpuser and httppass else "" + host = host if host else "127.0.0.1" + port = ":{}".format(port) if port else ":5000" + prefix = prefix if prefix else "" + + return "{}://{}{}{}{}".format(protocol, httpauth, host, port, prefix) + + +def init_client(settings, https=False, httpuser=None, httppass=None, host=None, port=None, prefix=None): + settings_host = settings.get(["server", "host"]) + settings_port = settings.getInt(["server", "port"]) + settings_apikey = settings.get(["api", "key"]) + + global apikey, baseurl + apikey = settings_apikey + baseurl = build_base_url(https=https, + httpuser=httpuser, + httppass=httppass, + host=host or settings_host if settings_host != "0.0.0.0" else "127.0.0.1", + port=port or settings_port, + prefix=prefix) + +def prepare_request(method=None, path=None, params=None): url = None if baseurl: while path.startswith("/"): path = path[1:] url = baseurl + "/" + path - return requests.Request(method=method, url=url, headers={"X-Api-Key": apikey}).prepare() + return requests.Request(method=method, url=url, params=params, headers={"X-Api-Key": apikey}).prepare() -def request(method, path, data=None, files=None, encoding=None): +def request(method, path, data=None, files=None, encoding=None, params=None): s = requests.Session() - request = prepare_request(method, path) + request = prepare_request(method, path, params=params) if data or files: if encoding == "json": request.prepare_body(None, None, json=data) @@ -29,22 +53,22 @@ def request(method, path, data=None, files=None, encoding=None): response = s.send(request) return response -def get(path): - return request("GET", path) +def get(path, params=None): + return request("GET", path, params=params) -def post(path, data, encoding=None): - return request("POST", path, data=data, encoding=encoding) +def post(path, data, encoding=None, params=None): + return request("POST", path, data=data, encoding=encoding, params=params) -def post_json(path, data): - return post(path, data, encoding="json") +def post_json(path, data, params=None): + return post(path, data, encoding="json", params=params) -def post_command(path, command, parameters=None): +def post_command(path, command, additional=None): data = dict(command=command) - if parameters: - data.update(parameters) - return post_json(path, data) + if additional: + data.update(additional) + return post_json(path, data, params=params) -def upload(path, file_path, parameters=None, file_name=None, content_type=None): +def upload(path, file_path, additional=None, file_name=None, content_type=None, params=None): import os if not os.path.isfile(file_path): @@ -59,15 +83,63 @@ def upload(path, file_path, parameters=None, file_name=None, content_type=None): else: files = dict(file=(file_name, fp)) - response = request("POST", path, data=parameters, files=files) + response = request("POST", path, data=additional, files=files, params=params) return response -def delete(path): - return request("DELETE", path) +def delete(path, params=None): + return request("DELETE", path, params=params) -def patch(path, data, encoding=None): - return request("PATCH", path, data=data, encoding=encoding) +def patch(path, data, encoding=None, params=None): + return request("PATCH", path, data=data, encoding=encoding, params=params) -def put(path, data, encoding=None): - return request("PUT", path, data=data, encoding=encoding) +def put(path, data, encoding=None, params=None): + return request("PUT", path, data=data, encoding=encoding, params=params) + +def connect_socket(**kwargs): + import uuid + import random + import threading + import websocket + import json + + url = "ws://{}/sockjs/{:0>3d}/{}/websocket".format(baseurl[baseurl.find("//") + 2:], random.randrange(0, stop=999), uuid.uuid4()) + + on_message_cb = kwargs.get("on_message", None) + on_close_cb = kwargs.get("on_close", None) + on_error_cb = kwargs.get("on_error", None) + + def on_message(ws, message): + if not callable(on_message_cb): + return + + if message.startswith(u"a["): + data = json.loads(message[2:-1]) + for msg_type, msg in data.items(): + on_message_cb(ws, msg_type, msg) + + def on_close(ws): + if callable(on_close_cb): + on_close_cb(ws) + + def on_error(ws, error): + if callable(on_error_cb): + on_error_cb(ws, error) + + ws = websocket.WebSocketApp(url, + on_message=on_message, + on_close=on_close, + on_error=on_error) + + class WebSocketThread(threading.Thread): + def __init__(self, ws): + threading.Thread.__init__(self) + self.ws = ws + + def run(self): + self.ws.run_forever() + + thread = WebSocketThread(ws) + thread.start() + + return thread From 169aff4f8fcf2ba99e1cd442dfb15b8319005e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 5 Nov 2015 16:54:47 +0100 Subject: [PATCH 3/5] CLI for the software update plugin --- .../plugins/softwareupdate/__init__.py | 141 +++++++++++++++++- 1 file changed, 139 insertions(+), 2 deletions(-) diff --git a/src/octoprint/plugins/softwareupdate/__init__.py b/src/octoprint/plugins/softwareupdate/__init__.py index 33451e57..a1904963 100644 --- a/src/octoprint/plugins/softwareupdate/__init__.py +++ b/src/octoprint/plugins/softwareupdate/__init__.py @@ -351,7 +351,7 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, @restricted_access def check_for_update(self): if "check" in flask.request.values: - check_targets = map(str.strip, flask.request.values["check"].split(",")) + check_targets = map(lambda x: x.strip(), flask.request.values["check"].split(",")) else: check_targets = None @@ -528,7 +528,8 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, updater_thread.daemon = False updater_thread.start() - return to_be_updated, dict((key, check["displayName"] if "displayName" in check else key) for key, check in checks.items() if key in to_be_updated) + check_data = dict((key, self._populated_check(key, check)["displayName"]) for key, check in checks.items() if key in to_be_updated) + return to_be_updated, check_data def _update_worker(self, checks, check_targets, force): @@ -761,6 +762,137 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, else: raise exceptions.UnknownUpdateType() +def cli_commands(cli_group, pass_octoprint_ctx, *args, **kwargs): + import click + import sys + import requests.exceptions + import octoprint_client as client + + @click.command("check") + @click.option("--force", is_flag=True, help="Ignore the cache for the update check") + @click.argument("targets", nargs=-1) + def check_command(force, targets): + """Check for updates.""" + params = dict(force=force) + if targets: + params["check"] = ",".join(targets) + + client.init_client(cli_group.settings) + r = client.get("plugin/softwareupdate/check", params=params) + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + click.echo("Could not get update information from server, got {}".format(e)) + sys.exit(1) + + data = r.json() + status = data["status"] + information = data["information"] + + lines = [] + octoprint_line = None + for key, info in information.items(): + status_text = "Up to date" + if info["updateAvailable"]: + if info["updatePossible"]: + status_text = "Update available" + else: + status_text = "Update available (manual)" + line = "{}\n\tInstalled: {}\n\tAvailable: {}\n\t=> {}".format(info["displayName"], + info["information"]["local"]["name"], + info["information"]["remote"]["name"], + status_text) + if key == "octoprint": + octoprint_line = line + else: + lines.append(line) + + lines.sort() + if octoprint_line: + lines = [octoprint_line] + lines + + for line in lines: + click.echo(line) + + click.echo() + if status == "current": + click.echo("Everything is up to date") + else: + click.echo("There are updates available!") + + + @click.command("update") + @click.option("--force", is_flag=True, help="Update even if already up to date") + @click.argument("targets", nargs=-1) + def update_command(force, targets): + """Apply updates.""" + client.init_client(cli_group.settings) + + data = dict(force=force) + if targets: + data["check"] = ",".join(targets) + + client.init_client(cli_group.settings) + + def on_message(ws, msg_type, msg): + if msg_type != "plugin" or msg["plugin"] != "softwareupdate": + return + + plugin_message = msg["data"] + if not "type" in plugin_message: + return + + plugin_message_type = plugin_message["type"] + plugin_message_data = plugin_message["data"] + + if plugin_message_type == "updating": + click.echo("Updating {} to {}...".format(plugin_message_data["name"], plugin_message_data["target"])) + + elif plugin_message_type == "update_failed": + click.echo("\t... failed: {}".format(plugin_message_data["reason"])) + + elif plugin_message_type == "loglines" and "loglines" in plugin_message_data: + for entry in plugin_message_data["loglines"]: + prefix = ">>> " if entry["stream"] == "call" else "" + error = entry["stream"] == "stderr" + click.echo("\t{}{}".format(prefix, entry["line"].replace("\n", "\n\t")), err=error) + + elif plugin_message_type == "success" or plugin_message_type == "restart_manually": + results = plugin_message_data["results"] if "results" in plugin_message_data else dict() + if results: + click.echo("The update finished successfully.") + if plugin_message_type == "restart_manually": + click.echo("Please restart the OctoPrint server.") + else: + click.echo("No update necessary") + ws.close() + + elif plugin_message_type == "failure": + click.echo("Error") + ws.close() + + thread = client.connect_socket(on_message=on_message) + + r = client.post_json("plugin/softwareupdate/update", data=data) + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + click.echo("Could not get update information from server, got {}".format(e)) + sys.exit(1) + + data = r.json() + to_be_updated = data["order"] + checks = data["checks"] + click.echo("Update in progress, updating:") + for name in to_be_updated: + click.echo("\t{}".format(name if not name in checks else checks[name])) + + thread.join() + + + return [check_command, update_command] + + __plugin_name__ = "Software Update" __plugin_author__ = "Gina Häußge" __plugin_url__ = "https://github.com/foosel/OctoPrint/wiki/Plugin:-Software-Update" @@ -778,4 +910,9 @@ def __plugin_load__(): util=util ) + global __plugin_hooks__ + __plugin_hooks__ = { + "octoprint.cli.commands": cli_commands + } + From 21520f7fc5d5a1c4d939dd71b82b66a21ec2127f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 16 Nov 2015 16:06:55 +0100 Subject: [PATCH 4/5] Added octoprint client listen command and fixed socket client --- src/octoprint/cli/client.py | 29 +++ src/octoprint_client/__init__.py | 297 ++++++++++++++++++++++++++++--- 2 files changed, 297 insertions(+), 29 deletions(-) diff --git a/src/octoprint/cli/client.py b/src/octoprint/cli/client.py index 7fe824d2..9615ddee 100644 --- a/src/octoprint/cli/client.py +++ b/src/octoprint/cli/client.py @@ -145,3 +145,32 @@ def delete(path): r = octoprint_client.delete(path) log_response(r) + +@client.command("listen") +def listen(): + def on_connect(ws): + click.echo(">>> Connected!") + + def on_close(ws): + click.echo(">>> Connection closed!") + + def on_error(ws, error): + click.echo("!!! Error: {}".format(error)) + + def on_heartbeat(ws): + click.echo("<3") + + def on_message(ws, message_type, message_payload): + click.echo("Message: {}, Payload: {}".format(message_type, json.dumps(message_payload))) + + socket = octoprint_client.connect_socket(on_connect=on_connect, + on_close=on_close, + on_error=on_error, + on_heartbeat=on_heartbeat, + on_message=on_message) + + click.echo(">>> Waiting for client to exit") + try: + socket.wait() + finally: + click.echo(">>> Goodbye...") diff --git a/src/octoprint_client/__init__.py b/src/octoprint_client/__init__.py index 7134d115..77cbfc1e 100644 --- a/src/octoprint_client/__init__.py +++ b/src/octoprint_client/__init__.py @@ -6,10 +6,203 @@ __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms import requests +import time apikey = None baseurl = None + +class SocketTimeout(BaseException): + pass + +class SocketClient(object): + def __init__(self, url, use_ssl=False, daemon=True, **kwargs): + self._url = url + self._use_ssl = use_ssl + self._daemon = daemon + self._ws_kwargs = kwargs + + self._seen_open = False + self._seen_close = False + self._waiting_for_reconnect = False + + self._ws = None + self._thread = None + + # hello world + + def _prepare(self): + """Prepares socket and thread for a new connection.""" + + # close the socket if it's currently open + if self._ws is not None: + try: + self._ws.close() + except: + # we can't handle that in any meaningful way right now + pass + + # prepare a bunch of callback methods + import functools + callbacks = dict() + for callback in ("on_open", "on_message", "on_error", "on_close"): + # now, normally we could just use functools.partial for something like + # this, but websocket does a type check against a python function type + # which the use of partial makes fail, so we have to use a lambda + # wrapper here, and since we need to pick the current value of + # callback from the current scope we need a factory method for that... + def factory(cb): + return lambda *fargs, **fkwargs: functools.partial(self._on_callback, cb)(*fargs, **fkwargs) + callbacks[callback] = factory(callback) + + # initialize socket instance with url and callbacks + import websocket + kwargs = dict(self._ws_kwargs) + kwargs.update(callbacks) + self._ws = websocket.WebSocketApp(self._url, **kwargs) + + # initialize thread + import threading + self._thread = threading.Thread(target=self._on_thread_run) + self._thread.daemon = self._daemon + + def _on_thread_run(self): + """Has the socket run forever (aka until closed...).""" + self._ws.run_forever() + + def _on_callback(self, cb, *args, **kwargs): + """ + Callback for socket events. + + Will call any callback method defined on ``self`` that matches ``cb`` + prefixed with an ``_ws_``, then will call any callback method provided in + the socket keyword arguments (``self._ws_kwargs``) that matches ``cb``. + + Calling args and kwargs will be the ones passed to ``_on_callback``. + + Arguments: + cb (str): the callback type + """ + internal = "_ws_" + cb + if hasattr(self, internal): + cb_func = getattr(self, internal) + if callable(cb_func): + cb_func(*args, **kwargs) + + cb_func = self._ws_kwargs.get(cb, None) + if callable(cb_func): + cb_func(*args, **kwargs) + + def _ws_on_open(self, ws): + """ + Callback for socket on_open event. + + Used only to track active reconnection attempts. + """ + + if not self._waiting_for_reconnect: + return + if ws != self._ws: + return + self._seen_open = True + + def _ws_on_close(self, ws): + """ + Callback for socket on_close event. + + Used only to track active reconnection attempts. + """ + + if not self._waiting_for_reconnect: + return + if ws != self._ws: + return + self._seen_close = True + + def connect(self): + """Connects the socket.""" + self._prepare() + self._thread.start() + + def wait(self, timeout=None): + """Waits for the closing of the socket or the timeout.""" + start = None + + def test_condition(): + if timeout and start and start + timeout > time.time(): + raise SocketTimeout() + + start = time.time() + while self._thread.is_alive(): + test_condition() + self._thread.join(timeout=1.0) + + @property + def is_connected(self): + """Whether the web socket is connected or not.""" + return self._thread and self._ws and self._thread.is_alive() + + def disconnect(self): + """Disconnect the web socket.""" + if self._ws: + self._ws.close() + + def reconnect(self, timeout=None, disconnect=True): + """ + Tries to reconnect to the web socket. + + If timeout is set will try to reconnect over the specified timeout in seconds + and return False if the connection could not be re-established. + + If no timeout is provided, the method will block until the connection could + be re-established. + + If disconnect is set to ``True`` will disconnect the socket explictly + first if it is currently connected. + + Arguments: + timeout (number): timeout in seconds to wait for the reconnect to happen. + disconnect (bool): Whether to disconnect explicitly from the socket if + a connection is currently established (True, default) or not (False). + + Returns: + bool - True if the reconnect was successful, False otherwise. + """ + + self._seen_close = False + self._seen_open = False + self._waiting_for_reconnect = True + + if not self.is_connected: + # not connected, so we are already closed + self._seen_close = True + elif disconnect: + # connected and disconnect is True, so we disconnect + self.disconnect() + + start = None + if timeout: + timeout_condition = lambda: start is not None and time.time() > start + timeout + else: + timeout_condition = lambda: False + + start = time.time() + while not timeout_condition(): + if self._seen_close and self._seen_open: + # we saw a connection close and open, so a reconnect, success! + return True + else: + # try to connect + self.connect() + + # sleep a bit + time.sleep(1.0) + + # if we land here the timeout condition became True without us seeing + # a reconnect, that's a failure + return False + + def build_base_url(https=False, httpuser=None, httppass=None, host=None, port=None, prefix=None): protocol = "https" if https else "http" httpauth = "{}:{}@".format(httpuser, httppass) if httpuser and httppass else "" @@ -21,6 +214,24 @@ def build_base_url(https=False, httpuser=None, httppass=None, host=None, port=No def init_client(settings, https=False, httpuser=None, httppass=None, host=None, port=None, prefix=None): + """ + Initializes the API client with the provided settings. + + Basically a convenience method to set ``apikey`` and ``baseurl`` from settings + and/or command line arguments. + + Arguments: + settings (octoprint.settings.Settings): A :class:`~octoprint.settings.Settings` instance to use + for client configuration + https (bool): Whether to connect via HTTPS (True) or not (False, default) + httpuser (str or None): HTTP Basic Auth username to use. No Basic Auth will be + used if unset. + httppass (str or None): HTTP Basic Auth password to use. No Basic Auth will be + used if unset. + host (str or None): Host to connect to, overrides data from settings if set. + port (int or None): Port to connect to, overrides data from settings if set. + prefix (str or None): Path prefix, overrides data from settings if set. + """ settings_host = settings.get(["server", "host"]) settings_port = settings.getInt(["server", "port"]) settings_apikey = settings.get(["api", "key"]) @@ -28,11 +239,11 @@ def init_client(settings, https=False, httpuser=None, httppass=None, host=None, global apikey, baseurl apikey = settings_apikey baseurl = build_base_url(https=https, - httpuser=httpuser, - httppass=httppass, - host=host or settings_host if settings_host != "0.0.0.0" else "127.0.0.1", - port=port or settings_port, - prefix=prefix) + httpuser=httpuser, + httppass=httppass, + host=host or settings_host if settings_host != "0.0.0.0" else "127.0.0.1", + port=port or settings_port, + prefix=prefix) def prepare_request(method=None, path=None, params=None): url = None @@ -66,7 +277,7 @@ def post_command(path, command, additional=None): data = dict(command=command) if additional: data.update(additional) - return post_json(path, data, params=params) + return post_json(path, data, params=data) def upload(path, file_path, additional=None, file_name=None, content_type=None, params=None): import os @@ -99,24 +310,59 @@ def put(path, data, encoding=None, params=None): def connect_socket(**kwargs): import uuid import random - import threading - import websocket import json - url = "ws://{}/sockjs/{:0>3d}/{}/websocket".format(baseurl[baseurl.find("//") + 2:], random.randrange(0, stop=999), uuid.uuid4()) + # creates websocket URL for SockJS according to + # - http://sockjs.github.io/sockjs-protocol/sockjs-protocol-0.3.3.html#section-37 + # - http://sockjs.github.io/sockjs-protocol/sockjs-protocol-0.3.3.html#section-50 + url = "ws://{}/sockjs/{:0>3d}/{}/websocket".format( + baseurl[baseurl.find("//") + 2:], # host + port + prefix, but no protocol + random.randrange(0, stop=999), # server_id + uuid.uuid4() # session_id + ) + use_ssl = baseurl.startswith("https:") + on_open_cb = kwargs.get("on_open", None) + on_heartbeat_cb = kwargs.get("on_heartbeat", None) on_message_cb = kwargs.get("on_message", None) on_close_cb = kwargs.get("on_close", None) on_error_cb = kwargs.get("on_error", None) + daemon = kwargs.get("daemon", True) def on_message(ws, message): + message_type = message[0] + + if message_type == "h": + # "heartbeat" message + if callable(on_heartbeat_cb): + on_heartbeat_cb(ws) + return + elif message_type == "o": + # "open" message + return + elif message_type == "c": + # "close" message + return + if not callable(on_message_cb): return - if message.startswith(u"a["): - data = json.loads(message[2:-1]) - for msg_type, msg in data.items(): - on_message_cb(ws, msg_type, msg) + message_body = message[1:] + if not message_body: + return + + data = json.loads(message_body) + + if message_type == "m": + data = [data,] + + for d in data: + for internal_type, internal_message in d.items(): + on_message_cb(ws, internal_type, internal_message) + + def on_open(ws): + if callable(on_open_cb): + on_open_cb(ws) def on_close(ws): if callable(on_close_cb): @@ -126,20 +372,13 @@ def connect_socket(**kwargs): if callable(on_error_cb): on_error_cb(ws, error) - ws = websocket.WebSocketApp(url, - on_message=on_message, - on_close=on_close, - on_error=on_error) + socket = SocketClient(url, + use_ssl=use_ssl, + daemon=daemon, + on_open=on_open, + on_message=on_message, + on_close=on_close, + on_error=on_error) + socket.connect() - class WebSocketThread(threading.Thread): - def __init__(self, ws): - threading.Thread.__init__(self) - self.ws = ws - - def run(self): - self.ws.run_forever() - - thread = WebSocketThread(ws) - thread.start() - - return thread + return socket From 46e54b9d3571f21af2dd5e21e0f571a11922bb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 16 Nov 2015 16:07:29 +0100 Subject: [PATCH 5/5] [SWU] First version of working CLI --- .../plugins/softwareupdate/__init__.py | 136 +------------- src/octoprint/plugins/softwareupdate/cli.py | 167 ++++++++++++++++++ 2 files changed, 170 insertions(+), 133 deletions(-) create mode 100644 src/octoprint/plugins/softwareupdate/cli.py diff --git a/src/octoprint/plugins/softwareupdate/__init__.py b/src/octoprint/plugins/softwareupdate/__init__.py index a1904963..fdc23c82 100644 --- a/src/octoprint/plugins/softwareupdate/__init__.py +++ b/src/octoprint/plugins/softwareupdate/__init__.py @@ -16,7 +16,7 @@ import logging import logging.handlers import hashlib -from . import version_checks, updaters, exceptions, util +from . import version_checks, updaters, exceptions, util, cli from octoprint.server.util.flask import restricted_access @@ -381,7 +381,7 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, json_data = flask.request.json if "check" in json_data: - check_targets = map(str.strip, json_data["check"]) + check_targets = map(lambda x: x.strip(), json_data["check"]) else: check_targets = None @@ -762,136 +762,6 @@ class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, else: raise exceptions.UnknownUpdateType() -def cli_commands(cli_group, pass_octoprint_ctx, *args, **kwargs): - import click - import sys - import requests.exceptions - import octoprint_client as client - - @click.command("check") - @click.option("--force", is_flag=True, help="Ignore the cache for the update check") - @click.argument("targets", nargs=-1) - def check_command(force, targets): - """Check for updates.""" - params = dict(force=force) - if targets: - params["check"] = ",".join(targets) - - client.init_client(cli_group.settings) - r = client.get("plugin/softwareupdate/check", params=params) - try: - r.raise_for_status() - except requests.exceptions.HTTPError as e: - click.echo("Could not get update information from server, got {}".format(e)) - sys.exit(1) - - data = r.json() - status = data["status"] - information = data["information"] - - lines = [] - octoprint_line = None - for key, info in information.items(): - status_text = "Up to date" - if info["updateAvailable"]: - if info["updatePossible"]: - status_text = "Update available" - else: - status_text = "Update available (manual)" - line = "{}\n\tInstalled: {}\n\tAvailable: {}\n\t=> {}".format(info["displayName"], - info["information"]["local"]["name"], - info["information"]["remote"]["name"], - status_text) - if key == "octoprint": - octoprint_line = line - else: - lines.append(line) - - lines.sort() - if octoprint_line: - lines = [octoprint_line] + lines - - for line in lines: - click.echo(line) - - click.echo() - if status == "current": - click.echo("Everything is up to date") - else: - click.echo("There are updates available!") - - - @click.command("update") - @click.option("--force", is_flag=True, help="Update even if already up to date") - @click.argument("targets", nargs=-1) - def update_command(force, targets): - """Apply updates.""" - client.init_client(cli_group.settings) - - data = dict(force=force) - if targets: - data["check"] = ",".join(targets) - - client.init_client(cli_group.settings) - - def on_message(ws, msg_type, msg): - if msg_type != "plugin" or msg["plugin"] != "softwareupdate": - return - - plugin_message = msg["data"] - if not "type" in plugin_message: - return - - plugin_message_type = plugin_message["type"] - plugin_message_data = plugin_message["data"] - - if plugin_message_type == "updating": - click.echo("Updating {} to {}...".format(plugin_message_data["name"], plugin_message_data["target"])) - - elif plugin_message_type == "update_failed": - click.echo("\t... failed: {}".format(plugin_message_data["reason"])) - - elif plugin_message_type == "loglines" and "loglines" in plugin_message_data: - for entry in plugin_message_data["loglines"]: - prefix = ">>> " if entry["stream"] == "call" else "" - error = entry["stream"] == "stderr" - click.echo("\t{}{}".format(prefix, entry["line"].replace("\n", "\n\t")), err=error) - - elif plugin_message_type == "success" or plugin_message_type == "restart_manually": - results = plugin_message_data["results"] if "results" in plugin_message_data else dict() - if results: - click.echo("The update finished successfully.") - if plugin_message_type == "restart_manually": - click.echo("Please restart the OctoPrint server.") - else: - click.echo("No update necessary") - ws.close() - - elif plugin_message_type == "failure": - click.echo("Error") - ws.close() - - thread = client.connect_socket(on_message=on_message) - - r = client.post_json("plugin/softwareupdate/update", data=data) - try: - r.raise_for_status() - except requests.exceptions.HTTPError as e: - click.echo("Could not get update information from server, got {}".format(e)) - sys.exit(1) - - data = r.json() - to_be_updated = data["order"] - checks = data["checks"] - click.echo("Update in progress, updating:") - for name in to_be_updated: - click.echo("\t{}".format(name if not name in checks else checks[name])) - - thread.join() - - - return [check_command, update_command] - __plugin_name__ = "Software Update" __plugin_author__ = "Gina Häußge" @@ -912,7 +782,7 @@ def __plugin_load__(): global __plugin_hooks__ __plugin_hooks__ = { - "octoprint.cli.commands": cli_commands + "octoprint.cli.commands": cli.commands } diff --git a/src/octoprint/plugins/softwareupdate/cli.py b/src/octoprint/plugins/softwareupdate/cli.py new file mode 100644 index 00000000..e0554aac --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/cli.py @@ -0,0 +1,167 @@ +# coding=utf-8 +from __future__ import absolute_import + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +def commands(cli_group, pass_octoprint_ctx, *args, **kwargs): + import click + import sys + import requests.exceptions + import octoprint_client as client + + @click.command("check") + @click.option("--force", is_flag=True, help="Ignore the cache for the update check") + @click.argument("targets", nargs=-1) + def check_command(force, targets): + """Check for updates.""" + params = dict(force=force) + if targets: + params["check"] = ",".join(targets) + + client.init_client(cli_group.settings) + r = client.get("plugin/softwareupdate/check", params=params) + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + click.echo("Could not get update information from server, got {}".format(e)) + sys.exit(1) + + data = r.json() + status = data["status"] + information = data["information"] + + lines = [] + octoprint_line = None + for key, info in information.items(): + status_text = "Up to date" + if info["updateAvailable"]: + if info["updatePossible"]: + status_text = "Update available" + else: + status_text = "Update available (manual)" + line = "{}\n\tInstalled: {}\n\tAvailable: {}\n\t=> {}".format(info["displayName"], + info["information"]["local"]["name"], + info["information"]["remote"]["name"], + status_text) + if key == "octoprint": + octoprint_line = line + else: + lines.append(line) + + lines.sort() + if octoprint_line: + lines = [octoprint_line] + lines + + for line in lines: + click.echo(line) + + click.echo() + if status == "current": + click.echo("Everything is up to date") + else: + click.echo("There are updates available!") + + + @click.command("update") + @click.option("--force", is_flag=True, help="Update even if already up to date") + @click.argument("targets", nargs=-1) + def update_command(force, targets): + """Apply updates.""" + + data = dict(force=force) + if targets: + data["check"] = targets + + client.init_client(cli_group.settings) + + flags = dict( + waiting_for_restart=False, + seen_close=False + ) + + def on_message(ws, msg_type, msg): + if msg_type != "plugin" or msg["plugin"] != "softwareupdate": + return + + plugin_message = msg["data"] + if not "type" in plugin_message: + return + + plugin_message_type = plugin_message["type"] + plugin_message_data = plugin_message["data"] + + if plugin_message_type == "updating": + click.echo("Updating {} to {}...".format(plugin_message_data["name"], plugin_message_data["target"])) + + elif plugin_message_type == "update_failed": + click.echo("\t... failed: {}".format(plugin_message_data["reason"])) + + elif plugin_message_type == "loglines" and "loglines" in plugin_message_data: + for entry in plugin_message_data["loglines"]: + prefix = ">>> " if entry["stream"] == "call" else "" + error = entry["stream"] == "stderr" + click.echo("\t{}{}".format(prefix, entry["line"].replace("\n", "\n\t")), err=error) + + elif plugin_message_type == "success" or plugin_message_type == "restart_manually": + results = plugin_message_data["results"] if "results" in plugin_message_data else dict() + if results: + click.echo("The update finished successfully.") + if plugin_message_type == "restart_manually": + click.echo("Please restart the OctoPrint server.") + else: + click.echo("No update necessary") + ws.close() + + elif plugin_message_type == "restarting": + flags["waiting_for_restart"] = True + click.echo("Restarting to apply changes...") + + elif plugin_message_type == "failure": + click.echo("Error") + ws.close() + + def on_open(ws): + if flags["waiting_for_restart"] and flags["seen_close"]: + click.echo(" Reconnected!") + else: + click.echo("Connected to server...") + + def on_close(ws): + if flags["waiting_for_restart"] and flags["seen_close"]: + click.echo(".", nl=False) + else: + flags["seen_close"] = True + click.echo("Disconnected from server...") + + socket = client.connect_socket(on_message=on_message, + on_open=on_open, + on_close=on_close) + + r = client.post_json("plugin/softwareupdate/update", data=data) + try: + r.raise_for_status() + except requests.exceptions.HTTPError as e: + click.echo("Could not get update information from server, got {}".format(e)) + sys.exit(1) + + data = r.json() + to_be_updated = data["order"] + checks = data["checks"] + click.echo("Update in progress, updating:") + for name in to_be_updated: + click.echo("\t{}".format(name if not name in checks else checks[name])) + + socket.wait() + + if flags["waiting_for_restart"]: + if socket.reconnect(timeout=60): + click.echo("The update finished successfully.") + else: + click.echo("The update finished successfully but the server apparently didn't restart as expected.") + click.echo("Please restart the OctoPrint server.") + + return [check_command, update_command] + +