Mercurial > libervia-backend
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) |