comparison libervia/cli/base.py @ 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 libervia/frontends/jp/base.py@26b7ed2817da
children 51744ad00a42
comparison
equal deleted inserted replaced
4074:26b7ed2817da 4075:47401850dec6
1 #!/usr/bin/env python3
2
3 # Libervia CLI
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 import asyncio
20 from libervia.backend.core.i18n import _
21
22 ### logging ###
23 import logging as log
24 log.basicConfig(level=log.WARNING,
25 format='[%(name)s] %(message)s')
26 ###
27
28 import sys
29 import os
30 import os.path
31 import argparse
32 import inspect
33 import tty
34 import termios
35 from pathlib import Path
36 from glob import iglob
37 from typing import Optional, Set, Union
38 from importlib import import_module
39 from libervia.frontends.tools.jid import JID
40 from libervia.backend.tools import config
41 from libervia.backend.tools.common import dynamic_import
42 from libervia.backend.tools.common import uri
43 from libervia.backend.tools.common import date_utils
44 from libervia.backend.tools.common import utils
45 from libervia.backend.tools.common import data_format
46 from libervia.backend.tools.common.ansi import ANSI as A
47 from libervia.backend.core import exceptions
48 import libervia.cli
49 from libervia.cli.loops import QuitException, get_libervia_cli_loop
50 from libervia.cli.constants import Const as C
51 from libervia.frontends.bridge.bridge_frontend import BridgeException
52 from libervia.frontends.tools import misc
53 import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI
54 from collections import OrderedDict
55
56 ## bridge handling
57 # we get bridge name from conf and initialise the right class accordingly
58 main_config = config.parse_main_conf()
59 bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
60 LiberviaCLILoop = get_libervia_cli_loop(bridge_name)
61
62
63 try:
64 import progressbar
65 except ImportError:
66 msg = (_('ProgressBar not available, please download it at '
67 'http://pypi.python.org/pypi/progressbar\n'
68 'Progress bar deactivated\n--\n'))
69 print(msg, file=sys.stderr)
70 progressbar=None
71
72 #consts
73 DESCRIPTION = """This software is a command line tool for XMPP.
74 Get the latest version at """ + C.APP_URL
75
76 COPYLEFT = """Copyright (C) 2009-2021 Jérôme Poisson, Adrien Cossa
77 This program comes with ABSOLUTELY NO WARRANTY;
78 This is free software, and you are welcome to redistribute it under certain conditions.
79 """
80
81 PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s
82
83
84 def date_decoder(arg):
85 return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL)
86
87
88 class LiberviaCli:
89 """
90 This class can be use to establish a connection with the
91 bridge. Moreover, it should manage a main loop.
92
93 To use it, you mainly have to redefine the method run to perform
94 specify what kind of operation you want to perform.
95
96 """
97 def __init__(self):
98 """
99
100 @attribute quit_on_progress_end (bool): set to False if you manage yourself
101 exiting, or if you want the user to stop by himself
102 @attribute progress_success(callable): method to call when progress just started
103 by default display a message
104 @attribute progress_success(callable): method to call when progress is
105 successfully finished by default display a message
106 @attribute progress_failure(callable): method to call when progress failed
107 by default display a message
108 """
109 self.sat_conf = main_config
110 self.set_color_theme()
111 bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge')
112 if bridge_module is None:
113 log.error("Can't import {} bridge".format(bridge_name))
114 sys.exit(1)
115
116 self.bridge = bridge_module.AIOBridge()
117 self._onQuitCallbacks = []
118
119 def get_config(self, name, section=C.CONFIG_SECTION, default=None):
120 """Retrieve a setting value from sat.conf"""
121 return config.config_get(self.sat_conf, section, name, default=default)
122
123 def guess_background(self):
124 # cf. https://unix.stackexchange.com/a/245568 (thanks!)
125 try:
126 # for VTE based terminals
127 vte_version = int(os.getenv("VTE_VERSION", 0))
128 except ValueError:
129 vte_version = 0
130
131 color_fg_bg = os.getenv("COLORFGBG")
132
133 if ((sys.stdin.isatty() and sys.stdout.isatty()
134 and (
135 # XTerm
136 os.getenv("XTERM_VERSION")
137 # Konsole
138 or os.getenv("KONSOLE_VERSION")
139 # All VTE based terminals
140 or vte_version >= 3502
141 ))):
142 # ANSI escape sequence
143 stdin_fd = sys.stdin.fileno()
144 old_settings = termios.tcgetattr(stdin_fd)
145 try:
146 tty.setraw(sys.stdin.fileno())
147 # we request background color
148 sys.stdout.write("\033]11;?\a")
149 sys.stdout.flush()
150 expected = "\033]11;rgb:"
151 for c in expected:
152 ch = sys.stdin.read(1)
153 if ch != c:
154 # background id is not supported, we default to "dark"
155 # TODO: log something?
156 return 'dark'
157 red, green, blue = [
158 int(c, 16)/65535 for c in sys.stdin.read(14).split('/')
159 ]
160 # '\a' is the last character
161 sys.stdin.read(1)
162 finally:
163 termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
164
165 lum = utils.per_luminance(red, green, blue)
166 if lum <= 0.5:
167 return 'dark'
168 else:
169 return 'light'
170 elif color_fg_bg:
171 # no luck with ANSI escape sequence, we try COLORFGBG environment variable
172 try:
173 bg = int(color_fg_bg.split(";")[-1])
174 except ValueError:
175 return "dark"
176 if bg in list(range(7)) + [8]:
177 return "dark"
178 else:
179 return "light"
180 else:
181 # no autodetection method found
182 return "dark"
183
184 def set_color_theme(self):
185 background = self.get_config('background', default='auto')
186 if background == 'auto':
187 background = self.guess_background()
188 if background not in ('dark', 'light'):
189 raise exceptions.ConfigError(_(
190 'Invalid value set for "background" ({background}), please check '
191 'your settings in libervia.conf').format(
192 background=repr(background)
193 ))
194 self.background = background
195 if background == 'light':
196 C.A_HEADER = A.FG_MAGENTA
197 C.A_SUBHEADER = A.BOLD + A.FG_RED
198 C.A_LEVEL_COLORS = (C.A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN)
199 C.A_SUCCESS = A.FG_GREEN
200 C.A_FAILURE = A.BOLD + A.FG_RED
201 C.A_WARNING = A.FG_RED
202 C.A_PROMPT_PATH = A.FG_BLUE
203 C.A_PROMPT_SUF = A.BOLD
204 C.A_DIRECTORY = A.BOLD + A.FG_MAGENTA
205 C.A_FILE = A.FG_BLACK
206
207 def _bridge_connected(self):
208 self.parser = argparse.ArgumentParser(
209 formatter_class=argparse.RawDescriptionHelpFormatter, description=DESCRIPTION)
210 self._make_parents()
211 self.add_parser_options()
212 self.subparsers = self.parser.add_subparsers(
213 title=_('Available commands'), dest='command', required=True)
214
215 # progress attributes
216 self._progress_id = None # TODO: manage several progress ids
217 self.quit_on_progress_end = True
218
219 # outputs
220 self._outputs = {}
221 for type_ in C.OUTPUT_TYPES:
222 self._outputs[type_] = OrderedDict()
223 self.default_output = {}
224
225 self.own_jid = None # must be filled at runtime if needed
226
227 @property
228 def progress_id(self):
229 return self._progress_id
230
231 async def set_progress_id(self, progress_id):
232 # because we use async, we need an explicit setter
233 self._progress_id = progress_id
234 await self.replay_cache('progress_ids_cache')
235
236 @property
237 def watch_progress(self):
238 try:
239 self.pbar
240 except AttributeError:
241 return False
242 else:
243 return True
244
245 @watch_progress.setter
246 def watch_progress(self, watch_progress):
247 if watch_progress:
248 self.pbar = None
249
250 @property
251 def verbosity(self):
252 try:
253 return self.args.verbose
254 except AttributeError:
255 return 0
256
257 async def replay_cache(self, cache_attribute):
258 """Replay cached signals
259
260 @param cache_attribute(str): name of the attribute containing the cache
261 if the attribute doesn't exist, there is no cache and the call is ignored
262 else the cache must be a list of tuples containing the replay callback as
263 first item, then the arguments to use
264 """
265 try:
266 cache = getattr(self, cache_attribute)
267 except AttributeError:
268 pass
269 else:
270 for cache_data in cache:
271 await cache_data[0](*cache_data[1:])
272
273 def disp(self, msg, verbosity=0, error=False, end='\n'):
274 """Print a message to user
275
276 @param msg(unicode): message to print
277 @param verbosity(int): minimal verbosity to display the message
278 @param error(bool): if True, print to stderr instead of stdout
279 @param end(str): string appended after the last value, default a newline
280 """
281 if self.verbosity >= verbosity:
282 if error:
283 print(msg, end=end, file=sys.stderr)
284 else:
285 print(msg, end=end)
286
287 async def output(self, type_, name, extra_outputs, data):
288 if name in extra_outputs:
289 method = extra_outputs[name]
290 else:
291 method = self._outputs[type_][name]['callback']
292
293 ret = method(data)
294 if inspect.isawaitable(ret):
295 await ret
296
297 def add_on_quit_callback(self, callback, *args, **kwargs):
298 """Add a callback which will be called on quit command
299
300 @param callback(callback): method to call
301 """
302 self._onQuitCallbacks.append((callback, args, kwargs))
303
304 def get_output_choices(self, output_type):
305 """Return valid output filters for output_type
306
307 @param output_type: True for default,
308 else can be any registered type
309 """
310 return list(self._outputs[output_type].keys())
311
312 def _make_parents(self):
313 self.parents = {}
314
315 # we have a special case here as the start-session option is present only if
316 # connection is not needed, so we create two similar parents, one with the
317 # option, the other one without it
318 for parent_name in ('profile', 'profile_session'):
319 parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False)
320 parent.add_argument(
321 "-p", "--profile", action="store", type=str, default='@DEFAULT@',
322 help=_("Use PROFILE profile key (default: %(default)s)"))
323 parent.add_argument(
324 "--pwd", action="store", metavar='PASSWORD',
325 help=_("Password used to connect profile, if necessary"))
326
327 profile_parent, profile_session_parent = (self.parents['profile'],
328 self.parents['profile_session'])
329
330 connect_short, connect_long, connect_action, connect_help = (
331 "-c", "--connect", "store_true",
332 _("Connect the profile before doing anything else")
333 )
334 profile_parent.add_argument(
335 connect_short, connect_long, action=connect_action, help=connect_help)
336
337 profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group()
338 profile_session_connect_group.add_argument(
339 connect_short, connect_long, action=connect_action, help=connect_help)
340 profile_session_connect_group.add_argument(
341 "--start-session", action="store_true",
342 help=_("Start a profile session without connecting"))
343
344 progress_parent = self.parents['progress'] = argparse.ArgumentParser(
345 add_help=False)
346 if progressbar:
347 progress_parent.add_argument(
348 "-P", "--progress", action="store_true", help=_("Show progress bar"))
349
350 verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False)
351 verbose_parent.add_argument(
352 '--verbose', '-v', action='count', default=0,
353 help=_("Add a verbosity level (can be used multiple times)"))
354
355 quiet_parent = self.parents['quiet'] = argparse.ArgumentParser(add_help=False)
356 quiet_parent.add_argument(
357 '--quiet', '-q', action='store_true',
358 help=_("be quiet (only output machine readable data)"))
359
360 draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False)
361 draft_group = draft_parent.add_argument_group(_('draft handling'))
362 draft_group.add_argument(
363 "-D", "--current", action="store_true", help=_("load current draft"))
364 draft_group.add_argument(
365 "-F", "--draft-path", type=Path, help=_("path to a draft file to retrieve"))
366
367
368 def make_pubsub_group(self, flags, defaults):
369 """Generate pubsub options according to flags
370
371 @param flags(iterable[unicode]): see [CommandBase.__init__]
372 @param defaults(dict[unicode, unicode]): help text for default value
373 key can be "service" or "node"
374 value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT
375 @return (ArgumentParser): parser to add
376 """
377 flags = misc.FlagsHandler(flags)
378 parent = argparse.ArgumentParser(add_help=False)
379 pubsub_group = parent.add_argument_group('pubsub')
380 pubsub_group.add_argument("-u", "--pubsub-url",
381 help=_("Pubsub URL (xmpp or http)"))
382
383 service_help = _("JID of the PubSub service")
384 if not flags.service:
385 default = defaults.pop('service', _('PEP service'))
386 if default is not None:
387 service_help += _(" (DEFAULT: {default})".format(default=default))
388 pubsub_group.add_argument("-s", "--service", default='',
389 help=service_help)
390
391 node_help = _("node to request")
392 if not flags.node:
393 default = defaults.pop('node', _('standard node'))
394 if default is not None:
395 node_help += _(" (DEFAULT: {default})".format(default=default))
396 pubsub_group.add_argument("-n", "--node", default='', help=node_help)
397
398 if flags.single_item:
399 item_help = ("item to retrieve")
400 if not flags.item:
401 default = defaults.pop('item', _('last item'))
402 if default is not None:
403 item_help += _(" (DEFAULT: {default})".format(default=default))
404 pubsub_group.add_argument("-i", "--item", default='',
405 help=item_help)
406 pubsub_group.add_argument(
407 "-L", "--last-item", action='store_true', help=_('retrieve last item'))
408 elif flags.multi_items:
409 # mutiple items, this activate several features: max-items, RSM, MAM
410 # and Orbder-by
411 pubsub_group.add_argument(
412 "-i", "--item", action='append', dest='items', default=[],
413 help=_("items to retrieve (DEFAULT: all)"))
414 if not flags.no_max:
415 max_group = pubsub_group.add_mutually_exclusive_group()
416 # XXX: defaut value for --max-items or --max is set in parse_pubsub_args
417 max_group.add_argument(
418 "-M", "--max-items", dest="max", type=int,
419 help=_("maximum number of items to get ({no_limit} to get all items)"
420 .format(no_limit=C.NO_LIMIT)))
421 # FIXME: it could be possible to no duplicate max (between pubsub
422 # max-items and RSM max)should not be duplicated, RSM could be
423 # used when available and pubsub max otherwise
424 max_group.add_argument(
425 "-m", "--max", dest="rsm_max", type=int,
426 help=_("maximum number of items to get per page (DEFAULT: 10)"))
427
428 # RSM
429
430 rsm_page_group = pubsub_group.add_mutually_exclusive_group()
431 rsm_page_group.add_argument(
432 "-a", "--after", dest="rsm_after",
433 help=_("find page after this item"), metavar='ITEM_ID')
434 rsm_page_group.add_argument(
435 "-b", "--before", dest="rsm_before",
436 help=_("find page before this item"), metavar='ITEM_ID')
437 rsm_page_group.add_argument(
438 "--index", dest="rsm_index", type=int,
439 help=_("index of the first item to retrieve"))
440
441
442 # MAM
443
444 pubsub_group.add_argument(
445 "-f", "--filter", dest='mam_filters', nargs=2,
446 action='append', default=[], help=_("MAM filters to use"),
447 metavar=("FILTER_NAME", "VALUE")
448 )
449
450 # Order-By
451
452 # TODO: order-by should be a list to handle several levels of ordering
453 # but this is not yet done in SàT (and not really useful with
454 # current specifications, as only "creation" and "modification" are
455 # available)
456 pubsub_group.add_argument(
457 "-o", "--order-by", choices=[C.ORDER_BY_CREATION,
458 C.ORDER_BY_MODIFICATION],
459 help=_("how items should be ordered"))
460
461 if flags[C.CACHE]:
462 pubsub_group.add_argument(
463 "-C", "--no-cache", dest="use_cache", action='store_false',
464 help=_("don't use Pubsub cache")
465 )
466
467 if not flags.all_used:
468 raise exceptions.InternalError('unknown flags: {flags}'.format(
469 flags=', '.join(flags.unused)))
470 if defaults:
471 raise exceptions.InternalError(f'unused defaults: {defaults}')
472
473 return parent
474
475 def add_parser_options(self):
476 self.parser.add_argument(
477 '--version',
478 action='version',
479 version=("{name} {version} {copyleft}".format(
480 name = C.APP_NAME,
481 version = self.version,
482 copyleft = COPYLEFT))
483 )
484
485 def register_output(self, type_, name, callback, description="", default=False):
486 if type_ not in C.OUTPUT_TYPES:
487 log.error("Invalid output type {}".format(type_))
488 return
489 self._outputs[type_][name] = {'callback': callback,
490 'description': description
491 }
492 if default:
493 if type_ in self.default_output:
494 self.disp(
495 _('there is already a default output for {type}, ignoring new one')
496 .format(type=type_)
497 )
498 else:
499 self.default_output[type_] = name
500
501
502 def parse_output_options(self):
503 options = self.command.args.output_opts
504 options_dict = {}
505 for option in options:
506 try:
507 key, value = option.split('=', 1)
508 except ValueError:
509 key, value = option, None
510 options_dict[key.strip()] = value.strip() if value is not None else None
511 return options_dict
512
513 def check_output_options(self, accepted_set, options):
514 if not accepted_set.issuperset(options):
515 self.disp(
516 _("The following output options are invalid: {invalid_options}").format(
517 invalid_options = ', '.join(set(options).difference(accepted_set))),
518 error=True)
519 self.quit(C.EXIT_BAD_ARG)
520
521 def import_plugins(self):
522 """Automaticaly import commands and outputs in CLI frontend
523
524 looks from modules names cmd_*.py in CLI frontend path and import them
525 """
526 path = os.path.dirname(libervia.cli.__file__)
527 # XXX: outputs must be imported before commands as they are used for arguments
528 for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'),
529 (C.PLUGIN_CMD, 'cmd_*.py')):
530 modules = (
531 os.path.splitext(module)[0]
532 for module in map(os.path.basename, iglob(os.path.join(path, pattern))))
533 for module_name in modules:
534 module_path = "libervia.cli." + module_name
535 try:
536 module = import_module(module_path)
537 self.import_plugin_module(module, type_)
538 except ImportError as e:
539 self.disp(
540 _("Can't import {module_path} plugin, ignoring it: {e}")
541 .format(module_path=module_path, e=e),
542 error=True)
543 except exceptions.CancelError:
544 continue
545 except exceptions.MissingModule as e:
546 self.disp(_("Missing module for plugin {name}: {missing}".format(
547 name = module_path,
548 missing = e)), error=True)
549
550
551 def import_plugin_module(self, module, type_):
552 """add commands or outpus from a module to CLI frontend
553
554 @param module: module containing commands or outputs
555 @param type_(str): one of C_PLUGIN_*
556 """
557 try:
558 class_names = getattr(module, '__{}__'.format(type_))
559 except AttributeError:
560 log.disp(
561 _("Invalid plugin module [{type}] {module}")
562 .format(type=type_, module=module),
563 error=True)
564 raise ImportError
565 else:
566 for class_name in class_names:
567 cls = getattr(module, class_name)
568 cls(self)
569
570 def get_xmpp_uri_from_http(self, http_url):
571 """parse HTML page at http(s) URL, and looks for xmpp: uri"""
572 if http_url.startswith('https'):
573 scheme = 'https'
574 elif http_url.startswith('http'):
575 scheme = 'http'
576 else:
577 raise exceptions.InternalError('An HTTP scheme is expected in this method')
578 self.disp(f"{scheme.upper()} URL found, trying to find associated xmpp: URI", 1)
579 # HTTP URL, we try to find xmpp: links
580 try:
581 from lxml import etree
582 except ImportError:
583 self.disp(
584 "lxml module must be installed to use http(s) scheme, please install it "
585 "with \"pip install lxml\"",
586 error=True)
587 self.quit(1)
588 import urllib.request, urllib.error, urllib.parse
589 parser = etree.HTMLParser()
590 try:
591 root = etree.parse(urllib.request.urlopen(http_url), parser)
592 except etree.XMLSyntaxError as e:
593 self.disp(_("Can't parse HTML page : {msg}").format(msg=e))
594 links = []
595 else:
596 links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]")
597 if not links:
598 self.disp(
599 _('Could not find alternate "xmpp:" URI, can\'t find associated XMPP '
600 'PubSub node/item'),
601 error=True)
602 self.quit(1)
603 xmpp_uri = links[0].get('href')
604 return xmpp_uri
605
606 def parse_pubsub_args(self):
607 if self.args.pubsub_url is not None:
608 url = self.args.pubsub_url
609
610 if url.startswith('http'):
611 # http(s) URL, we try to retrieve xmpp one from there
612 url = self.get_xmpp_uri_from_http(url)
613
614 try:
615 uri_data = uri.parse_xmpp_uri(url)
616 except ValueError:
617 self.parser.error(_('invalid XMPP URL: {url}').format(url=url))
618 else:
619 if uri_data['type'] == 'pubsub':
620 # URL is alright, we only set data not already set by other options
621 if not self.args.service:
622 self.args.service = uri_data['path']
623 if not self.args.node:
624 self.args.node = uri_data['node']
625 uri_item = uri_data.get('item')
626 if uri_item:
627 # there is an item in URI
628 # we use it only if item is not already set
629 # and item_last is not used either
630 try:
631 item = self.args.item
632 except AttributeError:
633 try:
634 items = self.args.items
635 except AttributeError:
636 self.disp(
637 _("item specified in URL but not needed in command, "
638 "ignoring it"),
639 error=True)
640 else:
641 if not items:
642 self.args.items = [uri_item]
643 else:
644 if not item:
645 try:
646 item_last = self.args.item_last
647 except AttributeError:
648 item_last = False
649 if not item_last:
650 self.args.item = uri_item
651 else:
652 self.parser.error(
653 _('XMPP URL is not a pubsub one: {url}').format(url=url)
654 )
655 flags = self.args._cmd._pubsub_flags
656 # we check required arguments here instead of using add_arguments' required option
657 # because the required argument can be set in URL
658 if C.SERVICE in flags and not self.args.service:
659 self.parser.error(_("argument -s/--service is required"))
660 if C.NODE in flags and not self.args.node:
661 self.parser.error(_("argument -n/--node is required"))
662 if C.ITEM in flags and not self.args.item:
663 self.parser.error(_("argument -i/--item is required"))
664
665 # FIXME: mutually groups can't be nested in a group and don't support title
666 # so we check conflict here. This may be fixed in Python 3, to be checked
667 try:
668 if self.args.item and self.args.item_last:
669 self.parser.error(
670 _("--item and --item-last can't be used at the same time"))
671 except AttributeError:
672 pass
673
674 try:
675 max_items = self.args.max
676 rsm_max = self.args.rsm_max
677 except AttributeError:
678 pass
679 else:
680 # we need to set a default value for max, but we need to know if we want
681 # to use pubsub's max or RSM's max. The later is used if any RSM or MAM
682 # argument is set
683 if max_items is None and rsm_max is None:
684 to_check = ('mam_filters', 'rsm_max', 'rsm_after', 'rsm_before',
685 'rsm_index')
686 if any((getattr(self.args, name) for name in to_check)):
687 # we use RSM
688 self.args.rsm_max = 10
689 else:
690 # we use pubsub without RSM
691 self.args.max = 10
692 if self.args.max is None:
693 self.args.max = C.NO_LIMIT
694
695 async def main(self, args, namespace):
696 try:
697 await self.bridge.bridge_connect()
698 except Exception as e:
699 if isinstance(e, exceptions.BridgeExceptionNoService):
700 print(
701 _("Can't connect to Libervia backend, are you sure that it's "
702 "launched ?")
703 )
704 self.quit(C.EXIT_BACKEND_NOT_FOUND, raise_exc=False)
705 elif isinstance(e, exceptions.BridgeInitError):
706 print(_("Can't init bridge"))
707 self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
708 else:
709 print(
710 _("Error while initialising bridge: {e}").format(e=e)
711 )
712 self.quit(C.EXIT_BRIDGE_ERROR, raise_exc=False)
713 return
714 await self.bridge.ready_get()
715 self.version = await self.bridge.version_get()
716 self._bridge_connected()
717 self.import_plugins()
718 try:
719 self.args = self.parser.parse_args(args, namespace=None)
720 if self.args._cmd._use_pubsub:
721 self.parse_pubsub_args()
722 await self.args._cmd.run()
723 except SystemExit as e:
724 self.quit(e.code, raise_exc=False)
725 return
726 except QuitException:
727 return
728
729 def _run(self, args=None, namespace=None):
730 self.loop = LiberviaCLILoop()
731 self.loop.run(self, args, namespace)
732
733 @classmethod
734 def run(cls):
735 cls()._run()
736
737 def _read_stdin(self, stdin_fut):
738 """Callback called by ainput to read stdin"""
739 line = sys.stdin.readline()
740 if line:
741 stdin_fut.set_result(line.rstrip(os.linesep))
742 else:
743 stdin_fut.set_exception(EOFError())
744
745 async def ainput(self, msg=''):
746 """Asynchronous version of buildin "input" function"""
747 self.disp(msg, end=' ')
748 sys.stdout.flush()
749 loop = asyncio.get_running_loop()
750 stdin_fut = loop.create_future()
751 loop.add_reader(sys.stdin, self._read_stdin, stdin_fut)
752 return await stdin_fut
753
754 async def confirm(self, message):
755 """Request user to confirm action, return answer as boolean"""
756 res = await self.ainput(f"{message} (y/N)? ")
757 return res in ("y", "Y")
758
759 async def confirm_or_quit(self, message, cancel_message=_("action cancelled by user")):
760 """Request user to confirm action, and quit if he doesn't"""
761 confirmed = await self.confirm(message)
762 if not confirmed:
763 self.disp(cancel_message)
764 self.quit(C.EXIT_USER_CANCELLED)
765
766 def quit_from_signal(self, exit_code=0):
767 r"""Same as self.quit, but from a signal handler
768
769 /!\: return must be used after calling this method !
770 """
771 # XXX: python-dbus will show a traceback if we exit in a signal handler
772 # so we use this little timeout trick to avoid it
773 self.loop.call_later(0, self.quit, exit_code)
774
775 def quit(self, exit_code=0, raise_exc=True):
776 """Terminate the execution with specified exit_code
777
778 This will stop the loop.
779 @param exit_code(int): code to return when quitting the program
780 @param raise_exp(boolean): if True raise a QuitException to stop code execution
781 The default value should be used most of time.
782 """
783 # first the onQuitCallbacks
784 try:
785 callbacks_list = self._onQuitCallbacks
786 except AttributeError:
787 pass
788 else:
789 for callback, args, kwargs in callbacks_list:
790 callback(*args, **kwargs)
791
792 self.loop.quit(exit_code)
793 if raise_exc:
794 raise QuitException
795
796 async def check_jids(self, jids):
797 """Check jids validity, transform roster name to corresponding jids
798
799 @param profile: profile name
800 @param jids: list of jids
801 @return: List of jids
802
803 """
804 names2jid = {}
805 nodes2jid = {}
806
807 try:
808 contacts = await self.bridge.contacts_get(self.profile)
809 except BridgeException as e:
810 if e.classname == "AttributeError":
811 # we may get an AttributeError if we use a component profile
812 # as components don't have roster
813 contacts = []
814 else:
815 raise e
816
817 for contact in contacts:
818 jid_s, attr, groups = contact
819 _jid = JID(jid_s)
820 try:
821 names2jid[attr["name"].lower()] = jid_s
822 except KeyError:
823 pass
824
825 if _jid.node:
826 nodes2jid[_jid.node.lower()] = jid_s
827
828 def expand_jid(jid):
829 _jid = jid.lower()
830 if _jid in names2jid:
831 expanded = names2jid[_jid]
832 elif _jid in nodes2jid:
833 expanded = nodes2jid[_jid]
834 else:
835 expanded = jid
836 return expanded
837
838 def check(jid):
839 if not jid.is_valid:
840 log.error (_("%s is not a valid JID !"), jid)
841 self.quit(1)
842
843 dest_jids=[]
844 try:
845 for i in range(len(jids)):
846 dest_jids.append(expand_jid(jids[i]))
847 check(dest_jids[i])
848 except AttributeError:
849 pass
850
851 return dest_jids
852
853 async def a_pwd_input(self, msg=''):
854 """Like ainput but with echo disabled (useful for passwords)"""
855 # we disable echo, code adapted from getpass standard module which has been
856 # written by Piers Lauder (original), Guido van Rossum (Windows support and
857 # cleanup) and Gregory P. Smith (tty support & GetPassWarning), a big thanks
858 # to them (and for all the amazing work on Python).
859 stdin_fd = sys.stdin.fileno()
860 old = termios.tcgetattr(sys.stdin)
861 new = old[:]
862 new[3] &= ~termios.ECHO
863 tcsetattr_flags = termios.TCSAFLUSH
864 if hasattr(termios, 'TCSASOFT'):
865 tcsetattr_flags |= termios.TCSASOFT
866 try:
867 termios.tcsetattr(stdin_fd, tcsetattr_flags, new)
868 pwd = await self.ainput(msg=msg)
869 finally:
870 termios.tcsetattr(stdin_fd, tcsetattr_flags, old)
871 sys.stderr.flush()
872 self.disp('')
873 return pwd
874
875 async def connect_or_prompt(self, method, err_msg=None):
876 """Try to connect/start profile session and prompt for password if needed
877
878 @param method(callable): bridge method to either connect or start profile session
879 It will be called with password as sole argument, use lambda to do the call
880 properly
881 @param err_msg(str): message to show if connection fail
882 """
883 password = self.args.pwd
884 while True:
885 try:
886 await method(password or '')
887 except Exception as e:
888 if ((isinstance(e, BridgeException)
889 and e.classname == 'PasswordError'
890 and self.args.pwd is None)):
891 if password is not None:
892 self.disp(A.color(C.A_WARNING, _("invalid password")))
893 password = await self.a_pwd_input(
894 _("please enter profile password:"))
895 else:
896 self.disp(err_msg.format(profile=self.profile, e=e), error=True)
897 self.quit(C.EXIT_ERROR)
898 else:
899 break
900
901 async def connect_profile(self):
902 """Check if the profile is connected and do it if requested
903
904 @exit: - 1 when profile is not connected and --connect is not set
905 - 1 when the profile doesn't exists
906 - 1 when there is a connection error
907 """
908 # FIXME: need better exit codes
909
910 self.profile = await self.bridge.profile_name_get(self.args.profile)
911
912 if not self.profile:
913 log.error(
914 _("The profile [{profile}] doesn't exist")
915 .format(profile=self.args.profile)
916 )
917 self.quit(C.EXIT_ERROR)
918
919 try:
920 start_session = self.args.start_session
921 except AttributeError:
922 pass
923 else:
924 if start_session:
925 await self.connect_or_prompt(
926 lambda pwd: self.bridge.profile_start_session(pwd, self.profile),
927 err_msg="Can't start {profile}'s session: {e}"
928 )
929 return
930 elif not await self.bridge.profile_is_session_started(self.profile):
931 if not self.args.connect:
932 self.disp(_(
933 "Session for [{profile}] is not started, please start it "
934 "before using libervia-cli, or use either --start-session or "
935 "--connect option"
936 .format(profile=self.profile)
937 ), error=True)
938 self.quit(1)
939 elif not getattr(self.args, "connect", False):
940 return
941
942
943 if not hasattr(self.args, 'connect'):
944 # a profile can be present without connect option (e.g. on profile
945 # creation/deletion)
946 return
947 elif self.args.connect is True: # if connection is asked, we connect the profile
948 await self.connect_or_prompt(
949 lambda pwd: self.bridge.connect(self.profile, pwd, {}),
950 err_msg = 'Can\'t connect profile "{profile!s}": {e}'
951 )
952 return
953 else:
954 if not await self.bridge.is_connected(self.profile):
955 log.error(
956 _("Profile [{profile}] is not connected, please connect it "
957 "before using libervia-cli, or use --connect option")
958 .format(profile=self.profile)
959 )
960 self.quit(1)
961
962 async def get_full_jid(self, param_jid):
963 """Return the full jid if possible (add main resource when find a bare jid)"""
964 # TODO: to be removed, bare jid should work with all commands, notably for file
965 # as backend now handle jingles message initiation
966 _jid = JID(param_jid)
967 if not _jid.resource:
968 #if the resource is not given, we try to add the main resource
969 main_resource = await self.bridge.main_resource_get(param_jid, self.profile)
970 if main_resource:
971 return f"{_jid.bare}/{main_resource}"
972 return param_jid
973
974 async def get_profile_jid(self):
975 """Retrieve current profile bare JID if possible"""
976 full_jid = await self.bridge.param_get_a_async(
977 "JabberID", "Connection", profile_key=self.profile
978 )
979 return full_jid.rsplit("/", 1)[0]
980
981
982 class CommandBase:
983
984 def __init__(
985 self,
986 host: LiberviaCli,
987 name: str,
988 use_profile: bool = True,
989 use_output: Union[bool, str] = False,
990 extra_outputs: Optional[dict] = None,
991 need_connect: Optional[bool] = None,
992 help: Optional[str] = None,
993 **kwargs
994 ):
995 """Initialise CommandBase
996
997 @param host: LiberviaCli instance
998 @param name: name of the new command
999 @param use_profile: if True, add profile selection/connection commands
1000 @param use_output: if not False, add --output option
1001 @param extra_outputs: list of command specific outputs:
1002 key is output name ("default" to use as main output)
1003 value is a callable which will format the output (data will be used as only
1004 argument)
1005 if a key already exists with normal outputs, the extra one will be used
1006 @param need_connect: True if profile connection is needed
1007 False else (profile session must still be started)
1008 None to set auto value (i.e. True if use_profile is set)
1009 Can't be set if use_profile is False
1010 @param help: help message to display
1011 @param **kwargs: args passed to ArgumentParser
1012 use_* are handled directly, they can be:
1013 - use_progress(bool): if True, add progress bar activation option
1014 progress* signals will be handled
1015 - use_verbose(bool): if True, add verbosity option
1016 - use_pubsub(bool): if True, add pubsub options
1017 mandatory arguments are controlled by pubsub_req
1018 - use_draft(bool): if True, add draft handling options
1019 ** other arguments **
1020 - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options,
1021 can be:
1022 C.SERVICE: service is required
1023 C.NODE: node is required
1024 C.ITEM: item is required
1025 C.SINGLE_ITEM: only one item is allowed
1026 """
1027 try: # If we have subcommands, host is a CommandBase and we need to use host.host
1028 self.host = host.host
1029 except AttributeError:
1030 self.host = host
1031
1032 # --profile option
1033 parents = kwargs.setdefault('parents', set())
1034 if use_profile:
1035 # self.host.parents['profile'] is an ArgumentParser with profile connection
1036 # arguments
1037 if need_connect is None:
1038 need_connect = True
1039 parents.add(
1040 self.host.parents['profile' if need_connect else 'profile_session'])
1041 else:
1042 assert need_connect is None
1043 self.need_connect = need_connect
1044 # from this point, self.need_connect is None if connection is not needed at all
1045 # False if session starting is needed, and True if full connection is needed
1046
1047 # --output option
1048 if use_output:
1049 if extra_outputs is None:
1050 extra_outputs = {}
1051 self.extra_outputs = extra_outputs
1052 if use_output == True:
1053 use_output = C.OUTPUT_TEXT
1054 assert use_output in C.OUTPUT_TYPES
1055 self._output_type = use_output
1056 output_parent = argparse.ArgumentParser(add_help=False)
1057 choices = set(self.host.get_output_choices(use_output))
1058 choices.update(extra_outputs)
1059 if not choices:
1060 raise exceptions.InternalError(
1061 "No choice found for {} output type".format(use_output))
1062 try:
1063 default = self.host.default_output[use_output]
1064 except KeyError:
1065 if 'default' in choices:
1066 default = 'default'
1067 elif 'simple' in choices:
1068 default = 'simple'
1069 else:
1070 default = list(choices)[0]
1071 output_parent.add_argument(
1072 '--output', '-O', choices=sorted(choices), default=default,
1073 help=_("select output format (default: {})".format(default)))
1074 output_parent.add_argument(
1075 '--output-option', '--oo', action="append", dest='output_opts',
1076 default=[], help=_("output specific option"))
1077 parents.add(output_parent)
1078 else:
1079 assert extra_outputs is None
1080
1081 self._use_pubsub = kwargs.pop('use_pubsub', False)
1082 if self._use_pubsub:
1083 flags = kwargs.pop('pubsub_flags', [])
1084 defaults = kwargs.pop('pubsub_defaults', {})
1085 parents.add(self.host.make_pubsub_group(flags, defaults))
1086 self._pubsub_flags = flags
1087
1088 # other common options
1089 use_opts = {k:v for k,v in kwargs.items() if k.startswith('use_')}
1090 for param, do_use in use_opts.items():
1091 opt=param[4:] # if param is use_verbose, opt is verbose
1092 if opt not in self.host.parents:
1093 raise exceptions.InternalError("Unknown parent option {}".format(opt))
1094 del kwargs[param]
1095 if do_use:
1096 parents.add(self.host.parents[opt])
1097
1098 self.parser = host.subparsers.add_parser(name, help=help, **kwargs)
1099 if hasattr(self, "subcommands"):
1100 self.subparsers = self.parser.add_subparsers(dest='subcommand', required=True)
1101 else:
1102 self.parser.set_defaults(_cmd=self)
1103 self.add_parser_options()
1104
1105 @property
1106 def sat_conf(self):
1107 return self.host.sat_conf
1108
1109 @property
1110 def args(self):
1111 return self.host.args
1112
1113 @property
1114 def profile(self):
1115 return self.host.profile
1116
1117 @property
1118 def verbosity(self):
1119 return self.host.verbosity
1120
1121 @property
1122 def progress_id(self):
1123 return self.host.progress_id
1124
1125 async def set_progress_id(self, progress_id):
1126 return await self.host.set_progress_id(progress_id)
1127
1128 async def progress_started_handler(self, uid, metadata, profile):
1129 if profile != self.profile:
1130 return
1131 if self.progress_id is None:
1132 # the progress started message can be received before the id
1133 # so we keep progress_started signals in cache to replay they
1134 # when the progress_id is received
1135 cache_data = (self.progress_started_handler, uid, metadata, profile)
1136 try:
1137 cache = self.host.progress_ids_cache
1138 except AttributeError:
1139 cache = self.host.progress_ids_cache = []
1140 cache.append(cache_data)
1141 else:
1142 if self.host.watch_progress and uid == self.progress_id:
1143 await self.on_progress_started(metadata)
1144 while True:
1145 await asyncio.sleep(PROGRESS_DELAY)
1146 cont = await self.progress_update()
1147 if not cont:
1148 break
1149
1150 async def progress_finished_handler(self, uid, metadata, profile):
1151 if profile != self.profile:
1152 return
1153 if uid == self.progress_id:
1154 try:
1155 self.host.pbar.finish()
1156 except AttributeError:
1157 pass
1158 await self.on_progress_finished(metadata)
1159 if self.host.quit_on_progress_end:
1160 self.host.quit_from_signal()
1161
1162 async def progress_error_handler(self, uid, message, profile):
1163 if profile != self.profile:
1164 return
1165 if uid == self.progress_id:
1166 if self.args.progress:
1167 self.disp('') # progress is not finished, so we skip a line
1168 if self.host.quit_on_progress_end:
1169 await self.on_progress_error(message)
1170 self.host.quit_from_signal(C.EXIT_ERROR)
1171
1172 async def progress_update(self):
1173 """This method is continualy called to update the progress bar
1174
1175 @return (bool): False to stop being called
1176 """
1177 data = await self.host.bridge.progress_get(self.progress_id, self.profile)
1178 if data:
1179 try:
1180 size = data['size']
1181 except KeyError:
1182 self.disp(_("file size is not known, we can't show a progress bar"), 1,
1183 error=True)
1184 return False
1185 if self.host.pbar is None:
1186 #first answer, we must construct the bar
1187
1188 # if the instance has a pbar_template attribute, it is used has model,
1189 # else default one is used
1190 # template is a list of part, where part can be either a str to show directly
1191 # or a list where first argument is a name of a progressbar widget, and others
1192 # are used as widget arguments
1193 try:
1194 template = self.pbar_template
1195 except AttributeError:
1196 template = [
1197 _("Progress: "), ["Percentage"], " ", ["Bar"], " ",
1198 ["FileTransferSpeed"], " ", ["ETA"]
1199 ]
1200
1201 widgets = []
1202 for part in template:
1203 if isinstance(part, str):
1204 widgets.append(part)
1205 else:
1206 widget = getattr(progressbar, part.pop(0))
1207 widgets.append(widget(*part))
1208
1209 self.host.pbar = progressbar.ProgressBar(max_value=int(size), widgets=widgets)
1210 self.host.pbar.start()
1211
1212 self.host.pbar.update(int(data['position']))
1213
1214 elif self.host.pbar is not None:
1215 return False
1216
1217 await self.on_progress_update(data)
1218
1219 return True
1220
1221 async def on_progress_started(self, metadata):
1222 """Called when progress has just started
1223
1224 can be overidden by a command
1225 @param metadata(dict): metadata as sent by bridge.progress_started
1226 """
1227 self.disp(_("Operation started"), 2)
1228
1229 async def on_progress_update(self, metadata):
1230 """Method called on each progress updata
1231
1232 can be overidden by a command to handle progress metadata
1233 @para metadata(dict): metadata as returned by bridge.progress_get
1234 """
1235 pass
1236
1237 async def on_progress_finished(self, metadata):
1238 """Called when progress has just finished
1239
1240 can be overidden by a command
1241 @param metadata(dict): metadata as sent by bridge.progress_finished
1242 """
1243 self.disp(_("Operation successfully finished"), 2)
1244
1245 async def on_progress_error(self, e):
1246 """Called when a progress failed
1247
1248 @param error_msg(unicode): error message as sent by bridge.progress_error
1249 """
1250 self.disp(_("Error while doing operation: {e}").format(e=e), error=True)
1251
1252 def disp(self, msg, verbosity=0, error=False, end='\n'):
1253 return self.host.disp(msg, verbosity, error, end)
1254
1255 def output(self, data):
1256 try:
1257 output_type = self._output_type
1258 except AttributeError:
1259 raise exceptions.InternalError(
1260 _('trying to use output when use_output has not been set'))
1261 return self.host.output(output_type, self.args.output, self.extra_outputs, data)
1262
1263 def get_pubsub_extra(self, extra: Optional[dict] = None) -> str:
1264 """Helper method to compute extra data from pubsub arguments
1265
1266 @param extra: base extra dict, or None to generate a new one
1267 @return: serialised dict which can be used directly in the bridge for pubsub
1268 """
1269 if extra is None:
1270 extra = {}
1271 else:
1272 intersection = {C.KEY_ORDER_BY}.intersection(list(extra.keys()))
1273 if intersection:
1274 raise exceptions.ConflictError(
1275 "given extra dict has conflicting keys with pubsub keys "
1276 "{intersection}".format(intersection=intersection))
1277
1278 # RSM
1279
1280 for attribute in ('max', 'after', 'before', 'index'):
1281 key = 'rsm_' + attribute
1282 if key in extra:
1283 raise exceptions.ConflictError(
1284 "This key already exists in extra: u{key}".format(key=key))
1285 value = getattr(self.args, key, None)
1286 if value is not None:
1287 extra[key] = str(value)
1288
1289 # MAM
1290
1291 if hasattr(self.args, 'mam_filters'):
1292 for key, value in self.args.mam_filters:
1293 key = 'filter_' + key
1294 if key in extra:
1295 raise exceptions.ConflictError(
1296 "This key already exists in extra: u{key}".format(key=key))
1297 extra[key] = value
1298
1299 # Order-By
1300
1301 try:
1302 order_by = self.args.order_by
1303 except AttributeError:
1304 pass
1305 else:
1306 if order_by is not None:
1307 extra[C.KEY_ORDER_BY] = self.args.order_by
1308
1309 # Cache
1310 try:
1311 use_cache = self.args.use_cache
1312 except AttributeError:
1313 pass
1314 else:
1315 if not use_cache:
1316 extra[C.KEY_USE_CACHE] = use_cache
1317
1318 return data_format.serialise(extra)
1319
1320 def add_parser_options(self):
1321 try:
1322 subcommands = self.subcommands
1323 except AttributeError:
1324 # We don't have subcommands, the class need to implements add_parser_options
1325 raise NotImplementedError
1326
1327 # now we add subcommands to ourself
1328 for cls in subcommands:
1329 cls(self)
1330
1331 def override_pubsub_flags(self, new_flags: Set[str]) -> None:
1332 """Replace pubsub_flags given in __init__
1333
1334 useful when a command is extending an other command (e.g. blog command which does
1335 the same as pubsub command, but with a default node)
1336 """
1337 self._pubsub_flags = new_flags
1338
1339 async def run(self):
1340 """this method is called when a command is actually run
1341
1342 It set stuff like progression callbacks and profile connection
1343 You should not overide this method: you should call self.start instead
1344 """
1345 # we keep a reference to run command, it may be useful e.g. for outputs
1346 self.host.command = self
1347
1348 try:
1349 show_progress = self.args.progress
1350 except AttributeError:
1351 # the command doesn't use progress bar
1352 pass
1353 else:
1354 if show_progress:
1355 self.host.watch_progress = True
1356 # we need to register the following signal even if we don't display the
1357 # progress bar
1358 self.host.bridge.register_signal(
1359 "progress_started", self.progress_started_handler)
1360 self.host.bridge.register_signal(
1361 "progress_finished", self.progress_finished_handler)
1362 self.host.bridge.register_signal(
1363 "progress_error", self.progress_error_handler)
1364
1365 if self.need_connect is not None:
1366 await self.host.connect_profile()
1367 await self.start()
1368
1369 async def start(self):
1370 """This is the starting point of the command, this method must be overriden
1371
1372 at this point, profile are connected if needed
1373 """
1374 raise NotImplementedError
1375
1376
1377 class CommandAnswering(CommandBase):
1378 """Specialised commands which answer to specific actions
1379
1380 to manage action_types answer,
1381 """
1382 action_callbacks = {} # XXX: set managed action types in a dict here:
1383 # key is the action_type, value is the callable
1384 # which will manage the answer. profile filtering is
1385 # already managed when callback is called
1386
1387 def __init__(self, *args, **kwargs):
1388 super(CommandAnswering, self).__init__(*args, **kwargs)
1389
1390 async def on_action_new(
1391 self,
1392 action_data_s: str,
1393 action_id: str,
1394 security_limit: int,
1395 profile: str
1396 ) -> None:
1397 if profile != self.profile:
1398 return
1399 action_data = data_format.deserialise(action_data_s)
1400 try:
1401 action_type = action_data['type']
1402 except KeyError:
1403 try:
1404 xml_ui = action_data["xmlui"]
1405 except KeyError:
1406 pass
1407 else:
1408 self.on_xmlui(xml_ui)
1409 else:
1410 try:
1411 callback = self.action_callbacks[action_type]
1412 except KeyError:
1413 pass
1414 else:
1415 await callback(action_data, action_id, security_limit, profile)
1416
1417 def on_xmlui(self, xml_ui):
1418 """Display a dialog received from the backend.
1419
1420 @param xml_ui (unicode): dialog XML representation
1421 """
1422 # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
1423 # should be available in the future
1424 # TODO: XMLUI module
1425 ui = ET.fromstring(xml_ui.encode('utf-8'))
1426 dialog = ui.find("dialog")
1427 if dialog is not None:
1428 self.disp(dialog.findtext("message"), error=dialog.get("level") == "error")
1429
1430 async def start_answering(self):
1431 """Auto reply to confirmation requests"""
1432 self.host.bridge.register_signal("action_new", self.on_action_new)
1433 actions = await self.host.bridge.actions_get(self.profile)
1434 for action_data_s, action_id, security_limit in actions:
1435 await self.on_action_new(action_data_s, action_id, security_limit, self.profile)