# HG changeset patch # User Goffi # Date 1685710466 -7200 # Node ID 47401850dec65e68beddf18a204b7e487526fe8a # Parent 26b7ed2817da509b73cac9279272023efdca4e54 refactoring: rename `libervia.frontends.jp` to `libervia.cli` diff -r 26b7ed2817da -r 47401850dec6 libervia/backend/core/launcher.py --- a/libervia/backend/core/launcher.py Fri Jun 02 14:12:38 2023 +0200 +++ b/libervia/backend/core/launcher.py Fri Jun 02 14:54:26 2023 +0200 @@ -73,7 +73,7 @@ pid = int(pid_file.read_text()) except Exception as e: print(f"Can't read PID file at {pid_file}: {e}") - # we use the same exit code as DATA_ERROR in jp + # we use the same exit code as DATA_ERROR in CLI frontend sys.exit(17) print(f"Terminating {self.APP_NAME}…") os.kill(pid, signal.SIGTERM) @@ -114,7 +114,7 @@ pid = int(pid_file.read_text()) except Exception as e: print(f"Can't read PID file at {pid_file}: {e}") - # we use the same exit code as DATA_ERROR in jp + # we use the same exit code as DATA_ERROR in CLI frontend sys.exit(17) # we check if there is a process # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314 diff -r 26b7ed2817da -r 47401850dec6 libervia/backend/plugins/plugin_blog_import_dokuwiki.py --- a/libervia/backend/plugins/plugin_blog_import_dokuwiki.py Fri Jun 02 14:12:38 2023 +0200 +++ b/libervia/backend/plugins/plugin_blog_import_dokuwiki.py Fri Jun 02 14:54:26 2023 +0200 @@ -86,9 +86,9 @@ media_repo: URL to the new remote media repository (default: none) limit: maximal number of posts to import (default: 100) -Example of usage (with jp frontend): +Example of usage (with CLI frontend): -jp import dokuwiki -p dave --pwd xxxxxx --connect +li import dokuwiki -p dave --pwd xxxxxx --connect http://127.0.1.1 -o user souliane -o passwd qwertz -o namespace public:2015:10 -o media_repo http://media.diekulturvermittlung.at diff -r 26b7ed2817da -r 47401850dec6 libervia/backend/tools/common/template.py --- a/libervia/backend/tools/common/template.py Fri Jun 02 14:12:38 2023 +0200 +++ b/libervia/backend/tools/common/template.py Fri Jun 02 14:54:26 2023 +0200 @@ -589,7 +589,7 @@ @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir @raise NotFound: requested site has not been found """ - # FIXME: check use in jp, and include site + # FIXME: check use in CLI frontend, and include site site, theme, __ = self.env.loader.parse_template(template) if site is None: # absolute template diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/__init__.py diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/arg_tools.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/arg_tools.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions + + +def escape(arg, smart=True): + """format arg with quotes + + @param smart(bool): if True, only escape if needed + """ + if smart and not " " in arg and not '"' in arg: + return arg + return '"' + arg.replace('"', '\\"') + '"' + + +def get_cmd_choices(cmd=None, parser=None): + try: + choices = parser._subparsers._group_actions[0].choices + return choices[cmd] if cmd is not None else choices + except (KeyError, AttributeError): + raise exceptions.NotFound + + +def get_use_args(host, args, use, verbose=False, parser=None): + """format args for argparse parser with values prefilled + + @param host(LiberviaCli): LiberviaCli instance + @param args(list(str)): arguments to use + @param use(dict[str, str]): arguments to fill if found in parser + @param verbose(bool): if True a message will be displayed when argument is used or not + @param parser(argparse.ArgumentParser): parser to use + @return (tuple[list[str],list[str]]): 2 args lists: + - parser args, i.e. given args corresponding to parsers + - use args, i.e. generated args from use + """ + # FIXME: positional args are not handled correclty + # if there is more that one, the position is not corrected + if parser is None: + parser = host.parser + + # we check not optional args to see if there + # is a corresonding parser + # else USE args would not work correctly (only for current parser) + parser_args = [] + for arg in args: + if arg.startswith("-"): + break + try: + parser = get_cmd_choices(arg, parser) + except exceptions.NotFound: + break + parser_args.append(arg) + + # post_args are remaning given args, + # without the ones corresponding to parsers + post_args = args[len(parser_args) :] + + opt_args = [] + pos_args = [] + actions = {a.dest: a for a in parser._actions} + for arg, value in use.items(): + try: + if arg == "item" and not "item" in actions: + # small hack when --item is appended to a --items list + arg = "items" + action = actions[arg] + except KeyError: + if verbose: + host.disp( + _( + "ignoring {name}={value}, not corresponding to any argument (in USE)" + ).format(name=arg, value=escape(value)) + ) + else: + if verbose: + host.disp( + _("arg {name}={value} (in USE)").format( + name=arg, value=escape(value) + ) + ) + if not action.option_strings: + pos_args.append(value) + else: + opt_args.append(action.option_strings[0]) + opt_args.append(value) + return parser_args, opt_args + pos_args + post_args diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/base.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/base.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,1435 @@ +#!/usr/bin/env python3 + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio +from libervia.backend.core.i18n import _ + +### logging ### +import logging as log +log.basicConfig(level=log.WARNING, + format='[%(name)s] %(message)s') +### + +import sys +import os +import os.path +import argparse +import inspect +import tty +import termios +from pathlib import Path +from glob import iglob +from typing import Optional, Set, Union +from importlib import import_module +from libervia.frontends.tools.jid import JID +from libervia.backend.tools import config +from libervia.backend.tools.common import dynamic_import +from libervia.backend.tools.common import uri +from libervia.backend.tools.common import date_utils +from libervia.backend.tools.common import utils +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.core import exceptions +import libervia.cli +from libervia.cli.loops import QuitException, get_libervia_cli_loop +from libervia.cli.constants import Const as C +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.frontends.tools import misc +import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI +from collections import OrderedDict + +## bridge handling +# we get bridge name from conf and initialise the right class accordingly +main_config = config.parse_main_conf() +bridge_name = config.config_get(main_config, '', 'bridge', 'dbus') +LiberviaCLILoop = get_libervia_cli_loop(bridge_name) + + +try: + import progressbar +except ImportError: + msg = (_('ProgressBar not available, please download it at ' + 'http://pypi.python.org/pypi/progressbar\n' + 'Progress bar deactivated\n--\n')) + print(msg, file=sys.stderr) + progressbar=None + +#consts +DESCRIPTION = """This software is a command line tool for XMPP. +Get the latest version at """ + C.APP_URL + +COPYLEFT = """Copyright (C) 2009-2021 Jérôme Poisson, Adrien Cossa +This program comes with ABSOLUTELY NO WARRANTY; +This is free software, and you are welcome to redistribute it under certain conditions. +""" + +PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s + + +def date_decoder(arg): + return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL) + + +class LiberviaCli: + """ + This class can be use to establish a connection with the + bridge. Moreover, it should manage a main loop. + + To use it, you mainly have to redefine the method run to perform + specify what kind of operation you want to perform. + + """ + def __init__(self): + """ + + @attribute quit_on_progress_end (bool): set to False if you manage yourself + exiting, or if you want the user to stop by himself + @attribute progress_success(callable): method to call when progress just started + by default display a message + @attribute progress_success(callable): method to call when progress is + successfully finished by default display a message + @attribute progress_failure(callable): method to call when progress failed + by default display a message + """ + self.sat_conf = main_config + self.set_color_theme() + bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge') + if bridge_module is None: + log.error("Can't import {} bridge".format(bridge_name)) + sys.exit(1) + + self.bridge = bridge_module.AIOBridge() + self._onQuitCallbacks = [] + + def get_config(self, name, section=C.CONFIG_SECTION, default=None): + """Retrieve a setting value from sat.conf""" + return config.config_get(self.sat_conf, section, name, default=default) + + def guess_background(self): + # cf. https://unix.stackexchange.com/a/245568 (thanks!) + try: + # for VTE based terminals + vte_version = int(os.getenv("VTE_VERSION", 0)) + except ValueError: + vte_version = 0 + + color_fg_bg = os.getenv("COLORFGBG") + + if ((sys.stdin.isatty() and sys.stdout.isatty() + and ( + # XTerm + os.getenv("XTERM_VERSION") + # Konsole + or os.getenv("KONSOLE_VERSION") + # All VTE based terminals + or vte_version >= 3502 + ))): + # ANSI escape sequence + stdin_fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(stdin_fd) + try: + tty.setraw(sys.stdin.fileno()) + # we request background color + sys.stdout.write("\033]11;?\a") + sys.stdout.flush() + expected = "\033]11;rgb:" + for c in expected: + ch = sys.stdin.read(1) + if ch != c: + # background id is not supported, we default to "dark" + # TODO: log something? + return 'dark' + red, green, blue = [ + int(c, 16)/65535 for c in sys.stdin.read(14).split('/') + ] + # '\a' is the last character + sys.stdin.read(1) + finally: + termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings) + + lum = utils.per_luminance(red, green, blue) + if lum <= 0.5: + return 'dark' + else: + return 'light' + elif color_fg_bg: + # no luck with ANSI escape sequence, we try COLORFGBG environment variable + try: + bg = int(color_fg_bg.split(";")[-1]) + except ValueError: + return "dark" + if bg in list(range(7)) + [8]: + return "dark" + else: + return "light" + else: + # no autodetection method found + return "dark" + + def set_color_theme(self): + background = self.get_config('background', default='auto') + if background == 'auto': + background = self.guess_background() + if background not in ('dark', 'light'): + raise exceptions.ConfigError(_( + 'Invalid value set for "background" ({background}), please check ' + 'your settings in libervia.conf').format( + background=repr(background) + )) + self.background = background + if background == 'light': + C.A_HEADER = A.FG_MAGENTA + C.A_SUBHEADER = A.BOLD + A.FG_RED + C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN) + C.A_SUCCESS = A.FG_GREEN + C.A_FAILURE = A.BOLD + A.FG_RED + C.A_WARNING = A.FG_RED + C.A_PROMPT_PATH = A.FG_BLUE + C.A_PROMPT_SUF = A.BOLD + C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA + C.A_FILE = A.FG_BLACK + + def _bridge_connected(self): + self.parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION) + self._make_parents() + self.add_parser_options() + self.subparsers = self.parser.add_subparsers( + title=_('Available commands'), dest='command', required=True) + + # progress attributes + self._progress_id = None # TODO: manage several progress ids + self.quit_on_progress_end = True + + # outputs + self._outputs = {} + for type_ in C.OUTPUT_TYPES: + self._outputs[type_] = OrderedDict() + self.default_output = {} + + self.own_jid = None # must be filled at runtime if needed + + @property + def progress_id(self): + return self._progress_id + + async def set_progress_id(self, progress_id): + # because we use async, we need an explicit setter + self._progress_id = progress_id + await self.replay_cache('progress_ids_cache') + + @property + def watch_progress(self): + try: + self.pbar + except AttributeError: + return False + else: + return True + + @watch_progress.setter + def watch_progress(self, watch_progress): + if watch_progress: + self.pbar = None + + @property + def verbosity(self): + try: + return self.args.verbose + except AttributeError: + return 0 + + async def replay_cache(self, cache_attribute): + """Replay cached signals + + @param cache_attribute(str): name of the attribute containing the cache + if the attribute doesn't exist, there is no cache and the call is ignored + else the cache must be a list of tuples containing the replay callback as + first item, then the arguments to use + """ + try: + cache = getattr(self, cache_attribute) + except AttributeError: + pass + else: + for cache_data in cache: + await cache_data[0](*cache_data[1:]) + + def disp(self, msg, verbosity=0, error=False, end='\n'): + """Print a message to user + + @param msg(unicode): message to print + @param verbosity(int): minimal verbosity to display the message + @param error(bool): if True, print to stderr instead of stdout + @param end(str): string appended after the last value, default a newline + """ + if self.verbosity >= verbosity: + if error: + print(msg, end=end, file=sys.stderr) + else: + print(msg, end=end) + + async def output(self, type_, name, extra_outputs, data): + if name in extra_outputs: + method = extra_outputs[name] + else: + method = self._outputs[type_][name]['callback'] + + ret = method(data) + if inspect.isawaitable(ret): + await ret + + def add_on_quit_callback(self, callback, *args, **kwargs): + """Add a callback which will be called on quit command + + @param callback(callback): method to call + """ + self._onQuitCallbacks.append((callback, args, kwargs)) + + def get_output_choices(self, output_type): + """Return valid output filters for output_type + + @param output_type: True for default, + else can be any registered type + """ + return list(self._outputs[output_type].keys()) + + def _make_parents(self): + self.parents = {} + + # we have a special case here as the start-session option is present only if + # connection is not needed, so we create two similar parents, one with the + # option, the other one without it + for parent_name in ('profile', 'profile_session'): + parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False) + parent.add_argument( + "-p", "--profile", action="store", type=str, default='@DEFAULT@', + help=_("Use PROFILE profile key (default: %(default)s)")) + parent.add_argument( + "--pwd", action="store", metavar='PASSWORD', + help=_("Password used to connect profile, if necessary")) + + profile_parent, profile_session_parent = (self.parents['profile'], + self.parents['profile_session']) + + connect_short, connect_long, connect_action, connect_help = ( + "-c", "--connect", "store_true", + _("Connect the profile before doing anything else") + ) + profile_parent.add_argument( + connect_short, connect_long, action=connect_action, help=connect_help) + + profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group() + profile_session_connect_group.add_argument( + connect_short, connect_long, action=connect_action, help=connect_help) + profile_session_connect_group.add_argument( + "--start-session", action="store_true", + help=_("Start a profile session without connecting")) + + progress_parent = self.parents['progress'] = argparse.ArgumentParser( + add_help=False) + if progressbar: + progress_parent.add_argument( + "-P", "--progress", action="store_true", help=_("Show progress bar")) + + verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False) + verbose_parent.add_argument( + '--verbose', '-v', action='count', default=0, + help=_("Add a verbosity level (can be used multiple times)")) + + quiet_parent = self.parents['quiet'] = argparse.ArgumentParser(add_help=False) + quiet_parent.add_argument( + '--quiet', '-q', action='store_true', + help=_("be quiet (only output machine readable data)")) + + draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False) + draft_group = draft_parent.add_argument_group(_('draft handling')) + draft_group.add_argument( + "-D", "--current", action="store_true", help=_("load current draft")) + draft_group.add_argument( + "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve")) + + + def make_pubsub_group(self, flags, defaults): + """Generate pubsub options according to flags + + @param flags(iterable[unicode]): see [CommandBase.__init__] + @param defaults(dict[unicode, unicode]): help text for default value + key can be "service" or "node" + value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT + @return (ArgumentParser): parser to add + """ + flags = misc.FlagsHandler(flags) + parent = argparse.ArgumentParser(add_help=False) + pubsub_group = parent.add_argument_group('pubsub') + pubsub_group.add_argument("-u", "--pubsub-url", + help=_("Pubsub URL (xmpp or http)")) + + service_help = _("JID of the PubSub service") + if not flags.service: + default = defaults.pop('service', _('PEP service')) + if default is not None: + service_help += _(" (DEFAULT: {default})".format(default=default)) + pubsub_group.add_argument("-s", "--service", default='', + help=service_help) + + node_help = _("node to request") + if not flags.node: + default = defaults.pop('node', _('standard node')) + if default is not None: + node_help += _(" (DEFAULT: {default})".format(default=default)) + pubsub_group.add_argument("-n", "--node", default='', help=node_help) + + if flags.single_item: + item_help = ("item to retrieve") + if not flags.item: + default = defaults.pop('item', _('last item')) + if default is not None: + item_help += _(" (DEFAULT: {default})".format(default=default)) + pubsub_group.add_argument("-i", "--item", default='', + help=item_help) + pubsub_group.add_argument( + "-L", "--last-item", action='store_true', help=_('retrieve last item')) + elif flags.multi_items: + # mutiple items, this activate several features: max-items, RSM, MAM + # and Orbder-by + pubsub_group.add_argument( + "-i", "--item", action='append', dest='items', default=[], + help=_("items to retrieve (DEFAULT: all)")) + if not flags.no_max: + max_group = pubsub_group.add_mutually_exclusive_group() + # XXX: defaut value for --max-items or --max is set in parse_pubsub_args + max_group.add_argument( + "-M", "--max-items", dest="max", type=int, + help=_("maximum number of items to get ({no_limit} to get all items)" + .format(no_limit=C.NO_LIMIT))) + # FIXME: it could be possible to no duplicate max (between pubsub + # max-items and RSM max)should not be duplicated, RSM could be + # used when available and pubsub max otherwise + max_group.add_argument( + "-m", "--max", dest="rsm_max", type=int, + help=_("maximum number of items to get per page (DEFAULT: 10)")) + + # RSM + + rsm_page_group = pubsub_group.add_mutually_exclusive_group() + rsm_page_group.add_argument( + "-a", "--after", dest="rsm_after", + help=_("find page after this item"), metavar='ITEM_ID') + rsm_page_group.add_argument( + "-b", "--before", dest="rsm_before", + help=_("find page before this item"), metavar='ITEM_ID') + rsm_page_group.add_argument( + "--index", dest="rsm_index", type=int, + help=_("index of the first item to retrieve")) + + + # MAM + + pubsub_group.add_argument( + "-f", "--filter", dest='mam_filters', nargs=2, + action='append', default=[], help=_("MAM filters to use"), + metavar=("FILTER_NAME", "VALUE") + ) + + # Order-By + + # TODO: order-by should be a list to handle several levels of ordering + # but this is not yet done in SàT (and not really useful with + # current specifications, as only "creation" and "modification" are + # available) + pubsub_group.add_argument( + "-o", "--order-by", choices=[C.ORDER_BY_CREATION, + C.ORDER_BY_MODIFICATION], + help=_("how items should be ordered")) + + if flags[C.CACHE]: + pubsub_group.add_argument( + "-C", "--no-cache", dest="use_cache", action='store_false', + help=_("don't use Pubsub cache") + ) + + if not flags.all_used: + raise exceptions.InternalError('unknown flags: {flags}'.format( + flags=', '.join(flags.unused))) + if defaults: + raise exceptions.InternalError(f'unused defaults: {defaults}') + + return parent + + def add_parser_options(self): + self.parser.add_argument( + '--version', + action='version', + version=("{name} {version} {copyleft}".format( + name = C.APP_NAME, + version = self.version, + copyleft = COPYLEFT)) + ) + + def register_output(self, type_, name, callback, description="", default=False): + if type_ not in C.OUTPUT_TYPES: + log.error("Invalid output type {}".format(type_)) + return + self._outputs[type_][name] = {'callback': callback, + 'description': description + } + if default: + if type_ in self.default_output: + self.disp( + _('there is already a default output for {type}, ignoring new one') + .format(type=type_) + ) + else: + self.default_output[type_] = name + + + def parse_output_options(self): + options = self.command.args.output_opts + options_dict = {} + for option in options: + try: + key, value = option.split('=', 1) + except ValueError: + key, value = option, None + options_dict[key.strip()] = value.strip() if value is not None else None + return options_dict + + def check_output_options(self, accepted_set, options): + if not accepted_set.issuperset(options): + self.disp( + _("The following output options are invalid: {invalid_options}").format( + invalid_options = ', '.join(set(options).difference(accepted_set))), + error=True) + self.quit(C.EXIT_BAD_ARG) + + def import_plugins(self): + """Automaticaly import commands and outputs in CLI frontend + + looks from modules names cmd_*.py in CLI frontend path and import them + """ + path = os.path.dirname(libervia.cli.__file__) + # XXX: outputs must be imported before commands as they are used for arguments + for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'), + (C.PLUGIN_CMD, 'cmd_*.py')): + modules = ( + os.path.splitext(module)[0] + for module in map(os.path.basename, iglob(os.path.join(path, pattern)))) + for module_name in modules: + module_path = "libervia.cli." + module_name + try: + module = import_module(module_path) + self.import_plugin_module(module, type_) + except ImportError as e: + self.disp( + _("Can't import {module_path} plugin, ignoring it: {e}") + .format(module_path=module_path, e=e), + error=True) + except exceptions.CancelError: + continue + except exceptions.MissingModule as e: + self.disp(_("Missing module for plugin {name}: {missing}".format( + name = module_path, + missing = e)), error=True) + + + def import_plugin_module(self, module, type_): + """add commands or outpus from a module to CLI frontend + + @param module: module containing commands or outputs + @param type_(str): one of C_PLUGIN_* + """ + try: + class_names = getattr(module, '__{}__'.format(type_)) + except AttributeError: + log.disp( + _("Invalid plugin module [{type}] {module}") + .format(type=type_, module=module), + error=True) + raise ImportError + else: + for class_name in class_names: + cls = getattr(module, class_name) + cls(self) + + def get_xmpp_uri_from_http(self, http_url): + """parse HTML page at http(s) URL, and looks for xmpp: uri""" + if http_url.startswith('https'): + scheme = 'https' + elif http_url.startswith('http'): + scheme = 'http' + else: + raise exceptions.InternalError('An HTTP scheme is expected in this method') + self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1) + # HTTP URL, we try to find xmpp: links + try: + from lxml import etree + except ImportError: + self.disp( + "lxml module must be installed to use http(s) scheme, please install it " + "with \"pip install lxml\"", + error=True) + self.quit(1) + import urllib.request, urllib.error, urllib.parse + parser = etree.HTMLParser() + try: + root = etree.parse(urllib.request.urlopen(http_url), parser) + except etree.XMLSyntaxError as e: + self.disp(_("Can't parse HTML page : {msg}").format(msg=e)) + links = [] + else: + links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]") + if not links: + self.disp( + _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP ' + 'PubSub node/item'), + error=True) + self.quit(1) + xmpp_uri = links[0].get('href') + return xmpp_uri + + def parse_pubsub_args(self): + if self.args.pubsub_url is not None: + url = self.args.pubsub_url + + if url.startswith('http'): + # http(s) URL, we try to retrieve xmpp one from there + url = self.get_xmpp_uri_from_http(url) + + try: + uri_data = uri.parse_xmpp_uri(url) + except ValueError: + self.parser.error(_('invalid XMPP URL: {url}').format(url=url)) + else: + if uri_data['type'] == 'pubsub': + # URL is alright, we only set data not already set by other options + if not self.args.service: + self.args.service = uri_data['path'] + if not self.args.node: + self.args.node = uri_data['node'] + uri_item = uri_data.get('item') + if uri_item: + # there is an item in URI + # we use it only if item is not already set + # and item_last is not used either + try: + item = self.args.item + except AttributeError: + try: + items = self.args.items + except AttributeError: + self.disp( + _("item specified in URL but not needed in command, " + "ignoring it"), + error=True) + else: + if not items: + self.args.items = [uri_item] + else: + if not item: + try: + item_last = self.args.item_last + except AttributeError: + item_last = False + if not item_last: + self.args.item = uri_item + else: + self.parser.error( + _('XMPP URL is not a pubsub one: {url}').format(url=url) + ) + flags = self.args._cmd._pubsub_flags + # we check required arguments here instead of using add_arguments' required option + # because the required argument can be set in URL + if C.SERVICE in flags and not self.args.service: + self.parser.error(_("argument -s/--service is required")) + if C.NODE in flags and not self.args.node: + self.parser.error(_("argument -n/--node is required")) + if C.ITEM in flags and not self.args.item: + self.parser.error(_("argument -i/--item is required")) + + # FIXME: mutually groups can't be nested in a group and don't support title + # so we check conflict here. This may be fixed in Python 3, to be checked + try: + if self.args.item and self.args.item_last: + self.parser.error( + _("--item and --item-last can't be used at the same time")) + except AttributeError: + pass + + try: + max_items = self.args.max + rsm_max = self.args.rsm_max + except AttributeError: + pass + else: + # we need to set a default value for max, but we need to know if we want + # to use pubsub's max or RSM's max. The later is used if any RSM or MAM + # argument is set + if max_items is None and rsm_max is None: + to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before', + 'rsm_index') + if any((getattr(self.args, name) for name in to_check)): + # we use RSM + self.args.rsm_max = 10 + else: + # we use pubsub without RSM + self.args.max = 10 + if self.args.max is None: + self.args.max = C.NO_LIMIT + + async def main(self, args, namespace): + try: + await self.bridge.bridge_connect() + except Exception as e: + if isinstance(e, exceptions.BridgeExceptionNoService): + print( + _("Can't connect to Libervia backend, are you sure that it's " + "launched ?") + ) + self.quit(C.EXIT_BACKEND_NOT_FOUND, raise_exc=False) + elif isinstance(e, exceptions.BridgeInitError): + print(_("Can't init bridge")) + self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False) + else: + print( + _("Error while initialising bridge: {e}").format(e=e) + ) + self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False) + return + await self.bridge.ready_get() + self.version = await self.bridge.version_get() + self._bridge_connected() + self.import_plugins() + try: + self.args = self.parser.parse_args(args, namespace=None) + if self.args._cmd._use_pubsub: + self.parse_pubsub_args() + await self.args._cmd.run() + except SystemExit as e: + self.quit(e.code, raise_exc=False) + return + except QuitException: + return + + def _run(self, args=None, namespace=None): + self.loop = LiberviaCLILoop() + self.loop.run(self, args, namespace) + + @classmethod + def run(cls): + cls()._run() + + def _read_stdin(self, stdin_fut): + """Callback called by ainput to read stdin""" + line = sys.stdin.readline() + if line: + stdin_fut.set_result(line.rstrip(os.linesep)) + else: + stdin_fut.set_exception(EOFError()) + + async def ainput(self, msg=''): + """Asynchronous version of buildin "input" function""" + self.disp(msg, end=' ') + sys.stdout.flush() + loop = asyncio.get_running_loop() + stdin_fut = loop.create_future() + loop.add_reader(sys.stdin, self._read_stdin, stdin_fut) + return await stdin_fut + + async def confirm(self, message): + """Request user to confirm action, return answer as boolean""" + res = await self.ainput(f"{message} (y/N)? ") + return res in ("y", "Y") + + async def confirm_or_quit(self, message, cancel_message=_("action cancelled by user")): + """Request user to confirm action, and quit if he doesn't""" + confirmed = await self.confirm(message) + if not confirmed: + self.disp(cancel_message) + self.quit(C.EXIT_USER_CANCELLED) + + def quit_from_signal(self, exit_code=0): + r"""Same as self.quit, but from a signal handler + + /!\: return must be used after calling this method ! + """ + # XXX: python-dbus will show a traceback if we exit in a signal handler + # so we use this little timeout trick to avoid it + self.loop.call_later(0, self.quit, exit_code) + + def quit(self, exit_code=0, raise_exc=True): + """Terminate the execution with specified exit_code + + This will stop the loop. + @param exit_code(int): code to return when quitting the program + @param raise_exp(boolean): if True raise a QuitException to stop code execution + The default value should be used most of time. + """ + # first the onQuitCallbacks + try: + callbacks_list = self._onQuitCallbacks + except AttributeError: + pass + else: + for callback, args, kwargs in callbacks_list: + callback(*args, **kwargs) + + self.loop.quit(exit_code) + if raise_exc: + raise QuitException + + async def check_jids(self, jids): + """Check jids validity, transform roster name to corresponding jids + + @param profile: profile name + @param jids: list of jids + @return: List of jids + + """ + names2jid = {} + nodes2jid = {} + + try: + contacts = await self.bridge.contacts_get(self.profile) + except BridgeException as e: + if e.classname == "AttributeError": + # we may get an AttributeError if we use a component profile + # as components don't have roster + contacts = [] + else: + raise e + + for contact in contacts: + jid_s, attr, groups = contact + _jid = JID(jid_s) + try: + names2jid[attr["name"].lower()] = jid_s + except KeyError: + pass + + if _jid.node: + nodes2jid[_jid.node.lower()] = jid_s + + def expand_jid(jid): + _jid = jid.lower() + if _jid in names2jid: + expanded = names2jid[_jid] + elif _jid in nodes2jid: + expanded = nodes2jid[_jid] + else: + expanded = jid + return expanded + + def check(jid): + if not jid.is_valid: + log.error (_("%s is not a valid JID !"), jid) + self.quit(1) + + dest_jids=[] + try: + for i in range(len(jids)): + dest_jids.append(expand_jid(jids[i])) + check(dest_jids[i]) + except AttributeError: + pass + + return dest_jids + + async def a_pwd_input(self, msg=''): + """Like ainput but with echo disabled (useful for passwords)""" + # we disable echo, code adapted from getpass standard module which has been + # written by Piers Lauder (original), Guido van Rossum (Windows support and + # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks + # to them (and for all the amazing work on Python). + stdin_fd = sys.stdin.fileno() + old = termios.tcgetattr(sys.stdin) + new = old[:] + new[3] &= ~termios.ECHO + tcsetattr_flags = termios.TCSAFLUSH + if hasattr(termios, 'TCSASOFT'): + tcsetattr_flags |= termios.TCSASOFT + try: + termios.tcsetattr(stdin_fd, tcsetattr_flags, new) + pwd = await self.ainput(msg=msg) + finally: + termios.tcsetattr(stdin_fd, tcsetattr_flags, old) + sys.stderr.flush() + self.disp('') + return pwd + + async def connect_or_prompt(self, method, err_msg=None): + """Try to connect/start profile session and prompt for password if needed + + @param method(callable): bridge method to either connect or start profile session + It will be called with password as sole argument, use lambda to do the call + properly + @param err_msg(str): message to show if connection fail + """ + password = self.args.pwd + while True: + try: + await method(password or '') + except Exception as e: + if ((isinstance(e, BridgeException) + and e.classname == 'PasswordError' + and self.args.pwd is None)): + if password is not None: + self.disp(A.color(C.A_WARNING, _("invalid password"))) + password = await self.a_pwd_input( + _("please enter profile password:")) + else: + self.disp(err_msg.format(profile=self.profile, e=e), error=True) + self.quit(C.EXIT_ERROR) + else: + break + + async def connect_profile(self): + """Check if the profile is connected and do it if requested + + @exit: - 1 when profile is not connected and --connect is not set + - 1 when the profile doesn't exists + - 1 when there is a connection error + """ + # FIXME: need better exit codes + + self.profile = await self.bridge.profile_name_get(self.args.profile) + + if not self.profile: + log.error( + _("The profile [{profile}] doesn't exist") + .format(profile=self.args.profile) + ) + self.quit(C.EXIT_ERROR) + + try: + start_session = self.args.start_session + except AttributeError: + pass + else: + if start_session: + await self.connect_or_prompt( + lambda pwd: self.bridge.profile_start_session(pwd, self.profile), + err_msg="Can't start {profile}'s session: {e}" + ) + return + elif not await self.bridge.profile_is_session_started(self.profile): + if not self.args.connect: + self.disp(_( + "Session for [{profile}] is not started, please start it " + "before using libervia-cli, or use either --start-session or " + "--connect option" + .format(profile=self.profile) + ), error=True) + self.quit(1) + elif not getattr(self.args, "connect", False): + return + + + if not hasattr(self.args, 'connect'): + # a profile can be present without connect option (e.g. on profile + # creation/deletion) + return + elif self.args.connect is True: # if connection is asked, we connect the profile + await self.connect_or_prompt( + lambda pwd: self.bridge.connect(self.profile, pwd, {}), + err_msg = 'Can\'t connect profile "{profile!s}": {e}' + ) + return + else: + if not await self.bridge.is_connected(self.profile): + log.error( + _("Profile [{profile}] is not connected, please connect it " + "before using libervia-cli, or use --connect option") + .format(profile=self.profile) + ) + self.quit(1) + + async def get_full_jid(self, param_jid): + """Return the full jid if possible (add main resource when find a bare jid)""" + # TODO: to be removed, bare jid should work with all commands, notably for file + # as backend now handle jingles message initiation + _jid = JID(param_jid) + if not _jid.resource: + #if the resource is not given, we try to add the main resource + main_resource = await self.bridge.main_resource_get(param_jid, self.profile) + if main_resource: + return f"{_jid.bare}/{main_resource}" + return param_jid + + async def get_profile_jid(self): + """Retrieve current profile bare JID if possible""" + full_jid = await self.bridge.param_get_a_async( + "JabberID", "Connection", profile_key=self.profile + ) + return full_jid.rsplit("/", 1)[0] + + +class CommandBase: + + def __init__( + self, + host: LiberviaCli, + name: str, + use_profile: bool = True, + use_output: Union[bool, str] = False, + extra_outputs: Optional[dict] = None, + need_connect: Optional[bool] = None, + help: Optional[str] = None, + **kwargs + ): + """Initialise CommandBase + + @param host: LiberviaCli instance + @param name: name of the new command + @param use_profile: if True, add profile selection/connection commands + @param use_output: if not False, add --output option + @param extra_outputs: list of command specific outputs: + key is output name ("default" to use as main output) + value is a callable which will format the output (data will be used as only + argument) + if a key already exists with normal outputs, the extra one will be used + @param need_connect: True if profile connection is needed + False else (profile session must still be started) + None to set auto value (i.e. True if use_profile is set) + Can't be set if use_profile is False + @param help: help message to display + @param **kwargs: args passed to ArgumentParser + use_* are handled directly, they can be: + - use_progress(bool): if True, add progress bar activation option + progress* signals will be handled + - use_verbose(bool): if True, add verbosity option + - use_pubsub(bool): if True, add pubsub options + mandatory arguments are controlled by pubsub_req + - use_draft(bool): if True, add draft handling options + ** other arguments ** + - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options, + can be: + C.SERVICE: service is required + C.NODE: node is required + C.ITEM: item is required + C.SINGLE_ITEM: only one item is allowed + """ + try: # If we have subcommands, host is a CommandBase and we need to use host.host + self.host = host.host + except AttributeError: + self.host = host + + # --profile option + parents = kwargs.setdefault('parents', set()) + if use_profile: + # self.host.parents['profile'] is an ArgumentParser with profile connection + # arguments + if need_connect is None: + need_connect = True + parents.add( + self.host.parents['profile' if need_connect else 'profile_session']) + else: + assert need_connect is None + self.need_connect = need_connect + # from this point, self.need_connect is None if connection is not needed at all + # False if session starting is needed, and True if full connection is needed + + # --output option + if use_output: + if extra_outputs is None: + extra_outputs = {} + self.extra_outputs = extra_outputs + if use_output == True: + use_output = C.OUTPUT_TEXT + assert use_output in C.OUTPUT_TYPES + self._output_type = use_output + output_parent = argparse.ArgumentParser(add_help=False) + choices = set(self.host.get_output_choices(use_output)) + choices.update(extra_outputs) + if not choices: + raise exceptions.InternalError( + "No choice found for {} output type".format(use_output)) + try: + default = self.host.default_output[use_output] + except KeyError: + if 'default' in choices: + default = 'default' + elif 'simple' in choices: + default = 'simple' + else: + default = list(choices)[0] + output_parent.add_argument( + '--output', '-O', choices=sorted(choices), default=default, + help=_("select output format (default: {})".format(default))) + output_parent.add_argument( + '--output-option', '--oo', action="append", dest='output_opts', + default=[], help=_("output specific option")) + parents.add(output_parent) + else: + assert extra_outputs is None + + self._use_pubsub = kwargs.pop('use_pubsub', False) + if self._use_pubsub: + flags = kwargs.pop('pubsub_flags', []) + defaults = kwargs.pop('pubsub_defaults', {}) + parents.add(self.host.make_pubsub_group(flags, defaults)) + self._pubsub_flags = flags + + # other common options + use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')} + for param, do_use in use_opts.items(): + opt=param[4:] # if param is use_verbose, opt is verbose + if opt not in self.host.parents: + raise exceptions.InternalError("Unknown parent option {}".format(opt)) + del kwargs[param] + if do_use: + parents.add(self.host.parents[opt]) + + self.parser = host.subparsers.add_parser(name, help=help, **kwargs) + if hasattr(self, "subcommands"): + self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True) + else: + self.parser.set_defaults(_cmd=self) + self.add_parser_options() + + @property + def sat_conf(self): + return self.host.sat_conf + + @property + def args(self): + return self.host.args + + @property + def profile(self): + return self.host.profile + + @property + def verbosity(self): + return self.host.verbosity + + @property + def progress_id(self): + return self.host.progress_id + + async def set_progress_id(self, progress_id): + return await self.host.set_progress_id(progress_id) + + async def progress_started_handler(self, uid, metadata, profile): + if profile != self.profile: + return + if self.progress_id is None: + # the progress started message can be received before the id + # so we keep progress_started signals in cache to replay they + # when the progress_id is received + cache_data = (self.progress_started_handler, uid, metadata, profile) + try: + cache = self.host.progress_ids_cache + except AttributeError: + cache = self.host.progress_ids_cache = [] + cache.append(cache_data) + else: + if self.host.watch_progress and uid == self.progress_id: + await self.on_progress_started(metadata) + while True: + await asyncio.sleep(PROGRESS_DELAY) + cont = await self.progress_update() + if not cont: + break + + async def progress_finished_handler(self, uid, metadata, profile): + if profile != self.profile: + return + if uid == self.progress_id: + try: + self.host.pbar.finish() + except AttributeError: + pass + await self.on_progress_finished(metadata) + if self.host.quit_on_progress_end: + self.host.quit_from_signal() + + async def progress_error_handler(self, uid, message, profile): + if profile != self.profile: + return + if uid == self.progress_id: + if self.args.progress: + self.disp('') # progress is not finished, so we skip a line + if self.host.quit_on_progress_end: + await self.on_progress_error(message) + self.host.quit_from_signal(C.EXIT_ERROR) + + async def progress_update(self): + """This method is continualy called to update the progress bar + + @return (bool): False to stop being called + """ + data = await self.host.bridge.progress_get(self.progress_id, self.profile) + if data: + try: + size = data['size'] + except KeyError: + self.disp(_("file size is not known, we can't show a progress bar"), 1, + error=True) + return False + if self.host.pbar is None: + #first answer, we must construct the bar + + # if the instance has a pbar_template attribute, it is used has model, + # else default one is used + # template is a list of part, where part can be either a str to show directly + # or a list where first argument is a name of a progressbar widget, and others + # are used as widget arguments + try: + template = self.pbar_template + except AttributeError: + template = [ + _("Progress: "), ["Percentage"], " ", ["Bar"], " ", + ["FileTransferSpeed"], " ", ["ETA"] + ] + + widgets = [] + for part in template: + if isinstance(part, str): + widgets.append(part) + else: + widget = getattr(progressbar, part.pop(0)) + widgets.append(widget(*part)) + + self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets) + self.host.pbar.start() + + self.host.pbar.update(int(data['position'])) + + elif self.host.pbar is not None: + return False + + await self.on_progress_update(data) + + return True + + async def on_progress_started(self, metadata): + """Called when progress has just started + + can be overidden by a command + @param metadata(dict): metadata as sent by bridge.progress_started + """ + self.disp(_("Operation started"), 2) + + async def on_progress_update(self, metadata): + """Method called on each progress updata + + can be overidden by a command to handle progress metadata + @para metadata(dict): metadata as returned by bridge.progress_get + """ + pass + + async def on_progress_finished(self, metadata): + """Called when progress has just finished + + can be overidden by a command + @param metadata(dict): metadata as sent by bridge.progress_finished + """ + self.disp(_("Operation successfully finished"), 2) + + async def on_progress_error(self, e): + """Called when a progress failed + + @param error_msg(unicode): error message as sent by bridge.progress_error + """ + self.disp(_("Error while doing operation: {e}").format(e=e), error=True) + + def disp(self, msg, verbosity=0, error=False, end='\n'): + return self.host.disp(msg, verbosity, error, end) + + def output(self, data): + try: + output_type = self._output_type + except AttributeError: + raise exceptions.InternalError( + _('trying to use output when use_output has not been set')) + return self.host.output(output_type, self.args.output, self.extra_outputs, data) + + def get_pubsub_extra(self, extra: Optional[dict] = None) -> str: + """Helper method to compute extra data from pubsub arguments + + @param extra: base extra dict, or None to generate a new one + @return: serialised dict which can be used directly in the bridge for pubsub + """ + if extra is None: + extra = {} + else: + intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys())) + if intersection: + raise exceptions.ConflictError( + "given extra dict has conflicting keys with pubsub keys " + "{intersection}".format(intersection=intersection)) + + # RSM + + for attribute in ('max', 'after', 'before', 'index'): + key = 'rsm_' + attribute + if key in extra: + raise exceptions.ConflictError( + "This key already exists in extra: u{key}".format(key=key)) + value = getattr(self.args, key, None) + if value is not None: + extra[key] = str(value) + + # MAM + + if hasattr(self.args, 'mam_filters'): + for key, value in self.args.mam_filters: + key = 'filter_' + key + if key in extra: + raise exceptions.ConflictError( + "This key already exists in extra: u{key}".format(key=key)) + extra[key] = value + + # Order-By + + try: + order_by = self.args.order_by + except AttributeError: + pass + else: + if order_by is not None: + extra[C.KEY_ORDER_BY] = self.args.order_by + + # Cache + try: + use_cache = self.args.use_cache + except AttributeError: + pass + else: + if not use_cache: + extra[C.KEY_USE_CACHE] = use_cache + + return data_format.serialise(extra) + + def add_parser_options(self): + try: + subcommands = self.subcommands + except AttributeError: + # We don't have subcommands, the class need to implements add_parser_options + raise NotImplementedError + + # now we add subcommands to ourself + for cls in subcommands: + cls(self) + + def override_pubsub_flags(self, new_flags: Set[str]) -> None: + """Replace pubsub_flags given in __init__ + + useful when a command is extending an other command (e.g. blog command which does + the same as pubsub command, but with a default node) + """ + self._pubsub_flags = new_flags + + async def run(self): + """this method is called when a command is actually run + + It set stuff like progression callbacks and profile connection + You should not overide this method: you should call self.start instead + """ + # we keep a reference to run command, it may be useful e.g. for outputs + self.host.command = self + + try: + show_progress = self.args.progress + except AttributeError: + # the command doesn't use progress bar + pass + else: + if show_progress: + self.host.watch_progress = True + # we need to register the following signal even if we don't display the + # progress bar + self.host.bridge.register_signal( + "progress_started", self.progress_started_handler) + self.host.bridge.register_signal( + "progress_finished", self.progress_finished_handler) + self.host.bridge.register_signal( + "progress_error", self.progress_error_handler) + + if self.need_connect is not None: + await self.host.connect_profile() + await self.start() + + async def start(self): + """This is the starting point of the command, this method must be overriden + + at this point, profile are connected if needed + """ + raise NotImplementedError + + +class CommandAnswering(CommandBase): + """Specialised commands which answer to specific actions + + to manage action_types answer, + """ + action_callbacks = {} # XXX: set managed action types in a dict here: + # key is the action_type, value is the callable + # which will manage the answer. profile filtering is + # already managed when callback is called + + def __init__(self, *args, **kwargs): + super(CommandAnswering, self).__init__(*args, **kwargs) + + async def on_action_new( + self, + action_data_s: str, + action_id: str, + security_limit: int, + profile: str + ) -> None: + if profile != self.profile: + return + action_data = data_format.deserialise(action_data_s) + try: + action_type = action_data['type'] + except KeyError: + try: + xml_ui = action_data["xmlui"] + except KeyError: + pass + else: + self.on_xmlui(xml_ui) + else: + try: + callback = self.action_callbacks[action_type] + except KeyError: + pass + else: + await callback(action_data, action_id, security_limit, profile) + + def on_xmlui(self, xml_ui): + """Display a dialog received from the backend. + + @param xml_ui (unicode): dialog XML representation + """ + # FIXME: we temporarily use ElementTree, but a real XMLUI managing module + # should be available in the future + # TODO: XMLUI module + ui = ET.fromstring(xml_ui.encode('utf-8')) + dialog = ui.find("dialog") + if dialog is not None: + self.disp(dialog.findtext("message"), error=dialog.get("level") == "error") + + async def start_answering(self): + """Auto reply to confirmation requests""" + self.host.bridge.register_signal("action_new", self.on_action_new) + actions = await self.host.bridge.actions_get(self.profile) + for action_data_s, action_id, security_limit in actions: + await self.on_action_new(action_data_s, action_id, security_limit, self.profile) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_account.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_account.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)""" + +from libervia.cli.constants import Const as C +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.backend.core.log import getLogger +from libervia.backend.core.i18n import _ +from libervia.cli import base +from libervia.frontends.tools import jid + + +log = getLogger(__name__) + +__commands__ = ["Account"] + + +class AccountCreate(base.CommandBase): + def __init__(self, host): + super(AccountCreate, self).__init__( + host, + "create", + use_profile=False, + use_verbose=True, + help=_("create a XMPP account"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "jid", help=_("jid to create") + ) + self.parser.add_argument( + "password", help=_("password of the account") + ) + self.parser.add_argument( + "-p", + "--profile", + help=_( + "create a profile to use this account (default: don't create profile)" + ), + ) + self.parser.add_argument( + "-e", + "--email", + default="", + help=_("email (usage depends of XMPP server)"), + ) + self.parser.add_argument( + "-H", + "--host", + default="", + help=_("server host (IP address or domain, default: use localhost)"), + ) + self.parser.add_argument( + "-P", + "--port", + type=int, + default=0, + help=_("server port (default: {port})").format( + port=C.XMPP_C2S_PORT + ), + ) + + async def start(self): + try: + await self.host.bridge.in_band_account_new( + self.args.jid, + self.args.password, + self.args.email, + self.args.host, + self.args.port, + ) + + except BridgeException as e: + if e.condition == 'conflict': + self.disp( + f"The account {self.args.jid} already exists", + error=True + ) + self.host.quit(C.EXIT_CONFLICT) + else: + self.disp( + f"can't create account on {self.args.host or 'localhost'!r} with jid " + f"{self.args.jid!r} using In-Band Registration: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + + self.disp(_("XMPP account created"), 1) + + if self.args.profile is None: + self.host.quit() + + + self.disp(_("creating profile"), 2) + try: + await self.host.bridge.profile_create( + self.args.profile, + self.args.password, + "", + ) + except BridgeException as e: + if e.condition == 'conflict': + self.disp( + f"The profile {self.args.profile} already exists", + error=True + ) + self.host.quit(C.EXIT_CONFLICT) + else: + self.disp( + _("Can't create profile {profile} to associate with jid " + "{jid}: {e}").format( + profile=self.args.profile, + jid=self.args.jid, + e=e + ), + error=True, + ) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + + self.disp(_("profile created"), 1) + try: + await self.host.bridge.profile_start_session( + self.args.password, + self.args.profile, + ) + except Exception as e: + self.disp(f"can't start profile session: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + try: + await self.host.bridge.param_set( + "JabberID", + self.args.jid, + "Connection", + profile_key=self.args.profile, + ) + except Exception as e: + self.disp(f"can't set JabberID parameter: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + try: + await self.host.bridge.param_set( + "Password", + self.args.password, + "Connection", + profile_key=self.args.profile, + ) + except Exception as e: + self.disp(f"can't set Password parameter: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + self.disp( + f"profile {self.args.profile} successfully created and associated to the new " + f"account", 1) + self.host.quit() + + +class AccountModify(base.CommandBase): + def __init__(self, host): + super(AccountModify, self).__init__( + host, "modify", help=_("change password for XMPP account") + ) + + def add_parser_options(self): + self.parser.add_argument( + "password", help=_("new XMPP password") + ) + + async def start(self): + try: + await self.host.bridge.in_band_password_change( + self.args.password, + self.args.profile, + ) + except Exception as e: + self.disp(f"can't change XMPP password: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class AccountDelete(base.CommandBase): + def __init__(self, host): + super(AccountDelete, self).__init__( + host, "delete", help=_("delete a XMPP account") + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("delete account without confirmation"), + ) + + async def start(self): + try: + jid_str = await self.host.bridge.param_get_a_async( + "JabberID", + "Connection", + profile_key=self.profile, + ) + except Exception as e: + self.disp(f"can't get JID of the profile: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + jid_ = jid.JID(jid_str) + if not self.args.force: + message = ( + f"You are about to delete the XMPP account with jid {jid_!r}\n" + f"This is the XMPP account of profile {self.profile!r}\n" + f"Are you sure that you want to delete this account?" + ) + await self.host.confirm_or_quit(message, _("Account deletion cancelled")) + + try: + await self.host.bridge.in_band_unregister(jid_.domain, self.args.profile) + except Exception as e: + self.disp(f"can't delete XMPP account with jid {jid_!r}: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + self.host.quit() + + +class Account(base.CommandBase): + subcommands = (AccountCreate, AccountModify, AccountDelete) + + def __init__(self, host): + super(Account, self).__init__( + host, "account", use_profile=False, help=("XMPP account management") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_adhoc.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_adhoc.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.cli import xmlui_manager + +__commands__ = ["AdHoc"] + +FLAG_LOOP = "LOOP" +MAGIC_BAREJID = "@PROFILE_BAREJID@" + + +class Remote(base.CommandBase): + def __init__(self, host): + super(Remote, self).__init__( + host, "remote", use_verbose=True, help=_("remote control a software") + ) + + def add_parser_options(self): + self.parser.add_argument("software", type=str, help=_("software name")) + self.parser.add_argument( + "-j", + "--jids", + nargs="*", + default=[], + help=_("jids allowed to use the command"), + ) + self.parser.add_argument( + "-g", + "--groups", + nargs="*", + default=[], + help=_("groups allowed to use the command"), + ) + self.parser.add_argument( + "--forbidden-groups", + nargs="*", + default=[], + help=_("groups that are *NOT* allowed to use the command"), + ) + self.parser.add_argument( + "--forbidden-jids", + nargs="*", + default=[], + help=_("jids that are *NOT* allowed to use the command"), + ) + self.parser.add_argument( + "-l", "--loop", action="store_true", help=_("loop on the commands") + ) + + async def start(self): + name = self.args.software.lower() + flags = [] + magics = {jid for jid in self.args.jids if jid.count("@") > 1} + magics.add(MAGIC_BAREJID) + jids = set(self.args.jids).difference(magics) + if self.args.loop: + flags.append(FLAG_LOOP) + try: + bus_name, methods = await self.host.bridge.ad_hoc_dbus_add_auto( + name, + list(jids), + self.args.groups, + magics, + self.args.forbidden_jids, + self.args.forbidden_groups, + flags, + self.profile, + ) + except Exception as e: + self.disp(f"can't create remote control: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if not bus_name: + self.disp(_("No bus name found"), 1) + self.host.quit(C.EXIT_NOT_FOUND) + else: + self.disp(_("Bus name found: [%s]" % bus_name), 1) + for method in methods: + path, iface, command = method + self.disp( + _("Command found: (path:{path}, iface: {iface}) [{command}]") + .format(path=path, iface=iface, command=command), + 1, + ) + self.host.quit() + + +class Run(base.CommandBase): + """Run an Ad-Hoc command""" + + def __init__(self, host): + super(Run, self).__init__( + host, "run", use_verbose=True, help=_("run an Ad-Hoc command") + ) + + def add_parser_options(self): + self.parser.add_argument( + "-j", + "--jid", + default="", + help=_("jid of the service (default: profile's server"), + ) + self.parser.add_argument( + "-S", + "--submit", + action="append_const", + const=xmlui_manager.SUBMIT, + dest="workflow", + help=_("submit form/page"), + ) + self.parser.add_argument( + "-f", + "--field", + action="append", + nargs=2, + dest="workflow", + metavar=("KEY", "VALUE"), + help=_("field value"), + ) + self.parser.add_argument( + "node", + nargs="?", + default="", + help=_("node of the command (default: list commands)"), + ) + + async def start(self): + try: + xmlui_raw = await self.host.bridge.ad_hoc_run( + self.args.jid, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't get ad-hoc commands list: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + xmlui = xmlui_manager.create(self.host, xmlui_raw) + workflow = self.args.workflow + await xmlui.show(workflow) + if not workflow: + if xmlui.type == "form": + await xmlui.submit_form() + self.host.quit() + + +class List(base.CommandBase): + """List Ad-Hoc commands available on a service""" + + def __init__(self, host): + super(List, self).__init__( + host, "list", use_verbose=True, help=_("list Ad-Hoc commands of a service") + ) + + def add_parser_options(self): + self.parser.add_argument( + "-j", + "--jid", + default="", + help=_("jid of the service (default: profile's server)"), + ) + + async def start(self): + try: + xmlui_raw = await self.host.bridge.ad_hoc_list( + self.args.jid, + self.profile, + ) + except Exception as e: + self.disp(f"can't get ad-hoc commands list: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + xmlui = xmlui_manager.create(self.host, xmlui_raw) + await xmlui.show(read_only=True) + self.host.quit() + + +class AdHoc(base.CommandBase): + subcommands = (Run, List, Remote) + + def __init__(self, host): + super(AdHoc, self).__init__( + host, "ad-hoc", use_profile=False, help=_("Ad-hoc commands") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_application.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_application.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from . import base +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli.constants import Const as C + +__commands__ = ["Application"] + + +class List(base.CommandBase): + """List available applications""" + + def __init__(self, host): + super(List, self).__init__( + host, "list", use_profile=False, use_output=C.OUTPUT_LIST, + help=_("list available applications") + ) + + def add_parser_options(self): + # FIXME: "extend" would be better here, but it's only available from Python 3.8+ + # so we use "append" until minimum version of Python is raised. + self.parser.add_argument( + "-f", + "--filter", + dest="filters", + action="append", + choices=["available", "running"], + help=_("show applications with this status"), + ) + + async def start(self): + + # FIXME: this is only needed because we can't use "extend" in + # add_parser_options, see note there + if self.args.filters: + self.args.filters = list(set(self.args.filters)) + else: + self.args.filters = ['available'] + + try: + found_apps = await self.host.bridge.applications_list(self.args.filters) + except Exception as e: + self.disp(f"can't get applications list: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(found_apps) + self.host.quit() + + +class Start(base.CommandBase): + """Start an application""" + + def __init__(self, host): + super(Start, self).__init__( + host, "start", use_profile=False, help=_("start an application") + ) + + def add_parser_options(self): + self.parser.add_argument( + "name", + help=_("name of the application to start"), + ) + + async def start(self): + try: + await self.host.bridge.application_start( + self.args.name, + "", + ) + except Exception as e: + self.disp(f"can't start {self.args.name}: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class Stop(base.CommandBase): + + def __init__(self, host): + super(Stop, self).__init__( + host, "stop", use_profile=False, help=_("stop a running application") + ) + + def add_parser_options(self): + id_group = self.parser.add_mutually_exclusive_group(required=True) + id_group.add_argument( + "name", + nargs="?", + help=_("name of the application to stop"), + ) + id_group.add_argument( + "-i", + "--id", + help=_("identifier of the instance to stop"), + ) + + async def start(self): + try: + if self.args.name is not None: + args = [self.args.name, "name"] + else: + args = [self.args.id, "instance"] + await self.host.bridge.application_stop( + *args, + "", + ) + except Exception as e: + if self.args.name is not None: + self.disp( + f"can't stop application {self.args.name!r}: {e}", error=True) + else: + self.disp( + f"can't stop application instance with id {self.args.id!r}: {e}", + error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class Exposed(base.CommandBase): + + def __init__(self, host): + super(Exposed, self).__init__( + host, "exposed", use_profile=False, use_output=C.OUTPUT_DICT, + help=_("show data exposed by a running application") + ) + + def add_parser_options(self): + id_group = self.parser.add_mutually_exclusive_group(required=True) + id_group.add_argument( + "name", + nargs="?", + help=_("name of the application to check"), + ) + id_group.add_argument( + "-i", + "--id", + help=_("identifier of the instance to check"), + ) + + async def start(self): + try: + if self.args.name is not None: + args = [self.args.name, "name"] + else: + args = [self.args.id, "instance"] + exposed_data_raw = await self.host.bridge.application_exposed_get( + *args, + "", + ) + except Exception as e: + if self.args.name is not None: + self.disp( + f"can't get values exposed from application {self.args.name!r}: {e}", + error=True) + else: + self.disp( + f"can't values exposed from application instance with id {self.args.id!r}: {e}", + error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + exposed_data = data_format.deserialise(exposed_data_raw) + await self.output(exposed_data) + self.host.quit() + + +class Application(base.CommandBase): + subcommands = (List, Start, Stop, Exposed) + + def __init__(self, host): + super(Application, self).__init__( + host, "application", use_profile=False, help=_("manage applications"), + aliases=['app'], + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_avatar.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_avatar.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import os +import os.path +import asyncio +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.backend.tools import config +from libervia.backend.tools.common import data_format + + +__commands__ = ["Avatar"] +DISPLAY_CMD = ["xdg-open", "xv", "display", "gwenview", "showtell"] + + +class Get(base.CommandBase): + def __init__(self, host): + super(Get, self).__init__( + host, "get", use_verbose=True, help=_("retrieve avatar of an entity") + ) + + def add_parser_options(self): + self.parser.add_argument( + "--no-cache", action="store_true", help=_("do no use cached values") + ) + self.parser.add_argument( + "-s", "--show", action="store_true", help=_("show avatar") + ) + self.parser.add_argument("jid", nargs='?', default='', help=_("entity")) + + async def show_image(self, path): + sat_conf = config.parse_main_conf() + cmd = config.config_get(sat_conf, C.CONFIG_SECTION, "image_cmd") + cmds = [cmd] + DISPLAY_CMD if cmd else DISPLAY_CMD + for cmd in cmds: + try: + process = await asyncio.create_subprocess_exec(cmd, path) + ret = await process.wait() + except OSError: + continue + + if ret in (0, 2): + # we can get exit code 2 with display when stopping it with C-c + break + else: + # didn't worked with commands, we try our luck with webbrowser + # in some cases, webbrowser can actually open the associated display program. + # Note that this may be possibly blocking, depending on the platform and + # available browser + import webbrowser + + webbrowser.open(path) + + async def start(self): + try: + avatar_data_raw = await self.host.bridge.avatar_get( + self.args.jid, + not self.args.no_cache, + self.profile, + ) + except Exception as e: + self.disp(f"can't retrieve avatar: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + avatar_data = data_format.deserialise(avatar_data_raw, type_check=None) + + if not avatar_data: + self.disp(_("No avatar found."), 1) + self.host.quit(C.EXIT_NOT_FOUND) + + avatar_path = avatar_data['path'] + + self.disp(avatar_path) + if self.args.show: + await self.show_image(avatar_path) + + self.host.quit() + + +class Set(base.CommandBase): + def __init__(self, host): + super(Set, self).__init__( + host, "set", use_verbose=True, + help=_("set avatar of the profile or an entity") + ) + + def add_parser_options(self): + self.parser.add_argument( + "-j", "--jid", default='', help=_("entity whose avatar must be changed")) + self.parser.add_argument( + "image_path", type=str, help=_("path to the image to upload") + ) + + async def start(self): + path = self.args.image_path + if not os.path.exists(path): + self.disp(_("file {path} doesn't exist!").format(path=repr(path)), error=True) + self.host.quit(C.EXIT_BAD_ARG) + path = os.path.abspath(path) + try: + await self.host.bridge.avatar_set(path, self.args.jid, self.profile) + except Exception as e: + self.disp(f"can't set avatar: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("avatar has been set"), 1) + self.host.quit() + + +class Avatar(base.CommandBase): + subcommands = (Get, Set) + + def __init__(self, host): + super(Avatar, self).__init__( + host, "avatar", use_profile=False, help=_("avatar uploading/retrieving") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_blocking.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_blocking.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import json +import os +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli import common +from libervia.cli.constants import Const as C +from . import base + +__commands__ = ["Blocking"] + + +class List(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "list", + use_output=C.OUTPUT_LIST, + help=_("list blocked entities"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + blocked_jids = await self.host.bridge.blocking_list( + self.profile, + ) + except Exception as e: + self.disp(f"can't get blocked entities: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(blocked_jids) + self.host.quit(C.EXIT_OK) + + +class Block(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "block", + help=_("block one or more entities"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "entities", + nargs="+", + metavar="JID", + help=_("JIDs of entities to block"), + ) + + async def start(self): + try: + await self.host.bridge.blocking_block( + self.args.entities, + self.profile + ) + except Exception as e: + self.disp(f"can't block entities: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit(C.EXIT_OK) + + +class Unblock(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "unblock", + help=_("unblock one or more entities"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "entities", + nargs="+", + metavar="JID", + help=_("JIDs of entities to unblock"), + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_('when "all" is used, unblock all entities without confirmation'), + ) + + async def start(self): + if self.args.entities == ["all"]: + if not self.args.force: + await self.host.confirm_or_quit( + _("All entities will be unblocked, are you sure"), + _("unblock cancelled") + ) + self.args.entities.clear() + elif self.args.force: + self.parser.error(_('--force is only allowed when "all" is used as target')) + + try: + await self.host.bridge.blocking_unblock( + self.args.entities, + self.profile + ) + except Exception as e: + self.disp(f"can't unblock entities: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit(C.EXIT_OK) + + +class Blocking(base.CommandBase): + subcommands = (List, Block, Unblock) + + def __init__(self, host): + super().__init__( + host, "blocking", use_profile=False, help=_("entities blocking") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_blog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_blog.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,1220 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import asyncio +from asyncio.subprocess import DEVNULL +from configparser import NoOptionError, NoSectionError +import json +import os +import os.path +from pathlib import Path +import re +import subprocess +import sys +import tempfile +from urllib.parse import urlparse + +from libervia.backend.core.i18n import _ +from libervia.backend.tools import config +from libervia.backend.tools.common import uri +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.cli import common +from libervia.cli.constants import Const as C + +from . import base, cmd_pubsub + +__commands__ = ["Blog"] + +SYNTAX_XHTML = "xhtml" +# extensions to use with known syntaxes +SYNTAX_EXT = { + # FIXME: default syntax doesn't sounds needed, there should always be a syntax set + # by the plugin. + "": "txt", # used when the syntax is not found + SYNTAX_XHTML: "xhtml", + "markdown": "md", +} + + +CONF_SYNTAX_EXT = "syntax_ext_dict" +BLOG_TMP_DIR = "blog" +# key to remove from metadata tmp file if they exist +KEY_TO_REMOVE_METADATA = ( + "id", + "content", + "content_xhtml", + "comments_node", + "comments_service", + "updated", +) + +URL_REDIRECT_PREFIX = "url_redirect_" +AIONOTIFY_INSTALL = '"pip install aionotify"' +MB_KEYS = ( + "id", + "url", + "atom_id", + "updated", + "published", + "language", + "comments", # this key is used for all comments* keys + "tags", # this key is used for all tag* keys + "author", + "author_jid", + "author_email", + "author_jid_verified", + "content", + "content_xhtml", + "title", + "title_xhtml", + "extra" +) +OUTPUT_OPT_NO_HEADER = "no-header" +RE_ATTACHMENT_METADATA = re.compile(r"^(?P[a-z_]+)=(?P.*)") +ALLOWER_ATTACH_MD_KEY = ("desc", "media_type", "external") + + +async def guess_syntax_from_path(host, sat_conf, path): + """Return syntax guessed according to filename extension + + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param path(str): path to the content file + @return(unicode): syntax to use + """ + # we first try to guess syntax with extension + ext = os.path.splitext(path)[1][1:] # we get extension without the '.' + if ext: + for k, v in SYNTAX_EXT.items(): + if k and ext == v: + return k + + # if not found, we use current syntax + return await host.bridge.param_get_a("Syntax", "Composition", "value", host.profile) + + +class BlogPublishCommon: + """handle common option for publising commands (Set and Edit)""" + + async def get_current_syntax(self): + """Retrieve current_syntax + + Use default syntax if --syntax has not been used, else check given syntax. + Will set self.default_syntax_used to True if default syntax has been used + """ + if self.args.syntax is None: + self.default_syntax_used = True + return await self.host.bridge.param_get_a( + "Syntax", "Composition", "value", self.profile + ) + else: + self.default_syntax_used = False + try: + syntax = await self.host.bridge.syntax_get(self.args.syntax) + self.current_syntax = self.args.syntax = syntax + except Exception as e: + if e.classname == "NotFound": + self.parser.error( + _("unknown syntax requested ({syntax})").format( + syntax=self.args.syntax + ) + ) + else: + raise e + return self.args.syntax + + def add_parser_options(self): + self.parser.add_argument("-T", "--title", help=_("title of the item")) + self.parser.add_argument( + "-t", + "--tag", + action="append", + help=_("tag (category) of your item"), + ) + self.parser.add_argument( + "-l", + "--language", + help=_("language of the item (ISO 639 code)"), + ) + + self.parser.add_argument( + "-a", + "--attachment", + dest="attachments", + nargs="+", + help=_( + "attachment in the form URL [metadata_name=value]" + ) + ) + + comments_group = self.parser.add_mutually_exclusive_group() + comments_group.add_argument( + "-C", + "--comments", + action="store_const", + const=True, + dest="comments", + help=_( + "enable comments (default: comments not enabled except if they " + "already exist)" + ), + ) + comments_group.add_argument( + "--no-comments", + action="store_const", + const=False, + dest="comments", + help=_("disable comments (will remove comments node if it exist)"), + ) + + self.parser.add_argument( + "-S", + "--syntax", + help=_("syntax to use (default: get profile's default syntax)"), + ) + self.parser.add_argument( + "-e", + "--encrypt", + action="store_true", + help=_("end-to-end encrypt the blog post") + ) + self.parser.add_argument( + "--encrypt-for", + metavar="JID", + action="append", + help=_("encrypt a single item for") + ) + self.parser.add_argument( + "-X", + "--sign", + action="store_true", + help=_("cryptographically sign the blog post") + ) + + async def set_mb_data_content(self, content, mb_data): + if self.default_syntax_used: + # default syntax has been used + mb_data["content_rich"] = content + elif self.current_syntax == SYNTAX_XHTML: + mb_data["content_xhtml"] = content + else: + mb_data["content_xhtml"] = await self.host.bridge.syntax_convert( + content, self.current_syntax, SYNTAX_XHTML, False, self.profile + ) + + def handle_attachments(self, mb_data: dict) -> None: + """Check, validate and add attachments to mb_data""" + if self.args.attachments: + attachments = [] + attachment = {} + for arg in self.args.attachments: + m = RE_ATTACHMENT_METADATA.match(arg) + if m is None: + # we should have an URL + url_parsed = urlparse(arg) + if url_parsed.scheme not in ("http", "https"): + self.parser.error( + "invalid URL in --attachment (only http(s) scheme is " + f" accepted): {arg}" + ) + if attachment: + # if we hae a new URL, we have a new attachment + attachments.append(attachment) + attachment = {} + attachment["url"] = arg + else: + # we should have a metadata + if "url" not in attachment: + self.parser.error( + "you must to specify an URL before any metadata in " + "--attachment" + ) + key = m.group("key") + if key not in ALLOWER_ATTACH_MD_KEY: + self.parser.error( + f"invalid metadata key in --attachment: {key!r}" + ) + value = m.group("value").strip() + if key == "external": + if not value: + value=True + else: + value = C.bool(value) + attachment[key] = value + if attachment: + attachments.append(attachment) + if attachments: + mb_data.setdefault("extra", {})["attachments"] = attachments + + def set_mb_data_from_args(self, mb_data): + """set microblog metadata according to command line options + + if metadata already exist, it will be overwritten + """ + if self.args.comments is not None: + mb_data["allow_comments"] = self.args.comments + if self.args.tag: + mb_data["tags"] = self.args.tag + if self.args.title is not None: + mb_data["title"] = self.args.title + if self.args.language is not None: + mb_data["language"] = self.args.language + if self.args.encrypt: + mb_data["encrypted"] = True + if self.args.sign: + mb_data["signed"] = True + if self.args.encrypt_for: + mb_data["encrypted_for"] = {"targets": self.args.encrypt_for} + self.handle_attachments(mb_data) + + +class Set(base.CommandBase, BlogPublishCommon): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + help=_("publish a new blog item or update an existing one"), + ) + BlogPublishCommon.__init__(self) + + def add_parser_options(self): + BlogPublishCommon.add_parser_options(self) + + async def start(self): + self.current_syntax = await self.get_current_syntax() + self.pubsub_item = self.args.item + mb_data = {} + self.set_mb_data_from_args(mb_data) + if self.pubsub_item: + mb_data["id"] = self.pubsub_item + content = sys.stdin.read() + await self.set_mb_data_content(content, mb_data) + + try: + item_id = await self.host.bridge.mb_send( + self.args.service, + self.args.node, + data_format.serialise(mb_data), + self.profile, + ) + except Exception as e: + self.disp(f"can't send item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(f"Item published with ID {item_id}") + self.host.quit(C.EXIT_OK) + + +class Get(base.CommandBase): + TEMPLATE = "blog/articles.html" + + def __init__(self, host): + extra_outputs = {"default": self.default_output, "fancy": self.fancy_output} + base.CommandBase.__init__( + self, + host, + "get", + use_verbose=True, + use_pubsub=True, + pubsub_flags={C.MULTI_ITEMS, C.CACHE}, + use_output=C.OUTPUT_COMPLEX, + extra_outputs=extra_outputs, + help=_("get blog item(s)"), + ) + + def add_parser_options(self): + #  TODO: a key(s) argument to select keys to display + self.parser.add_argument( + "-k", + "--key", + action="append", + dest="keys", + help=_("microblog data key(s) to display (default: depend of verbosity)"), + ) + # TODO: add MAM filters + + def template_data_mapping(self, data): + items, blog_items = data + blog_items["items"] = items + return {"blog_items": blog_items} + + def format_comments(self, item, keys): + lines = [] + for data in item.get("comments", []): + lines.append(data["uri"]) + for k in ("node", "service"): + if OUTPUT_OPT_NO_HEADER in self.args.output_opts: + header = "" + else: + header = f"{C.A_HEADER}comments_{k}: {A.RESET}" + lines.append(header + data[k]) + return "\n".join(lines) + + def format_tags(self, item, keys): + tags = item.pop("tags", []) + return ", ".join(tags) + + def format_updated(self, item, keys): + return common.format_time(item["updated"]) + + def format_published(self, item, keys): + return common.format_time(item["published"]) + + def format_url(self, item, keys): + return uri.build_xmpp_uri( + "pubsub", + subtype="microblog", + path=self.metadata["service"], + node=self.metadata["node"], + item=item["id"], + ) + + def get_keys(self): + """return keys to display according to verbosity or explicit key request""" + verbosity = self.args.verbose + if self.args.keys: + if not set(MB_KEYS).issuperset(self.args.keys): + self.disp( + "following keys are invalid: {invalid}.\n" + "Valid keys are: {valid}.".format( + invalid=", ".join(set(self.args.keys).difference(MB_KEYS)), + valid=", ".join(sorted(MB_KEYS)), + ), + error=True, + ) + self.host.quit(C.EXIT_BAD_ARG) + return self.args.keys + else: + if verbosity == 0: + return ("title", "content") + elif verbosity == 1: + return ( + "title", + "tags", + "author", + "author_jid", + "author_email", + "author_jid_verified", + "published", + "updated", + "content", + ) + else: + return MB_KEYS + + def default_output(self, data): + """simple key/value output""" + items, self.metadata = data + keys = self.get_keys() + + #  k_cb use format_[key] methods for complex formattings + k_cb = {} + for k in keys: + try: + callback = getattr(self, "format_" + k) + except AttributeError: + pass + else: + k_cb[k] = callback + for idx, item in enumerate(items): + for k in keys: + if k not in item and k not in k_cb: + continue + if OUTPUT_OPT_NO_HEADER in self.args.output_opts: + header = "" + else: + header = "{k_fmt}{key}:{k_fmt_e} {sep}".format( + k_fmt=C.A_HEADER, + key=k, + k_fmt_e=A.RESET, + sep="\n" if "content" in k else "", + ) + value = k_cb[k](item, keys) if k in k_cb else item[k] + if isinstance(value, bool): + value = str(value).lower() + elif isinstance(value, dict): + value = repr(value) + self.disp(header + (value or "")) + # we want a separation line after each item but the last one + if idx < len(items) - 1: + print("") + + def fancy_output(self, data): + """display blog is a nice to read way + + this output doesn't use keys filter + """ + # thanks to http://stackoverflow.com/a/943921 + rows, columns = list(map(int, os.popen("stty size", "r").read().split())) + items, metadata = data + verbosity = self.args.verbose + sep = A.color(A.FG_BLUE, columns * "▬") + if items: + print(("\n" + sep + "\n")) + + for idx, item in enumerate(items): + title = item.get("title") + if verbosity > 0: + author = item["author"] + published, updated = item["published"], item.get("updated") + else: + author = published = updated = None + if verbosity > 1: + tags = item.pop("tags", []) + else: + tags = None + content = item.get("content") + + if title: + print((A.color(A.BOLD, A.FG_CYAN, item["title"]))) + meta = [] + if author: + meta.append(A.color(A.FG_YELLOW, author)) + if published: + meta.append(A.color(A.FG_YELLOW, "on ", common.format_time(published))) + if updated != published: + meta.append( + A.color(A.FG_YELLOW, "(updated on ", common.format_time(updated), ")") + ) + print((" ".join(meta))) + if tags: + print((A.color(A.FG_MAGENTA, ", ".join(tags)))) + if (title or tags) and content: + print("") + if content: + self.disp(content) + + print(("\n" + sep + "\n")) + + async def start(self): + try: + mb_data = data_format.deserialise( + await self.host.bridge.mb_get( + self.args.service, + self.args.node, + self.args.max, + self.args.items, + self.get_pubsub_extra(), + self.profile, + ) + ) + except Exception as e: + self.disp(f"can't get blog items: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + items = mb_data.pop("items") + await self.output((items, mb_data)) + self.host.quit(C.EXIT_OK) + + +class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "edit", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + use_draft=True, + use_verbose=True, + help=_("edit an existing or new blog post"), + ) + BlogPublishCommon.__init__(self) + common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) + + def add_parser_options(self): + BlogPublishCommon.add_parser_options(self) + self.parser.add_argument( + "-P", + "--preview", + action="store_true", + help=_("launch a blog preview in parallel"), + ) + self.parser.add_argument( + "--no-publish", + action="store_true", + help=_('add "publish: False" to metadata'), + ) + + def build_metadata_file(self, content_file_path, mb_data=None): + """Build a metadata file using json + + The file is named after content_file_path, with extension replaced by + _metadata.json + @param content_file_path(str): path to the temporary file which will contain the + body + @param mb_data(dict, None): microblog metadata (for existing items) + @return (tuple[dict, Path]): merged metadata put originaly in metadata file + and path to temporary metadata file + """ + # we first construct metadata from edited item ones and CLI argumments + # or re-use the existing one if it exists + meta_file_path = content_file_path.with_name( + content_file_path.stem + common.METADATA_SUFF + ) + if meta_file_path.exists(): + self.disp("Metadata file already exists, we re-use it") + try: + with meta_file_path.open("rb") as f: + mb_data = json.load(f) + except (OSError, IOError, ValueError) as e: + self.disp( + f"Can't read existing metadata file at {meta_file_path}, " + f"aborting: {e}", + error=True, + ) + self.host.quit(1) + else: + mb_data = {} if mb_data is None else mb_data.copy() + + # in all cases, we want to remove unwanted keys + for key in KEY_TO_REMOVE_METADATA: + try: + del mb_data[key] + except KeyError: + pass + # and override metadata with command-line arguments + self.set_mb_data_from_args(mb_data) + + if self.args.no_publish: + mb_data["publish"] = False + + # then we create the file and write metadata there, as JSON dict + # XXX: if we port libervia-cli one day on Windows, O_BINARY may need to be + # added here + with os.fdopen( + os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b" + ) as f: + # we need to use an intermediate unicode buffer to write to the file + # unicode without escaping characters + unicode_dump = json.dumps( + mb_data, + ensure_ascii=False, + indent=4, + separators=(",", ": "), + sort_keys=True, + ) + f.write(unicode_dump.encode("utf-8")) + + return mb_data, meta_file_path + + async def edit(self, content_file_path, content_file_obj, mb_data=None): + """Edit the file contening the content using editor, and publish it""" + # we first create metadata file + meta_ori, meta_file_path = self.build_metadata_file(content_file_path, mb_data) + + coroutines = [] + + # do we need a preview ? + if self.args.preview: + self.disp("Preview requested, launching it", 1) + # we redirect outputs to /dev/null to avoid console pollution in editor + # if user wants to see messages, (s)he can call "blog preview" directly + coroutines.append( + asyncio.create_subprocess_exec( + sys.argv[0], + "blog", + "preview", + "--inotify", + "true", + "-p", + self.profile, + str(content_file_path), + stdout=DEVNULL, + stderr=DEVNULL, + ) + ) + + # we launch editor + coroutines.append( + self.run_editor( + "blog_editor_args", + content_file_path, + content_file_obj, + meta_file_path=meta_file_path, + meta_ori=meta_ori, + ) + ) + + await asyncio.gather(*coroutines) + + async def publish(self, content, mb_data): + await self.set_mb_data_content(content, mb_data) + + if self.pubsub_item: + mb_data["id"] = self.pubsub_item + + mb_data = data_format.serialise(mb_data) + + await self.host.bridge.mb_send( + self.pubsub_service, self.pubsub_node, mb_data, self.profile + ) + self.disp("Blog item published") + + def get_tmp_suff(self): + # we get current syntax to determine file extension + return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""]) + + async def get_item_data(self, service, node, item): + items = [item] if item else [] + + mb_data = data_format.deserialise( + await self.host.bridge.mb_get( + service, node, 1, items, data_format.serialise({}), self.profile + ) + ) + item = mb_data["items"][0] + + try: + content = item["content_xhtml"] + except KeyError: + content = item["content"] + if content: + content = await self.host.bridge.syntax_convert( + content, "text", SYNTAX_XHTML, False, self.profile + ) + + if content and self.current_syntax != SYNTAX_XHTML: + content = await self.host.bridge.syntax_convert( + content, SYNTAX_XHTML, self.current_syntax, False, self.profile + ) + + if content and self.current_syntax == SYNTAX_XHTML: + content = content.strip() + if not content.startswith("
"): + content = "
" + content + "
" + try: + from lxml import etree + except ImportError: + self.disp(_("You need lxml to edit pretty XHTML")) + else: + parser = etree.XMLParser(remove_blank_text=True) + root = etree.fromstring(content, parser) + content = etree.tostring(root, encoding=str, pretty_print=True) + + return content, item, item["id"] + + async def start(self): + # if there are user defined extension, we use them + SYNTAX_EXT.update( + config.config_get(self.sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {}) + ) + self.current_syntax = await self.get_current_syntax() + + ( + self.pubsub_service, + self.pubsub_node, + self.pubsub_item, + content_file_path, + content_file_obj, + mb_data, + ) = await self.get_item_path() + + await self.edit(content_file_path, content_file_obj, mb_data=mb_data) + self.host.quit() + + +class Rename(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "rename", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + help=_("rename an blog item"), + ) + + def add_parser_options(self): + self.parser.add_argument("new_id", help=_("new item id to use")) + + async def start(self): + try: + await self.host.bridge.mb_rename( + self.args.service, + self.args.node, + self.args.item, + self.args.new_id, + self.profile, + ) + except Exception as e: + self.disp(f"can't rename item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp("Item renamed") + self.host.quit(C.EXIT_OK) + + +class Repeat(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "repeat", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + help=_("repeat (re-publish) a blog item"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + repeat_id = await self.host.bridge.mb_repeat( + self.args.service, + self.args.node, + self.args.item, + "", + self.profile, + ) + except Exception as e: + self.disp(f"can't repeat item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if repeat_id: + self.disp(f"Item repeated at ID {str(repeat_id)!r}") + else: + self.disp("Item repeated") + self.host.quit(C.EXIT_OK) + + +class Preview(base.CommandBase, common.BaseEdit): + # TODO: need to be rewritten with template output + + def __init__(self, host): + base.CommandBase.__init__( + self, host, "preview", use_verbose=True, help=_("preview a blog content") + ) + common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) + + def add_parser_options(self): + self.parser.add_argument( + "--inotify", + type=str, + choices=("auto", "true", "false"), + default="auto", + help=_("use inotify to handle preview"), + ) + self.parser.add_argument( + "file", + nargs="?", + default="current", + help=_("path to the content file"), + ) + + async def show_preview(self): + # we implement show_preview here so we don't have to import webbrowser and urllib + # when preview is not used + url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path)) + self.webbrowser.open_new_tab(url) + + async def _launch_preview_ext(self, cmd_line, opt_name): + url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path)) + args = common.parse_args( + self.host, cmd_line, url=url, preview_file=self.preview_file_path + ) + if not args: + self.disp( + 'Couln\'t find command in "{name}", abording'.format(name=opt_name), + error=True, + ) + self.host.quit(1) + subprocess.Popen(args) + + async def open_preview_ext(self): + await self._launch_preview_ext(self.open_cb_cmd, "blog_preview_open_cmd") + + async def update_preview_ext(self): + await self._launch_preview_ext(self.update_cb_cmd, "blog_preview_update_cmd") + + async def update_content(self): + with self.content_file_path.open("rb") as f: + content = f.read().decode("utf-8-sig") + if content and self.syntax != SYNTAX_XHTML: + # we use safe=True because we want to have a preview as close as possible + # to what the people will see + content = await self.host.bridge.syntax_convert( + content, self.syntax, SYNTAX_XHTML, True, self.profile + ) + + xhtml = ( + f'' + f'' + f"" + f"{content}" + f"" + ) + + with open(self.preview_file_path, "wb") as f: + f.write(xhtml.encode("utf-8")) + + async def start(self): + import webbrowser + import urllib.request, urllib.parse, urllib.error + + self.webbrowser, self.urllib = webbrowser, urllib + + if self.args.inotify != "false": + try: + import aionotify + + except ImportError: + if self.args.inotify == "auto": + aionotify = None + self.disp( + f"aionotify module not found, deactivating feature. You can " + f"install it with {AIONOTIFY_INSTALL}" + ) + else: + self.disp( + f"aioinotify not found, can't activate the feature! Please " + f"install it with {AIONOTIFY_INSTALL}", + error=True, + ) + self.host.quit(1) + else: + aionotify = None + + sat_conf = self.sat_conf + SYNTAX_EXT.update( + config.config_get(sat_conf, C.CONFIG_SECTION, CONF_SYNTAX_EXT, {}) + ) + + try: + self.open_cb_cmd = config.config_get( + sat_conf, C.CONFIG_SECTION, "blog_preview_open_cmd", Exception + ) + except (NoOptionError, NoSectionError): + self.open_cb_cmd = None + open_cb = self.show_preview + else: + open_cb = self.open_preview_ext + + self.update_cb_cmd = config.config_get( + sat_conf, C.CONFIG_SECTION, "blog_preview_update_cmd", self.open_cb_cmd + ) + if self.update_cb_cmd is None: + update_cb = self.show_preview + else: + update_cb = self.update_preview_ext + + # which file do we need to edit? + if self.args.file == "current": + self.content_file_path = self.get_current_file(self.profile) + else: + try: + self.content_file_path = Path(self.args.file).resolve(strict=True) + except FileNotFoundError: + self.disp(_('File "{file}" doesn\'t exist!').format(file=self.args.file)) + self.host.quit(C.EXIT_NOT_FOUND) + + self.syntax = await guess_syntax_from_path( + self.host, sat_conf, self.content_file_path + ) + + # at this point the syntax is converted, we can display the preview + preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False) + self.preview_file_path = preview_file.name + preview_file.close() + await self.update_content() + + if aionotify is None: + # XXX: we don't delete file automatically because browser needs it + # (and webbrowser.open can return before it is read) + self.disp( + f"temporary file created at {self.preview_file_path}\nthis file will NOT " + f"BE DELETED AUTOMATICALLY, please delete it yourself when you have " + f"finished" + ) + await open_cb() + else: + await open_cb() + watcher = aionotify.Watcher() + watcher_kwargs = { + # Watcher don't accept Path so we convert to string + "path": str(self.content_file_path), + "alias": "content_file", + "flags": aionotify.Flags.CLOSE_WRITE + | aionotify.Flags.DELETE_SELF + | aionotify.Flags.MOVE_SELF, + } + watcher.watch(**watcher_kwargs) + + loop = asyncio.get_event_loop() + await watcher.setup(loop) + + try: + while True: + event = await watcher.get_event() + self.disp("Content updated", 1) + if event.flags & ( + aionotify.Flags.DELETE_SELF | aionotify.Flags.MOVE_SELF + ): + self.disp( + "DELETE/MOVE event catched, changing the watch", + 2, + ) + try: + watcher.unwatch("content_file") + except IOError as e: + self.disp( + f"Can't remove the watch: {e}", + 2, + ) + watcher = aionotify.Watcher() + watcher.watch(**watcher_kwargs) + try: + await watcher.setup(loop) + except OSError: + # if the new file is not here yet we can have an error + # as a workaround, we do a little rest and try again + await asyncio.sleep(1) + await watcher.setup(loop) + await self.update_content() + await update_cb() + except FileNotFoundError: + self.disp("The file seems to have been deleted.", error=True) + self.host.quit(C.EXIT_NOT_FOUND) + finally: + os.unlink(self.preview_file_path) + try: + watcher.unwatch("content_file") + except IOError as e: + self.disp( + f"Can't remove the watch: {e}", + 2, + ) + + +class Import(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "import", + use_pubsub=True, + use_progress=True, + help=_("import an external blog"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "importer", + nargs="?", + help=_("importer name, nothing to display importers list"), + ) + self.parser.add_argument("--host", help=_("original blog host")) + self.parser.add_argument( + "--no-images-upload", + action="store_true", + help=_("do *NOT* upload images (default: do upload images)"), + ) + self.parser.add_argument( + "--upload-ignore-host", + help=_("do not upload images from this host (default: upload all images)"), + ) + self.parser.add_argument( + "--ignore-tls-errors", + action="store_true", + help=_("ignore invalide TLS certificate for uploads"), + ) + self.parser.add_argument( + "-o", + "--option", + action="append", + nargs=2, + default=[], + metavar=("NAME", "VALUE"), + help=_("importer specific options (see importer description)"), + ) + self.parser.add_argument( + "location", + nargs="?", + help=_( + "importer data location (see importer description), nothing to show " + "importer description" + ), + ) + + async def on_progress_started(self, metadata): + self.disp(_("Blog upload started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("Blog uploaded successfully"), 2) + redirections = { + k[len(URL_REDIRECT_PREFIX) :]: v + for k, v in metadata.items() + if k.startswith(URL_REDIRECT_PREFIX) + } + if redirections: + conf = "\n".join( + [ + "url_redirections_dict = {}".format( + # we need to add ' ' before each new line + # and to double each '%' for ConfigParser + "\n ".join( + json.dumps(redirections, indent=1, separators=(",", ": ")) + .replace("%", "%%") + .split("\n") + ) + ), + ] + ) + self.disp( + _( + "\nTo redirect old URLs to new ones, put the following lines in your" + " sat.conf file, in [libervia] section:\n\n{conf}" + ).format(conf=conf) + ) + + async def on_progress_error(self, error_msg): + self.disp( + _("Error while uploading blog: {error_msg}").format(error_msg=error_msg), + error=True, + ) + + async def start(self): + if self.args.location is None: + for name in ("option", "service", "no_images_upload"): + if getattr(self.args, name): + self.parser.error( + _( + "{name} argument can't be used without location argument" + ).format(name=name) + ) + if self.args.importer is None: + self.disp( + "\n".join( + [ + f"{name}: {desc}" + for name, desc in await self.host.bridge.blogImportList() + ] + ) + ) + else: + try: + short_desc, long_desc = await self.host.bridge.blogImportDesc( + self.args.importer + ) + except Exception as e: + msg = [l for l in str(e).split("\n") if l][ + -1 + ] # we only keep the last line + self.disp(msg) + self.host.quit(1) + else: + self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}") + self.host.quit() + else: + # we have a location, an import is requested + options = {key: value for key, value in self.args.option} + if self.args.host: + options["host"] = self.args.host + if self.args.ignore_tls_errors: + options["ignore_tls_errors"] = C.BOOL_TRUE + if self.args.no_images_upload: + options["upload_images"] = C.BOOL_FALSE + if self.args.upload_ignore_host: + self.parser.error( + "upload-ignore-host option can't be used when no-images-upload " + "is set" + ) + elif self.args.upload_ignore_host: + options["upload_ignore_host"] = self.args.upload_ignore_host + + try: + progress_id = await self.host.bridge.blogImport( + self.args.importer, + self.args.location, + options, + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp( + _("Error while trying to import a blog: {e}").format(e=e), + error=True, + ) + self.host.quit(1) + else: + await self.set_progress_id(progress_id) + + +class AttachmentGet(cmd_pubsub.AttachmentGet): + + def __init__(self, host): + super().__init__(host) + self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM}) + + + async def start(self): + if not self.args.node: + namespaces = await self.host.bridge.namespaces_get() + try: + ns_microblog = namespaces["microblog"] + except KeyError: + self.disp("XEP-0277 plugin is not loaded", error=True) + self.host.quit(C.EXIT_MISSING_FEATURE) + else: + self.args.node = ns_microblog + return await super().start() + + +class AttachmentSet(cmd_pubsub.AttachmentSet): + + def __init__(self, host): + super().__init__(host) + self.override_pubsub_flags({C.SERVICE, C.SINGLE_ITEM}) + + async def start(self): + if not self.args.node: + namespaces = await self.host.bridge.namespaces_get() + try: + ns_microblog = namespaces["microblog"] + except KeyError: + self.disp("XEP-0277 plugin is not loaded", error=True) + self.host.quit(C.EXIT_MISSING_FEATURE) + else: + self.args.node = ns_microblog + return await super().start() + + +class Attachments(base.CommandBase): + subcommands = (AttachmentGet, AttachmentSet) + + def __init__(self, host): + super().__init__( + host, + "attachments", + use_profile=False, + help=_("set or retrieve blog attachments"), + ) + + +class Blog(base.CommandBase): + subcommands = (Set, Get, Edit, Rename, Repeat, Preview, Import, Attachments) + + def __init__(self, host): + super(Blog, self).__init__( + host, "blog", use_profile=False, help=_("blog/microblog management") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_bookmarks.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_bookmarks.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C + +__commands__ = ["Bookmarks"] + +STORAGE_LOCATIONS = ("local", "private", "pubsub") +TYPES = ("muc", "url") + + +class BookmarksCommon(base.CommandBase): + """Class used to group common options of bookmarks subcommands""" + + def add_parser_options(self, location_default="all"): + self.parser.add_argument( + "-l", + "--location", + type=str, + choices=(location_default,) + STORAGE_LOCATIONS, + default=location_default, + help=_("storage location (default: %(default)s)"), + ) + self.parser.add_argument( + "-t", + "--type", + type=str, + choices=TYPES, + default=TYPES[0], + help=_("bookmarks type (default: %(default)s)"), + ) + + +class BookmarksList(BookmarksCommon): + def __init__(self, host): + super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks")) + + async def start(self): + try: + data = await self.host.bridge.bookmarks_list( + self.args.type, self.args.location, self.host.profile + ) + except Exception as e: + self.disp(f"can't get bookmarks list: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + mess = [] + for location in STORAGE_LOCATIONS: + if not data[location]: + continue + loc_mess = [] + loc_mess.append(f"{location}:") + book_mess = [] + for book_link, book_data in list(data[location].items()): + name = book_data.get("name") + autojoin = book_data.get("autojoin", "false") == "true" + nick = book_data.get("nick") + book_mess.append( + "\t%s[%s%s]%s" + % ( + (name + " ") if name else "", + book_link, + " (%s)" % nick if nick else "", + " (*)" if autojoin else "", + ) + ) + loc_mess.append("\n".join(book_mess)) + mess.append("\n".join(loc_mess)) + + print("\n\n".join(mess)) + self.host.quit() + + +class BookmarksRemove(BookmarksCommon): + def __init__(self, host): + super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark")) + + def add_parser_options(self): + super(BookmarksRemove, self).add_parser_options() + self.parser.add_argument( + "bookmark", help=_("jid (for muc bookmark) or url of to remove") + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("delete bookmark without confirmation"), + ) + + async def start(self): + if not self.args.force: + await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?")) + + try: + await self.host.bridge.bookmarks_remove( + self.args.type, self.args.bookmark, self.args.location, self.host.profile + ) + except Exception as e: + self.disp(_("can't delete bookmark: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("bookmark deleted")) + self.host.quit() + + +class BookmarksAdd(BookmarksCommon): + def __init__(self, host): + super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark")) + + def add_parser_options(self): + super(BookmarksAdd, self).add_parser_options(location_default="auto") + self.parser.add_argument( + "bookmark", help=_("jid (for muc bookmark) or url of to remove") + ) + self.parser.add_argument("-n", "--name", help=_("bookmark name")) + muc_group = self.parser.add_argument_group(_("MUC specific options")) + muc_group.add_argument("-N", "--nick", help=_("nickname")) + muc_group.add_argument( + "-a", + "--autojoin", + action="store_true", + help=_("join room on profile connection"), + ) + + async def start(self): + if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None): + self.parser.error(_("You can't use --autojoin or --nick with --type url")) + data = {} + if self.args.autojoin: + data["autojoin"] = "true" + if self.args.nick is not None: + data["nick"] = self.args.nick + if self.args.name is not None: + data["name"] = self.args.name + try: + await self.host.bridge.bookmarks_add( + self.args.type, + self.args.bookmark, + data, + self.args.location, + self.host.profile, + ) + except Exception as e: + self.disp(f"can't add bookmark: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("bookmark successfully added")) + self.host.quit() + + +class Bookmarks(base.CommandBase): + subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd) + + def __init__(self, host): + super(Bookmarks, self).__init__( + host, "bookmarks", use_profile=False, help=_("manage bookmarks") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_debug.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_debug.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.backend.tools.common.ansi import ANSI as A +import json + +__commands__ = ["Debug"] + + +class BridgeCommon(object): + def eval_args(self): + if self.args.arg: + try: + return eval("[{}]".format(",".join(self.args.arg))) + except SyntaxError as e: + self.disp( + "Can't evaluate arguments: {mess}\n{text}\n{offset}^".format( + mess=e, text=e.text, offset=" " * (e.offset - 1) + ), + error=True, + ) + self.host.quit(C.EXIT_BAD_ARG) + else: + return [] + + +class Method(base.CommandBase, BridgeCommon): + def __init__(self, host): + base.CommandBase.__init__(self, host, "method", help=_("call a bridge method")) + BridgeCommon.__init__(self) + + def add_parser_options(self): + self.parser.add_argument( + "method", type=str, help=_("name of the method to execute") + ) + self.parser.add_argument("arg", nargs="*", help=_("argument of the method")) + + async def start(self): + method = getattr(self.host.bridge, self.args.method) + import inspect + + argspec = inspect.getargspec(method) + + kwargs = {} + if "profile_key" in argspec.args: + kwargs["profile_key"] = self.profile + elif "profile" in argspec.args: + kwargs["profile"] = self.profile + + args = self.eval_args() + + try: + ret = await method( + *args, + **kwargs, + ) + except Exception as e: + self.disp( + _("Error while executing {method}: {e}").format( + method=self.args.method, e=e + ), + error=True, + ) + self.host.quit(C.EXIT_ERROR) + else: + if ret is not None: + self.disp(str(ret)) + self.host.quit() + + +class Signal(base.CommandBase, BridgeCommon): + def __init__(self, host): + base.CommandBase.__init__( + self, host, "signal", help=_("send a fake signal from backend") + ) + BridgeCommon.__init__(self) + + def add_parser_options(self): + self.parser.add_argument("signal", type=str, help=_("name of the signal to send")) + self.parser.add_argument("arg", nargs="*", help=_("argument of the signal")) + + async def start(self): + args = self.eval_args() + json_args = json.dumps(args) + # XXX: we use self.args.profile and not self.profile + # because we want the raw profile_key (so plugin handle C.PROF_KEY_NONE) + try: + await self.host.bridge.debug_signal_fake( + self.args.signal, json_args, self.args.profile + ) + except Exception as e: + self.disp(_("Can't send fake signal: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_ERROR) + else: + self.host.quit() + + +class bridge(base.CommandBase): + subcommands = (Method, Signal) + + def __init__(self, host): + super(bridge, self).__init__( + host, "bridge", use_profile=False, help=_("bridge s(t)imulation") + ) + + +class Monitor(base.CommandBase): + def __init__(self, host): + super(Monitor, self).__init__( + host, + "monitor", + use_verbose=True, + use_profile=False, + use_output=C.OUTPUT_XML, + help=_("monitor XML stream"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-d", + "--direction", + choices=("in", "out", "both"), + default="both", + help=_("stream direction filter"), + ) + + async def print_xml(self, direction, xml_data, profile): + if self.args.direction == "in" and direction != "IN": + return + if self.args.direction == "out" and direction != "OUT": + return + verbosity = self.host.verbosity + if not xml_data.strip(): + if verbosity <= 2: + return + whiteping = True + else: + whiteping = False + + if verbosity: + profile_disp = f" ({profile})" if verbosity > 1 else "" + if direction == "IN": + self.disp( + A.color( + A.BOLD, A.FG_YELLOW, "<<<===== IN ====", A.FG_WHITE, profile_disp + ) + ) + else: + self.disp( + A.color( + A.BOLD, A.FG_CYAN, "==== OUT ====>>>", A.FG_WHITE, profile_disp + ) + ) + if whiteping: + self.disp("[WHITESPACE PING]") + else: + try: + await self.output(xml_data) + except Exception: + #  initial stream is not valid XML, + # in this case we print directly to data + #  FIXME: we should test directly lxml.etree.XMLSyntaxError + # but importing lxml directly here is not clean + # should be wrapped in a custom Exception + self.disp(xml_data) + self.disp("") + + async def start(self): + self.host.bridge.register_signal("xml_log", self.print_xml, "plugin") + + +class Theme(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, host, "theme", help=_("print colours used with your background") + ) + + def add_parser_options(self): + pass + + async def start(self): + print(f"background currently used: {A.BOLD}{self.host.background}{A.RESET}\n") + for attr in dir(C): + if not attr.startswith("A_"): + continue + color = getattr(C, attr) + if attr == "A_LEVEL_COLORS": + # This constant contains multiple colors + self.disp("LEVEL COLORS: ", end=" ") + for idx, c in enumerate(color): + last = idx == len(color) - 1 + end = "\n" if last else " " + self.disp( + c + f"LEVEL_{idx}" + A.RESET + (", " if not last else ""), end=end + ) + else: + text = attr[2:] + self.disp(A.color(color, text)) + self.host.quit() + + +class Debug(base.CommandBase): + subcommands = (bridge, Monitor, Theme) + + def __init__(self, host): + super(Debug, self).__init__( + host, "debug", use_profile=False, help=_("debugging tools") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_encryption.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_encryption.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from libervia.cli import base +from libervia.cli.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli import xmlui_manager + +__commands__ = ["Encryption"] + + +class EncryptionAlgorithms(base.CommandBase): + + def __init__(self, host): + extra_outputs = {"default": self.default_output} + super(EncryptionAlgorithms, self).__init__( + host, "algorithms", + use_output=C.OUTPUT_LIST_DICT, + extra_outputs=extra_outputs, + use_profile=False, + help=_("show available encryption algorithms")) + + def add_parser_options(self): + pass + + def default_output(self, plugins): + if not plugins: + self.disp(_("No encryption plugin registered!")) + else: + self.disp(_("Following encryption algorithms are available: {algos}").format( + algos=', '.join([p['name'] for p in plugins]))) + + async def start(self): + try: + plugins_ser = await self.host.bridge.encryption_plugins_get() + plugins = data_format.deserialise(plugins_ser, type_check=list) + except Exception as e: + self.disp(f"can't retrieve plugins: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(plugins) + self.host.quit() + + +class EncryptionGet(base.CommandBase): + + def __init__(self, host): + super(EncryptionGet, self).__init__( + host, "get", + use_output=C.OUTPUT_DICT, + help=_("get encryption session data")) + + def add_parser_options(self): + self.parser.add_argument( + "jid", + help=_("jid of the entity to check") + ) + + async def start(self): + jids = await self.host.check_jids([self.args.jid]) + jid = jids[0] + try: + serialised = await self.host.bridge.message_encryption_get(jid, self.profile) + except Exception as e: + self.disp(f"can't get session: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + session_data = data_format.deserialise(serialised) + if session_data is None: + self.disp( + "No encryption session found, the messages are sent in plain text.") + self.host.quit(C.EXIT_NOT_FOUND) + await self.output(session_data) + self.host.quit() + + +class EncryptionStart(base.CommandBase): + + def __init__(self, host): + super(EncryptionStart, self).__init__( + host, "start", + help=_("start encrypted session with an entity")) + + def add_parser_options(self): + self.parser.add_argument( + "--encrypt-noreplace", + action="store_true", + help=_("don't replace encryption algorithm if an other one is already used")) + algorithm = self.parser.add_mutually_exclusive_group() + algorithm.add_argument( + "-n", "--name", help=_("algorithm name (DEFAULT: choose automatically)")) + algorithm.add_argument( + "-N", "--namespace", + help=_("algorithm namespace (DEFAULT: choose automatically)")) + self.parser.add_argument( + "jid", + help=_("jid of the entity to stop encrypted session with") + ) + + async def start(self): + if self.args.name is not None: + try: + namespace = await self.host.bridge.encryption_namespace_get(self.args.name) + except Exception as e: + self.disp(f"can't get encryption namespace: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + elif self.args.namespace is not None: + namespace = self.args.namespace + else: + namespace = "" + + jids = await self.host.check_jids([self.args.jid]) + jid = jids[0] + + try: + await self.host.bridge.message_encryption_start( + jid, namespace, not self.args.encrypt_noreplace, + self.profile) + except Exception as e: + self.disp(f"can't get encryption namespace: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + self.host.quit() + + +class EncryptionStop(base.CommandBase): + + def __init__(self, host): + super(EncryptionStop, self).__init__( + host, "stop", + help=_("stop encrypted session with an entity")) + + def add_parser_options(self): + self.parser.add_argument( + "jid", + help=_("jid of the entity to stop encrypted session with") + ) + + async def start(self): + jids = await self.host.check_jids([self.args.jid]) + jid = jids[0] + try: + await self.host.bridge.message_encryption_stop(jid, self.profile) + except Exception as e: + self.disp(f"can't end encrypted session: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + self.host.quit() + + +class TrustUI(base.CommandBase): + + def __init__(self, host): + super(TrustUI, self).__init__( + host, "ui", + help=_("get UI to manage trust")) + + def add_parser_options(self): + self.parser.add_argument( + "jid", + help=_("jid of the entity to stop encrypted session with") + ) + algorithm = self.parser.add_mutually_exclusive_group() + algorithm.add_argument( + "-n", "--name", help=_("algorithm name (DEFAULT: current algorithm)")) + algorithm.add_argument( + "-N", "--namespace", + help=_("algorithm namespace (DEFAULT: current algorithm)")) + + async def start(self): + if self.args.name is not None: + try: + namespace = await self.host.bridge.encryption_namespace_get(self.args.name) + except Exception as e: + self.disp(f"can't get encryption namespace: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + elif self.args.namespace is not None: + namespace = self.args.namespace + else: + namespace = "" + + jids = await self.host.check_jids([self.args.jid]) + jid = jids[0] + + try: + xmlui_raw = await self.host.bridge.encryption_trust_ui_get( + jid, namespace, self.profile) + except Exception as e: + self.disp(f"can't get encryption session trust UI: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + xmlui = xmlui_manager.create(self.host, xmlui_raw) + await xmlui.show() + if xmlui.type != C.XMLUI_DIALOG: + await xmlui.submit_form() + self.host.quit() + +class EncryptionTrust(base.CommandBase): + subcommands = (TrustUI,) + + def __init__(self, host): + super(EncryptionTrust, self).__init__( + host, "trust", use_profile=False, help=_("trust manangement") + ) + + +class Encryption(base.CommandBase): + subcommands = (EncryptionAlgorithms, EncryptionGet, EncryptionStart, EncryptionStop, + EncryptionTrust) + + def __init__(self, host): + super(Encryption, self).__init__( + host, "encryption", use_profile=False, help=_("encryption sessions handling") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_event.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_event.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 + + +# libervia-cli: Libervia CLI frontend +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import argparse +import sys + +from sqlalchemy import desc + +from libervia.backend.core.i18n import _ +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import date_utils +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.cli import common +from libervia.cli.constants import Const as C +from libervia.cli.constants import Const as C + +from . import base + +__commands__ = ["Event"] + +OUTPUT_OPT_TABLE = "table" + + +class Get(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_LIST_DICT, + use_pubsub=True, + pubsub_flags={C.MULTI_ITEMS, C.CACHE}, + use_verbose=True, + extra_outputs={ + "default": self.default_output, + }, + help=_("get event(s) data"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + events_data_s = await self.host.bridge.events_get( + self.args.service, + self.args.node, + self.args.items, + self.get_pubsub_extra(), + self.profile, + ) + except Exception as e: + self.disp(f"can't get events data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + events_data = data_format.deserialise(events_data_s, type_check=list) + await self.output(events_data) + self.host.quit() + + def default_output(self, events): + nb_events = len(events) + for idx, event in enumerate(events): + names = event["name"] + name = names.get("") or next(iter(names.values())) + start = event["start"] + start_human = date_utils.date_fmt( + start, "medium", tz_info=date_utils.TZ_LOCAL + ) + end = event["end"] + self.disp(A.color( + A.BOLD, start_human, A.RESET, " ", + f"({date_utils.delta2human(start, end)}) ", + C.A_HEADER, name + )) + if self.verbosity > 0: + descriptions = event.get("descriptions", []) + if descriptions: + self.disp(descriptions[0]["description"]) + if idx < (nb_events-1): + self.disp("") + + +class CategoryAction(argparse.Action): + + def __init__(self, option_strings, dest, nargs=None, metavar=None, **kwargs): + if nargs is not None or metavar is not None: + raise ValueError("nargs and metavar must not be used") + if metavar is not None: + metavar="TERM WIKIDATA_ID LANG" + if "--help" in sys.argv: + # FIXME: dirty workaround to have correct --help message + # argparse doesn't normally allow variable number of arguments beside "+" + # and "*", this workaround show METAVAR as 3 arguments were expected, while + # we can actuall use 1, 2 or 3. + nargs = 3 + metavar = ("TERM", "[WIKIDATA_ID]", "[LANG]") + else: + nargs = "+" + + super().__init__(option_strings, dest, metavar=metavar, nargs=nargs, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + categories = getattr(namespace, self.dest) + if categories is None: + categories = [] + setattr(namespace, self.dest, categories) + + if not values: + parser.error("category values must be set") + + category = { + "term": values[0] + } + + if len(values) == 1: + pass + elif len(values) == 2: + value = values[1] + if value.startswith("Q"): + category["wikidata_id"] = value + else: + category["language"] = value + elif len(values) == 3: + __, wd, lang = values + category["wikidata_id"] = wd + category["language"] = lang + else: + parser.error("Category can't have more than 3 arguments") + + categories.append(category) + + +class EventBase: + def add_parser_options(self): + self.parser.add_argument( + "-S", "--start", type=base.date_decoder, metavar="TIME_PATTERN", + help=_("the start time of the event")) + end_group = self.parser.add_mutually_exclusive_group() + end_group.add_argument( + "-E", "--end", type=base.date_decoder, metavar="TIME_PATTERN", + help=_("the time of the end of the event")) + end_group.add_argument( + "-D", "--duration", help=_("duration of the event")) + self.parser.add_argument( + "-H", "--head-picture", help="URL to a picture to use as head-picture" + ) + self.parser.add_argument( + "-d", "--description", help="plain text description the event" + ) + self.parser.add_argument( + "-C", "--category", action=CategoryAction, dest="categories", + help="Category of the event" + ) + self.parser.add_argument( + "-l", "--location", action="append", nargs="+", metavar="[KEY] VALUE", + help="Location metadata" + ) + rsvp_group = self.parser.add_mutually_exclusive_group() + rsvp_group.add_argument( + "--rsvp", action="store_true", help=_("RSVP is requested")) + rsvp_group.add_argument( + "--rsvp_json", metavar="JSON", help=_("JSON description of RSVP form")) + for node_type in ("invitees", "comments", "blog", "schedule"): + self.parser.add_argument( + f"--{node_type}", + nargs=2, + metavar=("JID", "NODE"), + help=_("link {node_type} pubsub node").format(node_type=node_type) + ) + self.parser.add_argument( + "-a", "--attachment", action="append", dest="attachments", + help=_("attach a file") + ) + self.parser.add_argument("--website", help=_("website of the event")) + self.parser.add_argument( + "--status", choices=["confirmed", "tentative", "cancelled"], + help=_("status of the event") + ) + self.parser.add_argument( + "-T", "--language", metavar="LANG", action="append", dest="languages", + help=_("main languages spoken at the event") + ) + self.parser.add_argument( + "--wheelchair", choices=["full", "partial", "no"], + help=_("is the location accessible by wheelchair") + ) + self.parser.add_argument( + "--external", + nargs=3, + metavar=("JID", "NODE", "ITEM"), + help=_("link to an external event") + ) + + def get_event_data(self): + if self.args.duration is not None: + if self.args.start is None: + self.parser.error("--start must be send if --duration is used") + # if duration is used, we simply add it to start time to get end time + self.args.end = base.date_decoder(f"{self.args.start} + {self.args.duration}") + + event = {} + if self.args.name is not None: + event["name"] = {"": self.args.name} + + if self.args.start is not None: + event["start"] = self.args.start + + if self.args.end is not None: + event["end"] = self.args.end + + if self.args.head_picture: + event["head-picture"] = { + "sources": [{ + "url": self.args.head_picture + }] + } + if self.args.description: + event["descriptions"] = [ + { + "type": "text", + "description": self.args.description + } + ] + if self.args.categories: + event["categories"] = self.args.categories + if self.args.location is not None: + location = {} + for location_data in self.args.location: + if len(location_data) == 1: + location["description"] = location_data[0] + else: + key, *values = location_data + location[key] = " ".join(values) + event["locations"] = [location] + + if self.args.rsvp: + event["rsvp"] = [{}] + elif self.args.rsvp_json: + if isinstance(self.args.rsvp_elt, dict): + event["rsvp"] = [self.args.rsvp_json] + else: + event["rsvp"] = self.args.rsvp_json + + for node_type in ("invitees", "comments", "blog", "schedule"): + value = getattr(self.args, node_type) + if value: + service, node = value + event[node_type] = {"service": service, "node": node} + + if self.args.attachments: + attachments = event["attachments"] = [] + for attachment in self.args.attachments: + attachments.append({ + "sources": [{"url": attachment}] + }) + + extra = {} + + for arg in ("website", "status", "languages"): + value = getattr(self.args, arg) + if value is not None: + extra[arg] = value + if self.args.wheelchair is not None: + extra["accessibility"] = {"wheelchair": self.args.wheelchair} + + if extra: + event["extra"] = extra + + if self.args.external: + ext_jid, ext_node, ext_item = self.args.external + event["external"] = { + "jid": ext_jid, + "node": ext_node, + "item": ext_item + } + return event + + +class Create(EventBase, base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "create", + use_pubsub=True, + help=_("create or replace event"), + ) + + def add_parser_options(self): + super().add_parser_options() + self.parser.add_argument( + "-i", + "--id", + default="", + help=_("ID of the PubSub Item"), + ) + # name is mandatory here + self.parser.add_argument("name", help=_("name of the event")) + + async def start(self): + if self.args.start is None: + self.parser.error("--start must be set") + event_data = self.get_event_data() + # we check self.args.end after get_event_data because it may be set there id + # --duration is used + if self.args.end is None: + self.parser.error("--end or --duration must be set") + try: + await self.host.bridge.event_create( + data_format.serialise(event_data), + self.args.id, + self.args.node, + self.args.service, + self.profile, + ) + except Exception as e: + self.disp(f"can't create event: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("Event created successfuly)")) + self.host.quit() + + +class Modify(EventBase, base.CommandBase): + def __init__(self, host): + super(Modify, self).__init__( + host, + "modify", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + help=_("modify an existing event"), + ) + EventBase.__init__(self) + + def add_parser_options(self): + super().add_parser_options() + # name is optional here + self.parser.add_argument("-N", "--name", help=_("name of the event")) + + async def start(self): + event_data = self.get_event_data() + try: + await self.host.bridge.event_modify( + data_format.serialise(event_data), + self.args.item, + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't update event data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class InviteeGet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + use_verbose=True, + help=_("get event attendance"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-j", "--jid", action="append", dest="jids", default=[], + help=_("only retrieve RSVP from those JIDs") + ) + + async def start(self): + try: + event_data_s = await self.host.bridge.event_invitee_get( + self.args.service, + self.args.node, + self.args.item, + self.args.jids, + "", + self.profile, + ) + except Exception as e: + self.disp(f"can't get event data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + event_data = data_format.deserialise(event_data_s) + await self.output(event_data) + self.host.quit() + + +class InviteeSet(base.CommandBase): + def __init__(self, host): + super(InviteeSet, self).__init__( + host, + "set", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM}, + help=_("set event attendance"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", + "--field", + action="append", + nargs=2, + dest="fields", + metavar=("KEY", "VALUE"), + help=_("configuration field to set"), + ) + + async def start(self): + # TODO: handle RSVP with XMLUI in a similar way as for `ad-hoc run` + fields = dict(self.args.fields) if self.args.fields else {} + try: + self.host.bridge.event_invitee_set( + self.args.service, + self.args.node, + self.args.item, + data_format.serialise(fields), + self.profile, + ) + except Exception as e: + self.disp(f"can't set event data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class InviteesList(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + base.CommandBase.__init__( + self, + host, + "list", + use_output=C.OUTPUT_DICT_DICT, + extra_outputs=extra_outputs, + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("get event attendance"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-m", + "--missing", + action="store_true", + help=_("show missing people (invited but no R.S.V.P. so far)"), + ) + self.parser.add_argument( + "-R", + "--no-rsvp", + action="store_true", + help=_("don't show people which gave R.S.V.P."), + ) + + def _attend_filter(self, attend, row): + if attend == "yes": + attend_color = C.A_SUCCESS + elif attend == "no": + attend_color = C.A_FAILURE + else: + attend_color = A.FG_WHITE + return A.color(attend_color, attend) + + def _guests_filter(self, guests): + return "(" + str(guests) + ")" if guests else "" + + def default_output(self, event_data): + data = [] + attendees_yes = 0 + attendees_maybe = 0 + attendees_no = 0 + attendees_missing = 0 + guests = 0 + guests_maybe = 0 + for jid_, jid_data in event_data.items(): + jid_data["jid"] = jid_ + try: + guests_int = int(jid_data["guests"]) + except (ValueError, KeyError): + pass + attend = jid_data.get("attend", "") + if attend == "yes": + attendees_yes += 1 + guests += guests_int + elif attend == "maybe": + attendees_maybe += 1 + guests_maybe += guests_int + elif attend == "no": + attendees_no += 1 + jid_data["guests"] = "" + else: + attendees_missing += 1 + jid_data["guests"] = "" + data.append(jid_data) + + show_table = OUTPUT_OPT_TABLE in self.args.output_opts + + table = common.Table.from_list_dict( + self.host, + data, + ("nick",) + (("jid",) if self.host.verbosity else ()) + ("attend", "guests"), + headers=None, + filters={ + "nick": A.color(C.A_HEADER, "{}" if show_table else "{} "), + "jid": "{}" if show_table else "{} ", + "attend": self._attend_filter, + "guests": "{}" if show_table else self._guests_filter, + }, + defaults={"nick": "", "attend": "", "guests": 1}, + ) + if show_table: + table.display() + else: + table.display_blank(show_header=False, col_sep="") + + if not self.args.no_rsvp: + self.disp("") + self.disp( + A.color( + C.A_SUBHEADER, + _("Attendees: "), + A.RESET, + str(len(data)), + _(" ("), + C.A_SUCCESS, + _("yes: "), + str(attendees_yes), + A.FG_WHITE, + _(", maybe: "), + str(attendees_maybe), + ", ", + C.A_FAILURE, + _("no: "), + str(attendees_no), + A.RESET, + ")", + ) + ) + self.disp( + A.color(C.A_SUBHEADER, _("confirmed guests: "), A.RESET, str(guests)) + ) + self.disp( + A.color( + C.A_SUBHEADER, + _("unconfirmed guests: "), + A.RESET, + str(guests_maybe), + ) + ) + self.disp( + A.color(C.A_SUBHEADER, _("total: "), A.RESET, str(guests + guests_maybe)) + ) + if attendees_missing: + self.disp("") + self.disp( + A.color( + C.A_SUBHEADER, + _("missing people (no reply): "), + A.RESET, + str(attendees_missing), + ) + ) + + async def start(self): + if self.args.no_rsvp and not self.args.missing: + self.parser.error(_("you need to use --missing if you use --no-rsvp")) + if not self.args.missing: + prefilled = {} + else: + # we get prefilled data with all people + try: + affiliations = await self.host.bridge.ps_node_affiliations_get( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't get node affiliations: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + # we fill all affiliations with empty data, answered one will be filled + # below. We only consider people with "publisher" affiliation as invited, + # creators are not, and members can just observe + prefilled = { + jid_: {} + for jid_, affiliation in affiliations.items() + if affiliation in ("publisher",) + } + + try: + event_data = await self.host.bridge.event_invitees_list( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't get event data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + # we fill nicknames and keep only requested people + + if self.args.no_rsvp: + for jid_ in event_data: + # if there is a jid in event_data it must be there in prefilled too + # otherwie somebody is not on the invitees list + try: + del prefilled[jid_] + except KeyError: + self.disp( + A.color( + C.A_WARNING, + f"We got a RSVP from somebody who was not in invitees " + f"list: {jid_}", + ), + error=True, + ) + else: + # we replace empty dicts for existing people with R.S.V.P. data + prefilled.update(event_data) + + # we get nicknames for everybody, make it easier for organisers + for jid_, data in prefilled.items(): + id_data = await self.host.bridge.identity_get(jid_, [], True, self.profile) + id_data = data_format.deserialise(id_data) + data["nick"] = id_data["nicknames"][0] + + await self.output(prefilled) + self.host.quit() + + +class InviteeInvite(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "invite", + use_pubsub=True, + pubsub_flags={C.NODE, C.SINGLE_ITEM}, + help=_("invite someone to the event through email"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-e", + "--email", + action="append", + default=[], + help="email(s) to send the invitation to", + ) + self.parser.add_argument( + "-N", + "--name", + default="", + help="name of the invitee", + ) + self.parser.add_argument( + "-H", + "--host-name", + default="", + help="name of the host", + ) + self.parser.add_argument( + "-l", + "--lang", + default="", + help="main language spoken by the invitee", + ) + self.parser.add_argument( + "-U", + "--url-template", + default="", + help="template to construct the URL", + ) + self.parser.add_argument( + "-S", + "--subject", + default="", + help="subject of the invitation email (default: generic subject)", + ) + self.parser.add_argument( + "-b", + "--body", + default="", + help="body of the invitation email (default: generic body)", + ) + + async def start(self): + email = self.args.email[0] if self.args.email else None + emails_extra = self.args.email[1:] + + try: + await self.host.bridge.event_invite_by_email( + self.args.service, + self.args.node, + self.args.item, + email, + emails_extra, + self.args.name, + self.args.host_name, + self.args.lang, + self.args.url_template, + self.args.subject, + self.args.body, + self.args.profile, + ) + except Exception as e: + self.disp(f"can't create invitation: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class Invitee(base.CommandBase): + subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite) + + def __init__(self, host): + super(Invitee, self).__init__( + host, "invitee", use_profile=False, help=_("manage invities") + ) + + +class Event(base.CommandBase): + subcommands = (Get, Create, Modify, Invitee) + + def __init__(self, host): + super(Event, self).__init__( + host, "event", use_profile=False, help=_("event management") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_file.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_file.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,1108 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from . import base +from . import xmlui_manager +import sys +import os +import os.path +import tarfile +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli.constants import Const as C +from libervia.cli import common +from libervia.frontends.tools import jid +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.common import utils +from urllib.parse import urlparse +from pathlib import Path +import tempfile +import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI +import json + +__commands__ = ["File"] +DEFAULT_DEST = "downloaded_file" + + +class Send(base.CommandBase): + def __init__(self, host): + super(Send, self).__init__( + host, + "send", + use_progress=True, + use_verbose=True, + help=_("send a file to a contact"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "files", type=str, nargs="+", metavar="file", help=_("a list of file") + ) + self.parser.add_argument("jid", help=_("the destination jid")) + self.parser.add_argument( + "-b", "--bz2", action="store_true", help=_("make a bzip2 tarball") + ) + self.parser.add_argument( + "-d", + "--path", + help=("path to the directory where the file must be stored"), + ) + self.parser.add_argument( + "-N", + "--namespace", + help=("namespace of the file"), + ) + self.parser.add_argument( + "-n", + "--name", + default="", + help=("name to use (DEFAULT: use source file name)"), + ) + self.parser.add_argument( + "-e", + "--encrypt", + action="store_true", + help=_("end-to-end encrypt the file transfer") + ) + + async def on_progress_started(self, metadata): + self.disp(_("File copy started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("File sent successfully"), 2) + + async def on_progress_error(self, error_msg): + if error_msg == C.PROGRESS_ERROR_DECLINED: + self.disp(_("The file has been refused by your contact")) + else: + self.disp(_("Error while sending file: {}").format(error_msg), error=True) + + async def got_id(self, data, file_): + """Called when a progress id has been received + + @param pid(unicode): progress id + @param file_(str): file path + """ + # FIXME: this show progress only for last progress_id + self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1) + try: + await self.set_progress_id(data["progress"]) + except KeyError: + # TODO: if 'xmlui' key is present, manage xmlui message display + self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True) + self.host.quit(2) + + async def start(self): + for file_ in self.args.files: + if not os.path.exists(file_): + self.disp( + _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True + ) + self.host.quit(C.EXIT_BAD_ARG) + if not self.args.bz2 and os.path.isdir(file_): + self.disp( + _( + "{file_} is a dir! Please send files inside or use compression" + ).format(file_=repr(file_)) + ) + self.host.quit(C.EXIT_BAD_ARG) + + extra = {} + if self.args.path: + extra["path"] = self.args.path + if self.args.namespace: + extra["namespace"] = self.args.namespace + if self.args.encrypt: + extra["encrypted"] = True + + if self.args.bz2: + with tempfile.NamedTemporaryFile("wb", delete=False) as buf: + self.host.add_on_quit_callback(os.unlink, buf.name) + self.disp(_("bz2 is an experimental option, use with caution")) + # FIXME: check free space + self.disp(_("Starting compression, please wait...")) + sys.stdout.flush() + bz2 = tarfile.open(mode="w:bz2", fileobj=buf) + archive_name = "{}.tar.bz2".format( + os.path.basename(self.args.files[0]) or "compressed_files" + ) + for file_ in self.args.files: + self.disp(_("Adding {}").format(file_), 1) + bz2.add(file_) + bz2.close() + self.disp(_("Done !"), 1) + + try: + send_data = await self.host.bridge.file_send( + self.args.jid, + buf.name, + self.args.name or archive_name, + "", + data_format.serialise(extra), + self.profile, + ) + except Exception as e: + self.disp(f"can't send file: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.got_id(send_data, file_) + else: + for file_ in self.args.files: + path = os.path.abspath(file_) + try: + send_data = await self.host.bridge.file_send( + self.args.jid, + path, + self.args.name, + "", + data_format.serialise(extra), + self.profile, + ) + except Exception as e: + self.disp(f"can't send file {file_!r}: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.got_id(send_data, file_) + + +class Request(base.CommandBase): + def __init__(self, host): + super(Request, self).__init__( + host, + "request", + use_progress=True, + use_verbose=True, + help=_("request a file from a contact"), + ) + + @property + def filename(self): + return self.args.name or self.args.hash or "output" + + def add_parser_options(self): + self.parser.add_argument("jid", help=_("the destination jid")) + self.parser.add_argument( + "-D", + "--dest", + help=_( + "destination path where the file will be saved (default: " + "[current_dir]/[name|hash])" + ), + ) + self.parser.add_argument( + "-n", + "--name", + default="", + help=_("name of the file"), + ) + self.parser.add_argument( + "-H", + "--hash", + default="", + help=_("hash of the file"), + ) + self.parser.add_argument( + "-a", + "--hash-algo", + default="sha-256", + help=_("hash algorithm use for --hash (default: sha-256)"), + ) + self.parser.add_argument( + "-d", + "--path", + help=("path to the directory containing the file"), + ) + self.parser.add_argument( + "-N", + "--namespace", + help=("namespace of the file"), + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("overwrite existing file without confirmation"), + ) + + async def on_progress_started(self, metadata): + self.disp(_("File copy started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("File received successfully"), 2) + + async def on_progress_error(self, error_msg): + if error_msg == C.PROGRESS_ERROR_DECLINED: + self.disp(_("The file request has been refused")) + else: + self.disp(_("Error while requesting file: {}").format(error_msg), error=True) + + async def start(self): + if not self.args.name and not self.args.hash: + self.parser.error(_("at least one of --name or --hash must be provided")) + if self.args.dest: + path = os.path.abspath(os.path.expanduser(self.args.dest)) + if os.path.isdir(path): + path = os.path.join(path, self.filename) + else: + path = os.path.abspath(self.filename) + + if os.path.exists(path) and not self.args.force: + message = _("File {path} already exists! Do you want to overwrite?").format( + path=path + ) + await self.host.confirm_or_quit(message, _("file request cancelled")) + + self.full_dest_jid = await self.host.get_full_jid(self.args.jid) + extra = {} + if self.args.path: + extra["path"] = self.args.path + if self.args.namespace: + extra["namespace"] = self.args.namespace + try: + progress_id = await self.host.bridge.file_jingle_request( + self.full_dest_jid, + path, + self.args.name, + self.args.hash, + self.args.hash_algo if self.args.hash else "", + extra, + self.profile, + ) + except Exception as e: + self.disp(msg=_("can't request file: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.set_progress_id(progress_id) + + +class Receive(base.CommandAnswering): + def __init__(self, host): + super(Receive, self).__init__( + host, + "receive", + use_progress=True, + use_verbose=True, + help=_("wait for a file to be sent by a contact"), + ) + self._overwrite_refused = False # True when one overwrite as already been refused + self.action_callbacks = { + C.META_TYPE_FILE: self.on_file_action, + C.META_TYPE_OVERWRITE: self.on_overwrite_action, + C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action, + } + + def add_parser_options(self): + self.parser.add_argument( + "jids", + nargs="*", + help=_("jids accepted (accept everything if none is specified)"), + ) + self.parser.add_argument( + "-m", + "--multiple", + action="store_true", + help=_("accept multiple files (you'll have to stop manually)"), + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_( + "force overwritting of existing files (/!\\ name is choosed by sender)" + ), + ) + self.parser.add_argument( + "--path", + default=".", + metavar="DIR", + help=_("destination path (default: working directory)"), + ) + + async def on_progress_started(self, metadata): + self.disp(_("File copy started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("File received successfully"), 2) + if metadata.get("hash_verified", False): + try: + self.disp( + _("hash checked: {metadata['hash_algo']}:{metadata['hash']}"), 1 + ) + except KeyError: + self.disp(_("hash is checked but hash value is missing", 1), error=True) + else: + self.disp(_("hash can't be verified"), 1) + + async def on_progress_error(self, e): + self.disp(_("Error while receiving file: {e}").format(e=e), error=True) + + def get_xmlui_id(self, action_data): + # FIXME: we temporarily use ElementTree, but a real XMLUI managing module + # should be available in the futur + # TODO: XMLUI module + try: + xml_ui = action_data["xmlui"] + except KeyError: + self.disp(_("Action has no XMLUI"), 1) + else: + ui = ET.fromstring(xml_ui.encode("utf-8")) + xmlui_id = ui.get("submit") + if not xmlui_id: + self.disp(_("Invalid XMLUI received"), error=True) + return xmlui_id + + async def on_file_action(self, action_data, action_id, security_limit, profile): + xmlui_id = self.get_xmlui_id(action_data) + if xmlui_id is None: + return self.host.quit_from_signal(1) + try: + from_jid = jid.JID(action_data["from_jid"]) + except KeyError: + self.disp(_("Ignoring action without from_jid data"), 1) + return + try: + progress_id = action_data["progress_id"] + except KeyError: + self.disp(_("ignoring action without progress id"), 1) + return + + if not self.bare_jids or from_jid.bare in self.bare_jids: + if self._overwrite_refused: + self.disp(_("File refused because overwrite is needed"), error=True) + await self.host.bridge.action_launch( + xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}), + profile_key=profile + ) + return self.host.quit_from_signal(2) + await self.set_progress_id(progress_id) + xmlui_data = {"path": self.path} + await self.host.bridge.action_launch( + xmlui_id, data_format.serialise(xmlui_data), profile_key=profile + ) + + async def on_overwrite_action(self, action_data, action_id, security_limit, profile): + xmlui_id = self.get_xmlui_id(action_data) + if xmlui_id is None: + return self.host.quit_from_signal(1) + try: + progress_id = action_data["progress_id"] + except KeyError: + self.disp(_("ignoring action without progress id"), 1) + return + self.disp(_("Overwriting needed"), 1) + + if progress_id == self.progress_id: + if self.args.force: + self.disp(_("Overwrite accepted"), 2) + else: + self.disp(_("Refused to overwrite"), 2) + self._overwrite_refused = True + + xmlui_data = {"answer": C.bool_const(self.args.force)} + await self.host.bridge.action_launch( + xmlui_id, data_format.serialise(xmlui_data), profile_key=profile + ) + + async def on_not_in_roster_action(self, action_data, action_id, security_limit, profile): + xmlui_id = self.get_xmlui_id(action_data) + if xmlui_id is None: + return self.host.quit_from_signal(1) + try: + from_jid = jid.JID(action_data["from_jid"]) + except ValueError: + self.disp( + _('invalid "from_jid" value received, ignoring: {value}').format( + value=from_jid + ), + error=True, + ) + return + except KeyError: + self.disp(_('ignoring action without "from_jid" value'), error=True) + return + self.disp(_("Confirmation needed for request from an entity not in roster"), 1) + + if from_jid.bare in self.bare_jids: + # if the sender is expected, we can confirm the session + confirmed = True + self.disp(_("Sender confirmed because she or he is explicitly expected"), 1) + else: + xmlui = xmlui_manager.create(self.host, action_data["xmlui"]) + confirmed = await self.host.confirm(xmlui.dlg.message) + + xmlui_data = {"answer": C.bool_const(confirmed)} + await self.host.bridge.action_launch( + xmlui_id, data_format.serialise(xmlui_data), profile_key=profile + ) + if not confirmed and not self.args.multiple: + self.disp(_("Session refused for {from_jid}").format(from_jid=from_jid)) + self.host.quit_from_signal(0) + + async def start(self): + self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids] + self.path = os.path.abspath(self.args.path) + if not os.path.isdir(self.path): + self.disp(_("Given path is not a directory !", error=True)) + self.host.quit(C.EXIT_BAD_ARG) + if self.args.multiple: + self.host.quit_on_progress_end = False + self.disp(_("waiting for incoming file request"), 2) + await self.start_answering() + + +class Get(base.CommandBase): + def __init__(self, host): + super(Get, self).__init__( + host, + "get", + use_progress=True, + use_verbose=True, + help=_("download a file from URI"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-o", + "--dest-file", + type=str, + default="", + help=_("destination file (DEFAULT: filename from URL)"), + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("overwrite existing file without confirmation"), + ) + self.parser.add_argument( + "attachment", type=str, + help=_("URI of the file to retrieve or JSON of the whole attachment") + ) + + async def on_progress_started(self, metadata): + self.disp(_("File download started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("File downloaded successfully"), 2) + + async def on_progress_error(self, error_msg): + self.disp(_("Error while downloading file: {}").format(error_msg), error=True) + + async def got_id(self, data): + """Called when a progress id has been received""" + try: + await self.set_progress_id(data["progress"]) + except KeyError: + if "xmlui" in data: + ui = xmlui_manager.create(self.host, data["xmlui"]) + await ui.show() + else: + self.disp(_("Can't download file"), error=True) + self.host.quit(C.EXIT_ERROR) + + async def start(self): + try: + attachment = json.loads(self.args.attachment) + except json.JSONDecodeError: + attachment = {"uri": self.args.attachment} + dest_file = self.args.dest_file + if not dest_file: + try: + dest_file = attachment["name"].replace("/", "-").strip() + except KeyError: + try: + dest_file = Path(urlparse(attachment["uri"]).path).name.strip() + except KeyError: + pass + if not dest_file: + dest_file = "downloaded_file" + + dest_file = Path(dest_file).expanduser().resolve() + if dest_file.exists() and not self.args.force: + message = _("File {path} already exists! Do you want to overwrite?").format( + path=dest_file + ) + await self.host.confirm_or_quit(message, _("file download cancelled")) + + options = {} + + try: + download_data_s = await self.host.bridge.file_download( + data_format.serialise(attachment), + str(dest_file), + data_format.serialise(options), + self.profile, + ) + download_data = data_format.deserialise(download_data_s) + except Exception as e: + self.disp(f"error while trying to download a file: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.got_id(download_data) + + +class Upload(base.CommandBase): + def __init__(self, host): + super(Upload, self).__init__( + host, "upload", use_progress=True, use_verbose=True, help=_("upload a file") + ) + + def add_parser_options(self): + self.parser.add_argument( + "-e", + "--encrypt", + action="store_true", + help=_("encrypt file using AES-GCM"), + ) + self.parser.add_argument("file", type=str, help=_("file to upload")) + self.parser.add_argument( + "jid", + nargs="?", + help=_("jid of upload component (nothing to autodetect)"), + ) + self.parser.add_argument( + "--ignore-tls-errors", + action="store_true", + help=_(r"ignore invalide TLS certificate (/!\ Dangerous /!\)"), + ) + + async def on_progress_started(self, metadata): + self.disp(_("File upload started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("File uploaded successfully"), 2) + try: + url = metadata["url"] + except KeyError: + self.disp("download URL not found in metadata") + else: + self.disp(_("URL to retrieve the file:"), 1) + # XXX: url is displayed alone on a line to make parsing easier + self.disp(url) + + async def on_progress_error(self, error_msg): + self.disp(_("Error while uploading file: {}").format(error_msg), error=True) + + async def got_id(self, data, file_): + """Called when a progress id has been received + + @param pid(unicode): progress id + @param file_(str): file path + """ + try: + await self.set_progress_id(data["progress"]) + except KeyError: + if "xmlui" in data: + ui = xmlui_manager.create(self.host, data["xmlui"]) + await ui.show() + else: + self.disp(_("Can't upload file"), error=True) + self.host.quit(C.EXIT_ERROR) + + async def start(self): + file_ = self.args.file + if not os.path.exists(file_): + self.disp( + _("file {file_} doesn't exist !").format(file_=repr(file_)), error=True + ) + self.host.quit(C.EXIT_BAD_ARG) + if os.path.isdir(file_): + self.disp(_("{file_} is a dir! Can't upload a dir").format(file_=repr(file_))) + self.host.quit(C.EXIT_BAD_ARG) + + if self.args.jid is None: + self.full_dest_jid = "" + else: + self.full_dest_jid = await self.host.get_full_jid(self.args.jid) + + options = {} + if self.args.ignore_tls_errors: + options["ignore_tls_errors"] = True + if self.args.encrypt: + options["encryption"] = C.ENC_AES_GCM + + path = os.path.abspath(file_) + try: + upload_data = await self.host.bridge.file_upload( + path, + "", + self.full_dest_jid, + data_format.serialise(options), + self.profile, + ) + except Exception as e: + self.disp(f"error while trying to upload a file: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.got_id(upload_data, file_) + + +class ShareAffiliationsSet(base.CommandBase): + def __init__(self, host): + super(ShareAffiliationsSet, self).__init__( + host, + "set", + use_output=C.OUTPUT_DICT, + help=_("set affiliations for a shared file/directory"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-N", + "--namespace", + default="", + help=_("namespace of the repository"), + ) + self.parser.add_argument( + "-P", + "--path", + default="", + help=_("path to the repository"), + ) + self.parser.add_argument( + "-a", + "--affiliation", + dest="affiliations", + metavar=("JID", "AFFILIATION"), + required=True, + action="append", + nargs=2, + help=_("entity/affiliation couple(s)"), + ) + self.parser.add_argument( + "jid", + help=_("jid of file sharing entity"), + ) + + async def start(self): + affiliations = dict(self.args.affiliations) + try: + affiliations = await self.host.bridge.fis_affiliations_set( + self.args.jid, + self.args.namespace, + self.args.path, + affiliations, + self.profile, + ) + except Exception as e: + self.disp(f"can't set affiliations: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class ShareAffiliationsGet(base.CommandBase): + def __init__(self, host): + super(ShareAffiliationsGet, self).__init__( + host, + "get", + use_output=C.OUTPUT_DICT, + help=_("retrieve affiliations of a shared file/directory"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-N", + "--namespace", + default="", + help=_("namespace of the repository"), + ) + self.parser.add_argument( + "-P", + "--path", + default="", + help=_("path to the repository"), + ) + self.parser.add_argument( + "jid", + help=_("jid of sharing entity"), + ) + + async def start(self): + try: + affiliations = await self.host.bridge.fis_affiliations_get( + self.args.jid, + self.args.namespace, + self.args.path, + self.profile, + ) + except Exception as e: + self.disp(f"can't get affiliations: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(affiliations) + self.host.quit() + + +class ShareAffiliations(base.CommandBase): + subcommands = (ShareAffiliationsGet, ShareAffiliationsSet) + + def __init__(self, host): + super(ShareAffiliations, self).__init__( + host, "affiliations", use_profile=False, help=_("affiliations management") + ) + + +class ShareConfigurationSet(base.CommandBase): + def __init__(self, host): + super(ShareConfigurationSet, self).__init__( + host, + "set", + use_output=C.OUTPUT_DICT, + help=_("set configuration for a shared file/directory"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-N", + "--namespace", + default="", + help=_("namespace of the repository"), + ) + self.parser.add_argument( + "-P", + "--path", + default="", + help=_("path to the repository"), + ) + self.parser.add_argument( + "-f", + "--field", + action="append", + nargs=2, + dest="fields", + required=True, + metavar=("KEY", "VALUE"), + help=_("configuration field to set (required)"), + ) + self.parser.add_argument( + "jid", + help=_("jid of file sharing entity"), + ) + + async def start(self): + configuration = dict(self.args.fields) + try: + configuration = await self.host.bridge.fis_configuration_set( + self.args.jid, + self.args.namespace, + self.args.path, + configuration, + self.profile, + ) + except Exception as e: + self.disp(f"can't set configuration: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class ShareConfigurationGet(base.CommandBase): + def __init__(self, host): + super(ShareConfigurationGet, self).__init__( + host, + "get", + use_output=C.OUTPUT_DICT, + help=_("retrieve configuration of a shared file/directory"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-N", + "--namespace", + default="", + help=_("namespace of the repository"), + ) + self.parser.add_argument( + "-P", + "--path", + default="", + help=_("path to the repository"), + ) + self.parser.add_argument( + "jid", + help=_("jid of sharing entity"), + ) + + async def start(self): + try: + configuration = await self.host.bridge.fis_configuration_get( + self.args.jid, + self.args.namespace, + self.args.path, + self.profile, + ) + except Exception as e: + self.disp(f"can't get configuration: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(configuration) + self.host.quit() + + +class ShareConfiguration(base.CommandBase): + subcommands = (ShareConfigurationGet, ShareConfigurationSet) + + def __init__(self, host): + super(ShareConfiguration, self).__init__( + host, + "configuration", + use_profile=False, + help=_("file sharing node configuration"), + ) + + +class ShareList(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + super(ShareList, self).__init__( + host, + "list", + use_output=C.OUTPUT_LIST_DICT, + extra_outputs=extra_outputs, + help=_("retrieve files shared by an entity"), + use_verbose=True, + ) + + def add_parser_options(self): + self.parser.add_argument( + "-d", + "--path", + default="", + help=_("path to the directory containing the files"), + ) + self.parser.add_argument( + "jid", + nargs="?", + default="", + help=_("jid of sharing entity (nothing to check our own jid)"), + ) + + def _name_filter(self, name, row): + if row.type == C.FILE_TYPE_DIRECTORY: + return A.color(C.A_DIRECTORY, name) + elif row.type == C.FILE_TYPE_FILE: + return A.color(C.A_FILE, name) + else: + self.disp(_("unknown file type: {type}").format(type=row.type), error=True) + return name + + def _size_filter(self, size, row): + if not size: + return "" + return A.color(A.BOLD, utils.get_human_size(size)) + + def default_output(self, files_data): + """display files a way similar to ls""" + files_data.sort(key=lambda d: d["name"].lower()) + show_header = False + if self.verbosity == 0: + keys = headers = ("name", "type") + elif self.verbosity == 1: + keys = headers = ("name", "type", "size") + elif self.verbosity > 1: + show_header = True + keys = ("name", "type", "size", "file_hash") + headers = ("name", "type", "size", "hash") + table = common.Table.from_list_dict( + self.host, + files_data, + keys=keys, + headers=headers, + filters={"name": self._name_filter, "size": self._size_filter}, + defaults={"size": "", "file_hash": ""}, + ) + table.display_blank(show_header=show_header, hide_cols=["type"]) + + async def start(self): + try: + files_data = await self.host.bridge.fis_list( + self.args.jid, + self.args.path, + {}, + self.profile, + ) + except Exception as e: + self.disp(f"can't retrieve shared files: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + await self.output(files_data) + self.host.quit() + + +class SharePath(base.CommandBase): + def __init__(self, host): + super(SharePath, self).__init__( + host, "path", help=_("share a file or directory"), use_verbose=True + ) + + def add_parser_options(self): + self.parser.add_argument( + "-n", + "--name", + default="", + help=_("virtual name to use (default: use directory/file name)"), + ) + perm_group = self.parser.add_mutually_exclusive_group() + perm_group.add_argument( + "-j", + "--jid", + metavar="JID", + action="append", + dest="jids", + default=[], + help=_("jid of contacts allowed to retrieve the files"), + ) + perm_group.add_argument( + "--public", + action="store_true", + help=_( + r"share publicly the file(s) (/!\ *everybody* will be able to access " + r"them)" + ), + ) + self.parser.add_argument( + "path", + help=_("path to a file or directory to share"), + ) + + async def start(self): + self.path = os.path.abspath(self.args.path) + if self.args.public: + access = {"read": {"type": "public"}} + else: + jids = self.args.jids + if jids: + access = {"read": {"type": "whitelist", "jids": jids}} + else: + access = {} + try: + name = await self.host.bridge.fis_share_path( + self.args.name, + self.path, + json.dumps(access, ensure_ascii=False), + self.profile, + ) + except Exception as e: + self.disp(f"can't share path: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp( + _('{path} shared under the name "{name}"').format( + path=self.path, name=name + ) + ) + self.host.quit() + + +class ShareInvite(base.CommandBase): + def __init__(self, host): + super(ShareInvite, self).__init__( + host, "invite", help=_("send invitation for a shared repository") + ) + + def add_parser_options(self): + self.parser.add_argument( + "-n", + "--name", + default="", + help=_("name of the repository"), + ) + self.parser.add_argument( + "-N", + "--namespace", + default="", + help=_("namespace of the repository"), + ) + self.parser.add_argument( + "-P", + "--path", + help=_("path to the repository"), + ) + self.parser.add_argument( + "-t", + "--type", + choices=["files", "photos"], + default="files", + help=_("type of the repository"), + ) + self.parser.add_argument( + "-T", + "--thumbnail", + help=_("https URL of a image to use as thumbnail"), + ) + self.parser.add_argument( + "service", + help=_("jid of the file sharing service hosting the repository"), + ) + self.parser.add_argument( + "jid", + help=_("jid of the person to invite"), + ) + + async def start(self): + self.path = os.path.normpath(self.args.path) if self.args.path else "" + extra = {} + if self.args.thumbnail is not None: + if not self.args.thumbnail.startswith("http"): + self.parser.error(_("only http(s) links are allowed with --thumbnail")) + else: + extra["thumb_url"] = self.args.thumbnail + try: + await self.host.bridge.fis_invite( + self.args.jid, + self.args.service, + self.args.type, + self.args.namespace, + self.path, + self.args.name, + data_format.serialise(extra), + self.profile, + ) + except Exception as e: + self.disp(f"can't send invitation: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("invitation sent to {jid}").format(jid=self.args.jid)) + self.host.quit() + + +class Share(base.CommandBase): + subcommands = ( + ShareList, + SharePath, + ShareInvite, + ShareAffiliations, + ShareConfiguration, + ) + + def __init__(self, host): + super(Share, self).__init__( + host, "share", use_profile=False, help=_("files sharing management") + ) + + +class File(base.CommandBase): + subcommands = (Send, Request, Receive, Get, Upload, Share) + + def __init__(self, host): + super(File, self).__init__( + host, "file", use_profile=False, help=_("files sending/receiving/management") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_forums.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_forums.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.cli import common +from libervia.backend.tools.common.ansi import ANSI as A +import codecs +import json + +__commands__ = ["Forums"] + +FORUMS_TMP_DIR = "forums" + + +class Edit(base.CommandBase, common.BaseEdit): + use_items = False + + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "edit", + use_pubsub=True, + use_draft=True, + use_verbose=True, + help=_("edit forums"), + ) + common.BaseEdit.__init__(self, self.host, FORUMS_TMP_DIR) + + def add_parser_options(self): + self.parser.add_argument( + "-k", + "--key", + default="", + help=_("forum key (DEFAULT: default forums)"), + ) + + def get_tmp_suff(self): + """return suffix used for content file""" + return "json" + + async def publish(self, forums_raw): + try: + await self.host.bridge.forums_set( + forums_raw, + self.args.service, + self.args.node, + self.args.key, + self.profile, + ) + except Exception as e: + self.disp(f"can't set forums: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("forums have been edited"), 1) + self.host.quit() + + async def start(self): + try: + forums_json = await self.host.bridge.forums_get( + self.args.service, + self.args.node, + self.args.key, + self.profile, + ) + except Exception as e: + if e.classname == "NotFound": + forums_json = "" + else: + self.disp(f"can't get node configuration: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + content_file_obj, content_file_path = self.get_tmp_file() + forums_json = forums_json.strip() + if forums_json: + # we loads and dumps to have pretty printed json + forums = json.loads(forums_json) + # cf. https://stackoverflow.com/a/18337754 + f = codecs.getwriter("utf-8")(content_file_obj) + json.dump(forums, f, ensure_ascii=False, indent=4) + content_file_obj.seek(0) + await self.run_editor("forums_editor_args", content_file_path, content_file_obj) + + +class Get(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_COMPLEX, + extra_outputs=extra_outputs, + use_pubsub=True, + use_verbose=True, + help=_("get forums structure"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-k", + "--key", + default="", + help=_("forum key (DEFAULT: default forums)"), + ) + + def default_output(self, forums, level=0): + for forum in forums: + keys = list(forum.keys()) + keys.sort() + try: + keys.remove("title") + except ValueError: + pass + else: + keys.insert(0, "title") + try: + keys.remove("sub-forums") + except ValueError: + pass + else: + keys.append("sub-forums") + + for key in keys: + value = forum[key] + if key == "sub-forums": + self.default_output(value, level + 1) + else: + if self.host.verbosity < 1 and key != "title": + continue + head_color = C.A_LEVEL_COLORS[level % len(C.A_LEVEL_COLORS)] + self.disp( + A.color(level * 4 * " ", head_color, key, A.RESET, ": ", value) + ) + + async def start(self): + try: + forums_raw = await self.host.bridge.forums_get( + self.args.service, + self.args.node, + self.args.key, + self.profile, + ) + except Exception as e: + self.disp(f"can't get forums: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if not forums_raw: + self.disp(_("no schema found"), 1) + self.host.quit(1) + forums = json.loads(forums_raw) + await self.output(forums) + self.host.quit() + + +class Forums(base.CommandBase): + subcommands = (Get, Edit) + + def __init__(self, host): + super(Forums, self).__init__( + host, "forums", use_profile=False, help=_("Forums structure edition") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_identity.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_identity.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.backend.tools.common import data_format + +__commands__ = ["Identity"] + + +class Get(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_DICT, + use_verbose=True, + help=_("get identity data"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "--no-cache", action="store_true", help=_("do no use cached values") + ) + self.parser.add_argument( + "jid", help=_("entity to check") + ) + + async def start(self): + jid_ = (await self.host.check_jids([self.args.jid]))[0] + try: + data = await self.host.bridge.identity_get( + jid_, + [], + not self.args.no_cache, + self.profile + ) + except Exception as e: + self.disp(f"can't get identity data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + data = data_format.deserialise(data) + await self.output(data) + self.host.quit() + + +class Set(base.CommandBase): + def __init__(self, host): + super(Set, self).__init__(host, "set", help=_("update identity data")) + + def add_parser_options(self): + self.parser.add_argument( + "-n", + "--nickname", + action="append", + metavar="NICKNAME", + dest="nicknames", + help=_("nicknames of the entity"), + ) + self.parser.add_argument( + "-d", + "--description", + help=_("description of the entity"), + ) + + async def start(self): + id_data = {} + for field in ("nicknames", "description"): + value = getattr(self.args, field) + if value is not None: + id_data[field] = value + if not id_data: + self.parser.error("At least one metadata must be set") + try: + self.host.bridge.identity_set( + data_format.serialise(id_data), + self.profile, + ) + except Exception as e: + self.disp(f"can't set identity data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class Identity(base.CommandBase): + subcommands = (Get, Set) + + def __init__(self, host): + super(Identity, self).__init__( + host, "identity", use_profile=False, help=_("identity management") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_info.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_info.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from pprint import pformat + +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format, date_utils +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.cli import common +from libervia.cli.constants import Const as C + +from . import base + +__commands__ = ["Info"] + + +class Disco(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + super(Disco, self).__init__( + host, + "disco", + use_output="complex", + extra_outputs=extra_outputs, + help=_("service discovery"), + ) + + def add_parser_options(self): + self.parser.add_argument("jid", help=_("entity to discover")) + self.parser.add_argument( + "-t", + "--type", + type=str, + choices=("infos", "items", "both", "external", "all"), + default="all", + help=_("type of data to discover"), + ) + self.parser.add_argument("-n", "--node", default="", help=_("node to use")) + self.parser.add_argument( + "-C", + "--no-cache", + dest="use_cache", + action="store_false", + help=_("ignore cache"), + ) + + def default_output(self, data): + features = data.get("features", []) + identities = data.get("identities", []) + extensions = data.get("extensions", {}) + items = data.get("items", []) + external = data.get("external", []) + + identities_table = common.Table( + self.host, + identities, + headers=(_("category"), _("type"), _("name")), + use_buffer=True, + ) + + extensions_tpl = [] + extensions_types = list(extensions.keys()) + extensions_types.sort() + for type_ in extensions_types: + fields = [] + for field in extensions[type_]: + field_lines = [] + data, values = field + data_keys = list(data.keys()) + data_keys.sort() + for key in data_keys: + field_lines.append( + A.color("\t", C.A_SUBHEADER, key, A.RESET, ": ", data[key]) + ) + if len(values) == 1: + field_lines.append( + A.color( + "\t", + C.A_SUBHEADER, + "value", + A.RESET, + ": ", + values[0] or (A.BOLD + "UNSET"), + ) + ) + elif len(values) > 1: + field_lines.append( + A.color("\t", C.A_SUBHEADER, "values", A.RESET, ": ") + ) + + for value in values: + field_lines.append(A.color("\t - ", A.BOLD, value)) + fields.append("\n".join(field_lines)) + extensions_tpl.append( + "{type_}\n{fields}".format(type_=type_, fields="\n\n".join(fields)) + ) + + items_table = common.Table( + self.host, items, headers=(_("entity"), _("node"), _("name")), use_buffer=True + ) + + template = [] + fmt_kwargs = {} + if features: + template.append(A.color(C.A_HEADER, _("Features")) + "\n\n{features}") + if identities: + template.append(A.color(C.A_HEADER, _("Identities")) + "\n\n{identities}") + if extensions: + template.append(A.color(C.A_HEADER, _("Extensions")) + "\n\n{extensions}") + if items: + template.append(A.color(C.A_HEADER, _("Items")) + "\n\n{items}") + if external: + fmt_lines = [] + for e in external: + data = {k: e[k] for k in sorted(e)} + host = data.pop("host") + type_ = data.pop("type") + fmt_lines.append(A.color( + "\t", + C.A_SUBHEADER, + host, + " ", + A.RESET, + "[", + C.A_LEVEL_COLORS[1], + type_, + A.RESET, + "]", + )) + extended = data.pop("extended", None) + for key, value in data.items(): + fmt_lines.append(A.color( + "\t\t", + C.A_LEVEL_COLORS[2], + f"{key}: ", + C.A_LEVEL_COLORS[3], + str(value) + )) + if extended: + fmt_lines.append(A.color( + "\t\t", + C.A_HEADER, + "extended", + )) + nb_extended = len(extended) + for idx, form_data in enumerate(extended): + namespace = form_data.get("namespace") + if namespace: + fmt_lines.append(A.color( + "\t\t", + C.A_LEVEL_COLORS[2], + "namespace: ", + C.A_LEVEL_COLORS[3], + A.BOLD, + namespace + )) + for field_data in form_data["fields"]: + name = field_data.get("name") + if not name: + continue + field_type = field_data.get("type") + if "multi" in field_type: + value = ", ".join(field_data.get("values") or []) + else: + value = field_data.get("value") + if value is None: + continue + if field_type == "boolean": + value = C.bool(value) + fmt_lines.append(A.color( + "\t\t", + C.A_LEVEL_COLORS[2], + f"{name}: ", + C.A_LEVEL_COLORS[3], + A.BOLD, + str(value) + )) + if nb_extended>1 and idx < nb_extended-1: + fmt_lines.append("\n") + + fmt_lines.append("\n") + + template.append( + A.color(C.A_HEADER, _("External")) + "\n\n{external_formatted}" + ) + fmt_kwargs["external_formatted"] = "\n".join(fmt_lines) + + print( + "\n\n".join(template).format( + features="\n".join(features), + identities=identities_table.display().string, + extensions="\n".join(extensions_tpl), + items=items_table.display().string, + **fmt_kwargs, + ) + ) + + async def start(self): + infos_requested = self.args.type in ("infos", "both", "all") + items_requested = self.args.type in ("items", "both", "all") + exter_requested = self.args.type in ("external", "all") + if self.args.node: + if self.args.type == "external": + self.parser.error( + '--node can\'t be used with discovery of external services ' + '(--type="external")' + ) + else: + exter_requested = False + jids = await self.host.check_jids([self.args.jid]) + jid = jids[0] + data = {} + + # infos + if infos_requested: + try: + infos = await self.host.bridge.disco_infos( + jid, + node=self.args.node, + use_cache=self.args.use_cache, + profile_key=self.host.profile, + ) + except Exception as e: + self.disp(_("error while doing discovery: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + else: + features, identities, extensions = infos + features.sort() + identities.sort(key=lambda identity: identity[2]) + data.update( + {"features": features, "identities": identities, "extensions": extensions} + ) + + # items + if items_requested: + try: + items = await self.host.bridge.disco_items( + jid, + node=self.args.node, + use_cache=self.args.use_cache, + profile_key=self.host.profile, + ) + except Exception as e: + self.disp(_("error while doing discovery: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + items.sort(key=lambda item: item[2]) + data["items"] = items + + # external + if exter_requested: + try: + ext_services_s = await self.host.bridge.external_disco_get( + jid, + self.host.profile, + ) + except Exception as e: + self.disp( + _("error while doing external service discovery: {e}").format(e=e), + error=True + ) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + data["external"] = data_format.deserialise( + ext_services_s, type_check=list + ) + + # output + await self.output(data) + self.host.quit() + + +class Version(base.CommandBase): + def __init__(self, host): + super(Version, self).__init__(host, "version", help=_("software version")) + + def add_parser_options(self): + self.parser.add_argument("jid", type=str, help=_("Entity to request")) + + async def start(self): + jids = await self.host.check_jids([self.args.jid]) + jid = jids[0] + try: + data = await self.host.bridge.software_version_get(jid, self.host.profile) + except Exception as e: + self.disp(_("error while trying to get version: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + infos = [] + name, version, os = data + if name: + infos.append(_("Software name: {name}").format(name=name)) + if version: + infos.append(_("Software version: {version}").format(version=version)) + if os: + infos.append(_("Operating System: {os}").format(os=os)) + + print("\n".join(infos)) + self.host.quit() + + +class Session(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + super(Session, self).__init__( + host, + "session", + use_output="dict", + extra_outputs=extra_outputs, + help=_("running session"), + ) + + def add_parser_options(self): + pass + + async def default_output(self, data): + started = data["started"] + data["started"] = "{short} (UTC, {relative})".format( + short=date_utils.date_fmt(started), + relative=date_utils.date_fmt(started, "relative"), + ) + await self.host.output(C.OUTPUT_DICT, "simple", {}, data) + + async def start(self): + try: + data = await self.host.bridge.session_infos_get(self.host.profile) + except Exception as e: + self.disp(_("Error getting session infos: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(data) + self.host.quit() + + +class Devices(base.CommandBase): + def __init__(self, host): + super(Devices, self).__init__( + host, "devices", use_output=C.OUTPUT_LIST_DICT, help=_("devices of an entity") + ) + + def add_parser_options(self): + self.parser.add_argument( + "jid", type=str, nargs="?", default="", help=_("Entity to request") + ) + + async def start(self): + try: + data = await self.host.bridge.devices_infos_get( + self.args.jid, self.host.profile + ) + except Exception as e: + self.disp(_("Error getting devices infos: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + data = data_format.deserialise(data, type_check=list) + await self.output(data) + self.host.quit() + + +class Info(base.CommandBase): + subcommands = (Disco, Version, Session, Devices) + + def __init__(self, host): + super(Info, self).__init__( + host, + "info", + use_profile=False, + help=_("Get various pieces of information on entities"), + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_input.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_input.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import subprocess +import argparse +import sys +import shlex +import asyncio +from . import base +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions +from libervia.cli.constants import Const as C +from libervia.backend.tools.common.ansi import ANSI as A + +__commands__ = ["Input"] +OPT_STDIN = "stdin" +OPT_SHORT = "short" +OPT_LONG = "long" +OPT_POS = "positional" +OPT_IGNORE = "ignore" +OPT_TYPES = (OPT_STDIN, OPT_SHORT, OPT_LONG, OPT_POS, OPT_IGNORE) +OPT_EMPTY_SKIP = "skip" +OPT_EMPTY_IGNORE = "ignore" +OPT_EMPTY_CHOICES = (OPT_EMPTY_SKIP, OPT_EMPTY_IGNORE) + + +class InputCommon(base.CommandBase): + def __init__(self, host, name, help): + base.CommandBase.__init__( + self, host, name, use_verbose=True, use_profile=False, help=help + ) + self.idx = 0 + self.reset() + + def reset(self): + self.args_idx = 0 + self._stdin = [] + self._opts = [] + self._pos = [] + self._values_ori = [] + + def add_parser_options(self): + self.parser.add_argument( + "--encoding", default="utf-8", help=_("encoding of the input data") + ) + self.parser.add_argument( + "-i", + "--stdin", + action="append_const", + const=(OPT_STDIN, None), + dest="arguments", + help=_("standard input"), + ) + self.parser.add_argument( + "-s", + "--short", + type=self.opt(OPT_SHORT), + action="append", + dest="arguments", + help=_("short option"), + ) + self.parser.add_argument( + "-l", + "--long", + type=self.opt(OPT_LONG), + action="append", + dest="arguments", + help=_("long option"), + ) + self.parser.add_argument( + "-p", + "--positional", + type=self.opt(OPT_POS), + action="append", + dest="arguments", + help=_("positional argument"), + ) + self.parser.add_argument( + "-x", + "--ignore", + action="append_const", + const=(OPT_IGNORE, None), + dest="arguments", + help=_("ignore value"), + ) + self.parser.add_argument( + "-D", + "--debug", + action="store_true", + help=_("don't actually run commands but echo what would be launched"), + ) + self.parser.add_argument( + "--log", type=argparse.FileType("w"), help=_("log stdout to FILE") + ) + self.parser.add_argument( + "--log-err", type=argparse.FileType("w"), help=_("log stderr to FILE") + ) + self.parser.add_argument("command", nargs=argparse.REMAINDER) + + def opt(self, type_): + return lambda s: (type_, s) + + def add_value(self, value): + """add a parsed value according to arguments sequence""" + self._values_ori.append(value) + arguments = self.args.arguments + try: + arg_type, arg_name = arguments[self.args_idx] + except IndexError: + self.disp( + _("arguments in input data and in arguments sequence don't match"), + error=True, + ) + self.host.quit(C.EXIT_DATA_ERROR) + self.args_idx += 1 + while self.args_idx < len(arguments): + next_arg = arguments[self.args_idx] + if next_arg[0] not in OPT_TYPES: + # value will not be used if False or None, so we skip filter + if value not in (False, None): + # we have a filter + filter_type, filter_arg = arguments[self.args_idx] + value = self.filter(filter_type, filter_arg, value) + else: + break + self.args_idx += 1 + + if value is None: + # we ignore this argument + return + + if value is False: + # we skip the whole row + if self.args.debug: + self.disp( + A.color( + C.A_SUBHEADER, + _("values: "), + A.RESET, + ", ".join(self._values_ori), + ), + 2, + ) + self.disp(A.color(A.BOLD, _("**SKIPPING**\n"))) + self.reset() + self.idx += 1 + raise exceptions.CancelError + + if not isinstance(value, list): + value = [value] + + for v in value: + if arg_type == OPT_STDIN: + self._stdin.append(v) + elif arg_type == OPT_SHORT: + self._opts.append("-{}".format(arg_name)) + self._opts.append(v) + elif arg_type == OPT_LONG: + self._opts.append("--{}".format(arg_name)) + self._opts.append(v) + elif arg_type == OPT_POS: + self._pos.append(v) + elif arg_type == OPT_IGNORE: + pass + else: + self.parser.error( + _( + "Invalid argument, an option type is expected, got {type_}:{name}" + ).format(type_=arg_type, name=arg_name) + ) + + async def runCommand(self): + """run requested command with parsed arguments""" + if self.args_idx != len(self.args.arguments): + self.disp( + _("arguments in input data and in arguments sequence don't match"), + error=True, + ) + self.host.quit(C.EXIT_DATA_ERROR) + end = '\n' if self.args.debug else ' ' + self.disp( + A.color(C.A_HEADER, _("command {idx}").format(idx=self.idx)), + end = end, + ) + stdin = "".join(self._stdin) + if self.args.debug: + self.disp( + A.color( + C.A_SUBHEADER, + _("values: "), + A.RESET, + ", ".join([shlex.quote(a) for a in self._values_ori]) + ), + 2, + ) + + if stdin: + self.disp(A.color(C.A_SUBHEADER, "--- STDIN ---")) + self.disp(stdin) + self.disp(A.color(C.A_SUBHEADER, "-------------")) + + self.disp( + "{indent}{prog} {static} {options} {positionals}".format( + indent=4 * " ", + prog=sys.argv[0], + static=" ".join(self.args.command), + options=" ".join(shlex.quote(o) for o in self._opts), + positionals=" ".join(shlex.quote(p) for p in self._pos), + ) + ) + self.disp("\n") + else: + self.disp(" (" + ", ".join(self._values_ori) + ")", 2, end=' ') + args = [sys.argv[0]] + self.args.command + self._opts + self._pos + p = await asyncio.create_subprocess_exec( + *args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = await p.communicate(stdin.encode('utf-8')) + log = self.args.log + log_err = self.args.log_err + log_tpl = "{command}\n{buff}\n\n" + if log: + log.write(log_tpl.format( + command=" ".join(shlex.quote(a) for a in args), + buff=stdout.decode('utf-8', 'replace'))) + if log_err: + log_err.write(log_tpl.format( + command=" ".join(shlex.quote(a) for a in args), + buff=stderr.decode('utf-8', 'replace'))) + ret = p.returncode + if ret == 0: + self.disp(A.color(C.A_SUCCESS, _("OK"))) + else: + self.disp(A.color(C.A_FAILURE, _("FAILED"))) + + self.reset() + self.idx += 1 + + def filter(self, filter_type, filter_arg, value): + """change input value + + @param filter_type(unicode): name of the filter + @param filter_arg(unicode, None): argument of the filter + @param value(unicode): value to filter + @return (unicode, False, None): modified value + False to skip the whole row + None to ignore this argument (but continue row with other ones) + """ + raise NotImplementedError + + +class Csv(InputCommon): + def __init__(self, host): + super(Csv, self).__init__(host, "csv", _("comma-separated values")) + + def add_parser_options(self): + InputCommon.add_parser_options(self) + self.parser.add_argument( + "-r", + "--row", + type=int, + default=0, + help=_("starting row (previous ones will be ignored)"), + ) + self.parser.add_argument( + "-S", + "--split", + action="append_const", + const=("split", None), + dest="arguments", + help=_("split value in several options"), + ) + self.parser.add_argument( + "-E", + "--empty", + action="append", + type=self.opt("empty"), + dest="arguments", + help=_("action to do on empty value ({choices})").format( + choices=", ".join(OPT_EMPTY_CHOICES) + ), + ) + + def filter(self, filter_type, filter_arg, value): + if filter_type == "split": + return value.split() + elif filter_type == "empty": + if filter_arg == OPT_EMPTY_IGNORE: + return value if value else None + elif filter_arg == OPT_EMPTY_SKIP: + return value if value else False + else: + self.parser.error( + _("--empty value must be one of {choices}").format( + choices=", ".join(OPT_EMPTY_CHOICES) + ) + ) + + super(Csv, self).filter(filter_type, filter_arg, value) + + async def start(self): + import csv + + if self.args.encoding: + sys.stdin.reconfigure(encoding=self.args.encoding, errors="replace") + reader = csv.reader(sys.stdin) + for idx, row in enumerate(reader): + try: + if idx < self.args.row: + continue + for value in row: + self.add_value(value) + await self.runCommand() + except exceptions.CancelError: + #  this row has been cancelled, we skip it + continue + + self.host.quit() + + +class Input(base.CommandBase): + subcommands = (Csv,) + + def __init__(self, host): + super(Input, self).__init__( + host, + "input", + use_profile=False, + help=_("launch command with external input"), + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_invitation.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_invitation.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.common import data_format + +__commands__ = ["Invitation"] + + +class Create(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "create", + use_profile=False, + use_output=C.OUTPUT_DICT, + help=_("create and send an invitation"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-j", + "--jid", + default="", + help="jid of the invitee (default: generate one)", + ) + self.parser.add_argument( + "-P", + "--password", + default="", + help="password of the invitee profile/XMPP account (default: generate one)", + ) + self.parser.add_argument( + "-n", + "--name", + default="", + help="name of the invitee", + ) + self.parser.add_argument( + "-N", + "--host-name", + default="", + help="name of the host", + ) + self.parser.add_argument( + "-e", + "--email", + action="append", + default=[], + help="email(s) to send the invitation to (if --no-email is set, email will just be saved)", + ) + self.parser.add_argument( + "--no-email", action="store_true", help="do NOT send invitation email" + ) + self.parser.add_argument( + "-l", + "--lang", + default="", + help="main language spoken by the invitee", + ) + self.parser.add_argument( + "-u", + "--url", + default="", + help="template to construct the URL", + ) + self.parser.add_argument( + "-s", + "--subject", + default="", + help="subject of the invitation email (default: generic subject)", + ) + self.parser.add_argument( + "-b", + "--body", + default="", + help="body of the invitation email (default: generic body)", + ) + self.parser.add_argument( + "-x", + "--extra", + metavar=("KEY", "VALUE"), + action="append", + nargs=2, + default=[], + help="extra data to associate with invitation/invitee", + ) + self.parser.add_argument( + "-p", + "--profile", + default="", + help="profile doing the invitation (default: don't associate profile)", + ) + + async def start(self): + extra = dict(self.args.extra) + email = self.args.email[0] if self.args.email else None + emails_extra = self.args.email[1:] + if self.args.no_email: + if email: + extra["email"] = email + data_format.iter2dict("emails_extra", emails_extra) + else: + if not email: + self.parser.error( + _("you need to specify an email address to send email invitation") + ) + + try: + invitation_data = await self.host.bridge.invitation_create( + email, + emails_extra, + self.args.jid, + self.args.password, + self.args.name, + self.args.host_name, + self.args.lang, + self.args.url, + self.args.subject, + self.args.body, + extra, + self.args.profile, + ) + except Exception as e: + self.disp(f"can't create invitation: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(invitation_data) + self.host.quit(C.EXIT_OK) + + +class Get(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_profile=False, + use_output=C.OUTPUT_DICT, + help=_("get invitation data"), + ) + + def add_parser_options(self): + self.parser.add_argument("id", help=_("invitation UUID")) + self.parser.add_argument( + "-j", + "--with-jid", + action="store_true", + help=_("start profile session and retrieve jid"), + ) + + async def output_data(self, data, jid_=None): + if jid_ is not None: + data["jid"] = jid_ + await self.output(data) + self.host.quit() + + async def start(self): + try: + invitation_data = await self.host.bridge.invitation_get( + self.args.id, + ) + except Exception as e: + self.disp(msg=_("can't get invitation data: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + if not self.args.with_jid: + await self.output_data(invitation_data) + else: + profile = invitation_data["guest_profile"] + try: + await self.host.bridge.profile_start_session( + invitation_data["password"], + profile, + ) + except Exception as e: + self.disp(msg=_("can't start session: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + try: + jid_ = await self.host.bridge.param_get_a_async( + "JabberID", + "Connection", + profile_key=profile, + ) + except Exception as e: + self.disp(msg=_("can't retrieve jid: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + await self.output_data(invitation_data, jid_) + + +class Delete(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "delete", + use_profile=False, + help=_("delete guest account"), + ) + + def add_parser_options(self): + self.parser.add_argument("id", help=_("invitation UUID")) + + async def start(self): + try: + await self.host.bridge.invitation_delete( + self.args.id, + ) + except Exception as e: + self.disp(msg=_("can't delete guest account: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + self.host.quit() + + +class Modify(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, host, "modify", use_profile=False, help=_("modify existing invitation") + ) + + def add_parser_options(self): + self.parser.add_argument( + "--replace", action="store_true", help="replace the whole data" + ) + self.parser.add_argument( + "-n", + "--name", + default="", + help="name of the invitee", + ) + self.parser.add_argument( + "-N", + "--host-name", + default="", + help="name of the host", + ) + self.parser.add_argument( + "-e", + "--email", + default="", + help="email to send the invitation to (if --no-email is set, email will just be saved)", + ) + self.parser.add_argument( + "-l", + "--lang", + dest="language", + default="", + help="main language spoken by the invitee", + ) + self.parser.add_argument( + "-x", + "--extra", + metavar=("KEY", "VALUE"), + action="append", + nargs=2, + default=[], + help="extra data to associate with invitation/invitee", + ) + self.parser.add_argument( + "-p", + "--profile", + default="", + help="profile doing the invitation (default: don't associate profile", + ) + self.parser.add_argument("id", help=_("invitation UUID")) + + async def start(self): + extra = dict(self.args.extra) + for arg_name in ("name", "host_name", "email", "language", "profile"): + value = getattr(self.args, arg_name) + if not value: + continue + if arg_name in extra: + self.parser.error( + _( + "you can't set {arg_name} in both optional argument and extra" + ).format(arg_name=arg_name) + ) + extra[arg_name] = value + try: + await self.host.bridge.invitation_modify( + self.args.id, + extra, + self.args.replace, + ) + except Exception as e: + self.disp(f"can't modify invitation: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("invitations have been modified successfuly")) + self.host.quit(C.EXIT_OK) + + +class List(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + base.CommandBase.__init__( + self, + host, + "list", + use_profile=False, + use_output=C.OUTPUT_COMPLEX, + extra_outputs=extra_outputs, + help=_("list invitations data"), + ) + + def default_output(self, data): + for idx, datum in enumerate(data.items()): + if idx: + self.disp("\n") + key, invitation_data = datum + self.disp(A.color(C.A_HEADER, key)) + indent = " " + for k, v in invitation_data.items(): + self.disp(indent + A.color(C.A_SUBHEADER, k + ":") + " " + str(v)) + + def add_parser_options(self): + self.parser.add_argument( + "-p", + "--profile", + default=C.PROF_KEY_NONE, + help=_("return only invitations linked to this profile"), + ) + + async def start(self): + try: + data = await self.host.bridge.invitation_list( + self.args.profile, + ) + except Exception as e: + self.disp(f"return only invitations linked to this profile: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(data) + self.host.quit() + + +class Invitation(base.CommandBase): + subcommands = (Create, Get, Delete, Modify, List) + + def __init__(self, host): + super(Invitation, self).__init__( + host, + "invitation", + use_profile=False, + help=_("invitation of user(s) without XMPP account"), + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_list.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_list.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import json +import os +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli import common +from libervia.cli.constants import Const as C +from . import base + +__commands__ = ["List"] + +FIELDS_MAP = "mapping" + + +class Get(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_verbose=True, + use_pubsub=True, + pubsub_flags={C.MULTI_ITEMS}, + pubsub_defaults={"service": _("auto"), "node": _("auto")}, + use_output=C.OUTPUT_LIST_XMLUI, + help=_("get lists"), + ) + + def add_parser_options(self): + pass + + async def start(self): + await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={}) + try: + lists_data = data_format.deserialise( + await self.host.bridge.list_get( + self.args.service, + self.args.node, + self.args.max, + self.args.items, + "", + self.get_pubsub_extra(), + self.profile, + ), + type_check=list, + ) + except Exception as e: + self.disp(f"can't get lists: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(lists_data[0]) + self.host.quit(C.EXIT_OK) + + +class Set(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + pubsub_defaults={"service": _("auto"), "node": _("auto")}, + help=_("set a list item"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", + "--field", + action="append", + nargs="+", + dest="fields", + required=True, + metavar=("NAME", "VALUES"), + help=_("field(s) to set (required)"), + ) + self.parser.add_argument( + "-U", + "--update", + choices=("auto", "true", "false"), + default="auto", + help=_("update existing item instead of replacing it (DEFAULT: auto)"), + ) + self.parser.add_argument( + "item", + nargs="?", + default="", + help=_("id, URL of the item to update, or nothing for new item"), + ) + + async def start(self): + await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={}) + if self.args.update == "auto": + # we update if we have a item id specified + update = bool(self.args.item) + else: + update = C.bool(self.args.update) + + values = {} + + for field_data in self.args.fields: + values.setdefault(field_data[0], []).extend(field_data[1:]) + + extra = {"update": update} + + try: + item_id = await self.host.bridge.list_set( + self.args.service, + self.args.node, + values, + "", + self.args.item, + data_format.serialise(extra), + self.profile, + ) + except Exception as e: + self.disp(f"can't set list item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(f"item {str(item_id or self.args.item)!r} set successfully") + self.host.quit(C.EXIT_OK) + + +class Delete(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "delete", + use_pubsub=True, + pubsub_defaults={"service": _("auto"), "node": _("auto")}, + help=_("delete a list item"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", "--force", action="store_true", help=_("delete without confirmation") + ) + self.parser.add_argument( + "-N", "--notify", action="store_true", help=_("notify deletion") + ) + self.parser.add_argument( + "item", + help=_("id of the item to delete"), + ) + + async def start(self): + await common.fill_well_known_uri(self, os.getcwd(), "tickets", meta_map={}) + if not self.args.item: + self.parser.error(_("You need to specify a list item to delete")) + if not self.args.force: + message = _("Are you sure to delete list item {item_id} ?").format( + item_id=self.args.item + ) + await self.host.confirm_or_quit(message, _("item deletion cancelled")) + try: + await self.host.bridge.list_delete_item( + self.args.service, + self.args.node, + self.args.item, + self.args.notify, + self.profile, + ) + except Exception as e: + self.disp(_("can't delete item: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("item {item} has been deleted").format(item=self.args.item)) + self.host.quit(C.EXIT_OK) + + +class Import(base.CommandBase): + # TODO: factorize with blog/import + + def __init__(self, host): + super().__init__( + host, + "import", + use_progress=True, + use_verbose=True, + help=_("import tickets from external software/dataset"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "importer", + nargs="?", + help=_("importer name, nothing to display importers list"), + ) + self.parser.add_argument( + "-o", + "--option", + action="append", + nargs=2, + default=[], + metavar=("NAME", "VALUE"), + help=_("importer specific options (see importer description)"), + ) + self.parser.add_argument( + "-m", + "--map", + action="append", + nargs=2, + default=[], + metavar=("IMPORTED_FIELD", "DEST_FIELD"), + help=_( + "specified field in import data will be put in dest field (default: use " + "same field name, or ignore if it doesn't exist)" + ), + ) + self.parser.add_argument( + "-s", + "--service", + default="", + metavar="PUBSUB_SERVICE", + help=_("PubSub service where the items must be uploaded (default: server)"), + ) + self.parser.add_argument( + "-n", + "--node", + default="", + metavar="PUBSUB_NODE", + help=_( + "PubSub node where the items must be uploaded (default: tickets' " + "defaults)" + ), + ) + self.parser.add_argument( + "location", + nargs="?", + help=_( + "importer data location (see importer description), nothing to show " + "importer description" + ), + ) + + async def on_progress_started(self, metadata): + self.disp(_("Tickets upload started"), 2) + + async def on_progress_finished(self, metadata): + self.disp(_("Tickets uploaded successfully"), 2) + + async def on_progress_error(self, error_msg): + self.disp( + _("Error while uploading tickets: {error_msg}").format(error_msg=error_msg), + error=True, + ) + + async def start(self): + if self.args.location is None: + # no location, the list of importer or description is requested + for name in ("option", "service", "node"): + if getattr(self.args, name): + self.parser.error( + _( + "{name} argument can't be used without location argument" + ).format(name=name) + ) + if self.args.importer is None: + self.disp( + "\n".join( + [ + f"{name}: {desc}" + for name, desc in await self.host.bridge.ticketsImportList() + ] + ) + ) + else: + try: + short_desc, long_desc = await self.host.bridge.ticketsImportDesc( + self.args.importer + ) + except Exception as e: + self.disp(f"can't get importer description: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(f"{name}: {short_desc}\n\n{long_desc}") + self.host.quit() + else: + # we have a location, an import is requested + + if self.args.progress: + # we use a custom progress bar template as we want a counter + self.pbar_template = [ + _("Progress: "), + ["Percentage"], + " ", + ["Bar"], + " ", + ["Counter"], + " ", + ["ETA"], + ] + + options = {key: value for key, value in self.args.option} + fields_map = dict(self.args.map) + if fields_map: + if FIELDS_MAP in options: + self.parser.error( + _( + "fields_map must be specified either preencoded in --option or " + "using --map, but not both at the same time" + ) + ) + options[FIELDS_MAP] = json.dumps(fields_map) + + try: + progress_id = await self.host.bridge.ticketsImport( + self.args.importer, + self.args.location, + options, + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp( + _("Error while trying to import tickets: {e}").format(e=e), + error=True, + ) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.set_progress_id(progress_id) + + +class List(base.CommandBase): + subcommands = (Get, Set, Delete, Import) + + def __init__(self, host): + super(List, self).__init__( + host, "list", use_profile=False, help=_("pubsub lists handling") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_merge_request.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_merge_request.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import os.path +from . import base +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli.constants import Const as C +from libervia.cli import xmlui_manager +from libervia.cli import common + +__commands__ = ["MergeRequest"] + + +class Set(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + pubsub_defaults={"service": _("auto"), "node": _("auto")}, + help=_("publish or update a merge request"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-i", + "--item", + default="", + help=_("id or URL of the request to update, or nothing for a new one"), + ) + self.parser.add_argument( + "-r", + "--repository", + metavar="PATH", + default=".", + help=_("path of the repository (DEFAULT: current directory)"), + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("publish merge request without confirmation"), + ) + self.parser.add_argument( + "-l", + "--label", + dest="labels", + action="append", + help=_("labels to categorize your request"), + ) + + async def start(self): + self.repository = os.path.expanduser(os.path.abspath(self.args.repository)) + await common.fill_well_known_uri(self, self.repository, "merge requests") + if not self.args.force: + message = _( + "You are going to publish your changes to service " + "[{service}], are you sure ?" + ).format(service=self.args.service) + await self.host.confirm_or_quit( + message, _("merge request publication cancelled") + ) + + extra = {"update": True} if self.args.item else {} + values = {} + if self.args.labels is not None: + values["labels"] = self.args.labels + try: + published_id = await self.host.bridge.merge_request_set( + self.args.service, + self.args.node, + self.repository, + "auto", + values, + "", + self.args.item, + data_format.serialise(extra), + self.profile, + ) + except Exception as e: + self.disp(f"can't create merge requests: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + if published_id: + self.disp( + _("Merge request published at {published_id}").format( + published_id=published_id + ) + ) + else: + self.disp(_("Merge request published")) + + self.host.quit(C.EXIT_OK) + + +class Get(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_verbose=True, + use_pubsub=True, + pubsub_flags={C.MULTI_ITEMS}, + pubsub_defaults={"service": _("auto"), "node": _("auto")}, + help=_("get a merge request"), + ) + + def add_parser_options(self): + pass + + async def start(self): + await common.fill_well_known_uri(self, os.getcwd(), "merge requests", meta_map={}) + extra = {} + try: + requests_data = data_format.deserialise( + await self.host.bridge.merge_requests_get( + self.args.service, + self.args.node, + self.args.max, + self.args.items, + "", + data_format.serialise(extra), + self.profile, + ) + ) + except Exception as e: + self.disp(f"can't get merge request: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + if self.verbosity >= 1: + whitelist = None + else: + whitelist = {"id", "title", "body"} + for request_xmlui in requests_data["items"]: + xmlui = xmlui_manager.create(self.host, request_xmlui, whitelist=whitelist) + await xmlui.show(values_only=True) + self.disp("") + self.host.quit(C.EXIT_OK) + + +class Import(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "import", + use_pubsub=True, + pubsub_flags={C.SINGLE_ITEM, C.ITEM}, + pubsub_defaults={"service": _("auto"), "node": _("auto")}, + help=_("import a merge request"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-r", + "--repository", + metavar="PATH", + default=".", + help=_("path of the repository (DEFAULT: current directory)"), + ) + + async def start(self): + self.repository = os.path.expanduser(os.path.abspath(self.args.repository)) + await common.fill_well_known_uri( + self, self.repository, "merge requests", meta_map={} + ) + extra = {} + try: + await self.host.bridge.merge_requests_import( + self.repository, + self.args.item, + self.args.service, + self.args.node, + extra, + self.profile, + ) + except Exception as e: + self.disp(f"can't import merge request: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class MergeRequest(base.CommandBase): + subcommands = (Set, Get, Import) + + def __init__(self, host): + super(MergeRequest, self).__init__( + host, "merge-request", use_profile=False, help=_("merge-request management") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_message.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_message.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from pathlib import Path +import sys + +from twisted.python import filepath + +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.utils import clean_ustr +from libervia.cli import base +from libervia.cli.constants import Const as C +from libervia.frontends.tools import jid + + +__commands__ = ["Message"] + + +class Send(base.CommandBase): + def __init__(self, host): + super(Send, self).__init__(host, "send", help=_("send a message to a contact")) + + def add_parser_options(self): + self.parser.add_argument( + "-l", "--lang", type=str, default="", help=_("language of the message") + ) + self.parser.add_argument( + "-s", + "--separate", + action="store_true", + help=_( + "separate xmpp messages: send one message per line instead of one " + "message alone." + ), + ) + self.parser.add_argument( + "-n", + "--new-line", + action="store_true", + help=_( + "add a new line at the beginning of the input" + ), + ) + self.parser.add_argument( + "-S", + "--subject", + help=_("subject of the message"), + ) + self.parser.add_argument( + "-L", "--subject-lang", type=str, default="", help=_("language of subject") + ) + self.parser.add_argument( + "-t", + "--type", + choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,), + default=C.MESS_TYPE_AUTO, + help=_("type of the message"), + ) + self.parser.add_argument("-e", "--encrypt", metavar="ALGORITHM", + help=_("encrypt message using given algorithm")) + self.parser.add_argument( + "--encrypt-noreplace", + action="store_true", + help=_("don't replace encryption algorithm if an other one is already used")) + self.parser.add_argument( + "-a", "--attach", dest="attachments", action="append", metavar="FILE_PATH", + help=_("add a file as an attachment") + ) + syntax = self.parser.add_mutually_exclusive_group() + syntax.add_argument("-x", "--xhtml", action="store_true", help=_("XHTML body")) + syntax.add_argument("-r", "--rich", action="store_true", help=_("rich body")) + self.parser.add_argument( + "jid", help=_("the destination jid") + ) + + async def send_stdin(self, dest_jid): + """Send incomming data on stdin to jabber contact + + @param dest_jid: destination jid + """ + header = "\n" if self.args.new_line else "" + # FIXME: stdin is not read asynchronously at the moment + stdin_lines = [ + stream for stream in sys.stdin.readlines() + ] + extra = {} + if self.args.subject is None: + subject = {} + else: + subject = {self.args.subject_lang: self.args.subject} + + if self.args.xhtml or self.args.rich: + key = "xhtml" if self.args.xhtml else "rich" + if self.args.lang: + key = f"{key}_{self.args.lang}" + extra[key] = clean_ustr("".join(stdin_lines)) + stdin_lines = [] + + to_send = [] + + error = False + + if self.args.separate: + # we send stdin in several messages + if header: + # first we sent the header + try: + await self.host.bridge.message_send( + dest_jid, + {self.args.lang: header}, + subject, + self.args.type, + profile_key=self.profile, + ) + except Exception as e: + self.disp(f"can't send header: {e}", error=True) + error = True + + to_send.extend({self.args.lang: clean_ustr(l.replace("\n", ""))} + for l in stdin_lines) + else: + # we sent all in a single message + if not (self.args.xhtml or self.args.rich): + msg = {self.args.lang: header + clean_ustr("".join(stdin_lines))} + else: + msg = {} + to_send.append(msg) + + if self.args.attachments: + attachments = extra[C.KEY_ATTACHMENTS] = [] + for attachment in self.args.attachments: + try: + file_path = str(Path(attachment).resolve(strict=True)) + except FileNotFoundError: + self.disp("file {attachment} doesn't exists, ignoring", error=True) + else: + attachments.append({"path": file_path}) + + for idx, msg in enumerate(to_send): + if idx > 0 and C.KEY_ATTACHMENTS in extra: + # if we send several messages, we only want to send attachments with the + # first one + del extra[C.KEY_ATTACHMENTS] + try: + await self.host.bridge.message_send( + dest_jid, + msg, + subject, + self.args.type, + data_format.serialise(extra), + profile_key=self.host.profile) + except Exception as e: + self.disp(f"can't send message {msg!r}: {e}", error=True) + error = True + + if error: + # at least one message sending failed + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + self.host.quit() + + async def start(self): + if self.args.xhtml and self.args.separate: + self.disp( + "argument -s/--separate is not compatible yet with argument -x/--xhtml", + error=True, + ) + self.host.quit(C.EXIT_BAD_ARG) + + jids = await self.host.check_jids([self.args.jid]) + jid_ = jids[0] + + if self.args.encrypt_noreplace and self.args.encrypt is None: + self.parser.error("You need to use --encrypt if you use --encrypt-noreplace") + + if self.args.encrypt is not None: + try: + namespace = await self.host.bridge.encryption_namespace_get( + self.args.encrypt) + except Exception as e: + self.disp(f"can't get encryption namespace: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + try: + await self.host.bridge.message_encryption_start( + jid_, namespace, not self.args.encrypt_noreplace, self.profile + ) + except Exception as e: + self.disp(f"can't start encryption session: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + await self.send_stdin(jid_) + + +class Retract(base.CommandBase): + + def __init__(self, host): + super().__init__(host, "retract", help=_("retract a message")) + + def add_parser_options(self): + self.parser.add_argument( + "message_id", + help=_("ID of the message (internal ID)") + ) + + async def start(self): + try: + await self.host.bridge.message_retract( + self.args.message_id, + self.profile + ) + except Exception as e: + self.disp(f"can't retract message: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp( + "message retraction has been requested, please note that this is a " + "request which can't be enforced (see documentation for details).") + self.host.quit(C.EXIT_OK) + + +class MAM(base.CommandBase): + + def __init__(self, host): + super(MAM, self).__init__( + host, "mam", use_output=C.OUTPUT_MESS, use_verbose=True, + help=_("query archives using MAM")) + + def add_parser_options(self): + self.parser.add_argument( + "-s", "--service", default="", + help=_("jid of the service (default: profile's server")) + self.parser.add_argument( + "-S", "--start", dest="mam_start", type=base.date_decoder, + help=_( + "start fetching archive from this date (default: from the beginning)")) + self.parser.add_argument( + "-E", "--end", dest="mam_end", type=base.date_decoder, + help=_("end fetching archive after this date (default: no limit)")) + self.parser.add_argument( + "-W", "--with", dest="mam_with", + help=_("retrieve only archives with this jid")) + self.parser.add_argument( + "-m", "--max", dest="rsm_max", type=int, default=20, + help=_("maximum number of items to retrieve, using RSM (default: 20))")) + rsm_page_group = self.parser.add_mutually_exclusive_group() + rsm_page_group.add_argument( + "-a", "--after", dest="rsm_after", + help=_("find page after this item"), metavar='ITEM_ID') + rsm_page_group.add_argument( + "-b", "--before", dest="rsm_before", + help=_("find page before this item"), metavar='ITEM_ID') + rsm_page_group.add_argument( + "--index", dest="rsm_index", type=int, + help=_("index of the page to retrieve")) + + async def start(self): + extra = {} + if self.args.mam_start is not None: + extra["mam_start"] = float(self.args.mam_start) + if self.args.mam_end is not None: + extra["mam_end"] = float(self.args.mam_end) + if self.args.mam_with is not None: + extra["mam_with"] = self.args.mam_with + for suff in ('max', 'after', 'before', 'index'): + key = 'rsm_' + suff + value = getattr(self.args,key) + if value is not None: + extra[key] = str(value) + try: + data, metadata_s, profile = await self.host.bridge.mam_get( + self.args.service, data_format.serialise(extra), self.profile) + except Exception as e: + self.disp(f"can't retrieve MAM archives: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + metadata = data_format.deserialise(metadata_s) + + try: + session_info = await self.host.bridge.session_infos_get(self.profile) + except Exception as e: + self.disp(f"can't get session infos: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + # we need to fill own_jid for message output + self.host.own_jid = jid.JID(session_info["jid"]) + + await self.output(data) + + # FIXME: metadata are not displayed correctly and don't play nice with output + # they should be added to output data somehow + if self.verbosity: + for value in ("rsm_first", "rsm_last", "rsm_index", "rsm_count", + "mam_complete", "mam_stable"): + if value in metadata: + label = value.split("_")[1] + self.disp(A.color( + C.A_HEADER, label, ': ' , A.RESET, metadata[value])) + + self.host.quit() + + +class Message(base.CommandBase): + subcommands = (Send, Retract, MAM) + + def __init__(self, host): + super(Message, self).__init__( + host, "message", use_profile=False, help=_("messages handling") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_param.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_param.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from . import base +from libervia.backend.core.i18n import _ +from .constants import Const as C + +__commands__ = ["Param"] + + +class Get(base.CommandBase): + def __init__(self, host): + super(Get, self).__init__( + host, "get", need_connect=False, help=_("get a parameter value") + ) + + def add_parser_options(self): + self.parser.add_argument( + "category", nargs="?", help=_("category of the parameter") + ) + self.parser.add_argument("name", nargs="?", help=_("name of the parameter")) + self.parser.add_argument( + "-a", + "--attribute", + type=str, + default="value", + help=_("name of the attribute to get"), + ) + self.parser.add_argument( + "--security-limit", type=int, default=-1, help=_("security limit") + ) + + async def start(self): + if self.args.category is None: + categories = await self.host.bridge.params_categories_get() + print("\n".join(categories)) + elif self.args.name is None: + try: + values_dict = await self.host.bridge.params_values_from_category_get_async( + self.args.category, self.args.security_limit, "", "", self.profile + ) + except Exception as e: + self.disp( + _("can't find requested parameters: {e}").format(e=e), error=True + ) + self.host.quit(C.EXIT_NOT_FOUND) + else: + for name, value in values_dict.items(): + print(f"{name}\t{value}") + else: + try: + value = await self.host.bridge.param_get_a_async( + self.args.name, + self.args.category, + self.args.attribute, + self.args.security_limit, + self.profile, + ) + except Exception as e: + self.disp( + _("can't find requested parameter: {e}").format(e=e), error=True + ) + self.host.quit(C.EXIT_NOT_FOUND) + else: + print(value) + self.host.quit() + + +class Set(base.CommandBase): + def __init__(self, host): + super(Set, self).__init__( + host, "set", need_connect=False, help=_("set a parameter value") + ) + + def add_parser_options(self): + self.parser.add_argument("category", help=_("category of the parameter")) + self.parser.add_argument("name", help=_("name of the parameter")) + self.parser.add_argument("value", help=_("name of the parameter")) + self.parser.add_argument( + "--security-limit", type=int, default=-1, help=_("security limit") + ) + + async def start(self): + try: + await self.host.bridge.param_set( + self.args.name, + self.args.value, + self.args.category, + self.args.security_limit, + self.profile, + ) + except Exception as e: + self.disp(_("can't set requested parameter: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class SaveTemplate(base.CommandBase): + # FIXME: this should probably be removed, it's not used and not useful for end-user + + def __init__(self, host): + super(SaveTemplate, self).__init__( + host, + "save", + use_profile=False, + help=_("save parameters template to xml file"), + ) + + def add_parser_options(self): + self.parser.add_argument("filename", type=str, help=_("output file")) + + async def start(self): + """Save parameters template to XML file""" + try: + await self.host.bridge.params_template_save(self.args.filename) + except Exception as e: + self.disp(_("can't save parameters to file: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp( + _("parameters saved to file {filename}").format( + filename=self.args.filename + ) + ) + self.host.quit() + + +class LoadTemplate(base.CommandBase): + # FIXME: this should probably be removed, it's not used and not useful for end-user + + def __init__(self, host): + super(LoadTemplate, self).__init__( + host, + "load", + use_profile=False, + help=_("load parameters template from xml file"), + ) + + def add_parser_options(self): + self.parser.add_argument("filename", type=str, help=_("input file")) + + async def start(self): + """Load parameters template from xml file""" + try: + self.host.bridge.params_template_load(self.args.filename) + except Exception as e: + self.disp(_("can't load parameters from file: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp( + _("parameters loaded from file {filename}").format( + filename=self.args.filename + ) + ) + self.host.quit() + + +class Param(base.CommandBase): + subcommands = (Get, Set, SaveTemplate, LoadTemplate) + + def __init__(self, host): + super(Param, self).__init__( + host, "param", use_profile=False, help=_("Save/load parameters template") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_ping.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_ping.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C + +__commands__ = ["Ping"] + + +class Ping(base.CommandBase): + def __init__(self, host): + super(Ping, self).__init__(host, "ping", help=_("ping XMPP entity")) + + def add_parser_options(self): + self.parser.add_argument("jid", help=_("jid to ping")) + self.parser.add_argument( + "-d", "--delay-only", action="store_true", help=_("output delay only (in s)") + ) + + async def start(self): + try: + pong_time = await self.host.bridge.ping(self.args.jid, self.profile) + except Exception as e: + self.disp(msg=_("can't do the ping: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + msg = pong_time if self.args.delay_only else f"PONG ({pong_time} s)" + self.disp(msg) + self.host.quit() diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_pipe.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_pipe.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio +import errno +from functools import partial +import socket +import sys + +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.cli import base +from libervia.cli import xmlui_manager +from libervia.cli.constants import Const as C +from libervia.frontends.tools import jid + +__commands__ = ["Pipe"] + +START_PORT = 9999 + + +class PipeOut(base.CommandBase): + def __init__(self, host): + super(PipeOut, self).__init__(host, "out", help=_("send a pipe a stream")) + + def add_parser_options(self): + self.parser.add_argument( + "jid", help=_("the destination jid") + ) + + async def start(self): + """ Create named pipe, and send stdin to it """ + try: + port = await self.host.bridge.stream_out( + await self.host.get_full_jid(self.args.jid), + self.profile, + ) + except Exception as e: + self.disp(f"can't start stream: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + # FIXME: we use temporarily blocking code here, as it simplify + # asyncio port: "loop.connect_read_pipe(lambda: reader_protocol, + # sys.stdin.buffer)" doesn't work properly when a file is piped in + # (we get a "ValueError: Pipe transport is for pipes/sockets only.") + # while it's working well for simple text sending. + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect(("127.0.0.1", int(port))) + + while True: + buf = sys.stdin.buffer.read(4096) + if not buf: + break + try: + s.sendall(buf) + except socket.error as e: + if e.errno == errno.EPIPE: + sys.stderr.write(f"e\n") + self.host.quit(1) + else: + raise e + self.host.quit() + + +async def handle_stream_in(reader, writer, host): + """Write all received data to stdout""" + while True: + data = await reader.read(4096) + if not data: + break + sys.stdout.buffer.write(data) + try: + sys.stdout.flush() + except IOError as e: + sys.stderr.write(f"{e}\n") + break + host.quit_from_signal() + + +class PipeIn(base.CommandAnswering): + def __init__(self, host): + super(PipeIn, self).__init__(host, "in", help=_("receive a pipe stream")) + self.action_callbacks = {"STREAM": self.on_stream_action} + + def add_parser_options(self): + self.parser.add_argument( + "jids", + nargs="*", + help=_('Jids accepted (none means "accept everything")'), + ) + + def get_xmlui_id(self, action_data): + try: + xml_ui = action_data["xmlui"] + except KeyError: + self.disp(_("Action has no XMLUI"), 1) + else: + ui = xmlui_manager.create(self.host, xml_ui) + if not ui.submit_id: + self.disp(_("Invalid XMLUI received"), error=True) + self.quit_from_signal(C.EXIT_INTERNAL_ERROR) + return ui.submit_id + + async def on_stream_action(self, action_data, action_id, security_limit, profile): + xmlui_id = self.get_xmlui_id(action_data) + if xmlui_id is None: + self.host.quit_from_signal(C.EXIT_ERROR) + try: + from_jid = jid.JID(action_data["from_jid"]) + except KeyError: + self.disp(_("Ignoring action without from_jid data"), error=True) + return + + if not self.bare_jids or from_jid.bare in self.bare_jids: + host, port = "localhost", START_PORT + while True: + try: + server = await asyncio.start_server( + partial(handle_stream_in, host=self.host), host, port) + except socket.error as e: + if e.errno == errno.EADDRINUSE: + port += 1 + else: + raise e + else: + break + xmlui_data = {"answer": C.BOOL_TRUE, "port": str(port)} + await self.host.bridge.action_launch( + xmlui_id, data_format.serialise(xmlui_data), profile_key=profile + ) + async with server: + await server.serve_forever() + self.host.quit_from_signal() + + async def start(self): + self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids] + await self.start_answering() + + +class Pipe(base.CommandBase): + subcommands = (PipeOut, PipeIn) + + def __init__(self, host): + super(Pipe, self).__init__( + host, "pipe", use_profile=False, help=_("stream piping through XMPP") + ) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_profile.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_profile.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""This module permits to manage profiles. It can list, create, delete +and retrieve information about a profile.""" + +from libervia.cli.constants import Const as C +from libervia.backend.core.log import getLogger +from libervia.backend.core.i18n import _ +from libervia.cli import base + +log = getLogger(__name__) + + +__commands__ = ["Profile"] + +PROFILE_HELP = _('The name of the profile') + + +class ProfileConnect(base.CommandBase): + """Dummy command to use profile_session parent, i.e. to be able to connect without doing anything else""" + + def __init__(self, host): + # it's weird to have a command named "connect" with need_connect=False, but it can be handy to be able + # to launch just the session, so some paradoxes don't hurt + super(ProfileConnect, self).__init__(host, 'connect', need_connect=False, help=('connect a profile')) + + def add_parser_options(self): + pass + + async def start(self): + # connection is already managed by profile common commands + # so we just need to check arguments and quit + if not self.args.connect and not self.args.start_session: + self.parser.error(_("You need to use either --connect or --start-session")) + self.host.quit() + +class ProfileDisconnect(base.CommandBase): + + def __init__(self, host): + super(ProfileDisconnect, self).__init__(host, 'disconnect', need_connect=False, help=('disconnect a profile')) + + def add_parser_options(self): + pass + + async def start(self): + try: + await self.host.bridge.disconnect(self.args.profile) + except Exception as e: + self.disp(f"can't disconnect profile: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class ProfileCreate(base.CommandBase): + def __init__(self, host): + super(ProfileCreate, self).__init__( + host, 'create', use_profile=False, help=('create a new profile')) + + def add_parser_options(self): + self.parser.add_argument('profile', type=str, help=_('the name of the profile')) + self.parser.add_argument( + '-p', '--password', type=str, default='', + help=_('the password of the profile')) + self.parser.add_argument( + '-j', '--jid', type=str, help=_('the jid of the profile')) + self.parser.add_argument( + '-x', '--xmpp-password', type=str, + help=_( + 'the password of the XMPP account (use profile password if not specified)' + ), + metavar='PASSWORD') + self.parser.add_argument( + '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?', + const=C.BOOL_TRUE, + help=_('connect this profile automatically when backend starts') + ) + self.parser.add_argument( + '-C', '--component', default='', + help=_('set to component import name (entry point) if this is a component')) + + async def start(self): + """Create a new profile""" + if self.args.profile in await self.host.bridge.profiles_list_get(): + self.disp(f"Profile {self.args.profile} already exists.", error=True) + self.host.quit(C.EXIT_BRIDGE_ERROR) + try: + await self.host.bridge.profile_create( + self.args.profile, self.args.password, self.args.component) + except Exception as e: + self.disp(f"can't create profile: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + try: + await self.host.bridge.profile_start_session( + self.args.password, self.args.profile) + except Exception as e: + self.disp(f"can't start profile session: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + if self.args.jid: + await self.host.bridge.param_set( + "JabberID", self.args.jid, "Connection", profile_key=self.args.profile) + xmpp_pwd = self.args.password or self.args.xmpp_password + if xmpp_pwd: + await self.host.bridge.param_set( + "Password", xmpp_pwd, "Connection", profile_key=self.args.profile) + + if self.args.autoconnect is not None: + await self.host.bridge.param_set( + "autoconnect_backend", self.args.autoconnect, "Connection", + profile_key=self.args.profile) + + self.disp(f'profile {self.args.profile} created successfully', 1) + self.host.quit() + + +class ProfileDefault(base.CommandBase): + def __init__(self, host): + super(ProfileDefault, self).__init__( + host, 'default', use_profile=False, help=('print default profile')) + + def add_parser_options(self): + pass + + async def start(self): + print(await self.host.bridge.profile_name_get('@DEFAULT@')) + self.host.quit() + + +class ProfileDelete(base.CommandBase): + def __init__(self, host): + super(ProfileDelete, self).__init__(host, 'delete', use_profile=False, help=('delete a profile')) + + def add_parser_options(self): + self.parser.add_argument('profile', type=str, help=PROFILE_HELP) + self.parser.add_argument('-f', '--force', action='store_true', help=_('delete profile without confirmation')) + + async def start(self): + if self.args.profile not in await self.host.bridge.profiles_list_get(): + log.error(f"Profile {self.args.profile} doesn't exist.") + self.host.quit(C.EXIT_NOT_FOUND) + if not self.args.force: + message = f"Are you sure to delete profile [{self.args.profile}] ?" + cancel_message = "Profile deletion cancelled" + await self.host.confirm_or_quit(message, cancel_message) + + await self.host.bridge.profile_delete_async(self.args.profile) + self.host.quit() + + +class ProfileInfo(base.CommandBase): + + def __init__(self, host): + super(ProfileInfo, self).__init__( + host, 'info', need_connect=False, use_output=C.OUTPUT_DICT, + help=_('get information about a profile')) + self.to_show = [(_("jid"), "Connection", "JabberID"),] + + def add_parser_options(self): + self.parser.add_argument( + '--show-password', action='store_true', + help=_('show the XMPP password IN CLEAR TEXT')) + + async def start(self): + if self.args.show_password: + self.to_show.append((_("XMPP password"), "Connection", "Password")) + self.to_show.append((_("autoconnect (backend)"), "Connection", + "autoconnect_backend")) + data = {} + for label, category, name in self.to_show: + try: + value = await self.host.bridge.param_get_a_async( + name, category, profile_key=self.host.profile) + except Exception as e: + self.disp(f"can't get {name}/{category} param: {e}", error=True) + else: + data[label] = value + + await self.output(data) + self.host.quit() + + +class ProfileList(base.CommandBase): + def __init__(self, host): + super(ProfileList, self).__init__( + host, 'list', use_profile=False, use_output='list', help=('list profiles')) + + def add_parser_options(self): + group = self.parser.add_mutually_exclusive_group() + group.add_argument( + '-c', '--clients', action='store_true', help=_('get clients profiles only')) + group.add_argument( + '-C', '--components', action='store_true', + help=('get components profiles only')) + + async def start(self): + if self.args.clients: + clients, components = True, False + elif self.args.components: + clients, components = False, True + else: + clients, components = True, True + await self.output(await self.host.bridge.profiles_list_get(clients, components)) + self.host.quit() + + +class ProfileModify(base.CommandBase): + + def __init__(self, host): + super(ProfileModify, self).__init__( + host, 'modify', need_connect=False, help=_('modify an existing profile')) + + def add_parser_options(self): + profile_pwd_group = self.parser.add_mutually_exclusive_group() + profile_pwd_group.add_argument( + '-w', '--password', help=_('change the password of the profile')) + profile_pwd_group.add_argument( + '--disable-password', action='store_true', + help=_('disable profile password (dangerous!)')) + self.parser.add_argument('-j', '--jid', help=_('the jid of the profile')) + self.parser.add_argument( + '-x', '--xmpp-password', help=_('change the password of the XMPP account'), + metavar='PASSWORD') + self.parser.add_argument( + '-D', '--default', action='store_true', help=_('set as default profile')) + self.parser.add_argument( + '-A', '--autoconnect', choices=[C.BOOL_TRUE, C.BOOL_FALSE], nargs='?', + const=C.BOOL_TRUE, + help=_('connect this profile automatically when backend starts') + ) + + async def start(self): + if self.args.disable_password: + self.args.password = '' + if self.args.password is not None: + await self.host.bridge.param_set( + "Password", self.args.password, "General", profile_key=self.host.profile) + if self.args.jid is not None: + await self.host.bridge.param_set( + "JabberID", self.args.jid, "Connection", profile_key=self.host.profile) + if self.args.xmpp_password is not None: + await self.host.bridge.param_set( + "Password", self.args.xmpp_password, "Connection", + profile_key=self.host.profile) + if self.args.default: + await self.host.bridge.profile_set_default(self.host.profile) + if self.args.autoconnect is not None: + await self.host.bridge.param_set( + "autoconnect_backend", self.args.autoconnect, "Connection", + profile_key=self.host.profile) + + self.host.quit() + + +class Profile(base.CommandBase): + subcommands = ( + ProfileConnect, ProfileDisconnect, ProfileCreate, ProfileDefault, ProfileDelete, + ProfileInfo, ProfileList, ProfileModify) + + def __init__(self, host): + super(Profile, self).__init__( + host, 'profile', use_profile=False, help=_('profile commands')) diff -r 26b7ed2817da -r 47401850dec6 libervia/cli/cmd_pubsub.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_pubsub.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,3030 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import argparse +import os.path +import re +import sys +import subprocess +import asyncio +import json +from . import base +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions +from libervia.cli.constants import Const as C +from libervia.cli import common +from libervia.cli import arg_tools +from libervia.cli import xml_tools +from functools import partial +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import uri +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.common import date_utils +from libervia.frontends.tools import jid, strings +from libervia.frontends.bridge.bridge_frontend import BridgeException + +__commands__ = ["Pubsub"] + +PUBSUB_TMP_DIR = "pubsub" +PUBSUB_SCHEMA_TMP_DIR = PUBSUB_TMP_DIR + "_schema" +ALLOWED_SUBSCRIPTIONS_OWNER = ("subscribed", "pending", "none") + +# TODO: need to split this class in several modules, plugin should handle subcommands + + +class NodeInfo(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "info", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("retrieve node configuration"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-k", + "--key", + action="append", + dest="keys", + help=_("data key to filter"), + ) + + def remove_prefix(self, key): + return key[7:] if key.startswith("pubsub#") else key + + def filter_key(self, key): + return any((key == k or key == "pubsub#" + k) for k in self.args.keys) + + async def start(self): + try: + config_dict = await self.host.bridge.ps_node_configuration_get( + self.args.service, + self.args.node, + self.profile, + ) + except BridgeException as e: + if e.condition == "item-not-found": + self.disp( + f"The node {self.args.node} doesn't exist on {self.args.service}", + error=True, + ) + self.host.quit(C.EXIT_NOT_FOUND) + else: + self.disp(f"can't get node configuration: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + else: + key_filter = (lambda k: True) if not self.args.keys else self.filter_key + config_dict = { + self.remove_prefix(k): v for k, v in config_dict.items() if key_filter(k) + } + await self.output(config_dict) + self.host.quit() + + +class NodeCreate(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "create", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("create a node"), + ) + + @staticmethod + def add_node_config_options(parser): + parser.add_argument( + "-f", + "--field", + action="append", + nargs=2, + dest="fields", + default=[], + metavar=("KEY", "VALUE"), + help=_("configuration field to set"), + ) + parser.add_argument( + "-F", + "--full-prefix", + action="store_true", + help=_('don\'t prepend "pubsub#" prefix to field names'), + ) + + def add_parser_options(self): + self.add_node_config_options(self.parser) + + @staticmethod + def get_config_options(args): + if not args.full_prefix: + return {"pubsub#" + k: v for k, v in args.fields} + else: + return dict(args.fields) + + async def start(self): + options = self.get_config_options(self.args) + try: + node_id = await self.host.bridge.ps_node_create( + self.args.service, + self.args.node, + options, + self.profile, + ) + except Exception as e: + self.disp(msg=_("can't create node: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if self.host.verbosity: + announce = _("node created successfully: ") + else: + announce = "" + self.disp(announce + node_id) + self.host.quit() + + +class NodePurge(base.CommandBase): + def __init__(self, host): + super(NodePurge, self).__init__( + host, + "purge", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("purge a node (i.e. remove all items from it)"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("purge node without confirmation"), + ) + + async def start(self): + if not self.args.force: + if not self.args.service: + message = _( + "Are you sure to purge PEP node [{node}]? This will " + "delete ALL items from it!" + ).format(node=self.args.node) + else: + message = _( + "Are you sure to delete node [{node}] on service " + "[{service}]? This will delete ALL items from it!" + ).format(node=self.args.node, service=self.args.service) + await self.host.confirm_or_quit(message, _("node purge cancelled")) + + try: + await self.host.bridge.ps_node_purge( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(msg=_("can't purge node: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("node [{node}] purged successfully").format(node=self.args.node)) + self.host.quit() + + +class NodeDelete(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "delete", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("delete a node"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("delete node without confirmation"), + ) + + async def start(self): + if not self.args.force: + if not self.args.service: + message = _("Are you sure to delete PEP node [{node}] ?").format( + node=self.args.node + ) + else: + message = _( + "Are you sure to delete node [{node}] on " "service [{service}]?" + ).format(node=self.args.node, service=self.args.service) + await self.host.confirm_or_quit(message, _("node deletion cancelled")) + + try: + await self.host.bridge.ps_node_delete( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't delete node: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("node [{node}] deleted successfully").format(node=self.args.node)) + self.host.quit() + + +class NodeSet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("set node configuration"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", + "--field", + action="append", + nargs=2, + dest="fields", + required=True, + metavar=("KEY", "VALUE"), + help=_("configuration field to set (required)"), + ) + self.parser.add_argument( + "-F", + "--full-prefix", + action="store_true", + help=_('don\'t prepend "pubsub#" prefix to field names'), + ) + + def get_key_name(self, k): + if self.args.full_prefix or k.startswith("pubsub#"): + return k + else: + return "pubsub#" + k + + async def start(self): + try: + await self.host.bridge.ps_node_configuration_set( + self.args.service, + self.args.node, + {self.get_key_name(k): v for k, v in self.args.fields}, + self.profile, + ) + except Exception as e: + self.disp(f"can't set node configuration: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("node configuration successful"), 1) + self.host.quit() + + +class NodeImport(base.CommandBase): + def __init__(self, host): + super(NodeImport, self).__init__( + host, + "import", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("import raw XML to a node"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "--admin", + action="store_true", + help=_("do a pubsub admin request, needed to change publisher"), + ) + self.parser.add_argument( + "import_file", + type=argparse.FileType(), + help=_( + "path to the XML file with data to import. The file must contain " + "whole XML of each item to import." + ), + ) + + async def start(self): + try: + element, etree = xml_tools.etree_parse( + self, self.args.import_file, reraise=True + ) + except Exception as e: + from lxml.etree import XMLSyntaxError + + if isinstance(e, XMLSyntaxError) and e.code == 5: + # we have extra content, this probaby means that item are not wrapped + # so we wrap them here and try again + self.args.import_file.seek(0) + xml_buf = "" + self.args.import_file.read() + "" + element, etree = xml_tools.etree_parse(self, xml_buf) + + # we reverse element as we expect to have most recently published element first + # TODO: make this more explicit and add an option + element[:] = reversed(element) + + if not all([i.tag == "{http://jabber.org/protocol/pubsub}item" for i in element]): + self.disp( + _("You are not using list of pubsub items, we can't import this file"), + error=True, + ) + self.host.quit(C.EXIT_DATA_ERROR) + return + + items = [etree.tostring(i, encoding="unicode") for i in element] + if self.args.admin: + method = self.host.bridge.ps_admin_items_send + else: + self.disp( + _( + "Items are imported without using admin mode, publisher can't " + "be changed" + ) + ) + method = self.host.bridge.ps_items_send + + try: + items_ids = await method( + self.args.service, + self.args.node, + items, + "", + self.profile, + ) + except Exception as e: + self.disp(f"can't send items: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if items_ids: + self.disp( + _("items published with id(s) {items_ids}").format( + items_ids=", ".join(items_ids) + ) + ) + else: + self.disp(_("items published")) + self.host.quit() + + +class NodeAffiliationsGet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("retrieve node affiliations (for node owner)"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + affiliations = await self.host.bridge.ps_node_affiliations_get( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't get node affiliations: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(affiliations) + self.host.quit() + + +class NodeAffiliationsSet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("set affiliations (for node owner)"), + ) + + def add_parser_options(self): + # XXX: we use optional argument syntax for a required one because list of list of 2 elements + # (used to construct dicts) don't work with positional arguments + self.parser.add_argument( + "-a", + "--affiliation", + dest="affiliations", + metavar=("JID", "AFFILIATION"), + required=True, + action="append", + nargs=2, + help=_("entity/affiliation couple(s)"), + ) + + async def start(self): + affiliations = dict(self.args.affiliations) + try: + await self.host.bridge.ps_node_affiliations_set( + self.args.service, + self.args.node, + affiliations, + self.profile, + ) + except Exception as e: + self.disp(f"can't set node affiliations: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("affiliations have been set"), 1) + self.host.quit() + + +class NodeAffiliations(base.CommandBase): + subcommands = (NodeAffiliationsGet, NodeAffiliationsSet) + + def __init__(self, host): + super(NodeAffiliations, self).__init__( + host, + "affiliations", + use_profile=False, + help=_("set or retrieve node affiliations"), + ) + + +class NodeSubscriptionsGet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("retrieve node subscriptions (for node owner)"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "--public", + action="store_true", + help=_("get public subscriptions"), + ) + + async def start(self): + if self.args.public: + method = self.host.bridge.ps_public_node_subscriptions_get + else: + method = self.host.bridge.ps_node_subscriptions_get + try: + subscriptions = await method( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(f"can't get node subscriptions: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(subscriptions) + self.host.quit() + + +class StoreSubscriptionAction(argparse.Action): + """Action which handle subscription parameter for owner + + list is given by pairs: jid and subscription state + if subscription state is not specified, it default to "subscribed" + """ + + def __call__(self, parser, namespace, values, option_string): + dest_dict = getattr(namespace, self.dest) + while values: + jid_s = values.pop(0) + try: + subscription = values.pop(0) + except IndexError: + subscription = "subscribed" + if subscription not in ALLOWED_SUBSCRIPTIONS_OWNER: + parser.error( + _("subscription must be one of {}").format( + ", ".join(ALLOWED_SUBSCRIPTIONS_OWNER) + ) + ) + dest_dict[jid_s] = subscription + + +class NodeSubscriptionsSet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("set/modify subscriptions (for node owner)"), + ) + + def add_parser_options(self): + # XXX: we use optional argument syntax for a required one because list of list of 2 elements + # (uses to construct dicts) don't work with positional arguments + self.parser.add_argument( + "-S", + "--subscription", + dest="subscriptions", + default={}, + nargs="+", + metavar=("JID [SUSBSCRIPTION]"), + required=True, + action=StoreSubscriptionAction, + help=_("entity/subscription couple(s)"), + ) + + async def start(self): + try: + self.host.bridge.ps_node_subscriptions_set( + self.args.service, + self.args.node, + self.args.subscriptions, + self.profile, + ) + except Exception as e: + self.disp(f"can't set node subscriptions: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("subscriptions have been set"), 1) + self.host.quit() + + +class NodeSubscriptions(base.CommandBase): + subcommands = (NodeSubscriptionsGet, NodeSubscriptionsSet) + + def __init__(self, host): + super(NodeSubscriptions, self).__init__( + host, + "subscriptions", + use_profile=False, + help=_("get or modify node subscriptions"), + ) + + +class NodeSchemaSet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("set/replace a schema"), + ) + + def add_parser_options(self): + self.parser.add_argument("schema", help=_("schema to set (must be XML)")) + + async def start(self): + try: + await self.host.bridge.ps_schema_set( + self.args.service, + self.args.node, + self.args.schema, + self.profile, + ) + except Exception as e: + self.disp(f"can't set schema: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("schema has been set"), 1) + self.host.quit() + + +class NodeSchemaEdit(base.CommandBase, common.BaseEdit): + use_items = False + + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "edit", + use_pubsub=True, + pubsub_flags={C.NODE}, + use_draft=True, + use_verbose=True, + help=_("edit a schema"), + ) + common.BaseEdit.__init__(self, self.host, PUBSUB_SCHEMA_TMP_DIR) + + def add_parser_options(self): + pass + + async def publish(self, schema): + try: + await self.host.bridge.ps_schema_set( + self.args.service, + self.args.node, + schema, + self.profile, + ) + except Exception as e: + self.disp(f"can't set schema: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("schema has been set"), 1) + self.host.quit() + + async def ps_schema_get_cb(self, schema): + try: + from lxml import etree + except ImportError: + self.disp( + "lxml module must be installed to use edit, please install it " + 'with "pip install lxml"', + error=True, + ) + self.host.quit(1) + content_file_obj, content_file_path = self.get_tmp_file() + schema = schema.strip() + if schema: + parser = etree.XMLParser(remove_blank_text=True) + schema_elt = etree.fromstring(schema, parser) + content_file_obj.write( + etree.tostring(schema_elt, encoding="utf-8", pretty_print=True) + ) + content_file_obj.seek(0) + await self.run_editor( + "pubsub_schema_editor_args", content_file_path, content_file_obj + ) + + async def start(self): + try: + schema = await self.host.bridge.ps_schema_get( + self.args.service, + self.args.node, + self.profile, + ) + except BridgeException as e: + if e.condition == "item-not-found" or e.classname == "NotFound": + schema = "" + else: + self.disp(f"can't edit schema: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + await self.ps_schema_get_cb(schema) + + +class NodeSchemaGet(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "get", + use_output=C.OUTPUT_XML, + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("get schema"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + schema = await self.host.bridge.ps_schema_get( + self.args.service, + self.args.node, + self.profile, + ) + except BridgeException as e: + if e.condition == "item-not-found" or e.classname == "NotFound": + schema = None + else: + self.disp(f"can't get schema: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + if schema: + await self.output(schema) + self.host.quit() + else: + self.disp(_("no schema found"), 1) + self.host.quit(C.EXIT_NOT_FOUND) + + +class NodeSchema(base.CommandBase): + subcommands = (NodeSchemaSet, NodeSchemaEdit, NodeSchemaGet) + + def __init__(self, host): + super(NodeSchema, self).__init__( + host, "schema", use_profile=False, help=_("data schema manipulation") + ) + + +class Node(base.CommandBase): + subcommands = ( + NodeInfo, + NodeCreate, + NodePurge, + NodeDelete, + NodeSet, + NodeImport, + NodeAffiliations, + NodeSubscriptions, + NodeSchema, + ) + + def __init__(self, host): + super(Node, self).__init__( + host, "node", use_profile=False, help=_("node handling") + ) + + +class CacheGet(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "get", + use_output=C.OUTPUT_LIST_XML, + use_pubsub=True, + pubsub_flags={C.NODE, C.MULTI_ITEMS, C.CACHE}, + help=_("get pubsub item(s) from cache"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-S", + "--sub-id", + default="", + help=_("subscription id"), + ) + + async def start(self): + try: + ps_result = data_format.deserialise( + await self.host.bridge.ps_cache_get( + self.args.service, + self.args.node, + self.args.max, + self.args.items, + self.args.sub_id, + self.get_pubsub_extra(), + self.profile, + ) + ) + except BridgeException as e: + if e.classname == "NotFound": + self.disp( + f"The node {self.args.node} from {self.args.service} is not in cache " + f"for {self.profile}", + error=True, + ) + self.host.quit(C.EXIT_NOT_FOUND) + else: + self.disp(f"can't get pubsub items from cache: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + else: + await self.output(ps_result["items"]) + self.host.quit(C.EXIT_OK) + + +class CacheSync(base.CommandBase): + + def __init__(self, host): + super().__init__( + host, + "sync", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("(re)synchronise a pubsub node"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + await self.host.bridge.ps_cache_sync( + self.args.service, + self.args.node, + self.profile, + ) + except BridgeException as e: + if e.condition == "item-not-found" or e.classname == "NotFound": + self.disp( + f"The node {self.args.node} doesn't exist on {self.args.service}", + error=True, + ) + self.host.quit(C.EXIT_NOT_FOUND) + else: + self.disp(f"can't synchronise pubsub node: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + else: + self.host.quit(C.EXIT_OK) + + +class CachePurge(base.CommandBase): + + def __init__(self, host): + super().__init__( + host, + "purge", + use_profile=False, + help=_("purge (delete) items from cache"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-s", "--service", action="append", metavar="JID", dest="services", + help="purge items only for these services. If not specified, items from ALL " + "services will be purged. May be used several times." + ) + self.parser.add_argument( + "-n", "--node", action="append", dest="nodes", + help="purge items only for these nodes. If not specified, items from ALL " + "nodes will be purged. May be used several times." + ) + self.parser.add_argument( + "-p", "--profile", action="append", dest="profiles", + help="purge items only for these profiles. If not specified, items from ALL " + "profiles will be purged. May be used several times." + ) + self.parser.add_argument( + "-b", "--updated-before", type=base.date_decoder, metavar="TIME_PATTERN", + help="purge items which have been last updated before given time." + ) + self.parser.add_argument( + "-C", "--created-before", type=base.date_decoder, metavar="TIME_PATTERN", + help="purge items which have been last created before given time." + ) + self.parser.add_argument( + "-t", "--type", action="append", dest="types", + help="purge items flagged with TYPE. May be used several times." + ) + self.parser.add_argument( + "-S", "--subtype", action="append", dest="subtypes", + help="purge items flagged with SUBTYPE. May be used several times." + ) + self.parser.add_argument( + "-f", "--force", action="store_true", + help=_("purge items without confirmation") + ) + + async def start(self): + if not self.args.force: + await self.host.confirm_or_quit( + _( + "Are you sure to purge items from cache? You'll have to bypass cache " + "or resynchronise nodes to access deleted items again." + ), + _("Items purgins has been cancelled.") + ) + purge_data = {} + for key in ( + "services", "nodes", "profiles", "updated_before", "created_before", + "types", "subtypes" + ): + value = getattr(self.args, key) + if value is not None: + purge_data[key] = value + try: + await self.host.bridge.ps_cache_purge( + data_format.serialise( + purge_data + ) + ) + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + else: + self.host.quit(C.EXIT_OK) + + +class CacheReset(base.CommandBase): + + def __init__(self, host): + super().__init__( + host, + "reset", + use_profile=False, + help=_("remove everything from cache"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", "--force", action="store_true", + help=_("reset cache without confirmation") + ) + + async def start(self): + if not self.args.force: + await self.host.confirm_or_quit( + _( + "Are you sure to reset cache? All nodes and items will be removed " + "from it, then it will be progressively refilled as if it were new. " + "This may be resources intensive." + ), + _("Pubsub cache reset has been cancelled.") + ) + try: + await self.host.bridge.ps_cache_reset() + except Exception as e: + self.disp(f"Internal error: {e}", error=True) + self.host.quit(C.EXIT_INTERNAL_ERROR) + else: + self.host.quit(C.EXIT_OK) + + +class CacheSearch(base.CommandBase): + def __init__(self, host): + extra_outputs = { + "default": self.default_output, + "xml": self.xml_output, + "xml-raw": self.xml_raw_output, + } + super().__init__( + host, + "search", + use_profile=False, + use_output=C.OUTPUT_LIST_DICT, + extra_outputs=extra_outputs, + help=_("search for pubsub items in cache"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", "--fts", help=_("Full-Text Search query"), metavar="FTS_QUERY" + ) + self.parser.add_argument( + "-p", "--profile", action="append", dest="profiles", metavar="PROFILE", + help="search items only from these profiles. May be used several times." + ) + self.parser.add_argument( + "-s", "--service", action="append", dest="services", metavar="SERVICE", + help="items must be from specified service. May be used several times." + ) + self.parser.add_argument( + "-n", "--node", action="append", dest="nodes", metavar="NODE", + help="items must be in the specified node. May be used several times." + ) + self.parser.add_argument( + "-t", "--type", action="append", dest="types", metavar="TYPE", + help="items must be of specified type. May be used several times." + ) + self.parser.add_argument( + "-S", "--subtype", action="append", dest="subtypes", metavar="SUBTYPE", + help="items must be of specified subtype. May be used several times." + ) + self.parser.add_argument( + "-P", "--payload", action="store_true", help=_("include item XML payload") + ) + self.parser.add_argument( + "-o", "--order-by", action="append", nargs="+", + metavar=("ORDER", "[FIELD] [DIRECTION]"), + help=_("how items must be ordered. May be used several times.") + ) + self.parser.add_argument( + "-l", "--limit", type=int, help=_("maximum number of items to return") + ) + self.parser.add_argument( + "-i", "--index", type=int, help=_("return results starting from this index") + ) + self.parser.add_argument( + "-F", + "--field", + action="append", + nargs=3, + dest="fields", + default=[], + metavar=("PATH", "OPERATOR", "VALUE"), + help=_("parsed data field filter. May be used several times."), + ) + self.parser.add_argument( + "-k", + "--key", + action="append", + dest="keys", + metavar="KEY", + help=_( + "data key(s) to display. May be used several times. DEFAULT: show all " + "keys" + ), + ) + + async def start(self): + query = {} + for arg in ("fts", "profiles", "services", "nodes", "types", "subtypes"): + value = getattr(self.args, arg) + if value: + if arg in ("types", "subtypes"): + # empty string is used to find items without type and/or subtype + value = [v or None for v in value] + query[arg] = value + for arg in ("limit", "index"): + value = getattr(self.args, arg) + if value is not None: + query[arg] = value + if self.args.order_by is not None: + for order_data in self.args.order_by: + order, *args = order_data + if order == "field": + if not args: + self.parser.error(_("field data must be specified in --order-by")) + elif len(args) == 1: + path = args[0] + direction = "asc" + elif len(args) == 2: + path, direction = args + else: + self.parser.error(_( + "You can't specify more that 2 arguments for a field in " + "--order-by" + )) + try: + path = json.loads(path) + except json.JSONDecodeError: + pass + order_query = { + "path": path, + } + else: + order_query = { + "order": order + } + if not args: + direction = "asc" + elif len(args) == 1: + direction = args[0] + else: + self.parser.error(_( + "there are too many arguments in --order-by option" + )) + if direction.lower() not in ("asc", "desc"): + self.parser.error(_("invalid --order-by direction: {direction!r}")) + order_query["direction"] = direction + query.setdefault("order-by", []).append(order_query) + + if self.args.fields: + parsed = [] + for field in self.args.fields: + path, operator, value = field + try: + path = json.loads(path) + except json.JSONDecodeError: + # this is not a JSON encoded value, we keep it as a string + pass + + if not isinstance(path, list): + path = [path] + + # handling of TP(