Mercurial > libervia-backend
changeset 4075:47401850dec6
refactoring: rename `libervia.frontends.jp` to `libervia.cli`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 14:54:26 +0200 |
parents | 26b7ed2817da |
children | b620a8e882e1 |
files | libervia/backend/core/launcher.py libervia/backend/plugins/plugin_blog_import_dokuwiki.py libervia/backend/tools/common/template.py libervia/cli/__init__.py libervia/cli/arg_tools.py libervia/cli/base.py libervia/cli/cmd_account.py libervia/cli/cmd_adhoc.py libervia/cli/cmd_application.py libervia/cli/cmd_avatar.py libervia/cli/cmd_blocking.py libervia/cli/cmd_blog.py libervia/cli/cmd_bookmarks.py libervia/cli/cmd_debug.py libervia/cli/cmd_encryption.py libervia/cli/cmd_event.py libervia/cli/cmd_file.py libervia/cli/cmd_forums.py libervia/cli/cmd_identity.py libervia/cli/cmd_info.py libervia/cli/cmd_input.py libervia/cli/cmd_invitation.py libervia/cli/cmd_list.py libervia/cli/cmd_merge_request.py libervia/cli/cmd_message.py libervia/cli/cmd_param.py libervia/cli/cmd_ping.py libervia/cli/cmd_pipe.py libervia/cli/cmd_profile.py libervia/cli/cmd_pubsub.py libervia/cli/cmd_roster.py libervia/cli/cmd_shell.py libervia/cli/cmd_uri.py libervia/cli/common.py libervia/cli/constants.py libervia/cli/loops.py libervia/cli/output_std.py libervia/cli/output_template.py libervia/cli/output_xml.py libervia/cli/output_xmlui.py libervia/cli/xml_tools.py libervia/cli/xmlui_manager.py libervia/frontends/jp/__init__.py libervia/frontends/jp/arg_tools.py libervia/frontends/jp/base.py libervia/frontends/jp/cmd_account.py libervia/frontends/jp/cmd_adhoc.py libervia/frontends/jp/cmd_application.py libervia/frontends/jp/cmd_avatar.py libervia/frontends/jp/cmd_blocking.py libervia/frontends/jp/cmd_blog.py libervia/frontends/jp/cmd_bookmarks.py libervia/frontends/jp/cmd_debug.py libervia/frontends/jp/cmd_encryption.py libervia/frontends/jp/cmd_event.py libervia/frontends/jp/cmd_file.py libervia/frontends/jp/cmd_forums.py libervia/frontends/jp/cmd_identity.py libervia/frontends/jp/cmd_info.py libervia/frontends/jp/cmd_input.py libervia/frontends/jp/cmd_invitation.py libervia/frontends/jp/cmd_list.py libervia/frontends/jp/cmd_merge_request.py libervia/frontends/jp/cmd_message.py libervia/frontends/jp/cmd_param.py libervia/frontends/jp/cmd_ping.py libervia/frontends/jp/cmd_pipe.py libervia/frontends/jp/cmd_profile.py libervia/frontends/jp/cmd_pubsub.py libervia/frontends/jp/cmd_roster.py libervia/frontends/jp/cmd_shell.py libervia/frontends/jp/cmd_uri.py libervia/frontends/jp/common.py libervia/frontends/jp/constants.py libervia/frontends/jp/loops.py libervia/frontends/jp/output_std.py libervia/frontends/jp/output_template.py libervia/frontends/jp/output_xml.py libervia/frontends/jp/output_xmlui.py libervia/frontends/jp/xml_tools.py libervia/frontends/jp/xmlui_manager.py setup.py |
diffstat | 80 files changed, 15070 insertions(+), 15069 deletions(-) [+] |
line wrap: on
line diff
--- 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
--- 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
--- 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
--- /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 <http://www.gnu.org/licenses/>. + +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
--- /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 <http://www.gnu.org/licenses/>. + +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)
--- /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 <http://www.gnu.org/licenses/>. + +"""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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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'], + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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<key>[a-z_]+)=(?P<value>.*)") +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("<div>"): + content = "<div>" + content + "</div>" + 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'<html xmlns="http://www.w3.org/1999/xhtml">' + f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />' + f"</head>" + f"<body>{content}</body>" + f"</html>" + ) + + 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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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"), + )
--- /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 <http://www.gnu.org/licenses/>. + + +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"), + )
--- /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 <http://www.gnu.org/licenses/>. + + +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"), + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + +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()
--- /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 <http://www.gnu.org/licenses/>. + +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") + )
--- /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 <http://www.gnu.org/licenses/>. + +"""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'))
--- /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 <http://www.gnu.org/licenses/>. + + +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 = "<import>" + self.args.import_file.read() + "</import>" + 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(<time pattern>) + if operator in (">", "gt", "<", "le", "between"): + def datetime_sub(match): + return str(date_utils.date_parse_ext( + match.group(1), default_tz=date_utils.TZ_LOCAL + )) + value = re.sub(r"\bTP\(([^)]+)\)", datetime_sub, value) + + try: + value = json.loads(value) + except json.JSONDecodeError: + # not JSON, as above we keep it as string + pass + + if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"): + if not isinstance(value, list): + value = [value] + + parsed.append({ + "path": path, + "op": operator, + "value": value + }) + + query["parsed"] = parsed + + if self.args.payload or "xml" in self.args.output: + query["with_payload"] = True + if self.args.keys: + self.args.keys.append("item_payload") + try: + found_items = data_format.deserialise( + await self.host.bridge.ps_cache_search( + data_format.serialise(query) + ), + type_check=list, + ) + except BridgeException as e: + self.disp(f"can't search for pubsub items in 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: + if self.args.keys: + found_items = [ + {k: v for k,v in item.items() if k in self.args.keys} + for item in found_items + ] + await self.output(found_items) + self.host.quit(C.EXIT_OK) + + def default_output(self, found_items): + for item in found_items: + for field in ("created", "published", "updated"): + try: + timestamp = item[field] + except KeyError: + pass + else: + try: + item[field] = common.format_time(timestamp) + except ValueError: + pass + self.host._outputs[C.OUTPUT_LIST_DICT]["simple"]["callback"](found_items) + + def xml_output(self, found_items): + """Output prettified item payload""" + cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML]["callback"] + for item in found_items: + cb(item["item_payload"]) + + def xml_raw_output(self, found_items): + """Output item payload without prettifying""" + cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML_RAW]["callback"] + for item in found_items: + cb(item["item_payload"]) + + +class Cache(base.CommandBase): + subcommands = ( + CacheGet, + CacheSync, + CachePurge, + CacheReset, + CacheSearch, + ) + + def __init__(self, host): + super(Cache, self).__init__( + host, "cache", use_profile=False, help=_("pubsub cache handling") + ) + + +class Set(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "set", + use_pubsub=True, + use_quiet=True, + pubsub_flags={C.NODE}, + help=_("publish a new item or update an existing one"), + ) + + def add_parser_options(self): + NodeCreate.add_node_config_options(self.parser) + self.parser.add_argument( + "-e", + "--encrypt", + action="store_true", + help=_("end-to-end encrypt the blog item") + ) + 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") + ) + self.parser.add_argument( + "item", + nargs="?", + default="", + help=_("id, URL of the item to update, keyword, or nothing for new item"), + ) + + async def start(self): + element, etree = xml_tools.etree_parse(self, sys.stdin) + element = xml_tools.get_payload(self, element) + payload = etree.tostring(element, encoding="unicode") + extra = {} + if self.args.encrypt: + extra["encrypted"] = True + if self.args.encrypt_for: + extra["encrypted_for"] = {"targets": self.args.encrypt_for} + if self.args.sign: + extra["signed"] = True + publish_options = NodeCreate.get_config_options(self.args) + if publish_options: + extra["publish_options"] = publish_options + + try: + published_id = await self.host.bridge.ps_item_send( + self.args.service, + self.args.node, + payload, + self.args.item, + data_format.serialise(extra), + self.profile, + ) + except Exception as e: + self.disp(_("can't send item: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if published_id: + if self.args.quiet: + self.disp(published_id, end="") + else: + self.disp(f"Item published at {published_id}") + else: + self.disp("Item published") + self.host.quit(C.EXIT_OK) + + +class Get(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + 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)"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-S", + "--sub-id", + default="", + help=_("subscription id"), + ) + self.parser.add_argument( + "--no-decrypt", + action="store_true", + help=_("don't do automatic decryption of e2ee items"), + ) + # TODO: a key(s) argument to select keys to display + + async def start(self): + extra = {} + if self.args.no_decrypt: + extra["decrypt"] = False + try: + ps_result = data_format.deserialise( + await self.host.bridge.ps_items_get( + self.args.service, + self.args.node, + self.args.max, + self.args.items, + self.args.sub_id, + self.get_pubsub_extra(extra), + 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 get pubsub items: {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 Delete(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "delete", + use_pubsub=True, + pubsub_flags={C.NODE, C.ITEM, C.SINGLE_ITEM}, + help=_("delete an item"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-f", "--force", action="store_true", help=_("delete without confirmation") + ) + self.parser.add_argument( + "--no-notification", dest="notify", action="store_false", + help=_("do not send notification (not recommended)") + ) + + async def start(self): + if not self.args.item: + self.parser.error(_("You need to specify an item to delete")) + if not self.args.force: + message = _("Are you sure to delete item {item_id} ?").format( + item_id=self.args.item + ) + await self.host.confirm_or_quit(message, _("item deletion cancelled")) + try: + await self.host.bridge.ps_item_retract( + 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 Edit(base.CommandBase, common.BaseEdit): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "edit", + use_verbose=True, + use_pubsub=True, + pubsub_flags={C.NODE, C.SINGLE_ITEM}, + use_draft=True, + help=_("edit an existing or new pubsub item"), + ) + common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR) + + def add_parser_options(self): + self.parser.add_argument( + "-e", + "--encrypt", + action="store_true", + help=_("end-to-end encrypt the blog item") + ) + 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 publish(self, content): + extra = {} + if self.args.encrypt: + extra["encrypted"] = True + if self.args.encrypt_for: + extra["encrypted_for"] = {"targets": self.args.encrypt_for} + if self.args.sign: + extra["signed"] = True + published_id = await self.host.bridge.ps_item_send( + self.pubsub_service, + self.pubsub_node, + content, + self.pubsub_item or "", + data_format.serialise(extra), + self.profile, + ) + if published_id: + self.disp("Item published at {pub_id}".format(pub_id=published_id)) + else: + self.disp("Item published") + + async def get_item_data(self, service, node, item): + 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) + items = [item] if item else [] + ps_result = data_format.deserialise( + await self.host.bridge.ps_items_get( + service, node, 1, items, "", data_format.serialise({}), self.profile + ) + ) + item_raw = ps_result["items"][0] + parser = etree.XMLParser(remove_blank_text=True, recover=True) + item_elt = etree.fromstring(item_raw, parser) + item_id = item_elt.get("id") + try: + payload = item_elt[0] + except IndexError: + self.disp(_("Item has not payload"), 1) + return "", item_id + return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id + + async def start(self): + ( + self.pubsub_service, + self.pubsub_node, + self.pubsub_item, + content_file_path, + content_file_obj, + ) = await self.get_item_path() + await self.run_editor("pubsub_editor_args", content_file_path, content_file_obj) + self.host.quit() + + +class Rename(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "rename", + use_pubsub=True, + pubsub_flags={C.NODE, C.SINGLE_ITEM}, + help=_("rename a pubsub 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.ps_item_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 Subscribe(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "subscribe", + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("subscribe to a node"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "--public", + action="store_true", + help=_("make the registration visible for everybody"), + ) + + async def start(self): + options = {} + if self.args.public: + namespaces = await self.host.bridge.namespaces_get() + try: + ns_pps = namespaces["pps"] + except KeyError: + self.disp( + "Pubsub Public Subscription plugin is not loaded, can't use --public " + "option, subscription stopped", error=True + ) + self.host.quit(C.EXIT_MISSING_FEATURE) + else: + options[f"{{{ns_pps}}}public"] = True + try: + sub_id = await self.host.bridge.ps_subscribe( + self.args.service, + self.args.node, + data_format.serialise(options), + self.profile, + ) + except Exception as e: + self.disp(_("can't subscribe to node: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("subscription done"), 1) + if sub_id: + self.disp(_("subscription id: {sub_id}").format(sub_id=sub_id)) + self.host.quit() + + +class Unsubscribe(base.CommandBase): + # FIXME: check why we get a a NodeNotFound on subscribe just after unsubscribe + + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "unsubscribe", + use_pubsub=True, + pubsub_flags={C.NODE}, + use_verbose=True, + help=_("unsubscribe from a node"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + await self.host.bridge.ps_unsubscribe( + self.args.service, + self.args.node, + self.profile, + ) + except Exception as e: + self.disp(_("can't unsubscribe from node: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("subscription removed"), 1) + self.host.quit() + + +class Subscriptions(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "subscriptions", + use_output=C.OUTPUT_LIST_DICT, + use_pubsub=True, + help=_("retrieve all subscriptions on a service"), + ) + + 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_subscriptions_get + else: + method = self.host.bridge.ps_subscriptions_get + try: + subscriptions = data_format.deserialise( + await method( + self.args.service, + self.args.node, + self.profile, + ), + type_check=list + ) + except Exception as e: + self.disp(_("can't retrieve subscriptions: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(subscriptions) + self.host.quit() + + +class Affiliations(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "affiliations", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + help=_("retrieve all affiliations on a service"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + affiliations = await self.host.bridge.ps_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 Reference(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "reference", + use_pubsub=True, + pubsub_flags={C.NODE, C.SINGLE_ITEM}, + help=_("send a reference/mention to pubsub item"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-t", + "--type", + default="mention", + choices=("data", "mention"), + help=_("type of reference to send (DEFAULT: mention)"), + ) + self.parser.add_argument( + "recipient", + help=_("recipient of the reference") + ) + + async def start(self): + service = self.args.service or await self.host.get_profile_jid() + if self.args.item: + anchor = uri.build_xmpp_uri( + "pubsub", path=service, node=self.args.node, item=self.args.item + ) + else: + anchor = uri.build_xmpp_uri("pubsub", path=service, node=self.args.node) + + try: + await self.host.bridge.reference_send( + self.args.recipient, + anchor, + self.args.type, + "", + self.profile, + ) + except Exception as e: + self.disp(f"can't send reference: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class Search(base.CommandBase): + """This command do a search without using MAM + + This commands checks every items it finds by itself, + so it may be heavy in resources both for server and client + """ + + RE_FLAGS = re.MULTILINE | re.UNICODE + EXEC_ACTIONS = ("exec", "external") + + def __init__(self, host): + # FIXME: C.NO_MAX is not needed here, and this can be globally removed from consts + # the only interest is to change the help string, but this can be explained + # extensively in man pages (max is for each node found) + base.CommandBase.__init__( + self, + host, + "search", + use_output=C.OUTPUT_XML, + use_pubsub=True, + pubsub_flags={C.MULTI_ITEMS, C.NO_MAX}, + use_verbose=True, + help=_("search items corresponding to filters"), + ) + + @property + def etree(self): + """load lxml.etree only if needed""" + if self._etree is None: + from lxml import etree + + self._etree = etree + return self._etree + + def filter_opt(self, value, type_): + return (type_, value) + + def filter_flag(self, value, type_): + value = C.bool(value) + return (type_, value) + + def add_parser_options(self): + self.parser.add_argument( + "-D", + "--max-depth", + type=int, + default=0, + help=_( + "maximum depth of recursion (will search linked nodes if > 0, " + "DEFAULT: 0)" + ), + ) + self.parser.add_argument( + "-M", + "--node-max", + type=int, + default=30, + help=_( + "maximum number of items to get per node ({} to get all items, " + "DEFAULT: 30)".format(C.NO_LIMIT) + ), + ) + self.parser.add_argument( + "-N", + "--namespace", + action="append", + nargs=2, + default=[], + metavar="NAME NAMESPACE", + help=_("namespace to use for xpath"), + ) + + # filters + filter_text = partial(self.filter_opt, type_="text") + filter_re = partial(self.filter_opt, type_="regex") + filter_xpath = partial(self.filter_opt, type_="xpath") + filter_python = partial(self.filter_opt, type_="python") + filters = self.parser.add_argument_group( + _("filters"), + _("only items corresponding to following filters will be kept"), + ) + filters.add_argument( + "-t", + "--text", + action="append", + dest="filters", + type=filter_text, + metavar="TEXT", + help=_("full text filter, item must contain this string (XML included)"), + ) + filters.add_argument( + "-r", + "--regex", + action="append", + dest="filters", + type=filter_re, + metavar="EXPRESSION", + help=_("like --text but using a regular expression"), + ) + filters.add_argument( + "-x", + "--xpath", + action="append", + dest="filters", + type=filter_xpath, + metavar="XPATH", + help=_("filter items which has elements matching this xpath"), + ) + filters.add_argument( + "-P", + "--python", + action="append", + dest="filters", + type=filter_python, + metavar="PYTHON_CODE", + help=_( + "Python expression which much return a bool (True to keep item, " + 'False to reject it). "item" is raw text item, "item_xml" is ' + "lxml's etree.Element" + ), + ) + + # filters flags + flag_case = partial(self.filter_flag, type_="ignore-case") + flag_invert = partial(self.filter_flag, type_="invert") + flag_dotall = partial(self.filter_flag, type_="dotall") + flag_matching = partial(self.filter_flag, type_="only-matching") + flags = self.parser.add_argument_group( + _("filters flags"), + _("filters modifiers (change behaviour of following filters)"), + ) + flags.add_argument( + "-C", + "--ignore-case", + action="append", + dest="filters", + type=flag_case, + const=("ignore-case", True), + nargs="?", + metavar="BOOLEAN", + help=_("(don't) ignore case in following filters (DEFAULT: case sensitive)"), + ) + flags.add_argument( + "-I", + "--invert", + action="append", + dest="filters", + type=flag_invert, + const=("invert", True), + nargs="?", + metavar="BOOLEAN", + help=_("(don't) invert effect of following filters (DEFAULT: don't invert)"), + ) + flags.add_argument( + "-A", + "--dot-all", + action="append", + dest="filters", + type=flag_dotall, + const=("dotall", True), + nargs="?", + metavar="BOOLEAN", + help=_("(don't) use DOTALL option for regex (DEFAULT: don't use)"), + ) + flags.add_argument( + "-k", + "--only-matching", + action="append", + dest="filters", + type=flag_matching, + const=("only-matching", True), + nargs="?", + metavar="BOOLEAN", + help=_("keep only the matching part of the item"), + ) + + # action + self.parser.add_argument( + "action", + default="print", + nargs="?", + choices=("print", "exec", "external"), + help=_("action to do on found items (DEFAULT: print)"), + ) + self.parser.add_argument("command", nargs=argparse.REMAINDER) + + async def get_items(self, depth, service, node, items): + self.to_get += 1 + try: + ps_result = data_format.deserialise( + await self.host.bridge.ps_items_get( + service, + node, + self.args.node_max, + items, + "", + self.get_pubsub_extra(), + self.profile, + ) + ) + except Exception as e: + self.disp( + f"can't get pubsub items at {service} (node: {node}): {e}", + error=True, + ) + self.to_get -= 1 + else: + await self.search(ps_result, depth) + + def _check_pubsub_url(self, match, found_nodes): + """check that the matched URL is an xmpp: one + + @param found_nodes(list[unicode]): found_nodes + this list will be filled while xmpp: URIs are discovered + """ + url = match.group(0) + if url.startswith("xmpp"): + try: + url_data = uri.parse_xmpp_uri(url) + except ValueError: + return + if url_data["type"] == "pubsub": + found_node = {"service": url_data["path"], "node": url_data["node"]} + if "item" in url_data: + found_node["item"] = url_data["item"] + found_nodes.append(found_node) + + async def get_sub_nodes(self, item, depth): + """look for pubsub URIs in item, and get_items on the linked nodes""" + found_nodes = [] + checkURI = partial(self._check_pubsub_url, found_nodes=found_nodes) + strings.RE_URL.sub(checkURI, item) + for data in found_nodes: + await self.get_items( + depth + 1, + data["service"], + data["node"], + [data["item"]] if "item" in data else [], + ) + + def parseXml(self, item): + try: + return self.etree.fromstring(item) + except self.etree.XMLSyntaxError: + self.disp( + _( + "item doesn't looks like XML, you have probably used --only-matching " + "somewhere before and we have no more XML" + ), + error=True, + ) + self.host.quit(C.EXIT_BAD_ARG) + + def filter(self, item): + """apply filters given on command line + + if only-matching is used, item may be modified + @return (tuple[bool, unicode]): a tuple with: + - keep: True if item passed the filters + - item: it is returned in case of modifications + """ + ignore_case = False + invert = False + dotall = False + only_matching = False + item_xml = None + for type_, value in self.args.filters: + keep = True + + ## filters + + if type_ == "text": + if ignore_case: + if value.lower() not in item.lower(): + keep = False + else: + if value not in item: + keep = False + if keep and only_matching: + # doesn't really make sens to keep a fixed string + # so we raise an error + self.host.disp( + _("--only-matching used with fixed --text string, are you sure?"), + error=True, + ) + self.host.quit(C.EXIT_BAD_ARG) + elif type_ == "regex": + flags = self.RE_FLAGS + if ignore_case: + flags |= re.IGNORECASE + if dotall: + flags |= re.DOTALL + match = re.search(value, item, flags) + keep = match != None + if keep and only_matching: + item = match.group() + item_xml = None + elif type_ == "xpath": + if item_xml is None: + item_xml = self.parseXml(item) + try: + elts = item_xml.xpath(value, namespaces=self.args.namespace) + except self.etree.XPathEvalError as e: + self.disp(_("can't use xpath: {reason}").format(reason=e), error=True) + self.host.quit(C.EXIT_BAD_ARG) + keep = bool(elts) + if keep and only_matching: + item_xml = elts[0] + try: + item = self.etree.tostring(item_xml, encoding="unicode") + except TypeError: + # we have a string only, not an element + item = str(item_xml) + item_xml = None + elif type_ == "python": + if item_xml is None: + item_xml = self.parseXml(item) + cmd_ns = {"etree": self.etree, "item": item, "item_xml": item_xml} + try: + keep = eval(value, cmd_ns) + except SyntaxError as e: + self.disp(str(e), error=True) + self.host.quit(C.EXIT_BAD_ARG) + + ## flags + + elif type_ == "ignore-case": + ignore_case = value + elif type_ == "invert": + invert = value + # we need to continue, else loop would end here + continue + elif type_ == "dotall": + dotall = value + elif type_ == "only-matching": + only_matching = value + else: + raise exceptions.InternalError( + _("unknown filter type {type}").format(type=type_) + ) + + if invert: + keep = not keep + if not keep: + return False, item + + return True, item + + async def do_item_action(self, item, metadata): + """called when item has been kepts and the action need to be done + + @param item(unicode): accepted item + """ + action = self.args.action + if action == "print" or self.host.verbosity > 0: + try: + await self.output(item) + except self.etree.XMLSyntaxError: + # item is not valid XML, but a string + # can happen when --only-matching is used + self.disp(item) + if action in self.EXEC_ACTIONS: + item_elt = self.parseXml(item) + if action == "exec": + use = { + "service": metadata["service"], + "node": metadata["node"], + "item": item_elt.get("id"), + "profile": self.profile, + } + # we need to send a copy of self.args.command + # else it would be modified + parser_args, use_args = arg_tools.get_use_args( + self.host, self.args.command, use, verbose=self.host.verbosity > 1 + ) + cmd_args = sys.argv[0:1] + parser_args + use_args + else: + cmd_args = self.args.command + + self.disp( + "COMMAND: {command}".format( + command=" ".join([arg_tools.escape(a) for a in cmd_args]) + ), + 2, + ) + if action == "exec": + p = await asyncio.create_subprocess_exec(*cmd_args) + ret = await p.wait() + else: + p = await asyncio.create_subprocess_exec(*cmd_args, stdin=subprocess.PIPE) + await p.communicate(item.encode(sys.getfilesystemencoding())) + ret = p.returncode + if ret != 0: + self.disp( + A.color( + C.A_FAILURE, + _("executed command failed with exit code {ret}").format(ret=ret), + ) + ) + + async def search(self, ps_result, depth): + """callback of get_items + + this method filters items, get sub nodes if needed, + do the requested action, and exit the command when everything is done + @param items_data(tuple): result of get_items + @param depth(int): current depth level + 0 for first node, 1 for first children, and so on + """ + for item in ps_result["items"]: + if depth < self.args.max_depth: + await self.get_sub_nodes(item, depth) + keep, item = self.filter(item) + if not keep: + continue + await self.do_item_action(item, ps_result) + + # we check if we got all get_items results + self.to_get -= 1 + if self.to_get == 0: + # yes, we can quit + self.host.quit() + assert self.to_get > 0 + + async def start(self): + if self.args.command: + if self.args.action not in self.EXEC_ACTIONS: + self.parser.error( + _("Command can only be used with {actions} actions").format( + actions=", ".join(self.EXEC_ACTIONS) + ) + ) + else: + if self.args.action in self.EXEC_ACTIONS: + self.parser.error(_("you need to specify a command to execute")) + if not self.args.node: + # TODO: handle get service affiliations when node is not set + self.parser.error(_("empty node is not handled yet")) + # to_get is increased on each get and decreased on each answer + # when it reach 0 again, the command is finished + self.to_get = 0 + self._etree = None + if self.args.filters is None: + self.args.filters = [] + self.args.namespace = dict( + self.args.namespace + [("pubsub", "http://jabber.org/protocol/pubsub")] + ) + await self.get_items(0, self.args.service, self.args.node, self.args.items) + + +class Transform(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "transform", + use_pubsub=True, + pubsub_flags={C.NODE, C.MULTI_ITEMS}, + help=_("modify items of a node using an external command/script"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "--apply", + action="store_true", + help=_("apply transformation (DEFAULT: do a dry run)"), + ) + self.parser.add_argument( + "--admin", + action="store_true", + help=_("do a pubsub admin request, needed to change publisher"), + ) + self.parser.add_argument( + "-I", + "--ignore-errors", + action="store_true", + help=_( + "if command return a non zero exit code, ignore the item and continue" + ), + ) + self.parser.add_argument( + "-A", + "--all", + action="store_true", + help=_("get all items by looping over all pages using RSM"), + ) + self.parser.add_argument( + "command_path", + help=_( + "path to the command to use. Will be called repetitivly with an " + "item as input. Output (full item XML) will be used as new one. " + 'Return "DELETE" string to delete the item, and "SKIP" to ignore it' + ), + ) + + async def ps_items_send_cb(self, item_ids, metadata): + if item_ids: + self.disp( + _("items published with ids {item_ids}").format( + item_ids=", ".join(item_ids) + ) + ) + else: + self.disp(_("items published")) + if self.args.all: + return await self.handle_next_page(metadata) + else: + self.host.quit() + + async def handle_next_page(self, metadata): + """Retrieve new page through RSM or quit if we're in the last page + + use to handle --all option + @param metadata(dict): metadata as returned by ps_items_get + """ + try: + last = metadata["rsm"]["last"] + index = int(metadata["rsm"]["index"]) + count = int(metadata["rsm"]["count"]) + except KeyError: + self.disp( + _("Can't retrieve all items, RSM metadata not available"), error=True + ) + self.host.quit(C.EXIT_MISSING_FEATURE) + except ValueError as e: + self.disp( + _("Can't retrieve all items, bad RSM metadata: {msg}").format(msg=e), + error=True, + ) + self.host.quit(C.EXIT_ERROR) + + if index + self.args.rsm_max >= count: + self.disp(_("All items transformed")) + self.host.quit(0) + + self.disp( + _("Retrieving next page ({page_idx}/{page_total})").format( + page_idx=int(index / self.args.rsm_max) + 1, + page_total=int(count / self.args.rsm_max), + ) + ) + + extra = self.get_pubsub_extra() + extra["rsm_after"] = last + try: + ps_result = await data_format.deserialise( + self.host.bridge.ps_items_get( + self.args.service, + self.args.node, + self.args.rsm_max, + self.args.items, + "", + data_format.serialise(extra), + self.profile, + ) + ) + except Exception as e: + self.disp(f"can't retrieve items: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.ps_items_get_cb(ps_result) + + async def ps_items_get_cb(self, ps_result): + encoding = "utf-8" + new_items = [] + + for item in ps_result["items"]: + if self.check_duplicates: + # this is used when we are not ordering by creation + # to avoid infinite loop + item_elt, __ = xml_tools.etree_parse(self, item) + item_id = item_elt.get("id") + if item_id in self.items_ids: + self.disp( + _( + "Duplicate found on item {item_id}, we have probably handled " + "all items." + ).format(item_id=item_id) + ) + self.host.quit() + self.items_ids.append(item_id) + + # we launch the command to filter the item + try: + p = await asyncio.create_subprocess_exec( + self.args.command_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE + ) + except OSError as e: + exit_code = C.EXIT_CMD_NOT_FOUND if e.errno == 2 else C.EXIT_ERROR + self.disp(f"Can't execute the command: {e}", error=True) + self.host.quit(exit_code) + encoding = "utf-8" + cmd_std_out, cmd_std_err = await p.communicate(item.encode(encoding)) + ret = p.returncode + if ret != 0: + self.disp( + f"The command returned a non zero status while parsing the " + f"following item:\n\n{item}", + error=True, + ) + if self.args.ignore_errors: + continue + else: + self.host.quit(C.EXIT_CMD_ERROR) + if cmd_std_err is not None: + cmd_std_err = cmd_std_err.decode(encoding, errors="ignore") + self.disp(cmd_std_err, error=True) + cmd_std_out = cmd_std_out.decode(encoding).strip() + if cmd_std_out == "DELETE": + item_elt, __ = xml_tools.etree_parse(self, item) + item_id = item_elt.get("id") + self.disp(_("Deleting item {item_id}").format(item_id=item_id)) + if self.args.apply: + try: + await self.host.bridge.ps_item_retract( + self.args.service, + self.args.node, + item_id, + False, + self.profile, + ) + except Exception as e: + self.disp(f"can't delete item {item_id}: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + continue + elif cmd_std_out == "SKIP": + item_elt, __ = xml_tools.etree_parse(self, item) + item_id = item_elt.get("id") + self.disp(_("Skipping item {item_id}").format(item_id=item_id)) + continue + element, etree = xml_tools.etree_parse(self, cmd_std_out) + + # at this point command has been run and we have a etree.Element object + if element.tag not in ("item", "{http://jabber.org/protocol/pubsub}item"): + self.disp( + "your script must return a whole item, this is not:\n{xml}".format( + xml=etree.tostring(element, encoding="unicode") + ), + error=True, + ) + self.host.quit(C.EXIT_DATA_ERROR) + + if not self.args.apply: + # we have a dry run, we just display filtered items + serialised = etree.tostring( + element, encoding="unicode", pretty_print=True + ) + self.disp(serialised) + else: + new_items.append(etree.tostring(element, encoding="unicode")) + + if not self.args.apply: + # on dry run we have nothing to wait for, we can quit + if self.args.all: + return await self.handle_next_page(ps_result) + self.host.quit() + else: + if self.args.admin: + bridge_method = self.host.bridge.ps_admin_items_send + else: + bridge_method = self.host.bridge.ps_items_send + + try: + ps_items_send_result = await bridge_method( + self.args.service, + self.args.node, + new_items, + "", + self.profile, + ) + except Exception as e: + self.disp(f"can't send item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.ps_items_send_cb(ps_items_send_result, metadata=ps_result) + + async def start(self): + if self.args.all and self.args.order_by != C.ORDER_BY_CREATION: + self.check_duplicates = True + self.items_ids = [] + self.disp( + A.color( + A.FG_RED, + A.BOLD, + '/!\\ "--all" should be used with "--order-by creation" /!\\\n', + A.RESET, + "We'll update items, so order may change during transformation,\n" + "we'll try to mitigate that by stopping on first duplicate,\n" + "but this method is not safe, and some items may be missed.\n---\n", + ) + ) + else: + self.check_duplicates = False + + try: + ps_result = data_format.deserialise( + await self.host.bridge.ps_items_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 retrieve items: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.ps_items_get_cb(ps_result) + + +class Uri(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "uri", + use_profile=False, + use_pubsub=True, + pubsub_flags={C.NODE, C.SINGLE_ITEM}, + help=_("build URI"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-p", + "--profile", + default=C.PROF_KEY_DEFAULT, + help=_("profile (used when no server is specified)"), + ) + + def display_uri(self, jid_): + uri_args = {} + if not self.args.service: + self.args.service = jid.JID(jid_).bare + + for key in ("node", "service", "item"): + value = getattr(self.args, key) + if key == "service": + key = "path" + if value: + uri_args[key] = value + self.disp(uri.build_xmpp_uri("pubsub", **uri_args)) + self.host.quit() + + async def start(self): + if not self.args.service: + try: + jid_ = await self.host.bridge.param_get_a_async( + "JabberID", "Connection", profile_key=self.args.profile + ) + except Exception as e: + self.disp(f"can't retrieve jid: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.display_uri(jid_) + else: + self.display_uri(None) + + +class AttachmentGet(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "get", + use_output=C.OUTPUT_LIST_DICT, + use_pubsub=True, + pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM}, + help=_("get data attached to an item"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-j", + "--jid", + action="append", + dest="jids", + help=_( + "get attached data published only by those JIDs (DEFAULT: get all " + "attached data)" + ) + ) + + async def start(self): + try: + attached_data, __ = await self.host.bridge.ps_attachments_get( + self.args.service, + self.args.node, + self.args.item, + self.args.jids or [], + "", + self.profile, + ) + except Exception as e: + self.disp(f"can't get attached data: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + attached_data = data_format.deserialise(attached_data, type_check=list) + await self.output(attached_data) + self.host.quit(C.EXIT_OK) + + +class AttachmentSet(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "set", + use_pubsub=True, + pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM}, + help=_("attach data to an item"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "--replace", + action="store_true", + help=_( + "replace previous versions of attachments (DEFAULT: update previous " + "version)" + ) + ) + self.parser.add_argument( + "-N", + "--noticed", + metavar="BOOLEAN", + nargs="?", + default="keep", + help=_("mark item as (un)noticed (DEFAULT: keep current value))") + ) + self.parser.add_argument( + "-r", + "--reactions", + # FIXME: to be replaced by "extend" when we stop supporting python 3.7 + action="append", + help=_("emojis to add to react to an item") + ) + self.parser.add_argument( + "-R", + "--reactions-remove", + # FIXME: to be replaced by "extend" when we stop supporting python 3.7 + action="append", + help=_("emojis to remove from reactions to an item") + ) + + async def start(self): + attachments_data = { + "service": self.args.service, + "node": self.args.node, + "id": self.args.item, + "extra": {} + } + operation = "replace" if self.args.replace else "update" + if self.args.noticed != "keep": + if self.args.noticed is None: + self.args.noticed = C.BOOL_TRUE + attachments_data["extra"]["noticed"] = C.bool(self.args.noticed) + + if self.args.reactions or self.args.reactions_remove: + reactions = attachments_data["extra"]["reactions"] = { + "operation": operation + } + if self.args.replace: + reactions["reactions"] = self.args.reactions + else: + reactions["add"] = self.args.reactions + reactions["remove"] = self.args.reactions_remove + + + if not attachments_data["extra"]: + self.parser.error(_("At leat one attachment must be specified.")) + + try: + await self.host.bridge.ps_attachments_set( + data_format.serialise(attachments_data), + self.profile, + ) + except Exception as e: + self.disp(f"can't attach data to item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp("data attached") + self.host.quit(C.EXIT_OK) + + +class Attachments(base.CommandBase): + subcommands = (AttachmentGet, AttachmentSet) + + def __init__(self, host): + super().__init__( + host, + "attachments", + use_profile=False, + help=_("set or retrieve items attachments"), + ) + + +class SignatureSign(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "sign", + use_pubsub=True, + pubsub_flags={C.NODE, C.SINGLE_ITEM}, + help=_("sign an item"), + ) + + def add_parser_options(self): + pass + + async def start(self): + attachments_data = { + "service": self.args.service, + "node": self.args.node, + "id": self.args.item, + "extra": { + # we set None to use profile's bare JID + "signature": {"signer": None} + } + } + try: + await self.host.bridge.ps_attachments_set( + data_format.serialise(attachments_data), + self.profile, + ) + except Exception as e: + self.disp(f"can't sign the item: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(f"item {self.args.item!r} has been signed") + self.host.quit(C.EXIT_OK) + + +class SignatureCheck(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "check", + use_output=C.OUTPUT_DICT, + use_pubsub=True, + pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM}, + help=_("check the validity of pubsub signature"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "signature", + metavar="JSON", + help=_("signature data") + ) + + async def start(self): + try: + ret_s = await self.host.bridge.ps_signature_check( + self.args.service, + self.args.node, + self.args.item, + self.args.signature, + self.profile, + ) + except Exception as e: + self.disp(f"can't check signature: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self.output(data_format.deserialise((ret_s))) + self.host.quit() + + +class Signature(base.CommandBase): + subcommands = ( + SignatureSign, + SignatureCheck, + ) + + def __init__(self, host): + super().__init__( + host, "signature", use_profile=False, help=_("items signatures") + ) + + +class SecretShare(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "share", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("share a secret to let other entity encrypt or decrypt items"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-k", "--key", metavar="ID", dest="secret_ids", action="append", default=[], + help=_( + "only share secrets with those IDs (default: share all secrets of the " + "node)" + ) + ) + self.parser.add_argument( + "recipient", metavar="JID", help=_("entity who must get the shared secret") + ) + + async def start(self): + try: + await self.host.bridge.ps_secret_share( + self.args.recipient, + self.args.service, + self.args.node, + self.args.secret_ids, + self.profile, + ) + except Exception as e: + self.disp(f"can't share secret: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp("secrets have been shared") + self.host.quit(C.EXIT_OK) + + +class SecretRevoke(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "revoke", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("revoke an encrypted node secret"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "secret_id", help=_("ID of the secrets to revoke") + ) + self.parser.add_argument( + "-r", "--recipient", dest="recipients", metavar="JID", action="append", + default=[], help=_( + "entity who must get the revocation notification (default: send to all " + "entities known to have the shared secret)" + ) + ) + + async def start(self): + try: + await self.host.bridge.ps_secret_revoke( + self.args.service, + self.args.node, + self.args.secret_id, + self.args.recipients, + self.profile, + ) + except Exception as e: + self.disp(f"can't revoke secret: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp("secret {self.args.secret_id} has been revoked.") + self.host.quit(C.EXIT_OK) + + +class SecretRotate(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "rotate", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("revoke existing secrets, create a new one and send notifications"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-r", "--recipient", dest="recipients", metavar="JID", action="append", + default=[], help=_( + "entity who must get the revocation and shared secret notifications " + "(default: send to all entities known to have the shared secret)" + ) + ) + + async def start(self): + try: + await self.host.bridge.ps_secret_rotate( + self.args.service, + self.args.node, + self.args.recipients, + self.profile, + ) + except Exception as e: + self.disp(f"can't rotate secret: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp("secret has been rotated") + self.host.quit(C.EXIT_OK) + + +class SecretList(base.CommandBase): + def __init__(self, host): + super().__init__( + host, + "list", + use_pubsub=True, + use_verbose=True, + pubsub_flags={C.NODE}, + help=_("list known secrets for a pubsub node"), + use_output=C.OUTPUT_LIST_DICT + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + secrets = data_format.deserialise(await self.host.bridge.ps_secrets_list( + self.args.service, + self.args.node, + self.profile, + ), type_check=list) + except Exception as e: + self.disp(f"can't list node secrets: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if not self.verbosity: + # we don't print key if verbosity is not a least one, to avoid showing it + # on the screen accidentally + for secret in secrets: + del secret["key"] + await self.output(secrets) + self.host.quit(C.EXIT_OK) + + +class Secret(base.CommandBase): + subcommands = (SecretShare, SecretRevoke, SecretRotate, SecretList) + + def __init__(self, host): + super().__init__( + host, + "secret", + use_profile=False, + help=_("handle encrypted nodes secrets"), + ) + + +class HookCreate(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "create", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("create a Pubsub hook"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-t", + "--type", + default="python", + choices=("python", "python_file", "python_code"), + help=_("hook type"), + ) + self.parser.add_argument( + "-P", + "--persistent", + action="store_true", + help=_("make hook persistent across restarts"), + ) + self.parser.add_argument( + "hook_arg", + help=_("argument of the hook (depend of the type)"), + ) + + @staticmethod + def check_args(self): + if self.args.type == "python_file": + self.args.hook_arg = os.path.abspath(self.args.hook_arg) + if not os.path.isfile(self.args.hook_arg): + self.parser.error( + _("{path} is not a file").format(path=self.args.hook_arg) + ) + + async def start(self): + self.check_args(self) + try: + await self.host.bridge.ps_hook_add( + self.args.service, + self.args.node, + self.args.type, + self.args.hook_arg, + self.args.persistent, + self.profile, + ) + except Exception as e: + self.disp(f"can't create hook: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.host.quit() + + +class HookDelete(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "delete", + use_pubsub=True, + pubsub_flags={C.NODE}, + help=_("delete a Pubsub hook"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "-t", + "--type", + default="", + choices=("", "python", "python_file", "python_code"), + help=_("hook type to remove, empty to remove all (DEFAULT: remove all)"), + ) + self.parser.add_argument( + "-a", + "--arg", + dest="hook_arg", + default="", + help=_( + "argument of the hook to remove, empty to remove all (DEFAULT: remove all)" + ), + ) + + async def start(self): + HookCreate.check_args(self) + try: + nb_deleted = await self.host.bridge.ps_hook_remove( + self.args.service, + self.args.node, + self.args.type, + self.args.hook_arg, + self.profile, + ) + except Exception as e: + self.disp(f"can't delete hook: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp( + _("{nb_deleted} hook(s) have been deleted").format(nb_deleted=nb_deleted) + ) + self.host.quit() + + +class HookList(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "list", + use_output=C.OUTPUT_LIST_DICT, + help=_("list hooks of a profile"), + ) + + def add_parser_options(self): + pass + + async def start(self): + try: + data = await self.host.bridge.ps_hook_list( + self.profile, + ) + except Exception as e: + self.disp(f"can't list hooks: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + if not data: + self.disp(_("No hook found.")) + await self.output(data) + self.host.quit() + + +class Hook(base.CommandBase): + subcommands = (HookCreate, HookDelete, HookList) + + def __init__(self, host): + super(Hook, self).__init__( + host, + "hook", + use_profile=False, + use_verbose=True, + help=_("trigger action on Pubsub notifications"), + ) + + +class Pubsub(base.CommandBase): + subcommands = ( + Set, + Get, + Delete, + Edit, + Rename, + Subscribe, + Unsubscribe, + Subscriptions, + Affiliations, + Reference, + Search, + Transform, + Attachments, + Signature, + Secret, + Hook, + Uri, + Node, + Cache, + ) + + def __init__(self, host): + super(Pubsub, self).__init__( + host, "pubsub", use_profile=False, help=_("PubSub nodes/items management") + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_roster.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) +# Copyright (C) 2003-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 <http://www.gnu.org/licenses/>. + +from . import base +from collections import OrderedDict +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.frontends.tools import jid +from libervia.backend.tools.common.ansi import ANSI as A + +__commands__ = ["Roster"] + + +class Get(base.CommandBase): + + def __init__(self, host): + super().__init__( + host, 'get', use_output=C.OUTPUT_DICT, use_verbose=True, + extra_outputs = {"default": self.default_output}, + help=_('retrieve the roster entities')) + + def add_parser_options(self): + pass + + def default_output(self, data): + for contact_jid, contact_data in data.items(): + all_keys = list(contact_data.keys()) + keys_to_show = [] + name = contact_data.get('name', contact_jid.node) + + if self.verbosity >= 1: + keys_to_show.append('groups') + all_keys.remove('groups') + if self.verbosity >= 2: + keys_to_show.extend(all_keys) + + if name is None: + self.disp(A.color(C.A_HEADER, contact_jid)) + else: + self.disp(A.color(C.A_HEADER, name, A.RESET, f" ({contact_jid})")) + for k in keys_to_show: + value = contact_data[k] + if value: + if isinstance(value, list): + value = ', '.join(value) + self.disp(A.color( + " ", C.A_SUBHEADER, f"{k}: ", A.RESET, str(value))) + + async def start(self): + try: + contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile) + except Exception as e: + self.disp(f"error while retrieving the contacts: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + contacts_dict = {} + for contact_jid_s, data, groups in contacts: + # FIXME: we have to convert string to bool here for historical reason + # contacts_get format should be changed and serialised properly + for key in ('from', 'to', 'ask'): + if key in data: + data[key] = C.bool(data[key]) + data['groups'] = list(groups) + contacts_dict[jid.JID(contact_jid_s)] = data + + await self.output(contacts_dict) + self.host.quit() + + +class Set(base.CommandBase): + + def __init__(self, host): + super().__init__(host, 'set', help=_('set metadata for a roster entity')) + + def add_parser_options(self): + self.parser.add_argument( + "-n", "--name", default="", help=_('name to use for this entity')) + self.parser.add_argument( + "-g", "--group", dest='groups', action='append', metavar='GROUP', default=[], + help=_('groups for this entity')) + self.parser.add_argument( + "-R", "--replace", action="store_true", + help=_("replace all metadata instead of adding them")) + self.parser.add_argument( + "jid", help=_("jid of the roster entity")) + + async def start(self): + + if self.args.replace: + name = self.args.name + groups = self.args.groups + else: + try: + entity_data = await self.host.bridge.contact_get( + self.args.jid, self.host.profile) + except Exception as e: + self.disp(f"error while retrieving the contact: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + name = self.args.name or entity_data[0].get('name') or '' + groups = set(entity_data[1]) + groups = list(groups.union(self.args.groups)) + + try: + await self.host.bridge.contact_update( + self.args.jid, name, groups, self.host.profile) + except Exception as e: + self.disp(f"error while updating the contact: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + self.host.quit() + + +class Delete(base.CommandBase): + + def __init__(self, host): + super().__init__(host, 'delete', help=_('remove an entity from roster')) + + def add_parser_options(self): + self.parser.add_argument( + "-f", "--force", action="store_true", help=_("delete without confirmation") + ) + self.parser.add_argument( + "jid", help=_("jid of the roster entity")) + + async def start(self): + if not self.args.force: + message = _("Are you sure to delete {entity} from your roster?").format( + entity=self.args.jid + ) + await self.host.confirm_or_quit(message, _("entity deletion cancelled")) + try: + await self.host.bridge.contact_del( + self.args.jid, self.host.profile) + except Exception as e: + self.disp(f"error while deleting the entity: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + self.host.quit() + + +class Stats(base.CommandBase): + + def __init__(self, host): + super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster')) + + def add_parser_options(self): + pass + + async def start(self): + try: + contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile) + except Exception as e: + self.disp(f"error while retrieving the contacts: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + hosts = {} + unique_groups = set() + no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0 + for contact, attrs, groups in contacts: + from_, to = C.bool(attrs["from"]), C.bool(attrs["to"]) + if not from_: + if not to: + no_sub += 1 + else: + no_from += 1 + elif not to: + no_to += 1 + + host = jid.JID(contact).domain + + hosts.setdefault(host, 0) + hosts[host] += 1 + if groups: + unique_groups.update(groups) + total_group_subscription += len(groups) + if not groups: + no_group += 1 + hosts = OrderedDict(sorted(list(hosts.items()), key=lambda item:-item[1])) + + print() + print("Total number of contacts: %d" % len(contacts)) + print("Number of different hosts: %d" % len(hosts)) + print() + for host, count in hosts.items(): + print("Contacts on {host}: {count} ({rate:.1f}%)".format( + host=host, count=count, rate=100 * float(count) / len(contacts))) + print() + print("Contacts with no 'from' subscription: %d" % no_from) + print("Contacts with no 'to' subscription: %d" % no_to) + print("Contacts with no subscription at all: %d" % no_sub) + print() + print("Total number of groups: %d" % len(unique_groups)) + try: + contacts_per_group = float(total_group_subscription) / len(unique_groups) + except ZeroDivisionError: + contacts_per_group = 0 + print("Average contacts per group: {:.1f}".format(contacts_per_group)) + try: + groups_per_contact = float(total_group_subscription) / len(contacts) + except ZeroDivisionError: + groups_per_contact = 0 + print(f"Average groups' subscriptions per contact: {groups_per_contact:.1f}") + print("Contacts not assigned to any group: %d" % no_group) + self.host.quit() + + +class Purge(base.CommandBase): + + def __init__(self, host): + super(Purge, self).__init__( + host, 'purge', + help=_('purge the roster from its contacts with no subscription')) + + def add_parser_options(self): + self.parser.add_argument( + "--no-from", action="store_true", + help=_("also purge contacts with no 'from' subscription")) + self.parser.add_argument( + "--no-to", action="store_true", + help=_("also purge contacts with no 'to' subscription")) + + async def start(self): + try: + contacts = await self.host.bridge.contacts_get(self.host.profile) + except Exception as e: + self.disp(f"error while retrieving the contacts: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + no_sub, no_from, no_to = [], [], [] + for contact, attrs, groups in contacts: + from_, to = C.bool(attrs["from"]), C.bool(attrs["to"]) + if not from_: + if not to: + no_sub.append(contact) + elif self.args.no_from: + no_from.append(contact) + elif not to and self.args.no_to: + no_to.append(contact) + if not no_sub and not no_from and not no_to: + self.disp( + f"Nothing to do - there's a from and/or to subscription(s) between " + f"profile {self.host.profile!r} and each of its contacts" + ) + elif await self.ask_confirmation(no_sub, no_from, no_to): + for contact in no_sub + no_from + no_to: + try: + await self.host.bridge.contact_del( + contact, profile_key=self.host.profile) + except Exception as e: + self.disp(f"can't delete contact {contact!r}: {e}", error=True) + else: + self.disp(f"contact {contact!r} has been removed") + + self.host.quit() + + async def ask_confirmation(self, no_sub, no_from, no_to): + """Ask the confirmation before removing contacts. + + @param no_sub (list[unicode]): list of contacts with no subscription + @param no_from (list[unicode]): list of contacts with no 'from' subscription + @param no_to (list[unicode]): list of contacts with no 'to' subscription + @return bool + """ + if no_sub: + self.disp( + f"There's no subscription between profile {self.host.profile!r} and the " + f"following contacts:") + self.disp(" " + "\n ".join(no_sub)) + if no_from: + self.disp( + f"There's no 'from' subscription between profile {self.host.profile!r} " + f"and the following contacts:") + self.disp(" " + "\n ".join(no_from)) + if no_to: + self.disp( + f"There's no 'to' subscription between profile {self.host.profile!r} and " + f"the following contacts:") + self.disp(" " + "\n ".join(no_to)) + message = f"REMOVE them from profile {self.host.profile}'s roster" + while True: + res = await self.host.ainput(f"{message} (y/N)? ") + if not res or res.lower() == 'n': + return False + if res.lower() == 'y': + return True + + +class Resync(base.CommandBase): + + def __init__(self, host): + super(Resync, self).__init__( + host, 'resync', help=_('do a full resynchronisation of roster with server')) + + def add_parser_options(self): + pass + + async def start(self): + try: + await self.host.bridge.roster_resync(profile_key=self.host.profile) + except Exception as e: + self.disp(f"can't resynchronise roster: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("Roster resynchronized")) + self.host.quit(C.EXIT_OK) + + +class Roster(base.CommandBase): + subcommands = (Get, Set, Delete, Stats, Purge, Resync) + + def __init__(self, host): + super(Roster, self).__init__( + host, 'roster', use_profile=True, help=_("Manage an entity's roster"))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_shell.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,305 @@ +#!/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 <http://www.gnu.org/licenses/>. + + +import cmd +import sys +import shlex +import subprocess +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 arg_tools +from libervia.backend.tools.common.ansi import ANSI as A + +__commands__ = ["Shell"] +INTRO = _( + """Welcome to {app_name} shell, the Salut à Toi shell ! + +This enrironment helps you using several {app_name} commands with similar parameters. + +To quit, just enter "quit" or press C-d. +Enter "help" or "?" to know what to do +""" +).format(app_name=C.APP_NAME) + + +class Shell(base.CommandBase, cmd.Cmd): + def __init__(self, host): + base.CommandBase.__init__( + self, host, "shell", + help=_("launch libervia-cli in shell (REPL) mode") + ) + cmd.Cmd.__init__(self) + + def parse_args(self, args): + """parse line arguments""" + return shlex.split(args, posix=True) + + def update_path(self): + self._cur_parser = self.host.parser + self.help = "" + for idx, path_elt in enumerate(self.path): + try: + self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser) + except exceptions.NotFound: + self.disp(_("bad command path"), error=True) + self.path = self.path[:idx] + break + else: + self.help = self._cur_parser + + self.prompt = A.color(C.A_PROMPT_PATH, "/".join(self.path)) + A.color( + C.A_PROMPT_SUF, "> " + ) + try: + self.actions = list(arg_tools.get_cmd_choices(parser=self._cur_parser).keys()) + except exceptions.NotFound: + self.actions = [] + + def add_parser_options(self): + pass + + def format_args(self, args): + """format argument to be printed with quotes if needed""" + for arg in args: + if " " in arg: + yield arg_tools.escape(arg) + else: + yield arg + + def run_cmd(self, args, external=False): + """run command and retur exit code + + @param args[list[string]]: arguments of the command + must not include program name + @param external(bool): True if it's an external command (i.e. not libervia-cli) + @return (int): exit code (0 success, any other int failure) + """ + # FIXME: we have to use subprocess + # and relaunch whole python for now + # because if host.quit() is called in D-Bus callback + # GLib quit the whole app without possibility to stop it + # didn't found a nice way to work around it so far + # Situation should be better when we'll move away from python-dbus + if self.verbose: + self.disp( + _("COMMAND {external}=> {args}").format( + external=_("(external) ") if external else "", + args=" ".join(self.format_args(args)), + ) + ) + if not external: + args = sys.argv[0:1] + args + ret_code = subprocess.call(args) + # XXX: below is a way to launch the command without creating a new process + # may be used when a solution to the aforementioned issue is there + # try: + # self.host._run(args) + # except SystemExit as e: + # ret_code = e.code + # except Exception as e: + # self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True) + # ret_code = 1 + # else: + # ret_code = 0 + + if ret_code != 0: + self.disp( + A.color( + C.A_FAILURE, + "command failed with an error code of {err_no}".format( + err_no=ret_code + ), + ), + error=True, + ) + return ret_code + + def default(self, args): + """called when no shell command is recognized + + will launch the command with args on the line + (i.e. will launch do [args]) + """ + if args == "EOF": + self.do_quit("") + self.do_do(args) + + def do_help(self, args): + """show help message""" + if not args: + self.disp(A.color(C.A_HEADER, _("Shell commands:")), end=' ') + super(Shell, self).do_help(args) + if not args: + self.disp(A.color(C.A_HEADER, _("Action commands:"))) + help_list = self._cur_parser.format_help().split("\n\n") + print(("\n\n".join(help_list[1 if self.path else 2 :]))) + + # FIXME: debug crashes on exit and is not that useful, + # keeping it until refactoring, may be removed entirely then + # def do_debug(self, args): + # """launch internal debugger""" + # try: + # import ipdb as pdb + # except ImportError: + # import pdb + # pdb.set_trace() + + def do_verbose(self, args): + """show verbose mode, or (de)activate it""" + args = self.parse_args(args) + if args: + self.verbose = C.bool(args[0]) + self.disp( + _("verbose mode is {status}").format( + status=_("ENABLED") if self.verbose else _("DISABLED") + ) + ) + + def do_cmd(self, args): + """change command path""" + if args == "..": + self.path = self.path[:-1] + else: + if not args or args[0] == "/": + self.path = [] + args = "/".join(args.split()) + for path_elt in args.split("/"): + path_elt = path_elt.strip() + if not path_elt: + continue + self.path.append(path_elt) + self.update_path() + + def do_version(self, args): + """show current backend/CLI version""" + self.run_cmd(['--version']) + + def do_shell(self, args): + """launch an external command (you can use ![command] too)""" + args = self.parse_args(args) + self.run_cmd(args, external=True) + + def do_do(self, args): + """lauch a command""" + args = self.parse_args(args) + if ( + self._not_default_profile + and not "-p" in args + and not "--profile" in args + and not "profile" in self.use + ): + # profile is not specified and we are not using the default profile + # so we need to add it in arguments to use current user profile + if self.verbose: + self.disp( + _("arg profile={profile} (logged profile)").format( + profile=self.profile + ) + ) + use = self.use.copy() + use["profile"] = self.profile + else: + use = self.use + + # args may be modified by use_args + # to remove subparsers from it + parser_args, use_args = arg_tools.get_use_args( + self.host, args, use, verbose=self.verbose, parser=self._cur_parser + ) + cmd_args = self.path + parser_args + use_args + self.run_cmd(cmd_args) + + def do_use(self, args): + """fix an argument""" + args = self.parse_args(args) + if not args: + if not self.use: + self.disp(_("no argument in USE")) + else: + self.disp(_("arguments in USE:")) + for arg, value in self.use.items(): + self.disp( + _( + A.color( + C.A_SUBHEADER, + arg, + A.RESET, + " = ", + arg_tools.escape(value), + ) + ) + ) + elif len(args) != 2: + self.disp("bad syntax, please use:\nuse [arg] [value]", error=True) + else: + self.use[args[0]] = " ".join(args[1:]) + if self.verbose: + self.disp( + "set {name} = {value}".format( + name=args[0], value=arg_tools.escape(args[1]) + ) + ) + + def do_use_clear(self, args): + """unset one or many argument(s) in USE, or all of them if no arg is specified""" + args = self.parse_args(args) + if not args: + self.use.clear() + else: + for arg in args: + try: + del self.use[arg] + except KeyError: + self.disp( + A.color( + C.A_FAILURE, _("argument {name} not found").format(name=arg) + ), + error=True, + ) + else: + if self.verbose: + self.disp(_("argument {name} removed").format(name=arg)) + + def do_whoami(self, args): + """print profile currently used""" + self.disp(self.profile) + + def do_quit(self, args): + """quit the shell""" + self.disp(_("good bye!")) + self.host.quit() + + def do_exit(self, args): + """alias for quit""" + self.do_quit(args) + + async def start(self): + # FIXME: "shell" is currently kept synchronous as it works well as it + # and it will be refactored soon. + default_profile = self.host.bridge.profile_name_get(C.PROF_KEY_DEFAULT) + self._not_default_profile = self.profile != default_profile + self.path = [] + self._cur_parser = self.host.parser + self.use = {} + self.verbose = False + self.update_path() + self.cmdloop(INTRO)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/cmd_uri.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,81 @@ +#!/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 <http://www.gnu.org/licenses/>. + + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C +from libervia.backend.tools.common import uri + +__commands__ = ["Uri"] + + +class Parse(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, + host, + "parse", + use_profile=False, + use_output=C.OUTPUT_DICT, + help=_("parse URI"), + ) + + def add_parser_options(self): + self.parser.add_argument( + "uri", help=_("XMPP URI to parse") + ) + + async def start(self): + await self.output(uri.parse_xmpp_uri(self.args.uri)) + self.host.quit() + + +class Build(base.CommandBase): + def __init__(self, host): + base.CommandBase.__init__( + self, host, "build", use_profile=False, help=_("build URI") + ) + + def add_parser_options(self): + self.parser.add_argument("type", help=_("URI type")) + self.parser.add_argument("path", help=_("URI path")) + self.parser.add_argument( + "-f", + "--field", + action="append", + nargs=2, + dest="fields", + metavar=("KEY", "VALUE"), + help=_("URI fields"), + ) + + async def start(self): + fields = dict(self.args.fields) if self.args.fields else {} + self.disp(uri.build_xmpp_uri(self.args.type, path=self.args.path, **fields)) + self.host.quit() + + +class Uri(base.CommandBase): + subcommands = (Parse, Build) + + def __init__(self, host): + super(Uri, self).__init__( + host, "uri", use_profile=False, help=_("XMPP URI parsing/generation") + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/common.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,833 @@ +#!/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 <http://www.gnu.org/licenses/>. + +import json +import os +import os.path +import time +import tempfile +import asyncio +import shlex +import re +from pathlib import Path +from libervia.cli.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions +from libervia.backend.tools.common import regex +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.common import uri as xmpp_uri +from libervia.backend.tools import config +from configparser import NoSectionError, NoOptionError +from collections import namedtuple + +# default arguments used for some known editors (editing with metadata) +VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'" +EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"' +EDITOR_ARGS_MAGIC = { + "vim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}", + "nvim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}", + "gvim": VIM_SPLIT_ARGS + " --nofork {content_file} {metadata_file}", + "emacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}", + "xemacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}", + "nano": " -F {content_file} {metadata_file}", +} + +SECURE_UNLINK_MAX = 10 +SECURE_UNLINK_DIR = ".backup" +METADATA_SUFF = "_metadata.json" + + +def format_time(timestamp): + """Return formatted date for timestamp + + @param timestamp(str,int,float): unix timestamp + @return (unicode): formatted date + """ + fmt = "%d/%m/%Y %H:%M:%S %Z" + return time.strftime(fmt, time.localtime(float(timestamp))) + + +def ansi_ljust(s, width): + """ljust method handling ANSI escape codes""" + cleaned = regex.ansi_remove(s) + return s + " " * (width - len(cleaned)) + + +def ansi_center(s, width): + """ljust method handling ANSI escape codes""" + cleaned = regex.ansi_remove(s) + diff = width - len(cleaned) + half = diff / 2 + return half * " " + s + (half + diff % 2) * " " + + +def ansi_rjust(s, width): + """ljust method handling ANSI escape codes""" + cleaned = regex.ansi_remove(s) + return " " * (width - len(cleaned)) + s + + +def get_tmp_dir(sat_conf, cat_dir, sub_dir=None): + """Return directory used to store temporary files + + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param cat_dir(str): directory of the category (e.g. "blog") + @param sub_dir(str): sub directory where data need to be put + profile can be used here, or special directory name + sub_dir will be escaped to be usable in path (use regex.path_unescape to find + initial str) + @return (Path): path to the dir + """ + local_dir = config.config_get(sat_conf, "", "local_dir", Exception) + path_elts = [local_dir, cat_dir] + if sub_dir is not None: + path_elts.append(regex.path_escape(sub_dir)) + return Path(*path_elts) + + +def parse_args(host, cmd_line, **format_kw): + """Parse command arguments + + @param cmd_line(unicode): command line as found in sat.conf + @param format_kw: keywords used for formating + @return (list(unicode)): list of arguments to pass to subprocess function + """ + try: + # we split the arguments and add the known fields + # we split arguments first to avoid escaping issues in file names + return [a.format(**format_kw) for a in shlex.split(cmd_line)] + except ValueError as e: + host.disp( + "Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e) + ) + return [] + + +class BaseEdit(object): + """base class for editing commands + + This class allows to edit file for PubSub or something else. + It works with temporary files in SàT local_dir, in a "cat_dir" subdir + """ + + def __init__(self, host, cat_dir, use_metadata=False): + """ + @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration + @param cat_dir(unicode): directory to use for drafts + this will be a sub-directory of SàT's local_dir + @param use_metadata(bool): True is edition need a second file for metadata + most of signature change with use_metadata with an additional metadata + argument. + This is done to raise error if a command needs metadata but forget the flag, + and vice versa + """ + self.host = host + self.cat_dir = cat_dir + self.use_metadata = use_metadata + + def secure_unlink(self, path): + """Unlink given path after keeping it for a while + + This method is used to prevent accidental deletion of a draft + If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, + older file are deleted + @param path(Path, str): file to unlink + """ + path = Path(path).resolve() + if not path.is_file: + raise OSError("path must link to a regular file") + if path.parent != get_tmp_dir(self.sat_conf, self.cat_dir): + self.disp( + f"File {path} is not in SàT temporary hierarchy, we do not remove " f"it", + 2, + ) + return + # we have 2 files per draft with use_metadata, so we double max + unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX + backup_dir = get_tmp_dir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR) + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + filename = os.path.basename(path) + backup_path = os.path.join(backup_dir, filename) + # we move file to backup dir + self.host.disp( + "Backuping file {src} to {dst}".format(src=path, dst=backup_path), + 1, + ) + os.rename(path, backup_path) + # and if we exceeded the limit, we remove older file + backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)] + if len(backup_files) > unlink_max: + backup_files.sort(key=lambda path: os.stat(path).st_mtime) + for path in backup_files[: len(backup_files) - unlink_max]: + self.host.disp("Purging backup file {}".format(path), 2) + os.unlink(path) + + async def run_editor( + self, + editor_args_opt, + content_file_path, + content_file_obj, + meta_file_path=None, + meta_ori=None, + ): + """Run editor to edit content and metadata + + @param editor_args_opt(unicode): option in [cli] section in configuration for + specific args + @param content_file_path(str): path to the content file + @param content_file_obj(file): opened file instance + @param meta_file_path(str, Path, None): metadata file path + if None metadata will not be used + @param meta_ori(dict, None): original cotent of metadata + can't be used if use_metadata is False + """ + if not self.use_metadata: + assert meta_file_path is None + assert meta_ori is None + + # we calculate hashes to check for modifications + import hashlib + + content_file_obj.seek(0) + tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest() + content_file_obj.close() + + # we prepare arguments + editor = config.config_get(self.sat_conf, C.CONFIG_SECTION, "editor") or os.getenv( + "EDITOR", "vi" + ) + try: + # is there custom arguments in sat.conf ? + editor_args = config.config_get( + self.sat_conf, C.CONFIG_SECTION, editor_args_opt, Exception + ) + except (NoOptionError, NoSectionError): + # no, we check if we know the editor and have special arguments + if self.use_metadata: + editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), "") + else: + editor_args = "" + parse_kwargs = {"content_file": content_file_path} + if self.use_metadata: + parse_kwargs["metadata_file"] = meta_file_path + args = parse_args(self.host, editor_args, **parse_kwargs) + if not args: + args = [content_file_path] + + # actual editing + editor_process = await asyncio.create_subprocess_exec( + editor, *[str(a) for a in args] + ) + editor_exit = await editor_process.wait() + + # edition will now be checked, and data will be sent if it was a success + if editor_exit != 0: + self.disp( + f"Editor exited with an error code, so temporary file has not be " + f"deleted, and item is not published.\nYou can find temporary file " + f"at {content_file_path}", + error=True, + ) + else: + # main content + try: + with content_file_path.open("rb") as f: + content = f.read() + except (OSError, IOError): + self.disp( + f"Can read file at {content_file_path}, have it been deleted?\n" + f"Cancelling edition", + error=True, + ) + self.host.quit(C.EXIT_NOT_FOUND) + + # metadata + if self.use_metadata: + try: + with meta_file_path.open("rb") as f: + metadata = json.load(f) + except (OSError, IOError): + self.disp( + f"Can read file at {meta_file_path}, have it been deleted?\n" + f"Cancelling edition", + error=True, + ) + self.host.quit(C.EXIT_NOT_FOUND) + except ValueError: + self.disp( + f"Can't parse metadata, please check it is correct JSON format. " + f"Cancelling edition.\nYou can find tmp file at " + f"{content_file_path} and temporary meta file at " + f"{meta_file_path}.", + error=True, + ) + self.host.quit(C.EXIT_DATA_ERROR) + + if self.use_metadata and not metadata.get("publish", True): + self.disp( + f'Publication blocked by "publish" key in metadata, cancelling ' + f"edition.\n\ntemporary file path:\t{content_file_path}\nmetadata " + f"file path:\t{meta_file_path}", + error=True, + ) + self.host.quit() + + if len(content) == 0: + self.disp("Content is empty, cancelling the edition") + if content_file_path.parent != get_tmp_dir(self.sat_conf, self.cat_dir): + self.disp( + "File are not in SàT temporary hierarchy, we do not remove them", + 2, + ) + self.host.quit() + self.disp(f"Deletion of {content_file_path}", 2) + os.unlink(content_file_path) + if self.use_metadata: + self.disp(f"Deletion of {meta_file_path}".format(meta_file_path), 2) + os.unlink(meta_file_path) + self.host.quit() + + # time to re-check the hash + elif tmp_ori_hash == hashlib.sha1(content).digest() and ( + not self.use_metadata or meta_ori == metadata + ): + self.disp("The content has not been modified, cancelling the edition") + self.host.quit() + + else: + # we can now send the item + content = content.decode("utf-8-sig") # we use utf-8-sig to avoid BOM + try: + if self.use_metadata: + await self.publish(content, metadata) + else: + await self.publish(content) + except Exception as e: + if self.use_metadata: + self.disp( + f"Error while sending your item, the temporary files have " + f"been kept at {content_file_path} and {meta_file_path}: " + f"{e}", + error=True, + ) + else: + self.disp( + f"Error while sending your item, the temporary file has been " + f"kept at {content_file_path}: {e}", + error=True, + ) + self.host.quit(1) + + self.secure_unlink(content_file_path) + if self.use_metadata: + self.secure_unlink(meta_file_path) + + async def publish(self, content): + # if metadata is needed, publish will be called with it last argument + raise NotImplementedError + + def get_tmp_file(self): + """Create a temporary file + + @return (tuple(file, Path)): opened (w+b) file object and file path + """ + suff = "." + self.get_tmp_suff() + cat_dir_str = self.cat_dir + tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, self.profile) + if not tmp_dir.exists(): + try: + tmp_dir.mkdir(parents=True) + except OSError as e: + self.disp( + f"Can't create {tmp_dir} directory: {e}", + error=True, + ) + self.host.quit(1) + try: + fd, path = tempfile.mkstemp( + suffix=suff, + prefix=time.strftime(cat_dir_str + "_%Y-%m-%d_%H:%M:%S_"), + dir=tmp_dir, + text=True, + ) + return os.fdopen(fd, "w+b"), Path(path) + except OSError as e: + self.disp(f"Can't create temporary file: {e}", error=True) + self.host.quit(1) + + def get_current_file(self, profile): + """Get most recently edited file + + @param profile(unicode): profile linked to the draft + @return(Path): full path of current file + """ + # we guess the item currently edited by choosing + # the most recent file corresponding to temp file pattern + # in tmp_dir, excluding metadata files + tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, profile) + available = [ + p + for p in tmp_dir.glob(f"{self.cat_dir}_*") + if not p.match(f"*{METADATA_SUFF}") + ] + if not available: + self.disp( + f"Could not find any content draft in {tmp_dir}", + error=True, + ) + self.host.quit(1) + return max(available, key=lambda p: p.stat().st_mtime) + + async def get_item_data(self, service, node, item): + """return formatted content, metadata (or not if use_metadata is false), and item id""" + raise NotImplementedError + + def get_tmp_suff(self): + """return suffix used for content file""" + return "xml" + + async def get_item_path(self): + """Retrieve item path (i.e. service and node) from item argument + + This method is obviously only useful for edition of PubSub based features + """ + service = self.args.service + node = self.args.node + item = self.args.item + last_item = self.args.last_item + + if self.args.current: + # user wants to continue current draft + content_file_path = self.get_current_file(self.profile) + self.disp("Continuing edition of current draft", 2) + content_file_obj = content_file_path.open("r+b") + # we seek at the end of file in case of an item already exist + # this will write content of the existing item at the end of the draft. + # This way no data should be lost. + content_file_obj.seek(0, os.SEEK_END) + elif self.args.draft_path: + # there is an existing draft that we use + content_file_path = self.args.draft_path.expanduser() + content_file_obj = content_file_path.open("r+b") + # we seek at the end for the same reason as above + content_file_obj.seek(0, os.SEEK_END) + else: + # we need a temporary file + content_file_obj, content_file_path = self.get_tmp_file() + + if item or last_item: + self.disp("Editing requested published item", 2) + try: + if self.use_metadata: + content, metadata, item = await self.get_item_data(service, node, item) + else: + content, item = await self.get_item_data(service, node, item) + except Exception as e: + # FIXME: ugly but we have not good may to check errors in bridge + if "item-not-found" in str(e): + # item doesn't exist, we create a new one with requested id + metadata = None + if last_item: + self.disp(_("no item found at all, we create a new one"), 2) + else: + self.disp( + _( + 'item "{item}" not found, we create a new item with' + "this id" + ).format(item=item), + 2, + ) + content_file_obj.seek(0) + else: + self.disp(f"Error while retrieving item: {e}") + self.host.quit(C.EXIT_ERROR) + else: + # item exists, we write content + if content_file_obj.tell() != 0: + # we already have a draft, + # we copy item content after it and add an indicator + content_file_obj.write("\n*****\n") + content_file_obj.write(content.encode("utf-8")) + content_file_obj.seek(0) + self.disp(_('item "{item}" found, we edit it').format(item=item), 2) + else: + self.disp("Editing a new item", 2) + if self.use_metadata: + metadata = None + + if self.use_metadata: + return service, node, item, content_file_path, content_file_obj, metadata + else: + return service, node, item, content_file_path, content_file_obj + + +class Table(object): + def __init__(self, host, data, headers=None, filters=None, use_buffer=False): + """ + @param data(iterable[list]): table data + all lines must have the same number of columns + @param headers(iterable[unicode], None): names/titles of the columns + if not None, must have same number of columns as data + @param filters(iterable[(callable, unicode)], None): values filters + the callable will get 2 arguments: + - current column value + - RowData with all columns values + if may also only use 1 argument, which will then be current col value. + the callable must return a string + if it's unicode, it will be used with .format and must countain u'{}' which + will be replaced with the string. + if not None, must have same number of columns as data + @param use_buffer(bool): if True, bufferise output instead of printing it directly + """ + self.host = host + self._buffer = [] if use_buffer else None + # headers are columns names/titles, can be None + self.headers = headers + # sizes fof columns without headers, + # headers may be larger + self.sizes = [] + # rows countains one list per row with columns values + self.rows = [] + + size = None + if headers: + # we use a namedtuple to make the value easily accessible from filters + headers_safe = [re.sub(r"[^a-zA-Z_]", "_", h) for h in headers] + row_cls = namedtuple("RowData", headers_safe) + else: + row_cls = tuple + + for row_data in data: + new_row = [] + row_data_list = list(row_data) + for idx, value in enumerate(row_data_list): + if filters is not None and filters[idx] is not None: + filter_ = filters[idx] + if isinstance(filter_, str): + col_value = filter_.format(value) + else: + try: + col_value = filter_(value, row_cls(*row_data_list)) + except TypeError: + col_value = filter_(value) + # we count size without ANSI code as they will change length of the + # string when it's mostly style/color changes. + col_size = len(regex.ansi_remove(col_value)) + else: + col_value = str(value) + col_size = len(col_value) + new_row.append(col_value) + if size is None: + self.sizes.append(col_size) + else: + self.sizes[idx] = max(self.sizes[idx], col_size) + if size is None: + size = len(new_row) + if headers is not None and len(headers) != size: + raise exceptions.DataError("headers size is not coherent with rows") + else: + if len(new_row) != size: + raise exceptions.DataError("rows size is not coherent") + self.rows.append(new_row) + + if not data and headers is not None: + # the table is empty, we print headers at their lenght + self.sizes = [len(h) for h in headers] + + @property + def string(self): + if self._buffer is None: + raise exceptions.InternalError("buffer must be used to get a string") + return "\n".join(self._buffer) + + @staticmethod + def read_dict_values(data, keys, defaults=None): + if defaults is None: + defaults = {} + for key in keys: + try: + yield data[key] + except KeyError as e: + default = defaults.get(key) + if default is not None: + yield default + else: + raise e + + @classmethod + def from_list_dict( + cls, host, data, keys=None, headers=None, filters=None, defaults=None + ): + """Create a table from a list of dictionaries + + each dictionary is a row of the table, keys being columns names. + the whole data will be read and kept into memory, to be printed + @param data(list[dict[unicode, unicode]]): data to create the table from + @param keys(iterable[unicode], None): keys to get + if None, all keys will be used + @param headers(iterable[unicode], None): name of the columns + names must be in same order as keys + @param filters(dict[unicode, (callable,unicode)), None): filter to use on values + keys correspond to keys to filter, and value is the same as for Table.__init__ + @param defaults(dict[unicode, unicode]): default value to use + if None, an exception will be raised if not value is found + """ + if keys is None and headers is not None: + # FIXME: keys are not needed with OrderedDict, + raise exceptions.DataError("You must specify keys order to used headers") + if keys is None: + keys = list(data[0].keys()) + if headers is None: + headers = keys + if filters is None: + filters = {} + filters = [filters.get(k) for k in keys] + return cls( + host, (cls.read_dict_values(d, keys, defaults) for d in data), headers, filters + ) + + def _headers(self, head_sep, headers, sizes, alignment="left", style=None): + """Render headers + + @param head_sep(unicode): sequence to use as separator + @param alignment(unicode): how to align, can be left, center or right + @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply + @param headers(list[unicode]): headers to show + @param sizes(list[int]): sizes of columns + """ + rendered_headers = [] + if isinstance(style, str): + style = [style] + for idx, header in enumerate(headers): + size = sizes[idx] + if alignment == "left": + rendered = header[:size].ljust(size) + elif alignment == "center": + rendered = header[:size].center(size) + elif alignment == "right": + rendered = header[:size].rjust(size) + else: + raise exceptions.InternalError("bad alignment argument") + if style: + args = style + [rendered] + rendered = A.color(*args) + rendered_headers.append(rendered) + return head_sep.join(rendered_headers) + + def _disp(self, data): + """output data (can be either bufferised or printed)""" + if self._buffer is not None: + self._buffer.append(data) + else: + self.host.disp(data) + + def display( + self, + head_alignment="left", + columns_alignment="left", + head_style=None, + show_header=True, + show_borders=True, + hide_cols=None, + col_sep=" │ ", + top_left="┌", + top="─", + top_sep="─┬─", + top_right="┐", + left="│", + right=None, + head_sep=None, + head_line="┄", + head_line_left="├", + head_line_sep="┄┼┄", + head_line_right="┤", + bottom_left="└", + bottom=None, + bottom_sep="─┴─", + bottom_right="┘", + ): + """Print the table + + @param show_header(bool): True if header need no be shown + @param show_borders(bool): True if borders need no be shown + @param hide_cols(None, iterable(unicode)): columns which should not be displayed + @param head_alignment(unicode): how to align headers, can be left, center or right + @param columns_alignment(unicode): how to align columns, can be left, center or + right + @param col_sep(unicode): separator betweens columns + @param head_line(unicode): character to use to make line under head + @param disp(callable, None): method to use to display the table + None to use self.host.disp + """ + if not self.sizes: + # the table is empty + return + col_sep_size = len(regex.ansi_remove(col_sep)) + + # if we have columns to hide, we remove them from headers and size + if not hide_cols: + headers = self.headers + sizes = self.sizes + else: + headers = list(self.headers) + sizes = self.sizes[:] + ignore_idx = [headers.index(to_hide) for to_hide in hide_cols] + for to_hide in hide_cols: + hide_idx = headers.index(to_hide) + del headers[hide_idx] + del sizes[hide_idx] + + if right is None: + right = left + if top_sep is None: + top_sep = col_sep_size * top + if head_sep is None: + head_sep = col_sep + if bottom is None: + bottom = top + if bottom_sep is None: + bottom_sep = col_sep_size * bottom + if not show_borders: + left = right = head_line_left = head_line_right = "" + # top border + if show_borders: + self._disp( + top_left + top_sep.join([top * size for size in sizes]) + top_right + ) + + # headers + if show_header and self.headers is not None: + self._disp( + left + + self._headers(head_sep, headers, sizes, head_alignment, head_style) + + right + ) + # header line + self._disp( + head_line_left + + head_line_sep.join([head_line * size for size in sizes]) + + head_line_right + ) + + # content + if columns_alignment == "left": + alignment = lambda idx, s: ansi_ljust(s, sizes[idx]) + elif columns_alignment == "center": + alignment = lambda idx, s: ansi_center(s, sizes[idx]) + elif columns_alignment == "right": + alignment = lambda idx, s: ansi_rjust(s, sizes[idx]) + else: + raise exceptions.InternalError("bad columns alignment argument") + + for row in self.rows: + if hide_cols: + row = [v for idx, v in enumerate(row) if idx not in ignore_idx] + self._disp( + left + + col_sep.join([alignment(idx, c) for idx, c in enumerate(row)]) + + right + ) + + if show_borders: + # bottom border + self._disp( + bottom_left + + bottom_sep.join([bottom * size for size in sizes]) + + bottom_right + ) + # we return self so string can be used after display (table.display().string) + return self + + def display_blank(self, **kwargs): + """Display table without visible borders""" + kwargs_ = {"col_sep": " ", "head_line_sep": " ", "show_borders": False} + kwargs_.update(kwargs) + return self.display(**kwargs_) + + +async def fill_well_known_uri(command, path, key, meta_map=None): + """Look for URIs in well-known location and fill appropriate args if suitable + + @param command(CommandBase): command instance + args of this instance will be updated with found values + @param path(unicode): absolute path to use as a starting point to look for URIs + @param key(unicode): key to look for + @param meta_map(dict, None): if not None, map metadata to arg name + key is metadata used attribute name + value is name to actually use, or None to ignore + use empty dict to only retrieve URI + possible keys are currently: + - labels + """ + args = command.args + if args.service or args.node: + # we only look for URIs if a service and a node are not already specified + return + + host = command.host + + try: + uris_data = await host.bridge.uri_find(path, [key]) + except Exception as e: + host.disp(f"can't find {key} URI: {e}", error=True) + host.quit(C.EXIT_BRIDGE_ERRBACK) + + try: + uri_data = uris_data[key] + except KeyError: + host.disp( + _( + "No {key} URI specified for this project, please specify service and " + "node" + ).format(key=key), + error=True, + ) + host.quit(C.EXIT_NOT_FOUND) + + uri = uri_data["uri"] + + # set extra metadata if they are specified + for data_key in ["labels"]: + new_values_json = uri_data.get(data_key) + if uri_data is not None: + if meta_map is None: + dest = data_key + else: + dest = meta_map.get(data_key) + if dest is None: + continue + + try: + values = getattr(args, data_key) + except AttributeError: + raise exceptions.InternalError(f"there is no {data_key!r} arguments") + else: + if values is None: + values = [] + values.extend(json.loads(new_values_json)) + setattr(args, dest, values) + + parsed_uri = xmpp_uri.parse_xmpp_uri(uri) + try: + args.service = parsed_uri["path"] + args.node = parsed_uri["node"] + except KeyError: + host.disp(_("Invalid URI found: {uri}").format(uri=uri), error=True) + host.quit(C.EXIT_DATA_ERROR)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/constants.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 + + +# Primitivus: a SAT 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 <http://www.gnu.org/licenses/>. + +from libervia.frontends.quick_frontend import constants +from libervia.backend.tools.common.ansi import ANSI as A + + +class Const(constants.Const): + + APP_NAME = "Libervia CLI" + APP_COMPONENT = "CLI" + APP_NAME_ALT = "jp" + APP_NAME_FILE = "libervia_cli" + CONFIG_SECTION = APP_COMPONENT.lower() + PLUGIN_CMD = "commands" + PLUGIN_OUTPUT = "outputs" + OUTPUT_TEXT = "text" # blob of unicode text + OUTPUT_DICT = "dict" # simple key/value dictionary + OUTPUT_LIST = "list" + OUTPUT_LIST_DICT = "list_dict" # list of dictionaries + OUTPUT_DICT_DICT = "dict_dict" # dict of nested dictionaries + OUTPUT_MESS = "mess" # messages (chat) + OUTPUT_COMPLEX = "complex" # complex data (e.g. multi-level dictionary) + OUTPUT_XML = "xml" # XML node (as unicode string) + OUTPUT_LIST_XML = "list_xml" # list of XML nodes (as unicode strings) + OUTPUT_XMLUI = "xmlui" # XMLUI as unicode string + OUTPUT_LIST_XMLUI = "list_xmlui" # list of XMLUI (as unicode strings) + OUTPUT_TYPES = ( + OUTPUT_TEXT, + OUTPUT_DICT, + OUTPUT_LIST, + OUTPUT_LIST_DICT, + OUTPUT_DICT_DICT, + OUTPUT_MESS, + OUTPUT_COMPLEX, + OUTPUT_XML, + OUTPUT_LIST_XML, + OUTPUT_XMLUI, + OUTPUT_LIST_XMLUI, + ) + OUTPUT_NAME_SIMPLE = "simple" + OUTPUT_NAME_XML = "xml" + OUTPUT_NAME_XML_RAW = "xml-raw" + OUTPUT_NAME_JSON = "json" + OUTPUT_NAME_JSON_RAW = "json-raw" + + # Pubsub options flags + SERVICE = "service" # service required + NODE = "node" # node required + ITEM = "item" # item required + SINGLE_ITEM = "single_item" # only one item is allowed + MULTI_ITEMS = "multi_items" # multiple items are allowed + NO_MAX = "no_max" # don't add --max option for multi items + CACHE = "cache" # add cache control flag + + # ANSI + A_HEADER = A.BOLD + A.FG_YELLOW + A_SUBHEADER = A.BOLD + A.FG_RED + # A_LEVEL_COLORS may be used to cycle on colors according to depth of data + A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN) + A_SUCCESS = A.BOLD + A.FG_GREEN + A_FAILURE = A.BOLD + A.FG_RED + A_WARNING = A.BOLD + A.FG_RED + # A_PROMPT_* is for shell + A_PROMPT_PATH = A.BOLD + A.FG_CYAN + A_PROMPT_SUF = A.BOLD + # Files + A_DIRECTORY = A.BOLD + A.FG_CYAN + A_FILE = A.FG_WHITE
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/loops.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,145 @@ +#!/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 <http://www.gnu.org/licenses/>. + +import sys +import asyncio +import logging as log +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C + +log.basicConfig(level=log.WARNING, + format='[%(name)s] %(message)s') + +USER_INTER_MSG = _("User interruption: good bye") + + +class QuitException(BaseException): + """Quitting is requested + + This is used to stop execution when host.quit() is called + """ + + +def get_libervia_cli_loop(bridge_name): + if 'dbus' in bridge_name: + import signal + import threading + from gi.repository import GLib + + class LiberviaCLILoop: + + def run(self, libervia_cli, args, namespace): + signal.signal(signal.SIGINT, self._on_sigint) + self._glib_loop = GLib.MainLoop() + threading.Thread(target=self._glib_loop.run).start() + loop = asyncio.get_event_loop() + loop.run_until_complete(libervia_cli.main(args=args, namespace=namespace)) + loop.run_forever() + + def quit(self, exit_code): + loop = asyncio.get_event_loop() + loop.stop() + self._glib_loop.quit() + sys.exit(exit_code) + + def call_later(self, delay, callback, *args): + """call a callback repeatedly + + @param delay(int): delay between calls in s + @param callback(callable): method to call + if the callback return True, the call will continue + else the calls will stop + @param *args: args of the callbac + """ + loop = asyncio.get_event_loop() + loop.call_later(delay, callback, *args) + + def _on_sigint(self, sig_number, stack_frame): + """Called on keyboard interruption + + Print user interruption message, set exit code and stop reactor + """ + print("\r" + USER_INTER_MSG) + self.quit(C.EXIT_USER_CANCELLED) + else: + import signal + from twisted.internet import asyncioreactor + asyncioreactor.install() + from twisted.internet import reactor, defer + + class LiberviaCLILoop: + + def __init__(self): + # exit code must be set when using quit, so if it's not set + # something got wrong and we must report it + self._exit_code = C.EXIT_INTERNAL_ERROR + + def run(self, libervia_cli, *args): + self.libervia_cli = libervia_cli + signal.signal(signal.SIGINT, self._on_sigint) + defer.ensureDeferred(self._start(libervia_cli, *args)) + try: + reactor.run(installSignalHandlers=False) + except SystemExit as e: + self._exit_code = e.code + sys.exit(self._exit_code) + + async def _start(self, libervia_cli, *args): + fut = asyncio.ensure_future(libervia_cli.main(*args)) + try: + await defer.Deferred.fromFuture(fut) + except BaseException: + import traceback + traceback.print_exc() + libervia_cli.quit(1) + + def quit(self, exit_code): + self._exit_code = exit_code + reactor.stop() + + def _timeout_cb(self, args, callback, delay): + try: + ret = callback(*args) + # FIXME: temporary hack to avoid traceback when using XMLUI + # to be removed once create_task is not used anymore in + # xmlui_manager (i.e. once libervia.frontends.tools.xmlui fully supports + # async syntax) + except QuitException: + return + if ret: + reactor.callLater(delay, self._timeout_cb, args, callback, delay) + + def call_later(self, delay, callback, *args): + reactor.callLater(delay, self._timeout_cb, args, callback, delay) + + def _on_sigint(self, sig_number, stack_frame): + """Called on keyboard interruption + + Print user interruption message, set exit code and stop reactor + """ + print("\r" + USER_INTER_MSG) + self._exit_code = C.EXIT_USER_CANCELLED + reactor.callFromThread(reactor.stop) + + + if bridge_name == "embedded": + raise NotImplementedError + # from sat.core import sat_main + # sat = sat_main.SAT() + + return LiberviaCLILoop
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/output_std.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,127 @@ +#! /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 <http://www.gnu.org/licenses/>. +"""Standard outputs""" + + +from libervia.cli.constants import Const as C +from libervia.frontends.tools import jid +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.tools.common import date_utils +import json + +__outputs__ = ["Simple", "Json"] + + +class Simple(object): + """Default outputs""" + + def __init__(self, host): + self.host = host + host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_SIMPLE, self.simple_print) + host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_SIMPLE, self.list) + host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict) + host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_SIMPLE, self.list_dict) + host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict_dict) + host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_SIMPLE, self.messages) + host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_SIMPLE, self.simple_print) + + def simple_print(self, data): + self.host.disp(str(data)) + + def list(self, data): + self.host.disp("\n".join(data)) + + def dict(self, data, indent=0, header_color=C.A_HEADER): + options = self.host.parse_output_options() + self.host.check_output_options({"no-header"}, options) + show_header = not "no-header" in options + for k, v in data.items(): + if show_header: + header = A.color(header_color, k) + ": " + else: + header = "" + + self.host.disp( + ( + "{indent}{header}{value}".format( + indent=indent * " ", header=header, value=v + ) + ) + ) + + def list_dict(self, data): + for idx, datum in enumerate(data): + if idx: + self.host.disp("\n") + self.dict(datum) + + def dict_dict(self, data): + for key, sub_dict in data.items(): + self.host.disp(A.color(C.A_HEADER, key)) + self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER) + + def messages(self, data): + # TODO: handle lang, and non chat message (normal, headline) + for mess_data in data: + (uid, timestamp, from_jid, to_jid, message, subject, mess_type, + extra) = mess_data + time_str = date_utils.date_fmt(timestamp, "auto_day", + tz_info=date_utils.TZ_LOCAL) + from_jid = jid.JID(from_jid) + if mess_type == C.MESS_TYPE_GROUPCHAT: + nick = from_jid.resource + else: + nick = from_jid.node + + if self.host.own_jid is not None and self.host.own_jid.bare == from_jid.bare: + nick_color = A.BOLD + A.FG_BLUE + else: + nick_color = A.BOLD + A.FG_YELLOW + message = list(message.values())[0] if message else "" + + self.host.disp(A.color( + A.FG_CYAN, '['+time_str+'] ', + nick_color, nick, A.RESET, A.BOLD, '> ', + A.RESET, message)) + + +class Json(object): + """outputs in json format""" + + def __init__(self, host): + self.host = host + host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_JSON, self.dump) + host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON, self.dump_pretty) + host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON_RAW, self.dump) + host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty) + host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump) + host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty) + host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump) + host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty) + host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump) + host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON, self.dump_pretty) + host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON_RAW, self.dump) + host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON, self.dump_pretty) + host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON_RAW, self.dump) + + def dump(self, data): + self.host.disp(json.dumps(data, default=str)) + + def dump_pretty(self, data): + self.host.disp(json.dumps(data, indent=4, default=str))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/output_template.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,138 @@ +#! /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 <http://www.gnu.org/licenses/>. +"""Standard outputs""" + + +from libervia.cli.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.core import log +from libervia.backend.tools.common import template +from functools import partial +import logging +import webbrowser +import tempfile +import os.path + +__outputs__ = ["Template"] +TEMPLATE = "template" +OPTIONS = {"template", "browser", "inline-css"} + + +class Template(object): + """outputs data using SàT templates""" + + def __init__(self, libervia_cli): + self.host = libervia_cli + libervia_cli.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render) + + def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir): + """Get front URL for temporary directory""" + template_data = ctx['template_data'] + return "file://" + os.path.join(tmp_dir, template_data.theme, relative_url) + + def _do_render(self, template_path, css_inline, **kwargs): + try: + return self.renderer.render(template_path, css_inline=css_inline, **kwargs) + except template.TemplateNotFound: + self.host.disp(_("Can't find requested template: {template_path}") + .format(template_path=template_path), error=True) + self.host.quit(C.EXIT_NOT_FOUND) + + def render(self, data): + """render output data using requested template + + template to render the data can be either command's TEMPLATE or + template output_option requested by user. + @param data(dict): data is a dict which map from variable name to use in template + to the variable itself. + command's template_data_mapping attribute will be used if it exists to convert + data to a dict usable by the template. + """ + # media_dir is needed for the template + self.host.media_dir = self.host.bridge.config_get("", "media_dir") + cmd = self.host.command + try: + template_path = cmd.TEMPLATE + except AttributeError: + if not "template" in cmd.args.output_opts: + self.host.disp(_( + "no default template set for this command, you need to specify a " + "template using --oo template=[path/to/template.html]"), + error=True, + ) + self.host.quit(C.EXIT_BAD_ARG) + + options = self.host.parse_output_options() + self.host.check_output_options(OPTIONS, options) + try: + template_path = options["template"] + except KeyError: + # template is not specified, we use default one + pass + if template_path is None: + self.host.disp(_("Can't parse template, please check its syntax"), + error=True) + self.host.quit(C.EXIT_BAD_ARG) + + try: + mapping_cb = cmd.template_data_mapping + except AttributeError: + kwargs = data + else: + kwargs = mapping_cb(data) + + css_inline = "inline-css" in options + + if "browser" in options: + template_name = os.path.basename(template_path) + tmp_dir = tempfile.mkdtemp() + front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir) + self.renderer = template.Renderer( + self.host, front_url_filter=front_url_filter, trusted=True) + rendered = self._do_render(template_path, css_inline=css_inline, **kwargs) + self.host.disp(_( + "Browser opening requested.\n" + "Temporary files are put in the following directory, you'll have to " + "delete it yourself once finished viewing: {}").format(tmp_dir)) + tmp_file = os.path.join(tmp_dir, template_name) + with open(tmp_file, "w") as f: + f.write(rendered.encode("utf-8")) + theme, theme_root_path = self.renderer.get_theme_and_root(template_path) + if theme is None: + # we have an absolute path + webbrowser + static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR) + if os.path.exists(static_dir): + # we have to copy static files in a subdirectory, to avoid file download + # to be blocked by same origin policy + import shutil + shutil.copytree( + static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR) + ) + webbrowser.open(tmp_file) + else: + # FIXME: Q&D way to disable template logging + # logs are overcomplicated, and need to be reworked + template_logger = log.getLogger("sat.tools.common.template") + template_logger.log = lambda *args: None + + logging.disable(logging.WARNING) + self.renderer = template.Renderer(self.host, trusted=True) + rendered = self._do_render(template_path, css_inline=css_inline, **kwargs) + self.host.disp(rendered)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/output_xml.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,90 @@ +#! /usr/bin/env python3 + +# 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 <http://www.gnu.org/licenses/>. +"""Standard outputs""" + + +from libervia.cli.constants import Const as C +from libervia.backend.core.i18n import _ +from lxml import etree +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) +import sys + +try: + import pygments + from pygments.lexers.html import XmlLexer + from pygments.formatters import TerminalFormatter +except ImportError: + pygments = None + + +__outputs__ = ["XML"] + + +class XML(object): + """Outputs for XML""" + + def __init__(self, host): + self.host = host + host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML, self.pretty, default=True) + host.register_output( + C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML, self.pretty_list, default=True + ) + host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML_RAW, self.raw) + host.register_output(C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML_RAW, self.list_raw) + + def colorize(self, xml): + if pygments is None: + self.host.disp( + _( + "Pygments is not available, syntax highlighting is not possible. " + "Please install if from http://pygments.org or with pip install " + "pygments" + ), + error=True, + ) + return xml + if not sys.stdout.isatty(): + return xml + lexer = XmlLexer(encoding="utf-8") + formatter = TerminalFormatter(bg="dark") + return pygments.highlight(xml, lexer, formatter) + + def format(self, data, pretty=True): + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.fromstring(data, parser) + xml = etree.tostring(tree, encoding="unicode", pretty_print=pretty) + return self.colorize(xml) + + def format_no_pretty(self, data): + return self.format(data, pretty=False) + + def pretty(self, data): + self.host.disp(self.format(data)) + + def pretty_list(self, data, separator="\n"): + list_pretty = list(map(self.format, data)) + self.host.disp(separator.join(list_pretty)) + + def raw(self, data): + self.host.disp(self.format_no_pretty(data)) + + def list_raw(self, data, separator="\n"): + list_no_pretty = list(map(self.format_no_pretty, data)) + self.host.disp(separator.join(list_no_pretty))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/output_xmlui.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,49 @@ +#! /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 <http://www.gnu.org/licenses/>. +"""Standard outputs""" + + +from libervia.cli.constants import Const as C +from libervia.cli import xmlui_manager +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + + +__outputs__ = ["XMLUI"] + + +class XMLUI(object): + """Outputs for XMLUI""" + + def __init__(self, host): + self.host = host + host.register_output(C.OUTPUT_XMLUI, "simple", self.xmlui, default=True) + host.register_output( + C.OUTPUT_LIST_XMLUI, "simple", self.xmlui_list, default=True + ) + + async def xmlui(self, data): + xmlui = xmlui_manager.create(self.host, data) + await xmlui.show(values_only=True, read_only=True) + self.host.disp("") + + async def xmlui_list(self, data): + for d in data: + await self.xmlui(d)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/xml_tools.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,66 @@ +#!/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 <http://www.gnu.org/licenses/>. + +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C + +def etree_parse(cmd, raw_xml, reraise=False): + """import lxml and parse raw XML + + @param cmd(CommandBase): current command instance + @param raw_xml(file, str): an XML bytestring, string or file-like object + @param reraise(bool): if True, re raise exception on parse error instead of doing a + parser.error (which terminate the execution) + @return (tuple(etree.Element, module): parsed element, etree module + """ + try: + from lxml import etree + except ImportError: + cmd.disp( + 'lxml module must be installed, please install it with "pip install lxml"', + error=True, + ) + cmd.host.quit(C.EXIT_ERROR) + try: + if isinstance(raw_xml, str): + parser = etree.XMLParser(remove_blank_text=True) + element = etree.fromstring(raw_xml, parser) + else: + element = etree.parse(raw_xml).getroot() + except Exception as e: + if reraise: + raise e + cmd.parser.error( + _("Can't parse the payload XML in input: {msg}").format(msg=e) + ) + return element, etree + +def get_payload(cmd, element): + """Retrieve payload element and exit with and error if not found + + @param element(etree.Element): root element + @return element(etree.Element): payload element + """ + if element.tag in ("item", "{http://jabber.org/protocol/pubsub}item"): + if len(element) > 1: + cmd.disp(_("<item> can only have one child element (the payload)"), + error=True) + cmd.host.quit(C.EXIT_DATA_ERROR) + element = element[0] + return element
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/xmlui_manager.py Fri Jun 02 14:54:26 2023 +0200 @@ -0,0 +1,652 @@ +#!/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 <http://www.gnu.org/licenses/>. + +from functools import partial +from libervia.backend.core.log import getLogger +from libervia.frontends.tools import xmlui as xmlui_base +from libervia.cli.constants import Const as C +from libervia.backend.tools.common.ansi import ANSI as A +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format + +log = getLogger(__name__) + +# workflow constants + +SUBMIT = "SUBMIT" # submit form + + +## Widgets ## + + +class Base(object): + """Base for Widget and Container""" + + type = None + _root = None + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + self.host = self.xmlui_parent.host + + @property + def root(self): + """retrieve main XMLUI parent class""" + if self._root is not None: + return self._root + root = self + while not isinstance(root, xmlui_base.XMLUIBase): + root = root.xmlui_parent + self._root = root + return root + + def disp(self, *args, **kwargs): + self.host.disp(*args, **kwargs) + + +class Widget(Base): + category = "widget" + enabled = True + + @property + def name(self): + return self._xmlui_name + + async def show(self): + """display current widget + + must be overriden by subclasses + """ + raise NotImplementedError(self.__class__) + + def verbose_name(self, elems=None, value=None): + """add name in color to the elements + + helper method to display name which can then be used to automate commands + elems is only modified if verbosity is > 0 + @param elems(list[unicode], None): elements to display + None to display name directly + @param value(unicode, None): value to show + use self.name if None + """ + if value is None: + value = self.name + if self.host.verbosity: + to_disp = [ + A.FG_MAGENTA, + " " if elems else "", + "({})".format(value), + A.RESET, + ] + if elems is None: + self.host.disp(A.color(*to_disp)) + else: + elems.extend(to_disp) + + +class ValueWidget(Widget): + def __init__(self, xmlui_parent, value): + super(ValueWidget, self).__init__(xmlui_parent) + self.value = value + + @property + def values(self): + return [self.value] + + +class InputWidget(ValueWidget): + def __init__(self, xmlui_parent, value, read_only=False): + super(InputWidget, self).__init__(xmlui_parent, value) + self.read_only = read_only + + def _xmlui_get_value(self): + return self.value + + +class OptionsWidget(Widget): + def __init__(self, xmlui_parent, options, selected, style): + super(OptionsWidget, self).__init__(xmlui_parent) + self.options = options + self.selected = selected + self.style = style + + @property + def values(self): + return self.selected + + @values.setter + def values(self, values): + self.selected = values + + @property + def value(self): + return self.selected[0] + + @value.setter + def value(self, value): + self.selected = [value] + + def _xmlui_select_value(self, value): + self.value = value + + def _xmlui_select_values(self, values): + self.values = values + + def _xmlui_get_selected_values(self): + return self.values + + @property + def labels(self): + """return only labels from self.items""" + for value, label in self.items: + yield label + + @property + def items(self): + """return suitable items, according to style""" + no_select = self.no_select + for value, label in self.options: + if no_select or value in self.selected: + yield value, label + + @property + def inline(self): + return "inline" in self.style + + @property + def no_select(self): + return "noselect" in self.style + + +class EmptyWidget(xmlui_base.EmptyWidget, Widget): + def __init__(self, xmlui_parent): + Widget.__init__(self, xmlui_parent) + + async def show(self): + self.host.disp("") + + +class TextWidget(xmlui_base.TextWidget, ValueWidget): + type = "text" + + async def show(self): + self.host.disp(self.value) + + +class LabelWidget(xmlui_base.LabelWidget, ValueWidget): + type = "label" + + @property + def for_name(self): + try: + return self._xmlui_for_name + except AttributeError: + return None + + async def show(self, end="\n", ansi=""): + """show label + + @param end(str): same as for [LiberviaCli.disp] + @param ansi(unicode): ansi escape code to print before label + """ + self.disp(A.color(ansi, self.value), end=end) + + +class JidWidget(xmlui_base.JidWidget, TextWidget): + type = "jid" + + +class StringWidget(xmlui_base.StringWidget, InputWidget): + type = "string" + + async def show(self): + if self.read_only or self.root.read_only: + self.disp(self.value) + else: + elems = [] + self.verbose_name(elems) + if self.value: + elems.append(_("(enter: {value})").format(value=self.value)) + elems.extend([C.A_HEADER, "> "]) + value = await self.host.ainput(A.color(*elems)) + if value: + # TODO: empty value should be possible + # an escape key should be used for default instead of enter with empty value + self.value = value + + +class JidInputWidget(xmlui_base.JidInputWidget, StringWidget): + type = "jid_input" + + +class PasswordWidget(xmlui_base.PasswordWidget, StringWidget): + type = "password" + + +class TextBoxWidget(xmlui_base.TextWidget, StringWidget): + type = "textbox" + # TODO: use a more advanced input method + + async def show(self): + self.verbose_name() + if self.read_only or self.root.read_only: + self.disp(self.value) + else: + if self.value: + self.disp( + A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "") + ) + + values = [] + while True: + try: + if not values: + line = await self.host.ainput( + A.color(C.A_HEADER, "[Ctrl-D to finish]> ") + ) + else: + line = await self.host.ainput() + values.append(line) + except EOFError: + break + + self.value = "\n".join(values).rstrip() + + +class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget): + type = "xhtmlbox" + + async def show(self): + # FIXME: we use bridge in a blocking way as permitted by python-dbus + # this only for now to make it simpler, it must be refactored to use async + # when libervia-cli will be fully async (expected for 0.8) + self.value = await self.host.bridge.syntax_convert( + self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile + ) + await super(XHTMLBoxWidget, self).show() + + +class ListWidget(xmlui_base.ListWidget, OptionsWidget): + type = "list" + # TODO: handle flags, notably multi + + async def show(self): + if self.root.values_only: + for value in self.values: + self.disp(self.value) + return + if not self.options: + return + + # list display + self.verbose_name() + + for idx, (value, label) in enumerate(self.options): + elems = [] + if not self.root.read_only: + elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "]) + elems.append(label) + self.verbose_name(elems, value) + self.disp(A.color(*elems)) + + if self.root.read_only: + return + + if len(self.options) == 1: + # we have only one option, no need to ask + self.value = self.options[0][0] + return + + # we ask use to choose an option + choice = None + limit_max = len(self.options) - 1 + while choice is None or choice < 0 or choice > limit_max: + choice = await self.host.ainput( + A.color( + C.A_HEADER, + _("your choice (0-{limit_max}): ").format(limit_max=limit_max), + ) + ) + try: + choice = int(choice) + except ValueError: + choice = None + self.value = self.options[choice][0] + self.disp("") + + +class BoolWidget(xmlui_base.BoolWidget, InputWidget): + type = "bool" + + async def show(self): + disp_true = A.color(A.FG_GREEN, "TRUE") + disp_false = A.color(A.FG_RED, "FALSE") + if self.read_only or self.root.read_only: + self.disp(disp_true if self.value else disp_false) + else: + self.disp( + A.color( + C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else "" + ) + ) + self.disp( + A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "") + ) + choice = None + while choice not in ("0", "1"): + elems = [C.A_HEADER, _("your choice (0,1): ")] + self.verbose_name(elems) + choice = await self.host.ainput(A.color(*elems)) + self.value = bool(int(choice)) + self.disp("") + + def _xmlui_get_value(self): + return C.bool_const(self.value) + + ## Containers ## + + +class Container(Base): + category = "container" + + def __init__(self, xmlui_parent): + super(Container, self).__init__(xmlui_parent) + self.children = [] + + def __iter__(self): + return iter(self.children) + + def _xmlui_append(self, widget): + self.children.append(widget) + + def _xmlui_remove(self, widget): + self.children.remove(widget) + + async def show(self): + for child in self.children: + await child.show() + + +class VerticalContainer(xmlui_base.VerticalContainer, Container): + type = "vertical" + + +class PairsContainer(xmlui_base.PairsContainer, Container): + type = "pairs" + + +class LabelContainer(xmlui_base.PairsContainer, Container): + type = "label" + + async def show(self): + for child in self.children: + end = "\n" + # we check linked widget type + # to see if we want the label on the same line or not + if child.type == "label": + for_name = child.for_name + if for_name: + for_widget = self.root.widgets[for_name] + wid_type = for_widget.type + if self.root.values_only or wid_type in ( + "text", + "string", + "jid_input", + ): + end = " " + elif wid_type == "bool" and for_widget.read_only: + end = " " + await child.show(end=end, ansi=A.FG_CYAN) + else: + await child.show() + + ## Dialogs ## + + +class Dialog(object): + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + self.host = self.xmlui_parent.host + + def disp(self, *args, **kwargs): + self.host.disp(*args, **kwargs) + + async def show(self): + """display current dialog + + must be overriden by subclasses + """ + raise NotImplementedError(self.__class__) + + +class MessageDialog(xmlui_base.MessageDialog, Dialog): + def __init__(self, xmlui_parent, title, message, level): + Dialog.__init__(self, xmlui_parent) + xmlui_base.MessageDialog.__init__(self, xmlui_parent) + self.title, self.message, self.level = title, message, level + + async def show(self): + # TODO: handle level + if self.title: + self.disp(A.color(C.A_HEADER, self.title)) + self.disp(self.message) + + +class NoteDialog(xmlui_base.NoteDialog, Dialog): + def __init__(self, xmlui_parent, title, message, level): + Dialog.__init__(self, xmlui_parent) + xmlui_base.NoteDialog.__init__(self, xmlui_parent) + self.title, self.message, self.level = title, message, level + + async def show(self): + # TODO: handle title + error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR) + if self.level == C.XMLUI_DATA_LVL_WARNING: + msg = A.color(C.A_WARNING, self.message) + elif self.level == C.XMLUI_DATA_LVL_ERROR: + msg = A.color(C.A_FAILURE, self.message) + else: + msg = self.message + self.disp(msg, error=error) + + +class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog): + def __init__(self, xmlui_parent, title, message, level, buttons_set): + Dialog.__init__(self, xmlui_parent) + xmlui_base.ConfirmDialog.__init__(self, xmlui_parent) + self.title, self.message, self.level, self.buttons_set = ( + title, + message, + level, + buttons_set, + ) + + async def show(self): + # TODO: handle buttons_set and level + self.disp(self.message) + if self.title: + self.disp(A.color(C.A_HEADER, self.title)) + input_ = None + while input_ not in ("y", "n"): + input_ = await self.host.ainput(f"{self.message} (y/n)? ") + input_ = input_.lower() + if input_ == "y": + self._xmlui_validated() + else: + self._xmlui_cancelled() + + ## Factory ## + + +class WidgetFactory(object): + def __getattr__(self, attr): + if attr.startswith("create"): + cls = globals()[attr[6:]] + return cls + + +class XMLUIPanel(xmlui_base.AIOXMLUIPanel): + widget_factory = WidgetFactory() + _actions = 0 # use to keep track of bridge's action_launch calls + read_only = False + values_only = False + workflow = None + _submit_cb = None + + def __init__( + self, + host, + parsed_dom, + title=None, + flags=None, + callback=None, + ignore=None, + whitelist=None, + profile=None, + ): + xmlui_base.XMLUIPanel.__init__( + self, + host, + parsed_dom, + title=title, + flags=flags, + ignore=ignore, + whitelist=whitelist, + profile=host.profile, + ) + self.submitted = False + + @property + def command(self): + return self.host.command + + def disp(self, *args, **kwargs): + self.host.disp(*args, **kwargs) + + async def show(self, workflow=None, read_only=False, values_only=False): + """display the panel + + @param workflow(list, None): command to execute if not None + put here for convenience, the main workflow is the class attribute + (because workflow can continue in subclasses) + command are a list of consts or lists: + - SUBMIT is the only constant so far, it submits the XMLUI + - list must contain widget name/widget value to fill + @param read_only(bool): if True, don't request values + @param values_only(bool): if True, only show select values (imply read_only) + """ + self.read_only = read_only + self.values_only = values_only + if self.values_only: + self.read_only = True + if workflow: + XMLUIPanel.workflow = workflow + if XMLUIPanel.workflow: + await self.run_workflow() + else: + await self.main_cont.show() + + async def run_workflow(self): + """loop into workflow commands and execute commands + + SUBMIT will interrupt workflow (which will be continue on callback) + @param workflow(list): same as [show] + """ + workflow = XMLUIPanel.workflow + while True: + try: + cmd = workflow.pop(0) + except IndexError: + break + if cmd == SUBMIT: + await self.on_form_submitted() + self.submit_id = None # avoid double submit + return + elif isinstance(cmd, list): + name, value = cmd + widget = self.widgets[name] + if widget.type == "bool": + value = C.bool(value) + widget.value = value + await self.show() + + async def submit_form(self, callback=None): + XMLUIPanel._submit_cb = callback + await self.on_form_submitted() + + async def on_form_submitted(self, ignore=None): + # self.submitted is a Q&D workaround to avoid + # double submit when a workflow is set + if self.submitted: + return + self.submitted = True + await super(XMLUIPanel, self).on_form_submitted(ignore) + + def _xmlui_close(self): + pass + + async def _launch_action_cb(self, data): + XMLUIPanel._actions -= 1 + assert XMLUIPanel._actions >= 0 + if "xmlui" in data: + xmlui_raw = data["xmlui"] + xmlui = create(self.host, xmlui_raw) + await xmlui.show() + if xmlui.submit_id: + await xmlui.on_form_submitted() + # TODO: handle data other than XMLUI + if not XMLUIPanel._actions: + if self._submit_cb is None: + self.host.quit() + else: + self._submit_cb() + + async def _xmlui_launch_action(self, action_id, data): + XMLUIPanel._actions += 1 + try: + data = data_format.deserialise( + await self.host.bridge.action_launch( + action_id, + data_format.serialise(data), + self.profile, + ) + ) + except Exception as e: + self.disp(f"can't launch XMLUI action: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + await self._launch_action_cb(data) + + +class XMLUIDialog(xmlui_base.XMLUIDialog): + type = "dialog" + dialog_factory = WidgetFactory() + read_only = False + + async def show(self, __=None): + await self.dlg.show() + + def _xmlui_close(self): + pass + + +create = partial( + xmlui_base.create, + class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog}, +)
--- a/libervia/frontends/jp/arg_tools.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - -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(JP): jp 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
--- a/libervia/frontends/jp/base.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1435 +0,0 @@ -#!/usr/bin/env python3 - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -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.frontends.jp -from libervia.frontends.jp.loops import QuitException, get_jp_loop -from libervia.frontends.jp.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') -JPLoop = get_jp_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 jp - - looks from modules names cmd_*.py in jp path and import them - """ - path = os.path.dirname(libervia.frontends.jp.__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.frontends.jp." + 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 jp - - @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 = JPLoop() - 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 jp, 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 jp, 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: Jp 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)
--- a/libervia/frontends/jp/cmd_account.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)""" - -from libervia.frontends.jp.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.frontends.jp 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") - )
--- a/libervia/frontends/jp/cmd_adhoc.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.jp 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") - )
--- a/libervia/frontends/jp/cmd_application.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - -from . import base -from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import data_format -from libervia.frontends.jp.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'], - )
--- a/libervia/frontends/jp/cmd_avatar.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -import os -import os.path -import asyncio -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_blocking.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,137 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -import json -import os -from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import data_format -from libervia.frontends.jp import common -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_blog.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1219 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -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.frontends.jp import common -from libervia.frontends.jp.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<key>[a-z_]+)=(?P<value>.*)") -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 jp 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("<div>"): - content = "<div>" + content + "</div>" - 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'<html xmlns="http://www.w3.org/1999/xhtml">' - f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />' - f"</head>" - f"<body>{content}</body>" - f"</html>" - ) - - 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") - )
--- a/libervia/frontends/jp/cmd_bookmarks.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_debug.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_encryption.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -from libervia.frontends.jp import base -from libervia.frontends.jp.constants import Const as C -from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import data_format -from libervia.frontends.jp 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") - )
--- a/libervia/frontends/jp/cmd_event.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,755 +0,0 @@ -#!/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 <http://www.gnu.org/licenses/>. - - -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.frontends.jp import common -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_file.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1108 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -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.frontends.jp.constants import Const as C -from libervia.frontends.jp 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") - )
--- a/libervia/frontends/jp/cmd_forums.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.jp 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") - )
--- a/libervia/frontends/jp/cmd_identity.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_info.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,386 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -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.frontends.jp import common -from libervia.frontends.jp.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"), - )
--- a/libervia/frontends/jp/cmd_input.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,350 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -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.frontends.jp.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"), - )
--- a/libervia/frontends/jp/cmd_invitation.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,371 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.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"), - )
--- a/libervia/frontends/jp/cmd_list.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,351 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -import json -import os -from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import data_format -from libervia.frontends.jp import common -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_merge_request.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -import os.path -from . import base -from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import data_format -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.jp import xmlui_manager -from libervia.frontends.jp 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") - )
--- a/libervia/frontends/jp/cmd_message.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -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.frontends.jp import base -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_param.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -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") - )
--- a/libervia/frontends/jp/cmd_ping.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.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()
--- a/libervia/frontends/jp/cmd_pipe.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -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.frontends.jp import base -from libervia.frontends.jp import xmlui_manager -from libervia.frontends.jp.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") - )
--- a/libervia/frontends/jp/cmd_profile.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,280 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -"""This module permits to manage profiles. It can list, create, delete -and retrieve information about a profile.""" - -from libervia.frontends.jp.constants import Const as C -from libervia.backend.core.log import getLogger -from libervia.backend.core.i18n import _ -from libervia.frontends.jp 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'))
--- a/libervia/frontends/jp/cmd_pubsub.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,3030 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -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.frontends.jp.constants import Const as C -from libervia.frontends.jp import common -from libervia.frontends.jp import arg_tools -from libervia.frontends.jp 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 = "<import>" + self.args.import_file.read() + "</import>" - 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(<time pattern>) - if operator in (">", "gt", "<", "le", "between"): - def datetime_sub(match): - return str(date_utils.date_parse_ext( - match.group(1), default_tz=date_utils.TZ_LOCAL - )) - value = re.sub(r"\bTP\(([^)]+)\)", datetime_sub, value) - - try: - value = json.loads(value) - except json.JSONDecodeError: - # not JSON, as above we keep it as string - pass - - if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"): - if not isinstance(value, list): - value = [value] - - parsed.append({ - "path": path, - "op": operator, - "value": value - }) - - query["parsed"] = parsed - - if self.args.payload or "xml" in self.args.output: - query["with_payload"] = True - if self.args.keys: - self.args.keys.append("item_payload") - try: - found_items = data_format.deserialise( - await self.host.bridge.ps_cache_search( - data_format.serialise(query) - ), - type_check=list, - ) - except BridgeException as e: - self.disp(f"can't search for pubsub items in 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: - if self.args.keys: - found_items = [ - {k: v for k,v in item.items() if k in self.args.keys} - for item in found_items - ] - await self.output(found_items) - self.host.quit(C.EXIT_OK) - - def default_output(self, found_items): - for item in found_items: - for field in ("created", "published", "updated"): - try: - timestamp = item[field] - except KeyError: - pass - else: - try: - item[field] = common.format_time(timestamp) - except ValueError: - pass - self.host._outputs[C.OUTPUT_LIST_DICT]["simple"]["callback"](found_items) - - def xml_output(self, found_items): - """Output prettified item payload""" - cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML]["callback"] - for item in found_items: - cb(item["item_payload"]) - - def xml_raw_output(self, found_items): - """Output item payload without prettifying""" - cb = self.host._outputs[C.OUTPUT_XML][C.OUTPUT_NAME_XML_RAW]["callback"] - for item in found_items: - cb(item["item_payload"]) - - -class Cache(base.CommandBase): - subcommands = ( - CacheGet, - CacheSync, - CachePurge, - CacheReset, - CacheSearch, - ) - - def __init__(self, host): - super(Cache, self).__init__( - host, "cache", use_profile=False, help=_("pubsub cache handling") - ) - - -class Set(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "set", - use_pubsub=True, - use_quiet=True, - pubsub_flags={C.NODE}, - help=_("publish a new item or update an existing one"), - ) - - def add_parser_options(self): - NodeCreate.add_node_config_options(self.parser) - self.parser.add_argument( - "-e", - "--encrypt", - action="store_true", - help=_("end-to-end encrypt the blog item") - ) - 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") - ) - self.parser.add_argument( - "item", - nargs="?", - default="", - help=_("id, URL of the item to update, keyword, or nothing for new item"), - ) - - async def start(self): - element, etree = xml_tools.etree_parse(self, sys.stdin) - element = xml_tools.get_payload(self, element) - payload = etree.tostring(element, encoding="unicode") - extra = {} - if self.args.encrypt: - extra["encrypted"] = True - if self.args.encrypt_for: - extra["encrypted_for"] = {"targets": self.args.encrypt_for} - if self.args.sign: - extra["signed"] = True - publish_options = NodeCreate.get_config_options(self.args) - if publish_options: - extra["publish_options"] = publish_options - - try: - published_id = await self.host.bridge.ps_item_send( - self.args.service, - self.args.node, - payload, - self.args.item, - data_format.serialise(extra), - self.profile, - ) - except Exception as e: - self.disp(_("can't send item: {e}").format(e=e), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - if published_id: - if self.args.quiet: - self.disp(published_id, end="") - else: - self.disp(f"Item published at {published_id}") - else: - self.disp("Item published") - self.host.quit(C.EXIT_OK) - - -class Get(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - 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)"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-S", - "--sub-id", - default="", - help=_("subscription id"), - ) - self.parser.add_argument( - "--no-decrypt", - action="store_true", - help=_("don't do automatic decryption of e2ee items"), - ) - # TODO: a key(s) argument to select keys to display - - async def start(self): - extra = {} - if self.args.no_decrypt: - extra["decrypt"] = False - try: - ps_result = data_format.deserialise( - await self.host.bridge.ps_items_get( - self.args.service, - self.args.node, - self.args.max, - self.args.items, - self.args.sub_id, - self.get_pubsub_extra(extra), - 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 get pubsub items: {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 Delete(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "delete", - use_pubsub=True, - pubsub_flags={C.NODE, C.ITEM, C.SINGLE_ITEM}, - help=_("delete an item"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-f", "--force", action="store_true", help=_("delete without confirmation") - ) - self.parser.add_argument( - "--no-notification", dest="notify", action="store_false", - help=_("do not send notification (not recommended)") - ) - - async def start(self): - if not self.args.item: - self.parser.error(_("You need to specify an item to delete")) - if not self.args.force: - message = _("Are you sure to delete item {item_id} ?").format( - item_id=self.args.item - ) - await self.host.confirm_or_quit(message, _("item deletion cancelled")) - try: - await self.host.bridge.ps_item_retract( - 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 Edit(base.CommandBase, common.BaseEdit): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "edit", - use_verbose=True, - use_pubsub=True, - pubsub_flags={C.NODE, C.SINGLE_ITEM}, - use_draft=True, - help=_("edit an existing or new pubsub item"), - ) - common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR) - - def add_parser_options(self): - self.parser.add_argument( - "-e", - "--encrypt", - action="store_true", - help=_("end-to-end encrypt the blog item") - ) - 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 publish(self, content): - extra = {} - if self.args.encrypt: - extra["encrypted"] = True - if self.args.encrypt_for: - extra["encrypted_for"] = {"targets": self.args.encrypt_for} - if self.args.sign: - extra["signed"] = True - published_id = await self.host.bridge.ps_item_send( - self.pubsub_service, - self.pubsub_node, - content, - self.pubsub_item or "", - data_format.serialise(extra), - self.profile, - ) - if published_id: - self.disp("Item published at {pub_id}".format(pub_id=published_id)) - else: - self.disp("Item published") - - async def get_item_data(self, service, node, item): - 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) - items = [item] if item else [] - ps_result = data_format.deserialise( - await self.host.bridge.ps_items_get( - service, node, 1, items, "", data_format.serialise({}), self.profile - ) - ) - item_raw = ps_result["items"][0] - parser = etree.XMLParser(remove_blank_text=True, recover=True) - item_elt = etree.fromstring(item_raw, parser) - item_id = item_elt.get("id") - try: - payload = item_elt[0] - except IndexError: - self.disp(_("Item has not payload"), 1) - return "", item_id - return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id - - async def start(self): - ( - self.pubsub_service, - self.pubsub_node, - self.pubsub_item, - content_file_path, - content_file_obj, - ) = await self.get_item_path() - await self.run_editor("pubsub_editor_args", content_file_path, content_file_obj) - self.host.quit() - - -class Rename(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "rename", - use_pubsub=True, - pubsub_flags={C.NODE, C.SINGLE_ITEM}, - help=_("rename a pubsub 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.ps_item_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 Subscribe(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "subscribe", - use_pubsub=True, - pubsub_flags={C.NODE}, - use_verbose=True, - help=_("subscribe to a node"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "--public", - action="store_true", - help=_("make the registration visible for everybody"), - ) - - async def start(self): - options = {} - if self.args.public: - namespaces = await self.host.bridge.namespaces_get() - try: - ns_pps = namespaces["pps"] - except KeyError: - self.disp( - "Pubsub Public Subscription plugin is not loaded, can't use --public " - "option, subscription stopped", error=True - ) - self.host.quit(C.EXIT_MISSING_FEATURE) - else: - options[f"{{{ns_pps}}}public"] = True - try: - sub_id = await self.host.bridge.ps_subscribe( - self.args.service, - self.args.node, - data_format.serialise(options), - self.profile, - ) - except Exception as e: - self.disp(_("can't subscribe to node: {e}").format(e=e), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp(_("subscription done"), 1) - if sub_id: - self.disp(_("subscription id: {sub_id}").format(sub_id=sub_id)) - self.host.quit() - - -class Unsubscribe(base.CommandBase): - # FIXME: check why we get a a NodeNotFound on subscribe just after unsubscribe - - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "unsubscribe", - use_pubsub=True, - pubsub_flags={C.NODE}, - use_verbose=True, - help=_("unsubscribe from a node"), - ) - - def add_parser_options(self): - pass - - async def start(self): - try: - await self.host.bridge.ps_unsubscribe( - self.args.service, - self.args.node, - self.profile, - ) - except Exception as e: - self.disp(_("can't unsubscribe from node: {e}").format(e=e), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp(_("subscription removed"), 1) - self.host.quit() - - -class Subscriptions(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "subscriptions", - use_output=C.OUTPUT_LIST_DICT, - use_pubsub=True, - help=_("retrieve all subscriptions on a service"), - ) - - 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_subscriptions_get - else: - method = self.host.bridge.ps_subscriptions_get - try: - subscriptions = data_format.deserialise( - await method( - self.args.service, - self.args.node, - self.profile, - ), - type_check=list - ) - except Exception as e: - self.disp(_("can't retrieve subscriptions: {e}").format(e=e), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - await self.output(subscriptions) - self.host.quit() - - -class Affiliations(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "affiliations", - use_output=C.OUTPUT_DICT, - use_pubsub=True, - help=_("retrieve all affiliations on a service"), - ) - - def add_parser_options(self): - pass - - async def start(self): - try: - affiliations = await self.host.bridge.ps_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 Reference(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "reference", - use_pubsub=True, - pubsub_flags={C.NODE, C.SINGLE_ITEM}, - help=_("send a reference/mention to pubsub item"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-t", - "--type", - default="mention", - choices=("data", "mention"), - help=_("type of reference to send (DEFAULT: mention)"), - ) - self.parser.add_argument( - "recipient", - help=_("recipient of the reference") - ) - - async def start(self): - service = self.args.service or await self.host.get_profile_jid() - if self.args.item: - anchor = uri.build_xmpp_uri( - "pubsub", path=service, node=self.args.node, item=self.args.item - ) - else: - anchor = uri.build_xmpp_uri("pubsub", path=service, node=self.args.node) - - try: - await self.host.bridge.reference_send( - self.args.recipient, - anchor, - self.args.type, - "", - self.profile, - ) - except Exception as e: - self.disp(f"can't send reference: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.host.quit() - - -class Search(base.CommandBase): - """This command do a search without using MAM - - This commands checks every items it finds by itself, - so it may be heavy in resources both for server and client - """ - - RE_FLAGS = re.MULTILINE | re.UNICODE - EXEC_ACTIONS = ("exec", "external") - - def __init__(self, host): - # FIXME: C.NO_MAX is not needed here, and this can be globally removed from consts - # the only interest is to change the help string, but this can be explained - # extensively in man pages (max is for each node found) - base.CommandBase.__init__( - self, - host, - "search", - use_output=C.OUTPUT_XML, - use_pubsub=True, - pubsub_flags={C.MULTI_ITEMS, C.NO_MAX}, - use_verbose=True, - help=_("search items corresponding to filters"), - ) - - @property - def etree(self): - """load lxml.etree only if needed""" - if self._etree is None: - from lxml import etree - - self._etree = etree - return self._etree - - def filter_opt(self, value, type_): - return (type_, value) - - def filter_flag(self, value, type_): - value = C.bool(value) - return (type_, value) - - def add_parser_options(self): - self.parser.add_argument( - "-D", - "--max-depth", - type=int, - default=0, - help=_( - "maximum depth of recursion (will search linked nodes if > 0, " - "DEFAULT: 0)" - ), - ) - self.parser.add_argument( - "-M", - "--node-max", - type=int, - default=30, - help=_( - "maximum number of items to get per node ({} to get all items, " - "DEFAULT: 30)".format(C.NO_LIMIT) - ), - ) - self.parser.add_argument( - "-N", - "--namespace", - action="append", - nargs=2, - default=[], - metavar="NAME NAMESPACE", - help=_("namespace to use for xpath"), - ) - - # filters - filter_text = partial(self.filter_opt, type_="text") - filter_re = partial(self.filter_opt, type_="regex") - filter_xpath = partial(self.filter_opt, type_="xpath") - filter_python = partial(self.filter_opt, type_="python") - filters = self.parser.add_argument_group( - _("filters"), - _("only items corresponding to following filters will be kept"), - ) - filters.add_argument( - "-t", - "--text", - action="append", - dest="filters", - type=filter_text, - metavar="TEXT", - help=_("full text filter, item must contain this string (XML included)"), - ) - filters.add_argument( - "-r", - "--regex", - action="append", - dest="filters", - type=filter_re, - metavar="EXPRESSION", - help=_("like --text but using a regular expression"), - ) - filters.add_argument( - "-x", - "--xpath", - action="append", - dest="filters", - type=filter_xpath, - metavar="XPATH", - help=_("filter items which has elements matching this xpath"), - ) - filters.add_argument( - "-P", - "--python", - action="append", - dest="filters", - type=filter_python, - metavar="PYTHON_CODE", - help=_( - "Python expression which much return a bool (True to keep item, " - 'False to reject it). "item" is raw text item, "item_xml" is ' - "lxml's etree.Element" - ), - ) - - # filters flags - flag_case = partial(self.filter_flag, type_="ignore-case") - flag_invert = partial(self.filter_flag, type_="invert") - flag_dotall = partial(self.filter_flag, type_="dotall") - flag_matching = partial(self.filter_flag, type_="only-matching") - flags = self.parser.add_argument_group( - _("filters flags"), - _("filters modifiers (change behaviour of following filters)"), - ) - flags.add_argument( - "-C", - "--ignore-case", - action="append", - dest="filters", - type=flag_case, - const=("ignore-case", True), - nargs="?", - metavar="BOOLEAN", - help=_("(don't) ignore case in following filters (DEFAULT: case sensitive)"), - ) - flags.add_argument( - "-I", - "--invert", - action="append", - dest="filters", - type=flag_invert, - const=("invert", True), - nargs="?", - metavar="BOOLEAN", - help=_("(don't) invert effect of following filters (DEFAULT: don't invert)"), - ) - flags.add_argument( - "-A", - "--dot-all", - action="append", - dest="filters", - type=flag_dotall, - const=("dotall", True), - nargs="?", - metavar="BOOLEAN", - help=_("(don't) use DOTALL option for regex (DEFAULT: don't use)"), - ) - flags.add_argument( - "-k", - "--only-matching", - action="append", - dest="filters", - type=flag_matching, - const=("only-matching", True), - nargs="?", - metavar="BOOLEAN", - help=_("keep only the matching part of the item"), - ) - - # action - self.parser.add_argument( - "action", - default="print", - nargs="?", - choices=("print", "exec", "external"), - help=_("action to do on found items (DEFAULT: print)"), - ) - self.parser.add_argument("command", nargs=argparse.REMAINDER) - - async def get_items(self, depth, service, node, items): - self.to_get += 1 - try: - ps_result = data_format.deserialise( - await self.host.bridge.ps_items_get( - service, - node, - self.args.node_max, - items, - "", - self.get_pubsub_extra(), - self.profile, - ) - ) - except Exception as e: - self.disp( - f"can't get pubsub items at {service} (node: {node}): {e}", - error=True, - ) - self.to_get -= 1 - else: - await self.search(ps_result, depth) - - def _check_pubsub_url(self, match, found_nodes): - """check that the matched URL is an xmpp: one - - @param found_nodes(list[unicode]): found_nodes - this list will be filled while xmpp: URIs are discovered - """ - url = match.group(0) - if url.startswith("xmpp"): - try: - url_data = uri.parse_xmpp_uri(url) - except ValueError: - return - if url_data["type"] == "pubsub": - found_node = {"service": url_data["path"], "node": url_data["node"]} - if "item" in url_data: - found_node["item"] = url_data["item"] - found_nodes.append(found_node) - - async def get_sub_nodes(self, item, depth): - """look for pubsub URIs in item, and get_items on the linked nodes""" - found_nodes = [] - checkURI = partial(self._check_pubsub_url, found_nodes=found_nodes) - strings.RE_URL.sub(checkURI, item) - for data in found_nodes: - await self.get_items( - depth + 1, - data["service"], - data["node"], - [data["item"]] if "item" in data else [], - ) - - def parseXml(self, item): - try: - return self.etree.fromstring(item) - except self.etree.XMLSyntaxError: - self.disp( - _( - "item doesn't looks like XML, you have probably used --only-matching " - "somewhere before and we have no more XML" - ), - error=True, - ) - self.host.quit(C.EXIT_BAD_ARG) - - def filter(self, item): - """apply filters given on command line - - if only-matching is used, item may be modified - @return (tuple[bool, unicode]): a tuple with: - - keep: True if item passed the filters - - item: it is returned in case of modifications - """ - ignore_case = False - invert = False - dotall = False - only_matching = False - item_xml = None - for type_, value in self.args.filters: - keep = True - - ## filters - - if type_ == "text": - if ignore_case: - if value.lower() not in item.lower(): - keep = False - else: - if value not in item: - keep = False - if keep and only_matching: - # doesn't really make sens to keep a fixed string - # so we raise an error - self.host.disp( - _("--only-matching used with fixed --text string, are you sure?"), - error=True, - ) - self.host.quit(C.EXIT_BAD_ARG) - elif type_ == "regex": - flags = self.RE_FLAGS - if ignore_case: - flags |= re.IGNORECASE - if dotall: - flags |= re.DOTALL - match = re.search(value, item, flags) - keep = match != None - if keep and only_matching: - item = match.group() - item_xml = None - elif type_ == "xpath": - if item_xml is None: - item_xml = self.parseXml(item) - try: - elts = item_xml.xpath(value, namespaces=self.args.namespace) - except self.etree.XPathEvalError as e: - self.disp(_("can't use xpath: {reason}").format(reason=e), error=True) - self.host.quit(C.EXIT_BAD_ARG) - keep = bool(elts) - if keep and only_matching: - item_xml = elts[0] - try: - item = self.etree.tostring(item_xml, encoding="unicode") - except TypeError: - # we have a string only, not an element - item = str(item_xml) - item_xml = None - elif type_ == "python": - if item_xml is None: - item_xml = self.parseXml(item) - cmd_ns = {"etree": self.etree, "item": item, "item_xml": item_xml} - try: - keep = eval(value, cmd_ns) - except SyntaxError as e: - self.disp(str(e), error=True) - self.host.quit(C.EXIT_BAD_ARG) - - ## flags - - elif type_ == "ignore-case": - ignore_case = value - elif type_ == "invert": - invert = value - # we need to continue, else loop would end here - continue - elif type_ == "dotall": - dotall = value - elif type_ == "only-matching": - only_matching = value - else: - raise exceptions.InternalError( - _("unknown filter type {type}").format(type=type_) - ) - - if invert: - keep = not keep - if not keep: - return False, item - - return True, item - - async def do_item_action(self, item, metadata): - """called when item has been kepts and the action need to be done - - @param item(unicode): accepted item - """ - action = self.args.action - if action == "print" or self.host.verbosity > 0: - try: - await self.output(item) - except self.etree.XMLSyntaxError: - # item is not valid XML, but a string - # can happen when --only-matching is used - self.disp(item) - if action in self.EXEC_ACTIONS: - item_elt = self.parseXml(item) - if action == "exec": - use = { - "service": metadata["service"], - "node": metadata["node"], - "item": item_elt.get("id"), - "profile": self.profile, - } - # we need to send a copy of self.args.command - # else it would be modified - parser_args, use_args = arg_tools.get_use_args( - self.host, self.args.command, use, verbose=self.host.verbosity > 1 - ) - cmd_args = sys.argv[0:1] + parser_args + use_args - else: - cmd_args = self.args.command - - self.disp( - "COMMAND: {command}".format( - command=" ".join([arg_tools.escape(a) for a in cmd_args]) - ), - 2, - ) - if action == "exec": - p = await asyncio.create_subprocess_exec(*cmd_args) - ret = await p.wait() - else: - p = await asyncio.create_subprocess_exec(*cmd_args, stdin=subprocess.PIPE) - await p.communicate(item.encode(sys.getfilesystemencoding())) - ret = p.returncode - if ret != 0: - self.disp( - A.color( - C.A_FAILURE, - _("executed command failed with exit code {ret}").format(ret=ret), - ) - ) - - async def search(self, ps_result, depth): - """callback of get_items - - this method filters items, get sub nodes if needed, - do the requested action, and exit the command when everything is done - @param items_data(tuple): result of get_items - @param depth(int): current depth level - 0 for first node, 1 for first children, and so on - """ - for item in ps_result["items"]: - if depth < self.args.max_depth: - await self.get_sub_nodes(item, depth) - keep, item = self.filter(item) - if not keep: - continue - await self.do_item_action(item, ps_result) - - # we check if we got all get_items results - self.to_get -= 1 - if self.to_get == 0: - # yes, we can quit - self.host.quit() - assert self.to_get > 0 - - async def start(self): - if self.args.command: - if self.args.action not in self.EXEC_ACTIONS: - self.parser.error( - _("Command can only be used with {actions} actions").format( - actions=", ".join(self.EXEC_ACTIONS) - ) - ) - else: - if self.args.action in self.EXEC_ACTIONS: - self.parser.error(_("you need to specify a command to execute")) - if not self.args.node: - # TODO: handle get service affiliations when node is not set - self.parser.error(_("empty node is not handled yet")) - # to_get is increased on each get and decreased on each answer - # when it reach 0 again, the command is finished - self.to_get = 0 - self._etree = None - if self.args.filters is None: - self.args.filters = [] - self.args.namespace = dict( - self.args.namespace + [("pubsub", "http://jabber.org/protocol/pubsub")] - ) - await self.get_items(0, self.args.service, self.args.node, self.args.items) - - -class Transform(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "transform", - use_pubsub=True, - pubsub_flags={C.NODE, C.MULTI_ITEMS}, - help=_("modify items of a node using an external command/script"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "--apply", - action="store_true", - help=_("apply transformation (DEFAULT: do a dry run)"), - ) - self.parser.add_argument( - "--admin", - action="store_true", - help=_("do a pubsub admin request, needed to change publisher"), - ) - self.parser.add_argument( - "-I", - "--ignore-errors", - action="store_true", - help=_( - "if command return a non zero exit code, ignore the item and continue" - ), - ) - self.parser.add_argument( - "-A", - "--all", - action="store_true", - help=_("get all items by looping over all pages using RSM"), - ) - self.parser.add_argument( - "command_path", - help=_( - "path to the command to use. Will be called repetitivly with an " - "item as input. Output (full item XML) will be used as new one. " - 'Return "DELETE" string to delete the item, and "SKIP" to ignore it' - ), - ) - - async def ps_items_send_cb(self, item_ids, metadata): - if item_ids: - self.disp( - _("items published with ids {item_ids}").format( - item_ids=", ".join(item_ids) - ) - ) - else: - self.disp(_("items published")) - if self.args.all: - return await self.handle_next_page(metadata) - else: - self.host.quit() - - async def handle_next_page(self, metadata): - """Retrieve new page through RSM or quit if we're in the last page - - use to handle --all option - @param metadata(dict): metadata as returned by ps_items_get - """ - try: - last = metadata["rsm"]["last"] - index = int(metadata["rsm"]["index"]) - count = int(metadata["rsm"]["count"]) - except KeyError: - self.disp( - _("Can't retrieve all items, RSM metadata not available"), error=True - ) - self.host.quit(C.EXIT_MISSING_FEATURE) - except ValueError as e: - self.disp( - _("Can't retrieve all items, bad RSM metadata: {msg}").format(msg=e), - error=True, - ) - self.host.quit(C.EXIT_ERROR) - - if index + self.args.rsm_max >= count: - self.disp(_("All items transformed")) - self.host.quit(0) - - self.disp( - _("Retrieving next page ({page_idx}/{page_total})").format( - page_idx=int(index / self.args.rsm_max) + 1, - page_total=int(count / self.args.rsm_max), - ) - ) - - extra = self.get_pubsub_extra() - extra["rsm_after"] = last - try: - ps_result = await data_format.deserialise( - self.host.bridge.ps_items_get( - self.args.service, - self.args.node, - self.args.rsm_max, - self.args.items, - "", - data_format.serialise(extra), - self.profile, - ) - ) - except Exception as e: - self.disp(f"can't retrieve items: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - await self.ps_items_get_cb(ps_result) - - async def ps_items_get_cb(self, ps_result): - encoding = "utf-8" - new_items = [] - - for item in ps_result["items"]: - if self.check_duplicates: - # this is used when we are not ordering by creation - # to avoid infinite loop - item_elt, __ = xml_tools.etree_parse(self, item) - item_id = item_elt.get("id") - if item_id in self.items_ids: - self.disp( - _( - "Duplicate found on item {item_id}, we have probably handled " - "all items." - ).format(item_id=item_id) - ) - self.host.quit() - self.items_ids.append(item_id) - - # we launch the command to filter the item - try: - p = await asyncio.create_subprocess_exec( - self.args.command_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE - ) - except OSError as e: - exit_code = C.EXIT_CMD_NOT_FOUND if e.errno == 2 else C.EXIT_ERROR - self.disp(f"Can't execute the command: {e}", error=True) - self.host.quit(exit_code) - encoding = "utf-8" - cmd_std_out, cmd_std_err = await p.communicate(item.encode(encoding)) - ret = p.returncode - if ret != 0: - self.disp( - f"The command returned a non zero status while parsing the " - f"following item:\n\n{item}", - error=True, - ) - if self.args.ignore_errors: - continue - else: - self.host.quit(C.EXIT_CMD_ERROR) - if cmd_std_err is not None: - cmd_std_err = cmd_std_err.decode(encoding, errors="ignore") - self.disp(cmd_std_err, error=True) - cmd_std_out = cmd_std_out.decode(encoding).strip() - if cmd_std_out == "DELETE": - item_elt, __ = xml_tools.etree_parse(self, item) - item_id = item_elt.get("id") - self.disp(_("Deleting item {item_id}").format(item_id=item_id)) - if self.args.apply: - try: - await self.host.bridge.ps_item_retract( - self.args.service, - self.args.node, - item_id, - False, - self.profile, - ) - except Exception as e: - self.disp(f"can't delete item {item_id}: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - continue - elif cmd_std_out == "SKIP": - item_elt, __ = xml_tools.etree_parse(self, item) - item_id = item_elt.get("id") - self.disp(_("Skipping item {item_id}").format(item_id=item_id)) - continue - element, etree = xml_tools.etree_parse(self, cmd_std_out) - - # at this point command has been run and we have a etree.Element object - if element.tag not in ("item", "{http://jabber.org/protocol/pubsub}item"): - self.disp( - "your script must return a whole item, this is not:\n{xml}".format( - xml=etree.tostring(element, encoding="unicode") - ), - error=True, - ) - self.host.quit(C.EXIT_DATA_ERROR) - - if not self.args.apply: - # we have a dry run, we just display filtered items - serialised = etree.tostring( - element, encoding="unicode", pretty_print=True - ) - self.disp(serialised) - else: - new_items.append(etree.tostring(element, encoding="unicode")) - - if not self.args.apply: - # on dry run we have nothing to wait for, we can quit - if self.args.all: - return await self.handle_next_page(ps_result) - self.host.quit() - else: - if self.args.admin: - bridge_method = self.host.bridge.ps_admin_items_send - else: - bridge_method = self.host.bridge.ps_items_send - - try: - ps_items_send_result = await bridge_method( - self.args.service, - self.args.node, - new_items, - "", - self.profile, - ) - except Exception as e: - self.disp(f"can't send item: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - await self.ps_items_send_cb(ps_items_send_result, metadata=ps_result) - - async def start(self): - if self.args.all and self.args.order_by != C.ORDER_BY_CREATION: - self.check_duplicates = True - self.items_ids = [] - self.disp( - A.color( - A.FG_RED, - A.BOLD, - '/!\\ "--all" should be used with "--order-by creation" /!\\\n', - A.RESET, - "We'll update items, so order may change during transformation,\n" - "we'll try to mitigate that by stopping on first duplicate,\n" - "but this method is not safe, and some items may be missed.\n---\n", - ) - ) - else: - self.check_duplicates = False - - try: - ps_result = data_format.deserialise( - await self.host.bridge.ps_items_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 retrieve items: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - await self.ps_items_get_cb(ps_result) - - -class Uri(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "uri", - use_profile=False, - use_pubsub=True, - pubsub_flags={C.NODE, C.SINGLE_ITEM}, - help=_("build URI"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-p", - "--profile", - default=C.PROF_KEY_DEFAULT, - help=_("profile (used when no server is specified)"), - ) - - def display_uri(self, jid_): - uri_args = {} - if not self.args.service: - self.args.service = jid.JID(jid_).bare - - for key in ("node", "service", "item"): - value = getattr(self.args, key) - if key == "service": - key = "path" - if value: - uri_args[key] = value - self.disp(uri.build_xmpp_uri("pubsub", **uri_args)) - self.host.quit() - - async def start(self): - if not self.args.service: - try: - jid_ = await self.host.bridge.param_get_a_async( - "JabberID", "Connection", profile_key=self.args.profile - ) - except Exception as e: - self.disp(f"can't retrieve jid: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.display_uri(jid_) - else: - self.display_uri(None) - - -class AttachmentGet(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "get", - use_output=C.OUTPUT_LIST_DICT, - use_pubsub=True, - pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM}, - help=_("get data attached to an item"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-j", - "--jid", - action="append", - dest="jids", - help=_( - "get attached data published only by those JIDs (DEFAULT: get all " - "attached data)" - ) - ) - - async def start(self): - try: - attached_data, __ = await self.host.bridge.ps_attachments_get( - self.args.service, - self.args.node, - self.args.item, - self.args.jids or [], - "", - self.profile, - ) - except Exception as e: - self.disp(f"can't get attached data: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - attached_data = data_format.deserialise(attached_data, type_check=list) - await self.output(attached_data) - self.host.quit(C.EXIT_OK) - - -class AttachmentSet(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "set", - use_pubsub=True, - pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM}, - help=_("attach data to an item"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "--replace", - action="store_true", - help=_( - "replace previous versions of attachments (DEFAULT: update previous " - "version)" - ) - ) - self.parser.add_argument( - "-N", - "--noticed", - metavar="BOOLEAN", - nargs="?", - default="keep", - help=_("mark item as (un)noticed (DEFAULT: keep current value))") - ) - self.parser.add_argument( - "-r", - "--reactions", - # FIXME: to be replaced by "extend" when we stop supporting python 3.7 - action="append", - help=_("emojis to add to react to an item") - ) - self.parser.add_argument( - "-R", - "--reactions-remove", - # FIXME: to be replaced by "extend" when we stop supporting python 3.7 - action="append", - help=_("emojis to remove from reactions to an item") - ) - - async def start(self): - attachments_data = { - "service": self.args.service, - "node": self.args.node, - "id": self.args.item, - "extra": {} - } - operation = "replace" if self.args.replace else "update" - if self.args.noticed != "keep": - if self.args.noticed is None: - self.args.noticed = C.BOOL_TRUE - attachments_data["extra"]["noticed"] = C.bool(self.args.noticed) - - if self.args.reactions or self.args.reactions_remove: - reactions = attachments_data["extra"]["reactions"] = { - "operation": operation - } - if self.args.replace: - reactions["reactions"] = self.args.reactions - else: - reactions["add"] = self.args.reactions - reactions["remove"] = self.args.reactions_remove - - - if not attachments_data["extra"]: - self.parser.error(_("At leat one attachment must be specified.")) - - try: - await self.host.bridge.ps_attachments_set( - data_format.serialise(attachments_data), - self.profile, - ) - except Exception as e: - self.disp(f"can't attach data to item: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp("data attached") - self.host.quit(C.EXIT_OK) - - -class Attachments(base.CommandBase): - subcommands = (AttachmentGet, AttachmentSet) - - def __init__(self, host): - super().__init__( - host, - "attachments", - use_profile=False, - help=_("set or retrieve items attachments"), - ) - - -class SignatureSign(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "sign", - use_pubsub=True, - pubsub_flags={C.NODE, C.SINGLE_ITEM}, - help=_("sign an item"), - ) - - def add_parser_options(self): - pass - - async def start(self): - attachments_data = { - "service": self.args.service, - "node": self.args.node, - "id": self.args.item, - "extra": { - # we set None to use profile's bare JID - "signature": {"signer": None} - } - } - try: - await self.host.bridge.ps_attachments_set( - data_format.serialise(attachments_data), - self.profile, - ) - except Exception as e: - self.disp(f"can't sign the item: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp(f"item {self.args.item!r} has been signed") - self.host.quit(C.EXIT_OK) - - -class SignatureCheck(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "check", - use_output=C.OUTPUT_DICT, - use_pubsub=True, - pubsub_flags={C.SERVICE, C.NODE, C.SINGLE_ITEM}, - help=_("check the validity of pubsub signature"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "signature", - metavar="JSON", - help=_("signature data") - ) - - async def start(self): - try: - ret_s = await self.host.bridge.ps_signature_check( - self.args.service, - self.args.node, - self.args.item, - self.args.signature, - self.profile, - ) - except Exception as e: - self.disp(f"can't check signature: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - await self.output(data_format.deserialise((ret_s))) - self.host.quit() - - -class Signature(base.CommandBase): - subcommands = ( - SignatureSign, - SignatureCheck, - ) - - def __init__(self, host): - super().__init__( - host, "signature", use_profile=False, help=_("items signatures") - ) - - -class SecretShare(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "share", - use_pubsub=True, - pubsub_flags={C.NODE}, - help=_("share a secret to let other entity encrypt or decrypt items"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-k", "--key", metavar="ID", dest="secret_ids", action="append", default=[], - help=_( - "only share secrets with those IDs (default: share all secrets of the " - "node)" - ) - ) - self.parser.add_argument( - "recipient", metavar="JID", help=_("entity who must get the shared secret") - ) - - async def start(self): - try: - await self.host.bridge.ps_secret_share( - self.args.recipient, - self.args.service, - self.args.node, - self.args.secret_ids, - self.profile, - ) - except Exception as e: - self.disp(f"can't share secret: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp("secrets have been shared") - self.host.quit(C.EXIT_OK) - - -class SecretRevoke(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "revoke", - use_pubsub=True, - pubsub_flags={C.NODE}, - help=_("revoke an encrypted node secret"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "secret_id", help=_("ID of the secrets to revoke") - ) - self.parser.add_argument( - "-r", "--recipient", dest="recipients", metavar="JID", action="append", - default=[], help=_( - "entity who must get the revocation notification (default: send to all " - "entities known to have the shared secret)" - ) - ) - - async def start(self): - try: - await self.host.bridge.ps_secret_revoke( - self.args.service, - self.args.node, - self.args.secret_id, - self.args.recipients, - self.profile, - ) - except Exception as e: - self.disp(f"can't revoke secret: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp("secret {self.args.secret_id} has been revoked.") - self.host.quit(C.EXIT_OK) - - -class SecretRotate(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "rotate", - use_pubsub=True, - pubsub_flags={C.NODE}, - help=_("revoke existing secrets, create a new one and send notifications"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-r", "--recipient", dest="recipients", metavar="JID", action="append", - default=[], help=_( - "entity who must get the revocation and shared secret notifications " - "(default: send to all entities known to have the shared secret)" - ) - ) - - async def start(self): - try: - await self.host.bridge.ps_secret_rotate( - self.args.service, - self.args.node, - self.args.recipients, - self.profile, - ) - except Exception as e: - self.disp(f"can't rotate secret: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp("secret has been rotated") - self.host.quit(C.EXIT_OK) - - -class SecretList(base.CommandBase): - def __init__(self, host): - super().__init__( - host, - "list", - use_pubsub=True, - use_verbose=True, - pubsub_flags={C.NODE}, - help=_("list known secrets for a pubsub node"), - use_output=C.OUTPUT_LIST_DICT - ) - - def add_parser_options(self): - pass - - async def start(self): - try: - secrets = data_format.deserialise(await self.host.bridge.ps_secrets_list( - self.args.service, - self.args.node, - self.profile, - ), type_check=list) - except Exception as e: - self.disp(f"can't list node secrets: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - if not self.verbosity: - # we don't print key if verbosity is not a least one, to avoid showing it - # on the screen accidentally - for secret in secrets: - del secret["key"] - await self.output(secrets) - self.host.quit(C.EXIT_OK) - - -class Secret(base.CommandBase): - subcommands = (SecretShare, SecretRevoke, SecretRotate, SecretList) - - def __init__(self, host): - super().__init__( - host, - "secret", - use_profile=False, - help=_("handle encrypted nodes secrets"), - ) - - -class HookCreate(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "create", - use_pubsub=True, - pubsub_flags={C.NODE}, - help=_("create a Pubsub hook"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-t", - "--type", - default="python", - choices=("python", "python_file", "python_code"), - help=_("hook type"), - ) - self.parser.add_argument( - "-P", - "--persistent", - action="store_true", - help=_("make hook persistent across restarts"), - ) - self.parser.add_argument( - "hook_arg", - help=_("argument of the hook (depend of the type)"), - ) - - @staticmethod - def check_args(self): - if self.args.type == "python_file": - self.args.hook_arg = os.path.abspath(self.args.hook_arg) - if not os.path.isfile(self.args.hook_arg): - self.parser.error( - _("{path} is not a file").format(path=self.args.hook_arg) - ) - - async def start(self): - self.check_args(self) - try: - await self.host.bridge.ps_hook_add( - self.args.service, - self.args.node, - self.args.type, - self.args.hook_arg, - self.args.persistent, - self.profile, - ) - except Exception as e: - self.disp(f"can't create hook: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.host.quit() - - -class HookDelete(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "delete", - use_pubsub=True, - pubsub_flags={C.NODE}, - help=_("delete a Pubsub hook"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "-t", - "--type", - default="", - choices=("", "python", "python_file", "python_code"), - help=_("hook type to remove, empty to remove all (DEFAULT: remove all)"), - ) - self.parser.add_argument( - "-a", - "--arg", - dest="hook_arg", - default="", - help=_( - "argument of the hook to remove, empty to remove all (DEFAULT: remove all)" - ), - ) - - async def start(self): - HookCreate.check_args(self) - try: - nb_deleted = await self.host.bridge.ps_hook_remove( - self.args.service, - self.args.node, - self.args.type, - self.args.hook_arg, - self.profile, - ) - except Exception as e: - self.disp(f"can't delete hook: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp( - _("{nb_deleted} hook(s) have been deleted").format(nb_deleted=nb_deleted) - ) - self.host.quit() - - -class HookList(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "list", - use_output=C.OUTPUT_LIST_DICT, - help=_("list hooks of a profile"), - ) - - def add_parser_options(self): - pass - - async def start(self): - try: - data = await self.host.bridge.ps_hook_list( - self.profile, - ) - except Exception as e: - self.disp(f"can't list hooks: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - if not data: - self.disp(_("No hook found.")) - await self.output(data) - self.host.quit() - - -class Hook(base.CommandBase): - subcommands = (HookCreate, HookDelete, HookList) - - def __init__(self, host): - super(Hook, self).__init__( - host, - "hook", - use_profile=False, - use_verbose=True, - help=_("trigger action on Pubsub notifications"), - ) - - -class Pubsub(base.CommandBase): - subcommands = ( - Set, - Get, - Delete, - Edit, - Rename, - Subscribe, - Unsubscribe, - Subscriptions, - Affiliations, - Reference, - Search, - Transform, - Attachments, - Signature, - Secret, - Hook, - Uri, - Node, - Cache, - ) - - def __init__(self, host): - super(Pubsub, self).__init__( - host, "pubsub", use_profile=False, help=_("PubSub nodes/items management") - )
--- a/libervia/frontends/jp/cmd_roster.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 - -# jp: a SàT command line tool -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) -# Copyright (C) 2003-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 <http://www.gnu.org/licenses/>. - -from . import base -from collections import OrderedDict -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.tools import jid -from libervia.backend.tools.common.ansi import ANSI as A - -__commands__ = ["Roster"] - - -class Get(base.CommandBase): - - def __init__(self, host): - super().__init__( - host, 'get', use_output=C.OUTPUT_DICT, use_verbose=True, - extra_outputs = {"default": self.default_output}, - help=_('retrieve the roster entities')) - - def add_parser_options(self): - pass - - def default_output(self, data): - for contact_jid, contact_data in data.items(): - all_keys = list(contact_data.keys()) - keys_to_show = [] - name = contact_data.get('name', contact_jid.node) - - if self.verbosity >= 1: - keys_to_show.append('groups') - all_keys.remove('groups') - if self.verbosity >= 2: - keys_to_show.extend(all_keys) - - if name is None: - self.disp(A.color(C.A_HEADER, contact_jid)) - else: - self.disp(A.color(C.A_HEADER, name, A.RESET, f" ({contact_jid})")) - for k in keys_to_show: - value = contact_data[k] - if value: - if isinstance(value, list): - value = ', '.join(value) - self.disp(A.color( - " ", C.A_SUBHEADER, f"{k}: ", A.RESET, str(value))) - - async def start(self): - try: - contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile) - except Exception as e: - self.disp(f"error while retrieving the contacts: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - contacts_dict = {} - for contact_jid_s, data, groups in contacts: - # FIXME: we have to convert string to bool here for historical reason - # contacts_get format should be changed and serialised properly - for key in ('from', 'to', 'ask'): - if key in data: - data[key] = C.bool(data[key]) - data['groups'] = list(groups) - contacts_dict[jid.JID(contact_jid_s)] = data - - await self.output(contacts_dict) - self.host.quit() - - -class Set(base.CommandBase): - - def __init__(self, host): - super().__init__(host, 'set', help=_('set metadata for a roster entity')) - - def add_parser_options(self): - self.parser.add_argument( - "-n", "--name", default="", help=_('name to use for this entity')) - self.parser.add_argument( - "-g", "--group", dest='groups', action='append', metavar='GROUP', default=[], - help=_('groups for this entity')) - self.parser.add_argument( - "-R", "--replace", action="store_true", - help=_("replace all metadata instead of adding them")) - self.parser.add_argument( - "jid", help=_("jid of the roster entity")) - - async def start(self): - - if self.args.replace: - name = self.args.name - groups = self.args.groups - else: - try: - entity_data = await self.host.bridge.contact_get( - self.args.jid, self.host.profile) - except Exception as e: - self.disp(f"error while retrieving the contact: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - name = self.args.name or entity_data[0].get('name') or '' - groups = set(entity_data[1]) - groups = list(groups.union(self.args.groups)) - - try: - await self.host.bridge.contact_update( - self.args.jid, name, groups, self.host.profile) - except Exception as e: - self.disp(f"error while updating the contact: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - self.host.quit() - - -class Delete(base.CommandBase): - - def __init__(self, host): - super().__init__(host, 'delete', help=_('remove an entity from roster')) - - def add_parser_options(self): - self.parser.add_argument( - "-f", "--force", action="store_true", help=_("delete without confirmation") - ) - self.parser.add_argument( - "jid", help=_("jid of the roster entity")) - - async def start(self): - if not self.args.force: - message = _("Are you sure to delete {entity} from your roster?").format( - entity=self.args.jid - ) - await self.host.confirm_or_quit(message, _("entity deletion cancelled")) - try: - await self.host.bridge.contact_del( - self.args.jid, self.host.profile) - except Exception as e: - self.disp(f"error while deleting the entity: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - self.host.quit() - - -class Stats(base.CommandBase): - - def __init__(self, host): - super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster')) - - def add_parser_options(self): - pass - - async def start(self): - try: - contacts = await self.host.bridge.contacts_get(profile_key=self.host.profile) - except Exception as e: - self.disp(f"error while retrieving the contacts: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - hosts = {} - unique_groups = set() - no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0 - for contact, attrs, groups in contacts: - from_, to = C.bool(attrs["from"]), C.bool(attrs["to"]) - if not from_: - if not to: - no_sub += 1 - else: - no_from += 1 - elif not to: - no_to += 1 - - host = jid.JID(contact).domain - - hosts.setdefault(host, 0) - hosts[host] += 1 - if groups: - unique_groups.update(groups) - total_group_subscription += len(groups) - if not groups: - no_group += 1 - hosts = OrderedDict(sorted(list(hosts.items()), key=lambda item:-item[1])) - - print() - print("Total number of contacts: %d" % len(contacts)) - print("Number of different hosts: %d" % len(hosts)) - print() - for host, count in hosts.items(): - print("Contacts on {host}: {count} ({rate:.1f}%)".format( - host=host, count=count, rate=100 * float(count) / len(contacts))) - print() - print("Contacts with no 'from' subscription: %d" % no_from) - print("Contacts with no 'to' subscription: %d" % no_to) - print("Contacts with no subscription at all: %d" % no_sub) - print() - print("Total number of groups: %d" % len(unique_groups)) - try: - contacts_per_group = float(total_group_subscription) / len(unique_groups) - except ZeroDivisionError: - contacts_per_group = 0 - print("Average contacts per group: {:.1f}".format(contacts_per_group)) - try: - groups_per_contact = float(total_group_subscription) / len(contacts) - except ZeroDivisionError: - groups_per_contact = 0 - print(f"Average groups' subscriptions per contact: {groups_per_contact:.1f}") - print("Contacts not assigned to any group: %d" % no_group) - self.host.quit() - - -class Purge(base.CommandBase): - - def __init__(self, host): - super(Purge, self).__init__( - host, 'purge', - help=_('purge the roster from its contacts with no subscription')) - - def add_parser_options(self): - self.parser.add_argument( - "--no-from", action="store_true", - help=_("also purge contacts with no 'from' subscription")) - self.parser.add_argument( - "--no-to", action="store_true", - help=_("also purge contacts with no 'to' subscription")) - - async def start(self): - try: - contacts = await self.host.bridge.contacts_get(self.host.profile) - except Exception as e: - self.disp(f"error while retrieving the contacts: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - no_sub, no_from, no_to = [], [], [] - for contact, attrs, groups in contacts: - from_, to = C.bool(attrs["from"]), C.bool(attrs["to"]) - if not from_: - if not to: - no_sub.append(contact) - elif self.args.no_from: - no_from.append(contact) - elif not to and self.args.no_to: - no_to.append(contact) - if not no_sub and not no_from and not no_to: - self.disp( - f"Nothing to do - there's a from and/or to subscription(s) between " - f"profile {self.host.profile!r} and each of its contacts" - ) - elif await self.ask_confirmation(no_sub, no_from, no_to): - for contact in no_sub + no_from + no_to: - try: - await self.host.bridge.contact_del( - contact, profile_key=self.host.profile) - except Exception as e: - self.disp(f"can't delete contact {contact!r}: {e}", error=True) - else: - self.disp(f"contact {contact!r} has been removed") - - self.host.quit() - - async def ask_confirmation(self, no_sub, no_from, no_to): - """Ask the confirmation before removing contacts. - - @param no_sub (list[unicode]): list of contacts with no subscription - @param no_from (list[unicode]): list of contacts with no 'from' subscription - @param no_to (list[unicode]): list of contacts with no 'to' subscription - @return bool - """ - if no_sub: - self.disp( - f"There's no subscription between profile {self.host.profile!r} and the " - f"following contacts:") - self.disp(" " + "\n ".join(no_sub)) - if no_from: - self.disp( - f"There's no 'from' subscription between profile {self.host.profile!r} " - f"and the following contacts:") - self.disp(" " + "\n ".join(no_from)) - if no_to: - self.disp( - f"There's no 'to' subscription between profile {self.host.profile!r} and " - f"the following contacts:") - self.disp(" " + "\n ".join(no_to)) - message = f"REMOVE them from profile {self.host.profile}'s roster" - while True: - res = await self.host.ainput(f"{message} (y/N)? ") - if not res or res.lower() == 'n': - return False - if res.lower() == 'y': - return True - - -class Resync(base.CommandBase): - - def __init__(self, host): - super(Resync, self).__init__( - host, 'resync', help=_('do a full resynchronisation of roster with server')) - - def add_parser_options(self): - pass - - async def start(self): - try: - await self.host.bridge.roster_resync(profile_key=self.host.profile) - except Exception as e: - self.disp(f"can't resynchronise roster: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - self.disp(_("Roster resynchronized")) - self.host.quit(C.EXIT_OK) - - -class Roster(base.CommandBase): - subcommands = (Get, Set, Delete, Stats, Purge, Resync) - - def __init__(self, host): - super(Roster, self).__init__( - host, 'roster', use_profile=True, help=_("Manage an entity's roster"))
--- a/libervia/frontends/jp/cmd_shell.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,305 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -import cmd -import sys -import shlex -import subprocess -from . import base -from libervia.backend.core.i18n import _ -from libervia.backend.core import exceptions -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.jp import arg_tools -from libervia.backend.tools.common.ansi import ANSI as A - -__commands__ = ["Shell"] -INTRO = _( - """Welcome to {app_name} shell, the Salut à Toi shell ! - -This enrironment helps you using several {app_name} commands with similar parameters. - -To quit, just enter "quit" or press C-d. -Enter "help" or "?" to know what to do -""" -).format(app_name=C.APP_NAME) - - -class Shell(base.CommandBase, cmd.Cmd): - def __init__(self, host): - base.CommandBase.__init__( - self, host, "shell", - help=_("launch jp in shell (REPL) mode") - ) - cmd.Cmd.__init__(self) - - def parse_args(self, args): - """parse line arguments""" - return shlex.split(args, posix=True) - - def update_path(self): - self._cur_parser = self.host.parser - self.help = "" - for idx, path_elt in enumerate(self.path): - try: - self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser) - except exceptions.NotFound: - self.disp(_("bad command path"), error=True) - self.path = self.path[:idx] - break - else: - self.help = self._cur_parser - - self.prompt = A.color(C.A_PROMPT_PATH, "/".join(self.path)) + A.color( - C.A_PROMPT_SUF, "> " - ) - try: - self.actions = list(arg_tools.get_cmd_choices(parser=self._cur_parser).keys()) - except exceptions.NotFound: - self.actions = [] - - def add_parser_options(self): - pass - - def format_args(self, args): - """format argument to be printed with quotes if needed""" - for arg in args: - if " " in arg: - yield arg_tools.escape(arg) - else: - yield arg - - def run_cmd(self, args, external=False): - """run command and retur exit code - - @param args[list[string]]: arguments of the command - must not include program name - @param external(bool): True if it's an external command (i.e. not jp) - @return (int): exit code (0 success, any other int failure) - """ - # FIXME: we have to use subprocess - # and relaunch whole python for now - # because if host.quit() is called in D-Bus callback - # GLib quit the whole app without possibility to stop it - # didn't found a nice way to work around it so far - # Situation should be better when we'll move away from python-dbus - if self.verbose: - self.disp( - _("COMMAND {external}=> {args}").format( - external=_("(external) ") if external else "", - args=" ".join(self.format_args(args)), - ) - ) - if not external: - args = sys.argv[0:1] + args - ret_code = subprocess.call(args) - # XXX: below is a way to launch the command without creating a new process - # may be used when a solution to the aforementioned issue is there - # try: - # self.host._run(args) - # except SystemExit as e: - # ret_code = e.code - # except Exception as e: - # self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True) - # ret_code = 1 - # else: - # ret_code = 0 - - if ret_code != 0: - self.disp( - A.color( - C.A_FAILURE, - "command failed with an error code of {err_no}".format( - err_no=ret_code - ), - ), - error=True, - ) - return ret_code - - def default(self, args): - """called when no shell command is recognized - - will launch the command with args on the line - (i.e. will launch do [args]) - """ - if args == "EOF": - self.do_quit("") - self.do_do(args) - - def do_help(self, args): - """show help message""" - if not args: - self.disp(A.color(C.A_HEADER, _("Shell commands:")), end=' ') - super(Shell, self).do_help(args) - if not args: - self.disp(A.color(C.A_HEADER, _("Action commands:"))) - help_list = self._cur_parser.format_help().split("\n\n") - print(("\n\n".join(help_list[1 if self.path else 2 :]))) - - # FIXME: debug crashes on exit and is not that useful, - # keeping it until refactoring, may be removed entirely then - # def do_debug(self, args): - # """launch internal debugger""" - # try: - # import ipdb as pdb - # except ImportError: - # import pdb - # pdb.set_trace() - - def do_verbose(self, args): - """show verbose mode, or (de)activate it""" - args = self.parse_args(args) - if args: - self.verbose = C.bool(args[0]) - self.disp( - _("verbose mode is {status}").format( - status=_("ENABLED") if self.verbose else _("DISABLED") - ) - ) - - def do_cmd(self, args): - """change command path""" - if args == "..": - self.path = self.path[:-1] - else: - if not args or args[0] == "/": - self.path = [] - args = "/".join(args.split()) - for path_elt in args.split("/"): - path_elt = path_elt.strip() - if not path_elt: - continue - self.path.append(path_elt) - self.update_path() - - def do_version(self, args): - """show current SàT/jp version""" - self.run_cmd(['--version']) - - def do_shell(self, args): - """launch an external command (you can use ![command] too)""" - args = self.parse_args(args) - self.run_cmd(args, external=True) - - def do_do(self, args): - """lauch a command""" - args = self.parse_args(args) - if ( - self._not_default_profile - and not "-p" in args - and not "--profile" in args - and not "profile" in self.use - ): - # profile is not specified and we are not using the default profile - # so we need to add it in arguments to use current user profile - if self.verbose: - self.disp( - _("arg profile={profile} (logged profile)").format( - profile=self.profile - ) - ) - use = self.use.copy() - use["profile"] = self.profile - else: - use = self.use - - # args may be modified by use_args - # to remove subparsers from it - parser_args, use_args = arg_tools.get_use_args( - self.host, args, use, verbose=self.verbose, parser=self._cur_parser - ) - cmd_args = self.path + parser_args + use_args - self.run_cmd(cmd_args) - - def do_use(self, args): - """fix an argument""" - args = self.parse_args(args) - if not args: - if not self.use: - self.disp(_("no argument in USE")) - else: - self.disp(_("arguments in USE:")) - for arg, value in self.use.items(): - self.disp( - _( - A.color( - C.A_SUBHEADER, - arg, - A.RESET, - " = ", - arg_tools.escape(value), - ) - ) - ) - elif len(args) != 2: - self.disp("bad syntax, please use:\nuse [arg] [value]", error=True) - else: - self.use[args[0]] = " ".join(args[1:]) - if self.verbose: - self.disp( - "set {name} = {value}".format( - name=args[0], value=arg_tools.escape(args[1]) - ) - ) - - def do_use_clear(self, args): - """unset one or many argument(s) in USE, or all of them if no arg is specified""" - args = self.parse_args(args) - if not args: - self.use.clear() - else: - for arg in args: - try: - del self.use[arg] - except KeyError: - self.disp( - A.color( - C.A_FAILURE, _("argument {name} not found").format(name=arg) - ), - error=True, - ) - else: - if self.verbose: - self.disp(_("argument {name} removed").format(name=arg)) - - def do_whoami(self, args): - """print profile currently used""" - self.disp(self.profile) - - def do_quit(self, args): - """quit the shell""" - self.disp(_("good bye!")) - self.host.quit() - - def do_exit(self, args): - """alias for quit""" - self.do_quit(args) - - async def start(self): - # FIXME: "shell" is currently kept synchronous as it works well as it - # and it will be refactored soon. - default_profile = self.host.bridge.profile_name_get(C.PROF_KEY_DEFAULT) - self._not_default_profile = self.profile != default_profile - self.path = [] - self._cur_parser = self.host.parser - self.use = {} - self.verbose = False - self.update_path() - self.cmdloop(INTRO)
--- a/libervia/frontends/jp/cmd_uri.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - - -from . import base -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.constants import Const as C -from libervia.backend.tools.common import uri - -__commands__ = ["Uri"] - - -class Parse(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, - host, - "parse", - use_profile=False, - use_output=C.OUTPUT_DICT, - help=_("parse URI"), - ) - - def add_parser_options(self): - self.parser.add_argument( - "uri", help=_("XMPP URI to parse") - ) - - async def start(self): - await self.output(uri.parse_xmpp_uri(self.args.uri)) - self.host.quit() - - -class Build(base.CommandBase): - def __init__(self, host): - base.CommandBase.__init__( - self, host, "build", use_profile=False, help=_("build URI") - ) - - def add_parser_options(self): - self.parser.add_argument("type", help=_("URI type")) - self.parser.add_argument("path", help=_("URI path")) - self.parser.add_argument( - "-f", - "--field", - action="append", - nargs=2, - dest="fields", - metavar=("KEY", "VALUE"), - help=_("URI fields"), - ) - - async def start(self): - fields = dict(self.args.fields) if self.args.fields else {} - self.disp(uri.build_xmpp_uri(self.args.type, path=self.args.path, **fields)) - self.host.quit() - - -class Uri(base.CommandBase): - subcommands = (Parse, Build) - - def __init__(self, host): - super(Uri, self).__init__( - host, "uri", use_profile=False, help=_("XMPP URI parsing/generation") - )
--- a/libervia/frontends/jp/common.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,833 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - -import json -import os -import os.path -import time -import tempfile -import asyncio -import shlex -import re -from pathlib import Path -from libervia.frontends.jp.constants import Const as C -from libervia.backend.core.i18n import _ -from libervia.backend.core import exceptions -from libervia.backend.tools.common import regex -from libervia.backend.tools.common.ansi import ANSI as A -from libervia.backend.tools.common import uri as xmpp_uri -from libervia.backend.tools import config -from configparser import NoSectionError, NoOptionError -from collections import namedtuple - -# default arguments used for some known editors (editing with metadata) -VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'" -EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"' -EDITOR_ARGS_MAGIC = { - "vim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}", - "nvim": VIM_SPLIT_ARGS + " {content_file} {metadata_file}", - "gvim": VIM_SPLIT_ARGS + " --nofork {content_file} {metadata_file}", - "emacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}", - "xemacs": EMACS_SPLIT_ARGS + " {content_file} {metadata_file}", - "nano": " -F {content_file} {metadata_file}", -} - -SECURE_UNLINK_MAX = 10 -SECURE_UNLINK_DIR = ".backup" -METADATA_SUFF = "_metadata.json" - - -def format_time(timestamp): - """Return formatted date for timestamp - - @param timestamp(str,int,float): unix timestamp - @return (unicode): formatted date - """ - fmt = "%d/%m/%Y %H:%M:%S %Z" - return time.strftime(fmt, time.localtime(float(timestamp))) - - -def ansi_ljust(s, width): - """ljust method handling ANSI escape codes""" - cleaned = regex.ansi_remove(s) - return s + " " * (width - len(cleaned)) - - -def ansi_center(s, width): - """ljust method handling ANSI escape codes""" - cleaned = regex.ansi_remove(s) - diff = width - len(cleaned) - half = diff / 2 - return half * " " + s + (half + diff % 2) * " " - - -def ansi_rjust(s, width): - """ljust method handling ANSI escape codes""" - cleaned = regex.ansi_remove(s) - return " " * (width - len(cleaned)) + s - - -def get_tmp_dir(sat_conf, cat_dir, sub_dir=None): - """Return directory used to store temporary files - - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param cat_dir(str): directory of the category (e.g. "blog") - @param sub_dir(str): sub directory where data need to be put - profile can be used here, or special directory name - sub_dir will be escaped to be usable in path (use regex.path_unescape to find - initial str) - @return (Path): path to the dir - """ - local_dir = config.config_get(sat_conf, "", "local_dir", Exception) - path_elts = [local_dir, cat_dir] - if sub_dir is not None: - path_elts.append(regex.path_escape(sub_dir)) - return Path(*path_elts) - - -def parse_args(host, cmd_line, **format_kw): - """Parse command arguments - - @param cmd_line(unicode): command line as found in sat.conf - @param format_kw: keywords used for formating - @return (list(unicode)): list of arguments to pass to subprocess function - """ - try: - # we split the arguments and add the known fields - # we split arguments first to avoid escaping issues in file names - return [a.format(**format_kw) for a in shlex.split(cmd_line)] - except ValueError as e: - host.disp( - "Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e) - ) - return [] - - -class BaseEdit(object): - """base class for editing commands - - This class allows to edit file for PubSub or something else. - It works with temporary files in SàT local_dir, in a "cat_dir" subdir - """ - - def __init__(self, host, cat_dir, use_metadata=False): - """ - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param cat_dir(unicode): directory to use for drafts - this will be a sub-directory of SàT's local_dir - @param use_metadata(bool): True is edition need a second file for metadata - most of signature change with use_metadata with an additional metadata - argument. - This is done to raise error if a command needs metadata but forget the flag, - and vice versa - """ - self.host = host - self.cat_dir = cat_dir - self.use_metadata = use_metadata - - def secure_unlink(self, path): - """Unlink given path after keeping it for a while - - This method is used to prevent accidental deletion of a draft - If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, - older file are deleted - @param path(Path, str): file to unlink - """ - path = Path(path).resolve() - if not path.is_file: - raise OSError("path must link to a regular file") - if path.parent != get_tmp_dir(self.sat_conf, self.cat_dir): - self.disp( - f"File {path} is not in SàT temporary hierarchy, we do not remove " f"it", - 2, - ) - return - # we have 2 files per draft with use_metadata, so we double max - unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX - backup_dir = get_tmp_dir(self.sat_conf, self.cat_dir, SECURE_UNLINK_DIR) - if not os.path.exists(backup_dir): - os.makedirs(backup_dir) - filename = os.path.basename(path) - backup_path = os.path.join(backup_dir, filename) - # we move file to backup dir - self.host.disp( - "Backuping file {src} to {dst}".format(src=path, dst=backup_path), - 1, - ) - os.rename(path, backup_path) - # and if we exceeded the limit, we remove older file - backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)] - if len(backup_files) > unlink_max: - backup_files.sort(key=lambda path: os.stat(path).st_mtime) - for path in backup_files[: len(backup_files) - unlink_max]: - self.host.disp("Purging backup file {}".format(path), 2) - os.unlink(path) - - async def run_editor( - self, - editor_args_opt, - content_file_path, - content_file_obj, - meta_file_path=None, - meta_ori=None, - ): - """Run editor to edit content and metadata - - @param editor_args_opt(unicode): option in [jp] section in configuration for - specific args - @param content_file_path(str): path to the content file - @param content_file_obj(file): opened file instance - @param meta_file_path(str, Path, None): metadata file path - if None metadata will not be used - @param meta_ori(dict, None): original cotent of metadata - can't be used if use_metadata is False - """ - if not self.use_metadata: - assert meta_file_path is None - assert meta_ori is None - - # we calculate hashes to check for modifications - import hashlib - - content_file_obj.seek(0) - tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest() - content_file_obj.close() - - # we prepare arguments - editor = config.config_get(self.sat_conf, C.CONFIG_SECTION, "editor") or os.getenv( - "EDITOR", "vi" - ) - try: - # is there custom arguments in sat.conf ? - editor_args = config.config_get( - self.sat_conf, C.CONFIG_SECTION, editor_args_opt, Exception - ) - except (NoOptionError, NoSectionError): - # no, we check if we know the editor and have special arguments - if self.use_metadata: - editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), "") - else: - editor_args = "" - parse_kwargs = {"content_file": content_file_path} - if self.use_metadata: - parse_kwargs["metadata_file"] = meta_file_path - args = parse_args(self.host, editor_args, **parse_kwargs) - if not args: - args = [content_file_path] - - # actual editing - editor_process = await asyncio.create_subprocess_exec( - editor, *[str(a) for a in args] - ) - editor_exit = await editor_process.wait() - - # edition will now be checked, and data will be sent if it was a success - if editor_exit != 0: - self.disp( - f"Editor exited with an error code, so temporary file has not be " - f"deleted, and item is not published.\nYou can find temporary file " - f"at {content_file_path}", - error=True, - ) - else: - # main content - try: - with content_file_path.open("rb") as f: - content = f.read() - except (OSError, IOError): - self.disp( - f"Can read file at {content_file_path}, have it been deleted?\n" - f"Cancelling edition", - error=True, - ) - self.host.quit(C.EXIT_NOT_FOUND) - - # metadata - if self.use_metadata: - try: - with meta_file_path.open("rb") as f: - metadata = json.load(f) - except (OSError, IOError): - self.disp( - f"Can read file at {meta_file_path}, have it been deleted?\n" - f"Cancelling edition", - error=True, - ) - self.host.quit(C.EXIT_NOT_FOUND) - except ValueError: - self.disp( - f"Can't parse metadata, please check it is correct JSON format. " - f"Cancelling edition.\nYou can find tmp file at " - f"{content_file_path} and temporary meta file at " - f"{meta_file_path}.", - error=True, - ) - self.host.quit(C.EXIT_DATA_ERROR) - - if self.use_metadata and not metadata.get("publish", True): - self.disp( - f'Publication blocked by "publish" key in metadata, cancelling ' - f"edition.\n\ntemporary file path:\t{content_file_path}\nmetadata " - f"file path:\t{meta_file_path}", - error=True, - ) - self.host.quit() - - if len(content) == 0: - self.disp("Content is empty, cancelling the edition") - if content_file_path.parent != get_tmp_dir(self.sat_conf, self.cat_dir): - self.disp( - "File are not in SàT temporary hierarchy, we do not remove them", - 2, - ) - self.host.quit() - self.disp(f"Deletion of {content_file_path}", 2) - os.unlink(content_file_path) - if self.use_metadata: - self.disp(f"Deletion of {meta_file_path}".format(meta_file_path), 2) - os.unlink(meta_file_path) - self.host.quit() - - # time to re-check the hash - elif tmp_ori_hash == hashlib.sha1(content).digest() and ( - not self.use_metadata or meta_ori == metadata - ): - self.disp("The content has not been modified, cancelling the edition") - self.host.quit() - - else: - # we can now send the item - content = content.decode("utf-8-sig") # we use utf-8-sig to avoid BOM - try: - if self.use_metadata: - await self.publish(content, metadata) - else: - await self.publish(content) - except Exception as e: - if self.use_metadata: - self.disp( - f"Error while sending your item, the temporary files have " - f"been kept at {content_file_path} and {meta_file_path}: " - f"{e}", - error=True, - ) - else: - self.disp( - f"Error while sending your item, the temporary file has been " - f"kept at {content_file_path}: {e}", - error=True, - ) - self.host.quit(1) - - self.secure_unlink(content_file_path) - if self.use_metadata: - self.secure_unlink(meta_file_path) - - async def publish(self, content): - # if metadata is needed, publish will be called with it last argument - raise NotImplementedError - - def get_tmp_file(self): - """Create a temporary file - - @return (tuple(file, Path)): opened (w+b) file object and file path - """ - suff = "." + self.get_tmp_suff() - cat_dir_str = self.cat_dir - tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, self.profile) - if not tmp_dir.exists(): - try: - tmp_dir.mkdir(parents=True) - except OSError as e: - self.disp( - f"Can't create {tmp_dir} directory: {e}", - error=True, - ) - self.host.quit(1) - try: - fd, path = tempfile.mkstemp( - suffix=suff, - prefix=time.strftime(cat_dir_str + "_%Y-%m-%d_%H:%M:%S_"), - dir=tmp_dir, - text=True, - ) - return os.fdopen(fd, "w+b"), Path(path) - except OSError as e: - self.disp(f"Can't create temporary file: {e}", error=True) - self.host.quit(1) - - def get_current_file(self, profile): - """Get most recently edited file - - @param profile(unicode): profile linked to the draft - @return(Path): full path of current file - """ - # we guess the item currently edited by choosing - # the most recent file corresponding to temp file pattern - # in tmp_dir, excluding metadata files - tmp_dir = get_tmp_dir(self.sat_conf, self.cat_dir, profile) - available = [ - p - for p in tmp_dir.glob(f"{self.cat_dir}_*") - if not p.match(f"*{METADATA_SUFF}") - ] - if not available: - self.disp( - f"Could not find any content draft in {tmp_dir}", - error=True, - ) - self.host.quit(1) - return max(available, key=lambda p: p.stat().st_mtime) - - async def get_item_data(self, service, node, item): - """return formatted content, metadata (or not if use_metadata is false), and item id""" - raise NotImplementedError - - def get_tmp_suff(self): - """return suffix used for content file""" - return "xml" - - async def get_item_path(self): - """Retrieve item path (i.e. service and node) from item argument - - This method is obviously only useful for edition of PubSub based features - """ - service = self.args.service - node = self.args.node - item = self.args.item - last_item = self.args.last_item - - if self.args.current: - # user wants to continue current draft - content_file_path = self.get_current_file(self.profile) - self.disp("Continuing edition of current draft", 2) - content_file_obj = content_file_path.open("r+b") - # we seek at the end of file in case of an item already exist - # this will write content of the existing item at the end of the draft. - # This way no data should be lost. - content_file_obj.seek(0, os.SEEK_END) - elif self.args.draft_path: - # there is an existing draft that we use - content_file_path = self.args.draft_path.expanduser() - content_file_obj = content_file_path.open("r+b") - # we seek at the end for the same reason as above - content_file_obj.seek(0, os.SEEK_END) - else: - # we need a temporary file - content_file_obj, content_file_path = self.get_tmp_file() - - if item or last_item: - self.disp("Editing requested published item", 2) - try: - if self.use_metadata: - content, metadata, item = await self.get_item_data(service, node, item) - else: - content, item = await self.get_item_data(service, node, item) - except Exception as e: - # FIXME: ugly but we have not good may to check errors in bridge - if "item-not-found" in str(e): - # item doesn't exist, we create a new one with requested id - metadata = None - if last_item: - self.disp(_("no item found at all, we create a new one"), 2) - else: - self.disp( - _( - 'item "{item}" not found, we create a new item with' - "this id" - ).format(item=item), - 2, - ) - content_file_obj.seek(0) - else: - self.disp(f"Error while retrieving item: {e}") - self.host.quit(C.EXIT_ERROR) - else: - # item exists, we write content - if content_file_obj.tell() != 0: - # we already have a draft, - # we copy item content after it and add an indicator - content_file_obj.write("\n*****\n") - content_file_obj.write(content.encode("utf-8")) - content_file_obj.seek(0) - self.disp(_('item "{item}" found, we edit it').format(item=item), 2) - else: - self.disp("Editing a new item", 2) - if self.use_metadata: - metadata = None - - if self.use_metadata: - return service, node, item, content_file_path, content_file_obj, metadata - else: - return service, node, item, content_file_path, content_file_obj - - -class Table(object): - def __init__(self, host, data, headers=None, filters=None, use_buffer=False): - """ - @param data(iterable[list]): table data - all lines must have the same number of columns - @param headers(iterable[unicode], None): names/titles of the columns - if not None, must have same number of columns as data - @param filters(iterable[(callable, unicode)], None): values filters - the callable will get 2 arguments: - - current column value - - RowData with all columns values - if may also only use 1 argument, which will then be current col value. - the callable must return a string - if it's unicode, it will be used with .format and must countain u'{}' which - will be replaced with the string. - if not None, must have same number of columns as data - @param use_buffer(bool): if True, bufferise output instead of printing it directly - """ - self.host = host - self._buffer = [] if use_buffer else None - # headers are columns names/titles, can be None - self.headers = headers - # sizes fof columns without headers, - # headers may be larger - self.sizes = [] - # rows countains one list per row with columns values - self.rows = [] - - size = None - if headers: - # we use a namedtuple to make the value easily accessible from filters - headers_safe = [re.sub(r"[^a-zA-Z_]", "_", h) for h in headers] - row_cls = namedtuple("RowData", headers_safe) - else: - row_cls = tuple - - for row_data in data: - new_row = [] - row_data_list = list(row_data) - for idx, value in enumerate(row_data_list): - if filters is not None and filters[idx] is not None: - filter_ = filters[idx] - if isinstance(filter_, str): - col_value = filter_.format(value) - else: - try: - col_value = filter_(value, row_cls(*row_data_list)) - except TypeError: - col_value = filter_(value) - # we count size without ANSI code as they will change length of the - # string when it's mostly style/color changes. - col_size = len(regex.ansi_remove(col_value)) - else: - col_value = str(value) - col_size = len(col_value) - new_row.append(col_value) - if size is None: - self.sizes.append(col_size) - else: - self.sizes[idx] = max(self.sizes[idx], col_size) - if size is None: - size = len(new_row) - if headers is not None and len(headers) != size: - raise exceptions.DataError("headers size is not coherent with rows") - else: - if len(new_row) != size: - raise exceptions.DataError("rows size is not coherent") - self.rows.append(new_row) - - if not data and headers is not None: - # the table is empty, we print headers at their lenght - self.sizes = [len(h) for h in headers] - - @property - def string(self): - if self._buffer is None: - raise exceptions.InternalError("buffer must be used to get a string") - return "\n".join(self._buffer) - - @staticmethod - def read_dict_values(data, keys, defaults=None): - if defaults is None: - defaults = {} - for key in keys: - try: - yield data[key] - except KeyError as e: - default = defaults.get(key) - if default is not None: - yield default - else: - raise e - - @classmethod - def from_list_dict( - cls, host, data, keys=None, headers=None, filters=None, defaults=None - ): - """Create a table from a list of dictionaries - - each dictionary is a row of the table, keys being columns names. - the whole data will be read and kept into memory, to be printed - @param data(list[dict[unicode, unicode]]): data to create the table from - @param keys(iterable[unicode], None): keys to get - if None, all keys will be used - @param headers(iterable[unicode], None): name of the columns - names must be in same order as keys - @param filters(dict[unicode, (callable,unicode)), None): filter to use on values - keys correspond to keys to filter, and value is the same as for Table.__init__ - @param defaults(dict[unicode, unicode]): default value to use - if None, an exception will be raised if not value is found - """ - if keys is None and headers is not None: - # FIXME: keys are not needed with OrderedDict, - raise exceptions.DataError("You must specify keys order to used headers") - if keys is None: - keys = list(data[0].keys()) - if headers is None: - headers = keys - if filters is None: - filters = {} - filters = [filters.get(k) for k in keys] - return cls( - host, (cls.read_dict_values(d, keys, defaults) for d in data), headers, filters - ) - - def _headers(self, head_sep, headers, sizes, alignment="left", style=None): - """Render headers - - @param head_sep(unicode): sequence to use as separator - @param alignment(unicode): how to align, can be left, center or right - @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply - @param headers(list[unicode]): headers to show - @param sizes(list[int]): sizes of columns - """ - rendered_headers = [] - if isinstance(style, str): - style = [style] - for idx, header in enumerate(headers): - size = sizes[idx] - if alignment == "left": - rendered = header[:size].ljust(size) - elif alignment == "center": - rendered = header[:size].center(size) - elif alignment == "right": - rendered = header[:size].rjust(size) - else: - raise exceptions.InternalError("bad alignment argument") - if style: - args = style + [rendered] - rendered = A.color(*args) - rendered_headers.append(rendered) - return head_sep.join(rendered_headers) - - def _disp(self, data): - """output data (can be either bufferised or printed)""" - if self._buffer is not None: - self._buffer.append(data) - else: - self.host.disp(data) - - def display( - self, - head_alignment="left", - columns_alignment="left", - head_style=None, - show_header=True, - show_borders=True, - hide_cols=None, - col_sep=" │ ", - top_left="┌", - top="─", - top_sep="─┬─", - top_right="┐", - left="│", - right=None, - head_sep=None, - head_line="┄", - head_line_left="├", - head_line_sep="┄┼┄", - head_line_right="┤", - bottom_left="└", - bottom=None, - bottom_sep="─┴─", - bottom_right="┘", - ): - """Print the table - - @param show_header(bool): True if header need no be shown - @param show_borders(bool): True if borders need no be shown - @param hide_cols(None, iterable(unicode)): columns which should not be displayed - @param head_alignment(unicode): how to align headers, can be left, center or right - @param columns_alignment(unicode): how to align columns, can be left, center or - right - @param col_sep(unicode): separator betweens columns - @param head_line(unicode): character to use to make line under head - @param disp(callable, None): method to use to display the table - None to use self.host.disp - """ - if not self.sizes: - # the table is empty - return - col_sep_size = len(regex.ansi_remove(col_sep)) - - # if we have columns to hide, we remove them from headers and size - if not hide_cols: - headers = self.headers - sizes = self.sizes - else: - headers = list(self.headers) - sizes = self.sizes[:] - ignore_idx = [headers.index(to_hide) for to_hide in hide_cols] - for to_hide in hide_cols: - hide_idx = headers.index(to_hide) - del headers[hide_idx] - del sizes[hide_idx] - - if right is None: - right = left - if top_sep is None: - top_sep = col_sep_size * top - if head_sep is None: - head_sep = col_sep - if bottom is None: - bottom = top - if bottom_sep is None: - bottom_sep = col_sep_size * bottom - if not show_borders: - left = right = head_line_left = head_line_right = "" - # top border - if show_borders: - self._disp( - top_left + top_sep.join([top * size for size in sizes]) + top_right - ) - - # headers - if show_header and self.headers is not None: - self._disp( - left - + self._headers(head_sep, headers, sizes, head_alignment, head_style) - + right - ) - # header line - self._disp( - head_line_left - + head_line_sep.join([head_line * size for size in sizes]) - + head_line_right - ) - - # content - if columns_alignment == "left": - alignment = lambda idx, s: ansi_ljust(s, sizes[idx]) - elif columns_alignment == "center": - alignment = lambda idx, s: ansi_center(s, sizes[idx]) - elif columns_alignment == "right": - alignment = lambda idx, s: ansi_rjust(s, sizes[idx]) - else: - raise exceptions.InternalError("bad columns alignment argument") - - for row in self.rows: - if hide_cols: - row = [v for idx, v in enumerate(row) if idx not in ignore_idx] - self._disp( - left - + col_sep.join([alignment(idx, c) for idx, c in enumerate(row)]) - + right - ) - - if show_borders: - # bottom border - self._disp( - bottom_left - + bottom_sep.join([bottom * size for size in sizes]) - + bottom_right - ) - # we return self so string can be used after display (table.display().string) - return self - - def display_blank(self, **kwargs): - """Display table without visible borders""" - kwargs_ = {"col_sep": " ", "head_line_sep": " ", "show_borders": False} - kwargs_.update(kwargs) - return self.display(**kwargs_) - - -async def fill_well_known_uri(command, path, key, meta_map=None): - """Look for URIs in well-known location and fill appropriate args if suitable - - @param command(CommandBase): command instance - args of this instance will be updated with found values - @param path(unicode): absolute path to use as a starting point to look for URIs - @param key(unicode): key to look for - @param meta_map(dict, None): if not None, map metadata to arg name - key is metadata used attribute name - value is name to actually use, or None to ignore - use empty dict to only retrieve URI - possible keys are currently: - - labels - """ - args = command.args - if args.service or args.node: - # we only look for URIs if a service and a node are not already specified - return - - host = command.host - - try: - uris_data = await host.bridge.uri_find(path, [key]) - except Exception as e: - host.disp(f"can't find {key} URI: {e}", error=True) - host.quit(C.EXIT_BRIDGE_ERRBACK) - - try: - uri_data = uris_data[key] - except KeyError: - host.disp( - _( - "No {key} URI specified for this project, please specify service and " - "node" - ).format(key=key), - error=True, - ) - host.quit(C.EXIT_NOT_FOUND) - - uri = uri_data["uri"] - - # set extra metadata if they are specified - for data_key in ["labels"]: - new_values_json = uri_data.get(data_key) - if uri_data is not None: - if meta_map is None: - dest = data_key - else: - dest = meta_map.get(data_key) - if dest is None: - continue - - try: - values = getattr(args, data_key) - except AttributeError: - raise exceptions.InternalError(f"there is no {data_key!r} arguments") - else: - if values is None: - values = [] - values.extend(json.loads(new_values_json)) - setattr(args, dest, values) - - parsed_uri = xmpp_uri.parse_xmpp_uri(uri) - try: - args.service = parsed_uri["path"] - args.node = parsed_uri["node"] - except KeyError: - host.disp(_("Invalid URI found: {uri}").format(uri=uri), error=True) - host.quit(C.EXIT_DATA_ERROR)
--- a/libervia/frontends/jp/constants.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 - - -# Primitivus: a SAT 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 <http://www.gnu.org/licenses/>. - -from libervia.frontends.quick_frontend import constants -from libervia.backend.tools.common.ansi import ANSI as A - - -class Const(constants.Const): - - APP_NAME = "Libervia CLI" - APP_COMPONENT = "CLI" - APP_NAME_ALT = "jp" - APP_NAME_FILE = "libervia_cli" - CONFIG_SECTION = APP_COMPONENT.lower() - PLUGIN_CMD = "commands" - PLUGIN_OUTPUT = "outputs" - OUTPUT_TEXT = "text" # blob of unicode text - OUTPUT_DICT = "dict" # simple key/value dictionary - OUTPUT_LIST = "list" - OUTPUT_LIST_DICT = "list_dict" # list of dictionaries - OUTPUT_DICT_DICT = "dict_dict" # dict of nested dictionaries - OUTPUT_MESS = "mess" # messages (chat) - OUTPUT_COMPLEX = "complex" # complex data (e.g. multi-level dictionary) - OUTPUT_XML = "xml" # XML node (as unicode string) - OUTPUT_LIST_XML = "list_xml" # list of XML nodes (as unicode strings) - OUTPUT_XMLUI = "xmlui" # XMLUI as unicode string - OUTPUT_LIST_XMLUI = "list_xmlui" # list of XMLUI (as unicode strings) - OUTPUT_TYPES = ( - OUTPUT_TEXT, - OUTPUT_DICT, - OUTPUT_LIST, - OUTPUT_LIST_DICT, - OUTPUT_DICT_DICT, - OUTPUT_MESS, - OUTPUT_COMPLEX, - OUTPUT_XML, - OUTPUT_LIST_XML, - OUTPUT_XMLUI, - OUTPUT_LIST_XMLUI, - ) - OUTPUT_NAME_SIMPLE = "simple" - OUTPUT_NAME_XML = "xml" - OUTPUT_NAME_XML_RAW = "xml-raw" - OUTPUT_NAME_JSON = "json" - OUTPUT_NAME_JSON_RAW = "json-raw" - - # Pubsub options flags - SERVICE = "service" # service required - NODE = "node" # node required - ITEM = "item" # item required - SINGLE_ITEM = "single_item" # only one item is allowed - MULTI_ITEMS = "multi_items" # multiple items are allowed - NO_MAX = "no_max" # don't add --max option for multi items - CACHE = "cache" # add cache control flag - - # ANSI - A_HEADER = A.BOLD + A.FG_YELLOW - A_SUBHEADER = A.BOLD + A.FG_RED - # A_LEVEL_COLORS may be used to cycle on colors according to depth of data - A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN) - A_SUCCESS = A.BOLD + A.FG_GREEN - A_FAILURE = A.BOLD + A.FG_RED - A_WARNING = A.BOLD + A.FG_RED - # A_PROMPT_* is for shell - A_PROMPT_PATH = A.BOLD + A.FG_CYAN - A_PROMPT_SUF = A.BOLD - # Files - A_DIRECTORY = A.BOLD + A.FG_CYAN - A_FILE = A.FG_WHITE
--- a/libervia/frontends/jp/loops.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 - -# jp: a SAT command line tool -# 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 <http://www.gnu.org/licenses/>. - -import sys -import asyncio -import logging as log -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.constants import Const as C - -log.basicConfig(level=log.WARNING, - format='[%(name)s] %(message)s') - -USER_INTER_MSG = _("User interruption: good bye") - - -class QuitException(BaseException): - """Quitting is requested - - This is used to stop execution when host.quit() is called - """ - - -def get_jp_loop(bridge_name): - if 'dbus' in bridge_name: - import signal - import threading - from gi.repository import GLib - - class JPLoop: - - def run(self, jp, args, namespace): - signal.signal(signal.SIGINT, self._on_sigint) - self._glib_loop = GLib.MainLoop() - threading.Thread(target=self._glib_loop.run).start() - loop = asyncio.get_event_loop() - loop.run_until_complete(jp.main(args=args, namespace=namespace)) - loop.run_forever() - - def quit(self, exit_code): - loop = asyncio.get_event_loop() - loop.stop() - self._glib_loop.quit() - sys.exit(exit_code) - - def call_later(self, delay, callback, *args): - """call a callback repeatedly - - @param delay(int): delay between calls in s - @param callback(callable): method to call - if the callback return True, the call will continue - else the calls will stop - @param *args: args of the callbac - """ - loop = asyncio.get_event_loop() - loop.call_later(delay, callback, *args) - - def _on_sigint(self, sig_number, stack_frame): - """Called on keyboard interruption - - Print user interruption message, set exit code and stop reactor - """ - print("\r" + USER_INTER_MSG) - self.quit(C.EXIT_USER_CANCELLED) - else: - import signal - from twisted.internet import asyncioreactor - asyncioreactor.install() - from twisted.internet import reactor, defer - - class JPLoop: - - def __init__(self): - # exit code must be set when using quit, so if it's not set - # something got wrong and we must report it - self._exit_code = C.EXIT_INTERNAL_ERROR - - def run(self, jp, *args): - self.jp = jp - signal.signal(signal.SIGINT, self._on_sigint) - defer.ensureDeferred(self._start(jp, *args)) - try: - reactor.run(installSignalHandlers=False) - except SystemExit as e: - self._exit_code = e.code - sys.exit(self._exit_code) - - async def _start(self, jp, *args): - fut = asyncio.ensure_future(jp.main(*args)) - try: - await defer.Deferred.fromFuture(fut) - except BaseException: - import traceback - traceback.print_exc() - jp.quit(1) - - def quit(self, exit_code): - self._exit_code = exit_code - reactor.stop() - - def _timeout_cb(self, args, callback, delay): - try: - ret = callback(*args) - # FIXME: temporary hack to avoid traceback when using XMLUI - # to be removed once create_task is not used anymore in - # xmlui_manager (i.e. once libervia.frontends.tools.xmlui fully supports - # async syntax) - except QuitException: - return - if ret: - reactor.callLater(delay, self._timeout_cb, args, callback, delay) - - def call_later(self, delay, callback, *args): - reactor.callLater(delay, self._timeout_cb, args, callback, delay) - - def _on_sigint(self, sig_number, stack_frame): - """Called on keyboard interruption - - Print user interruption message, set exit code and stop reactor - """ - print("\r" + USER_INTER_MSG) - self._exit_code = C.EXIT_USER_CANCELLED - reactor.callFromThread(reactor.stop) - - - if bridge_name == "embedded": - raise NotImplementedError - # from sat.core import sat_main - # sat = sat_main.SAT() - - return JPLoop
--- a/libervia/frontends/jp/output_std.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,127 +0,0 @@ -#! /usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. -"""Standard outputs""" - - -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.tools import jid -from libervia.backend.tools.common.ansi import ANSI as A -from libervia.backend.tools.common import date_utils -import json - -__outputs__ = ["Simple", "Json"] - - -class Simple(object): - """Default outputs""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_SIMPLE, self.simple_print) - host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_SIMPLE, self.list) - host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict) - host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_SIMPLE, self.list_dict) - host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_SIMPLE, self.dict_dict) - host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_SIMPLE, self.messages) - host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_SIMPLE, self.simple_print) - - def simple_print(self, data): - self.host.disp(str(data)) - - def list(self, data): - self.host.disp("\n".join(data)) - - def dict(self, data, indent=0, header_color=C.A_HEADER): - options = self.host.parse_output_options() - self.host.check_output_options({"no-header"}, options) - show_header = not "no-header" in options - for k, v in data.items(): - if show_header: - header = A.color(header_color, k) + ": " - else: - header = "" - - self.host.disp( - ( - "{indent}{header}{value}".format( - indent=indent * " ", header=header, value=v - ) - ) - ) - - def list_dict(self, data): - for idx, datum in enumerate(data): - if idx: - self.host.disp("\n") - self.dict(datum) - - def dict_dict(self, data): - for key, sub_dict in data.items(): - self.host.disp(A.color(C.A_HEADER, key)) - self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER) - - def messages(self, data): - # TODO: handle lang, and non chat message (normal, headline) - for mess_data in data: - (uid, timestamp, from_jid, to_jid, message, subject, mess_type, - extra) = mess_data - time_str = date_utils.date_fmt(timestamp, "auto_day", - tz_info=date_utils.TZ_LOCAL) - from_jid = jid.JID(from_jid) - if mess_type == C.MESS_TYPE_GROUPCHAT: - nick = from_jid.resource - else: - nick = from_jid.node - - if self.host.own_jid is not None and self.host.own_jid.bare == from_jid.bare: - nick_color = A.BOLD + A.FG_BLUE - else: - nick_color = A.BOLD + A.FG_YELLOW - message = list(message.values())[0] if message else "" - - self.host.disp(A.color( - A.FG_CYAN, '['+time_str+'] ', - nick_color, nick, A.RESET, A.BOLD, '> ', - A.RESET, message)) - - -class Json(object): - """outputs in json format""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_TEXT, C.OUTPUT_NAME_JSON, self.dump) - host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON, self.dump_pretty) - host.register_output(C.OUTPUT_LIST, C.OUTPUT_NAME_JSON_RAW, self.dump) - host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty) - host.register_output(C.OUTPUT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump) - host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty) - host.register_output(C.OUTPUT_LIST_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump) - host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON, self.dump_pretty) - host.register_output(C.OUTPUT_DICT_DICT, C.OUTPUT_NAME_JSON_RAW, self.dump) - host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON, self.dump_pretty) - host.register_output(C.OUTPUT_MESS, C.OUTPUT_NAME_JSON_RAW, self.dump) - host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON, self.dump_pretty) - host.register_output(C.OUTPUT_COMPLEX, C.OUTPUT_NAME_JSON_RAW, self.dump) - - def dump(self, data): - self.host.disp(json.dumps(data, default=str)) - - def dump_pretty(self, data): - self.host.disp(json.dumps(data, indent=4, default=str))
--- a/libervia/frontends/jp/output_template.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,138 +0,0 @@ -#! /usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. -"""Standard outputs""" - - -from libervia.frontends.jp.constants import Const as C -from libervia.backend.core.i18n import _ -from libervia.backend.core import log -from libervia.backend.tools.common import template -from functools import partial -import logging -import webbrowser -import tempfile -import os.path - -__outputs__ = ["Template"] -TEMPLATE = "template" -OPTIONS = {"template", "browser", "inline-css"} - - -class Template(object): - """outputs data using SàT templates""" - - def __init__(self, jp): - self.host = jp - jp.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render) - - def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir): - """Get front URL for temporary directory""" - template_data = ctx['template_data'] - return "file://" + os.path.join(tmp_dir, template_data.theme, relative_url) - - def _do_render(self, template_path, css_inline, **kwargs): - try: - return self.renderer.render(template_path, css_inline=css_inline, **kwargs) - except template.TemplateNotFound: - self.host.disp(_("Can't find requested template: {template_path}") - .format(template_path=template_path), error=True) - self.host.quit(C.EXIT_NOT_FOUND) - - def render(self, data): - """render output data using requested template - - template to render the data can be either command's TEMPLATE or - template output_option requested by user. - @param data(dict): data is a dict which map from variable name to use in template - to the variable itself. - command's template_data_mapping attribute will be used if it exists to convert - data to a dict usable by the template. - """ - # media_dir is needed for the template - self.host.media_dir = self.host.bridge.config_get("", "media_dir") - cmd = self.host.command - try: - template_path = cmd.TEMPLATE - except AttributeError: - if not "template" in cmd.args.output_opts: - self.host.disp(_( - "no default template set for this command, you need to specify a " - "template using --oo template=[path/to/template.html]"), - error=True, - ) - self.host.quit(C.EXIT_BAD_ARG) - - options = self.host.parse_output_options() - self.host.check_output_options(OPTIONS, options) - try: - template_path = options["template"] - except KeyError: - # template is not specified, we use default one - pass - if template_path is None: - self.host.disp(_("Can't parse template, please check its syntax"), - error=True) - self.host.quit(C.EXIT_BAD_ARG) - - try: - mapping_cb = cmd.template_data_mapping - except AttributeError: - kwargs = data - else: - kwargs = mapping_cb(data) - - css_inline = "inline-css" in options - - if "browser" in options: - template_name = os.path.basename(template_path) - tmp_dir = tempfile.mkdtemp() - front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir) - self.renderer = template.Renderer( - self.host, front_url_filter=front_url_filter, trusted=True) - rendered = self._do_render(template_path, css_inline=css_inline, **kwargs) - self.host.disp(_( - "Browser opening requested.\n" - "Temporary files are put in the following directory, you'll have to " - "delete it yourself once finished viewing: {}").format(tmp_dir)) - tmp_file = os.path.join(tmp_dir, template_name) - with open(tmp_file, "w") as f: - f.write(rendered.encode("utf-8")) - theme, theme_root_path = self.renderer.get_theme_and_root(template_path) - if theme is None: - # we have an absolute path - webbrowser - static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR) - if os.path.exists(static_dir): - # we have to copy static files in a subdirectory, to avoid file download - # to be blocked by same origin policy - import shutil - shutil.copytree( - static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR) - ) - webbrowser.open(tmp_file) - else: - # FIXME: Q&D way to disable template logging - # logs are overcomplicated, and need to be reworked - template_logger = log.getLogger("sat.tools.common.template") - template_logger.log = lambda *args: None - - logging.disable(logging.WARNING) - self.renderer = template.Renderer(self.host, trusted=True) - rendered = self._do_render(template_path, css_inline=css_inline, **kwargs) - self.host.disp(rendered)
--- a/libervia/frontends/jp/output_xml.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -#! /usr/bin/env python3 - -# 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 <http://www.gnu.org/licenses/>. -"""Standard outputs""" - - -from libervia.frontends.jp.constants import Const as C -from libervia.backend.core.i18n import _ -from lxml import etree -from libervia.backend.core.log import getLogger - -log = getLogger(__name__) -import sys - -try: - import pygments - from pygments.lexers.html import XmlLexer - from pygments.formatters import TerminalFormatter -except ImportError: - pygments = None - - -__outputs__ = ["XML"] - - -class XML(object): - """Outputs for XML""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML, self.pretty, default=True) - host.register_output( - C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML, self.pretty_list, default=True - ) - host.register_output(C.OUTPUT_XML, C.OUTPUT_NAME_XML_RAW, self.raw) - host.register_output(C.OUTPUT_LIST_XML, C.OUTPUT_NAME_XML_RAW, self.list_raw) - - def colorize(self, xml): - if pygments is None: - self.host.disp( - _( - "Pygments is not available, syntax highlighting is not possible. " - "Please install if from http://pygments.org or with pip install " - "pygments" - ), - error=True, - ) - return xml - if not sys.stdout.isatty(): - return xml - lexer = XmlLexer(encoding="utf-8") - formatter = TerminalFormatter(bg="dark") - return pygments.highlight(xml, lexer, formatter) - - def format(self, data, pretty=True): - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.fromstring(data, parser) - xml = etree.tostring(tree, encoding="unicode", pretty_print=pretty) - return self.colorize(xml) - - def format_no_pretty(self, data): - return self.format(data, pretty=False) - - def pretty(self, data): - self.host.disp(self.format(data)) - - def pretty_list(self, data, separator="\n"): - list_pretty = list(map(self.format, data)) - self.host.disp(separator.join(list_pretty)) - - def raw(self, data): - self.host.disp(self.format_no_pretty(data)) - - def list_raw(self, data, separator="\n"): - list_no_pretty = list(map(self.format_no_pretty, data)) - self.host.disp(separator.join(list_no_pretty))
--- a/libervia/frontends/jp/output_xmlui.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,49 +0,0 @@ -#! /usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. -"""Standard outputs""" - - -from libervia.frontends.jp.constants import Const as C -from libervia.frontends.jp import xmlui_manager -from libervia.backend.core.log import getLogger - -log = getLogger(__name__) - - -__outputs__ = ["XMLUI"] - - -class XMLUI(object): - """Outputs for XMLUI""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_XMLUI, "simple", self.xmlui, default=True) - host.register_output( - C.OUTPUT_LIST_XMLUI, "simple", self.xmlui_list, default=True - ) - - async def xmlui(self, data): - xmlui = xmlui_manager.create(self.host, data) - await xmlui.show(values_only=True, read_only=True) - self.host.disp("") - - async def xmlui_list(self, data): - for d in data: - await self.xmlui(d)
--- a/libervia/frontends/jp/xml_tools.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 - - -# jp: a SàT command line tool -# 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 <http://www.gnu.org/licenses/>. - -from libervia.backend.core.i18n import _ -from libervia.frontends.jp.constants import Const as C - -def etree_parse(cmd, raw_xml, reraise=False): - """import lxml and parse raw XML - - @param cmd(CommandBase): current command instance - @param raw_xml(file, str): an XML bytestring, string or file-like object - @param reraise(bool): if True, re raise exception on parse error instead of doing a - parser.error (which terminate the execution) - @return (tuple(etree.Element, module): parsed element, etree module - """ - try: - from lxml import etree - except ImportError: - cmd.disp( - 'lxml module must be installed, please install it with "pip install lxml"', - error=True, - ) - cmd.host.quit(C.EXIT_ERROR) - try: - if isinstance(raw_xml, str): - parser = etree.XMLParser(remove_blank_text=True) - element = etree.fromstring(raw_xml, parser) - else: - element = etree.parse(raw_xml).getroot() - except Exception as e: - if reraise: - raise e - cmd.parser.error( - _("Can't parse the payload XML in input: {msg}").format(msg=e) - ) - return element, etree - -def get_payload(cmd, element): - """Retrieve payload element and exit with and error if not found - - @param element(etree.Element): root element - @return element(etree.Element): payload element - """ - if element.tag in ("item", "{http://jabber.org/protocol/pubsub}item"): - if len(element) > 1: - cmd.disp(_("<item> can only have one child element (the payload)"), - error=True) - cmd.host.quit(C.EXIT_DATA_ERROR) - element = element[0] - return element
--- a/libervia/frontends/jp/xmlui_manager.py Fri Jun 02 14:12:38 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,652 +0,0 @@ -#!/usr/bin/env python3 - - -# JP: a SàT 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 <http://www.gnu.org/licenses/>. - -from functools import partial -from libervia.backend.core.log import getLogger -from libervia.frontends.tools import xmlui as xmlui_base -from libervia.frontends.jp.constants import Const as C -from libervia.backend.tools.common.ansi import ANSI as A -from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import data_format - -log = getLogger(__name__) - -# workflow constants - -SUBMIT = "SUBMIT" # submit form - - -## Widgets ## - - -class Base(object): - """Base for Widget and Container""" - - type = None - _root = None - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - self.host = self.xmlui_parent.host - - @property - def root(self): - """retrieve main XMLUI parent class""" - if self._root is not None: - return self._root - root = self - while not isinstance(root, xmlui_base.XMLUIBase): - root = root.xmlui_parent - self._root = root - return root - - def disp(self, *args, **kwargs): - self.host.disp(*args, **kwargs) - - -class Widget(Base): - category = "widget" - enabled = True - - @property - def name(self): - return self._xmlui_name - - async def show(self): - """display current widget - - must be overriden by subclasses - """ - raise NotImplementedError(self.__class__) - - def verbose_name(self, elems=None, value=None): - """add name in color to the elements - - helper method to display name which can then be used to automate commands - elems is only modified if verbosity is > 0 - @param elems(list[unicode], None): elements to display - None to display name directly - @param value(unicode, None): value to show - use self.name if None - """ - if value is None: - value = self.name - if self.host.verbosity: - to_disp = [ - A.FG_MAGENTA, - " " if elems else "", - "({})".format(value), - A.RESET, - ] - if elems is None: - self.host.disp(A.color(*to_disp)) - else: - elems.extend(to_disp) - - -class ValueWidget(Widget): - def __init__(self, xmlui_parent, value): - super(ValueWidget, self).__init__(xmlui_parent) - self.value = value - - @property - def values(self): - return [self.value] - - -class InputWidget(ValueWidget): - def __init__(self, xmlui_parent, value, read_only=False): - super(InputWidget, self).__init__(xmlui_parent, value) - self.read_only = read_only - - def _xmlui_get_value(self): - return self.value - - -class OptionsWidget(Widget): - def __init__(self, xmlui_parent, options, selected, style): - super(OptionsWidget, self).__init__(xmlui_parent) - self.options = options - self.selected = selected - self.style = style - - @property - def values(self): - return self.selected - - @values.setter - def values(self, values): - self.selected = values - - @property - def value(self): - return self.selected[0] - - @value.setter - def value(self, value): - self.selected = [value] - - def _xmlui_select_value(self, value): - self.value = value - - def _xmlui_select_values(self, values): - self.values = values - - def _xmlui_get_selected_values(self): - return self.values - - @property - def labels(self): - """return only labels from self.items""" - for value, label in self.items: - yield label - - @property - def items(self): - """return suitable items, according to style""" - no_select = self.no_select - for value, label in self.options: - if no_select or value in self.selected: - yield value, label - - @property - def inline(self): - return "inline" in self.style - - @property - def no_select(self): - return "noselect" in self.style - - -class EmptyWidget(xmlui_base.EmptyWidget, Widget): - def __init__(self, xmlui_parent): - Widget.__init__(self, xmlui_parent) - - async def show(self): - self.host.disp("") - - -class TextWidget(xmlui_base.TextWidget, ValueWidget): - type = "text" - - async def show(self): - self.host.disp(self.value) - - -class LabelWidget(xmlui_base.LabelWidget, ValueWidget): - type = "label" - - @property - def for_name(self): - try: - return self._xmlui_for_name - except AttributeError: - return None - - async def show(self, end="\n", ansi=""): - """show label - - @param end(str): same as for [JP.disp] - @param ansi(unicode): ansi escape code to print before label - """ - self.disp(A.color(ansi, self.value), end=end) - - -class JidWidget(xmlui_base.JidWidget, TextWidget): - type = "jid" - - -class StringWidget(xmlui_base.StringWidget, InputWidget): - type = "string" - - async def show(self): - if self.read_only or self.root.read_only: - self.disp(self.value) - else: - elems = [] - self.verbose_name(elems) - if self.value: - elems.append(_("(enter: {value})").format(value=self.value)) - elems.extend([C.A_HEADER, "> "]) - value = await self.host.ainput(A.color(*elems)) - if value: - # TODO: empty value should be possible - # an escape key should be used for default instead of enter with empty value - self.value = value - - -class JidInputWidget(xmlui_base.JidInputWidget, StringWidget): - type = "jid_input" - - -class PasswordWidget(xmlui_base.PasswordWidget, StringWidget): - type = "password" - - -class TextBoxWidget(xmlui_base.TextWidget, StringWidget): - type = "textbox" - # TODO: use a more advanced input method - - async def show(self): - self.verbose_name() - if self.read_only or self.root.read_only: - self.disp(self.value) - else: - if self.value: - self.disp( - A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "") - ) - - values = [] - while True: - try: - if not values: - line = await self.host.ainput( - A.color(C.A_HEADER, "[Ctrl-D to finish]> ") - ) - else: - line = await self.host.ainput() - values.append(line) - except EOFError: - break - - self.value = "\n".join(values).rstrip() - - -class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget): - type = "xhtmlbox" - - async def show(self): - # FIXME: we use bridge in a blocking way as permitted by python-dbus - # this only for now to make it simpler, it must be refactored - # to use async when jp will be fully async (expected for 0.8) - self.value = await self.host.bridge.syntax_convert( - self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile - ) - await super(XHTMLBoxWidget, self).show() - - -class ListWidget(xmlui_base.ListWidget, OptionsWidget): - type = "list" - # TODO: handle flags, notably multi - - async def show(self): - if self.root.values_only: - for value in self.values: - self.disp(self.value) - return - if not self.options: - return - - # list display - self.verbose_name() - - for idx, (value, label) in enumerate(self.options): - elems = [] - if not self.root.read_only: - elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "]) - elems.append(label) - self.verbose_name(elems, value) - self.disp(A.color(*elems)) - - if self.root.read_only: - return - - if len(self.options) == 1: - # we have only one option, no need to ask - self.value = self.options[0][0] - return - - # we ask use to choose an option - choice = None - limit_max = len(self.options) - 1 - while choice is None or choice < 0 or choice > limit_max: - choice = await self.host.ainput( - A.color( - C.A_HEADER, - _("your choice (0-{limit_max}): ").format(limit_max=limit_max), - ) - ) - try: - choice = int(choice) - except ValueError: - choice = None - self.value = self.options[choice][0] - self.disp("") - - -class BoolWidget(xmlui_base.BoolWidget, InputWidget): - type = "bool" - - async def show(self): - disp_true = A.color(A.FG_GREEN, "TRUE") - disp_false = A.color(A.FG_RED, "FALSE") - if self.read_only or self.root.read_only: - self.disp(disp_true if self.value else disp_false) - else: - self.disp( - A.color( - C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else "" - ) - ) - self.disp( - A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "") - ) - choice = None - while choice not in ("0", "1"): - elems = [C.A_HEADER, _("your choice (0,1): ")] - self.verbose_name(elems) - choice = await self.host.ainput(A.color(*elems)) - self.value = bool(int(choice)) - self.disp("") - - def _xmlui_get_value(self): - return C.bool_const(self.value) - - ## Containers ## - - -class Container(Base): - category = "container" - - def __init__(self, xmlui_parent): - super(Container, self).__init__(xmlui_parent) - self.children = [] - - def __iter__(self): - return iter(self.children) - - def _xmlui_append(self, widget): - self.children.append(widget) - - def _xmlui_remove(self, widget): - self.children.remove(widget) - - async def show(self): - for child in self.children: - await child.show() - - -class VerticalContainer(xmlui_base.VerticalContainer, Container): - type = "vertical" - - -class PairsContainer(xmlui_base.PairsContainer, Container): - type = "pairs" - - -class LabelContainer(xmlui_base.PairsContainer, Container): - type = "label" - - async def show(self): - for child in self.children: - end = "\n" - # we check linked widget type - # to see if we want the label on the same line or not - if child.type == "label": - for_name = child.for_name - if for_name: - for_widget = self.root.widgets[for_name] - wid_type = for_widget.type - if self.root.values_only or wid_type in ( - "text", - "string", - "jid_input", - ): - end = " " - elif wid_type == "bool" and for_widget.read_only: - end = " " - await child.show(end=end, ansi=A.FG_CYAN) - else: - await child.show() - - ## Dialogs ## - - -class Dialog(object): - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - self.host = self.xmlui_parent.host - - def disp(self, *args, **kwargs): - self.host.disp(*args, **kwargs) - - async def show(self): - """display current dialog - - must be overriden by subclasses - """ - raise NotImplementedError(self.__class__) - - -class MessageDialog(xmlui_base.MessageDialog, Dialog): - def __init__(self, xmlui_parent, title, message, level): - Dialog.__init__(self, xmlui_parent) - xmlui_base.MessageDialog.__init__(self, xmlui_parent) - self.title, self.message, self.level = title, message, level - - async def show(self): - # TODO: handle level - if self.title: - self.disp(A.color(C.A_HEADER, self.title)) - self.disp(self.message) - - -class NoteDialog(xmlui_base.NoteDialog, Dialog): - def __init__(self, xmlui_parent, title, message, level): - Dialog.__init__(self, xmlui_parent) - xmlui_base.NoteDialog.__init__(self, xmlui_parent) - self.title, self.message, self.level = title, message, level - - async def show(self): - # TODO: handle title - error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR) - if self.level == C.XMLUI_DATA_LVL_WARNING: - msg = A.color(C.A_WARNING, self.message) - elif self.level == C.XMLUI_DATA_LVL_ERROR: - msg = A.color(C.A_FAILURE, self.message) - else: - msg = self.message - self.disp(msg, error=error) - - -class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog): - def __init__(self, xmlui_parent, title, message, level, buttons_set): - Dialog.__init__(self, xmlui_parent) - xmlui_base.ConfirmDialog.__init__(self, xmlui_parent) - self.title, self.message, self.level, self.buttons_set = ( - title, - message, - level, - buttons_set, - ) - - async def show(self): - # TODO: handle buttons_set and level - self.disp(self.message) - if self.title: - self.disp(A.color(C.A_HEADER, self.title)) - input_ = None - while input_ not in ("y", "n"): - input_ = await self.host.ainput(f"{self.message} (y/n)? ") - input_ = input_.lower() - if input_ == "y": - self._xmlui_validated() - else: - self._xmlui_cancelled() - - ## Factory ## - - -class WidgetFactory(object): - def __getattr__(self, attr): - if attr.startswith("create"): - cls = globals()[attr[6:]] - return cls - - -class XMLUIPanel(xmlui_base.AIOXMLUIPanel): - widget_factory = WidgetFactory() - _actions = 0 # use to keep track of bridge's action_launch calls - read_only = False - values_only = False - workflow = None - _submit_cb = None - - def __init__( - self, - host, - parsed_dom, - title=None, - flags=None, - callback=None, - ignore=None, - whitelist=None, - profile=None, - ): - xmlui_base.XMLUIPanel.__init__( - self, - host, - parsed_dom, - title=title, - flags=flags, - ignore=ignore, - whitelist=whitelist, - profile=host.profile, - ) - self.submitted = False - - @property - def command(self): - return self.host.command - - def disp(self, *args, **kwargs): - self.host.disp(*args, **kwargs) - - async def show(self, workflow=None, read_only=False, values_only=False): - """display the panel - - @param workflow(list, None): command to execute if not None - put here for convenience, the main workflow is the class attribute - (because workflow can continue in subclasses) - command are a list of consts or lists: - - SUBMIT is the only constant so far, it submits the XMLUI - - list must contain widget name/widget value to fill - @param read_only(bool): if True, don't request values - @param values_only(bool): if True, only show select values (imply read_only) - """ - self.read_only = read_only - self.values_only = values_only - if self.values_only: - self.read_only = True - if workflow: - XMLUIPanel.workflow = workflow - if XMLUIPanel.workflow: - await self.run_workflow() - else: - await self.main_cont.show() - - async def run_workflow(self): - """loop into workflow commands and execute commands - - SUBMIT will interrupt workflow (which will be continue on callback) - @param workflow(list): same as [show] - """ - workflow = XMLUIPanel.workflow - while True: - try: - cmd = workflow.pop(0) - except IndexError: - break - if cmd == SUBMIT: - await self.on_form_submitted() - self.submit_id = None # avoid double submit - return - elif isinstance(cmd, list): - name, value = cmd - widget = self.widgets[name] - if widget.type == "bool": - value = C.bool(value) - widget.value = value - await self.show() - - async def submit_form(self, callback=None): - XMLUIPanel._submit_cb = callback - await self.on_form_submitted() - - async def on_form_submitted(self, ignore=None): - # self.submitted is a Q&D workaround to avoid - # double submit when a workflow is set - if self.submitted: - return - self.submitted = True - await super(XMLUIPanel, self).on_form_submitted(ignore) - - def _xmlui_close(self): - pass - - async def _launch_action_cb(self, data): - XMLUIPanel._actions -= 1 - assert XMLUIPanel._actions >= 0 - if "xmlui" in data: - xmlui_raw = data["xmlui"] - xmlui = create(self.host, xmlui_raw) - await xmlui.show() - if xmlui.submit_id: - await xmlui.on_form_submitted() - # TODO: handle data other than XMLUI - if not XMLUIPanel._actions: - if self._submit_cb is None: - self.host.quit() - else: - self._submit_cb() - - async def _xmlui_launch_action(self, action_id, data): - XMLUIPanel._actions += 1 - try: - data = data_format.deserialise( - await self.host.bridge.action_launch( - action_id, - data_format.serialise(data), - self.profile, - ) - ) - except Exception as e: - self.disp(f"can't launch XMLUI action: {e}", error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - else: - await self._launch_action_cb(data) - - -class XMLUIDialog(xmlui_base.XMLUIDialog): - type = "dialog" - dialog_factory = WidgetFactory() - read_only = False - - async def show(self, __=None): - await self.dlg.show() - - def _xmlui_close(self): - pass - - -create = partial( - xmlui_base.create, - class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog}, -)
--- a/setup.py Fri Jun 02 14:12:38 2023 +0200 +++ b/setup.py Fri Jun 02 14:54:26 2023 +0200 @@ -132,9 +132,9 @@ "sat = libervia.backend.core.launcher:Launcher.run", # CLI + aliases - "libervia-cli = libervia.frontends.jp.base:LiberviaCli.run", - "li = libervia.frontends.jp.base:LiberviaCli.run", - "jp = libervia.frontends.jp.base:LiberviaCli.run", + "libervia-cli = libervia.cli.base:LiberviaCli.run", + "li = libervia.cli.base:LiberviaCli.run", + "jp = libervia.cli.base:LiberviaCli.run", # TUI + alias "libervia-tui = libervia.frontends.primitivus.base:PrimitivusApp.run",