comparison frontends/src/jp/cmd_pubsub.py @ 2316:7b448ac50a69

jp (pubsub): new search command: search is a kind of "grep for Pubsub". It's a powerful command which allows to look for specific data in a pubsub node, recurse sub nodes if requested, and execute an action on the result. search allows to look for items with following filter: - simple text search - regex - xpath - python code filters are read an applied in the order in which they appear on the command line. Then flags can be used to modify behaviour, currently there are: - ignore-case to specify if search must be case sensitive or not - invert to invert result of the search (i.e. don't match instead of match) - dot-all which is specific for regex, cf. re module - only-matching which return the matching part instead of the full item Once a item match filters, an action is applied to it, currenlty there are: - print, which do a simple output of the full item (default) - exec, which run a jp command, specifying the service, node and item corresponding to the match - exteral, which run a external command, sending the full item on stdin By default search is only done on requested node, but if max-depth is more than 0, sub nodes will be searched too.
author Goffi <goffi@goffi.org>
date Sat, 08 Jul 2017 21:54:24 +0200
parents 0b21d87c91cf
children f4e05600577b
comparison
equal deleted inserted replaced
2315:8d9bd5d77336 2316:7b448ac50a69
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 20
21 import base 21 import base
22 from sat.core.i18n import _ 22 from sat.core.i18n import _
23 from sat.core import exceptions
23 from sat_frontends.jp.constants import Const as C 24 from sat_frontends.jp.constants import Const as C
24 from sat_frontends.jp import common 25 from sat_frontends.jp import common
26 from sat_frontends.jp import arg_tools
25 from functools import partial 27 from functools import partial
26 from sat.tools.common import uri 28 from sat.tools.common import uri
27 from sat_frontends.tools import jid 29 from sat.tools.common.ansi import ANSI as A
30 from sat_frontends.tools import jid, strings
31 import argparse
28 import os.path 32 import os.path
33 import re
34 import subprocess
35 import sys
29 36
30 __commands__ = ["Pubsub"] 37 __commands__ = ["Pubsub"]
31 38
32 PUBSUB_TMP_DIR = u"pubsub" 39 PUBSUB_TMP_DIR = u"pubsub"
33 40
411 self.profile, 418 self.profile,
412 callback=self.psAffiliationsGetCb, 419 callback=self.psAffiliationsGetCb,
413 errback=self.psAffiliationsGetEb) 420 errback=self.psAffiliationsGetEb)
414 421
415 422
423 class Search(base.CommandBase):
424 """this command to a search without using MAM, i.e. by checking every items if dound by itself, so it may be heavy in resources both for server and client"""
425 RE_FLAGS = re.MULTILINE | re.UNICODE
426 EXEC_ACTIONS = (u'exec', u'external')
427
428 def __init__(self, host):
429 base.CommandBase.__init__(self, host, 'search', use_output=C.OUTPUT_XML, use_pubsub=True, use_verbose=True, help=_(u'search items corresponding to filters'))
430 self.need_loop=True
431
432 @property
433 def etree(self):
434 """load lxml.etree only if needed"""
435 if self._etree is None:
436 from lxml import etree
437 self._etree = etree
438 return self._etree
439
440 def filter_opt(self, value, type_):
441 value = base.unicode_decoder(value)
442 return (type_, value)
443
444 def filter_flag(self, value, type_):
445 value = C.bool(value)
446 return (type_, value)
447
448 def add_parser_options(self):
449 self.parser.add_argument("-i", "--item", action="append", default=[], dest='items', type=base.unicode_decoder, help=_(u"item id(s)"))
450 self.parser.add_argument("-D", "--max-depth", type=int, default=0, help=_(u"maximum depth of recursion (will search linked nodes if > 0, default: 0)"))
451 self.parser.add_argument("-m", "--max", type=int, default=30, help=_(u"maximum number of items to get per node ({} to get all items, default: 30)".format(C.NO_LIMIT)))
452 self.parser.add_argument("-N", "--namespace", action='append', nargs=2, default=[],
453 metavar="NAME NAMESPACE", help=_(u"namespace to use for xpath"))
454
455 # filters
456 filter_text = partial(self.filter_opt, type_=u'text')
457 filter_re = partial(self.filter_opt, type_=u'regex')
458 filter_xpath = partial(self.filter_opt, type_=u'xpath')
459 filter_python = partial(self.filter_opt, type_=u'python')
460 filters = self.parser.add_argument_group(_(u'filters'), _(u'only items corresponding to following filters will be kept'))
461 filters.add_argument("-t", "--text",
462 action='append', dest='filters', type=filter_text,
463 metavar='TEXT',
464 help=_(u"full text filter, item must contain this string (XML included)"))
465 filters.add_argument("-r", "--regex",
466 action='append', dest='filters', type=filter_re,
467 metavar='EXPRESSION',
468 help=_(u"like --text but using a regular expression"))
469 filters.add_argument("-x", "--xpath",
470 action='append', dest='filters', type=filter_xpath,
471 metavar='XPATH',
472 help=_(u"filter items which has elements matching this xpath"))
473 filters.add_argument("-P", "--python",
474 action='append', dest='filters', type=filter_python,
475 metavar='PYTHON_CODE',
476 help=_(u'Python expression which much return a bool (True to keep item, False to reject it). "item" is raw text item, "item_xml" is lxml\'s etree.Element'))
477
478 # filters flags
479 flag_case = partial(self.filter_flag, type_=u'ignore-case')
480 flag_invert = partial(self.filter_flag, type_=u'invert')
481 flag_dotall = partial(self.filter_flag, type_=u'dotall')
482 flag_matching = partial(self.filter_flag, type_=u'only-matching')
483 flags = self.parser.add_argument_group(_(u'filters flags'), _(u'filters modifiers (change behaviour of following filters)'))
484 flags.add_argument("-C", "--ignore-case",
485 action='append', dest='filters', type=flag_case,
486 const=('ignore-case', True), nargs='?',
487 metavar='BOOLEAN',
488 help=_(u"(don't) ignore case in following filters (default: case sensitive)"))
489 flags.add_argument("-I", "--invert",
490 action='append', dest='filters', type=flag_invert,
491 const=('invert', True), nargs='?',
492 metavar='BOOLEAN',
493 help=_(u"(don't) invert effect of following filters (default: don't invert)"))
494 flags.add_argument("-A", "--dot-all",
495 action='append', dest='filters', type=flag_dotall,
496 const=('dotall', True), nargs='?',
497 metavar='BOOLEAN',
498 help=_(u"(don't) use DOTALL option for regex (default: don't use)"))
499 flags.add_argument("-o", "--only-matching",
500 action='append', dest='filters', type=flag_matching,
501 const=('only-matching', True), nargs='?',
502 metavar='BOOLEAN',
503 help=_(u"keep only the matching part of the item"))
504
505 # action
506 self.parser.add_argument("action",
507 default="print",
508 nargs='?',
509 choices=('print', 'exec', 'external'),
510 help=_(u"action to do on found items (default: print)"))
511 self.parser.add_argument("command", nargs=argparse.REMAINDER)
512
513 def psItemsGetEb(self, failure_, service, node):
514 self.disp(u"can't get pubsub items at {service} (node: {node}): {reason}".format(
515 service=service,
516 node=node,
517 reason=failure_), error=True)
518 self.to_get -= 1
519
520 def getItems(self, depth, service, node, items):
521 search = partial(self.search, depth=depth)
522 errback = partial(self.psItemsGetEb, service=service, node=node)
523 self.host.bridge.psItemsGet(
524 service,
525 node,
526 self.args.max,
527 [],
528 "",
529 {},
530 self.profile,
531 callback=search,
532 errback=errback
533 )
534 self.to_get += 1
535
536 def _checkPubsubURL(self, match, found_nodes):
537 """check that the matched URL is an xmpp: one
538
539 @param found_nodes(list[unicode]): found_nodes
540 this list will be filled while xmpp: URIs are discovered
541 """
542 url = match.group(0)
543 if url.startswith(u'xmpp'):
544 try:
545 url_data = uri.parseXMPPUri(url)
546 except ValueError:
547 return
548 if url_data[u'type'] == u'pubsub':
549 found_node = {u'service': url_data[u'path'],
550 u'node': url_data[u'node']}
551 if u'item' in url_data:
552 found_node[u'item'] = url_data[u'item']
553 found_nodes.append(found_node)
554
555 def getSubNodes(self, item, depth):
556 """look for pubsub URIs in item, and getItems on the linked nodes"""
557 found_nodes = []
558 checkURI = partial(self._checkPubsubURL, found_nodes=found_nodes)
559 strings.RE_URL.sub(checkURI, item)
560 for data in found_nodes:
561 self.getItems(depth+1,
562 data[u'service'],
563 data[u'node'],
564 [data[u'item']] if u'item' in data else []
565 )
566
567 def parseXml(self, item):
568 try:
569 return self.etree.fromstring(item)
570 except self.etree.XMLSyntaxError:
571 self.disp(_(u"item doesn't looks like XML, you have probably used --only-matching somewhere before and we have no more XML"), error=True)
572 self.host.quit(C.EXIT_BAD_ARG)
573
574 def filter(self, item):
575 """apply filters given on command line
576
577 if only-matching is used, item may be modified
578 @return (tuple[bool, unicode]): a tuple with:
579 - keep: True if item passed the filters
580 - item: it is returned in case of modifications
581 """
582 ignore_case = False
583 invert = False
584 dotall = False
585 only_matching = False
586 item_xml = None
587 for type_, value in self.args.filters:
588 keep = True
589
590 ## filters
591
592 if type_ == u'text':
593 if ignore_case:
594 if value.lower() not in item.lower():
595 keep = False
596 else:
597 if value not in item:
598 keep = False
599 if keep and only_matching:
600 # doesn't really make sens to keep a fixed string
601 # so we raise an error
602 self.host.disp(_(u"--only-matching used with fixed --text string, are you sure?"), error=True)
603 self.host.quit(C.EXIT_BAD_ARG)
604 elif type_ == u'regex':
605 flags = self.RE_FLAGS
606 if ignore_case:
607 flags |= re.IGNORECASE
608 if dotall:
609 flags |= re.DOTALL
610 match = re.search(value, item, flags)
611 keep = match != None
612 if keep and only_matching:
613 item = match.group()
614 item_xml = None
615 elif type_ == u'xpath':
616 if item_xml is None:
617 item_xml = self.parseXml(item)
618 try:
619 elts = item_xml.xpath(value, namespaces=self.args.namespace)
620 except self.etree.XPathEvalError as e:
621 self.disp(_(u"can't use xpath: {reason}").format(reason=e), error=True)
622 self.host.quit(C.EXIT_BAD_ARG)
623 keep = bool(elts)
624 if keep and only_matching:
625 item_xml = elts[0]
626 try:
627 item = self.etree.tostring(item_xml, encoding='unicode')
628 except TypeError:
629 # we have a string only, not an element
630 item = unicode(item_xml)
631 item_xml = None
632 elif type_ == u'python':
633 if item_xml is None:
634 item_xml = self.parseXml(item)
635 cmd_ns = {u'item': item,
636 u'item_xml': item_xml
637 }
638 try:
639 keep = eval(value, cmd_ns)
640 except SyntaxError as e:
641 self.disp(unicode(e), error=True)
642 self.host.quit(C.EXIT_BAD_ARG)
643
644 ## flags
645
646 elif type_ == u'ignore-case':
647 ignore_case = value
648 elif type_ == u'invert':
649 invert = value
650 # we need to continue, else loop would end here
651 continue
652 elif type_ == u'dotall':
653 dotall = value
654 elif type_ == u'only-matching':
655 only_matching = value
656 else:
657 raise exceptions.InternalError(_(u"unknown filter type {type}").format(type=type_))
658
659 if invert:
660 keep = not keep
661 if not keep:
662 return False, item
663
664 return True, item
665
666 def doItemAction(self, item, metadata):
667 """called when item has been kepts and the action need to be done
668
669 @param item(unicode): accepted item
670 """
671 action = self.args.action
672 if action == u'print' or self.host.verbosity > 0:
673 try:
674 self.output(item)
675 except self.etree.XMLSyntaxError:
676 # item is not valid XML, but a string
677 # can happen when --only-matching is used
678 self.disp(item)
679 if action in self.EXEC_ACTIONS:
680 item_elt = self.parseXml(item)
681 if action == u'exec':
682 use = {'service': metadata[u'service'],
683 'node': metadata[u'node'],
684 'item': item_elt.get('id'),
685 }
686 args = arg_tools.get_use_args(self.host,
687 self.args.command,
688 use,
689 verbose=self.host.verbosity > 1
690 )
691 cmd_args = sys.argv[0:1] + args + self.args.command
692 else:
693 cmd_args = self.args.command
694
695
696 self.disp(u'COMMAND: {command}'.format(
697 command = u' '.join([arg_tools.escape(a) for a in cmd_args])), 2)
698 if action == u'exec':
699 ret = subprocess.call(cmd_args)
700 else:
701 p = subprocess.Popen(cmd_args, stdin=subprocess.PIPE)
702 p.communicate(item)
703 ret = p.wait()
704 if ret != 0:
705 self.disp(A.color(C.A_FAILURE, _(u"executed command failed with exit code {code}").format(code=ret)))
706
707 def search(self, items_data, depth):
708 """callback of getItems
709
710 this method filters items, get sub nodes if needed,
711 do the requested action, and exit the command when everything is done
712 @param items_data(tuple): result of getItems
713 @param depth(int): current depth level
714 0 for first node, 1 for first children, and so on
715 """
716 items, metadata = items_data
717 for item in items:
718 if depth < self.args.max_depth:
719 self.getSubNodes(item, depth)
720 keep, item = self.filter(item)
721 if not keep:
722 continue
723 self.doItemAction(item, metadata)
724
725 # we check if we got all getItems results
726 self.to_get -= 1
727 if self.to_get == 0:
728 # yes, we can quit
729 self.host.quit()
730 assert self.to_get > 0
731
732 def start(self):
733 if self.args.command:
734 if self.args.action not in self.EXEC_ACTIONS:
735 self.parser.error(_(u"Command can only be used with {actions} actions").format(
736 actions=u', '.join(self.EXEC_ACTIONS)))
737 else:
738 if self.args.action in self.EXEC_ACTIONS:
739 self.parser.error(_(u"you need to specify a command to execute"))
740 if not self.args.node:
741 # TODO: handle get service affiliations when node is not set
742 self.parser.error(_(u"empty node is not handled yet"))
743 # to_get is increased on each get and decreased on each answer
744 # when it reach 0 again, the command is finished
745 self.to_get = 0
746 self._etree = None
747 if self.args.filters is None:
748 self.args.filters = []
749 self.args.namespace = dict(self.args.namespace + [('pubsub', "http://jabber.org/protocol/pubsub")])
750 common.checkURI(self.args)
751 self.getItems(0, self.args.service, self.args.node, self.args.items)
752
753
416 class Uri(base.CommandBase): 754 class Uri(base.CommandBase):
417 755
418 def __init__(self, host): 756 def __init__(self, host):
419 base.CommandBase.__init__(self, host, 'uri', use_profile=False, use_pubsub_node_req=True, help=_(u'build URI')) 757 base.CommandBase.__init__(self, host, 'uri', use_profile=False, use_pubsub_node_req=True, help=_(u'build URI'))
420 self.need_loop=True 758 self.need_loop=True
490 def __init__(self, host): 828 def __init__(self, host):
491 base.CommandBase.__init__(self, host, 'delete', use_pubsub_node_req=True, help=_(u'delete a Pubsub hook')) 829 base.CommandBase.__init__(self, host, 'delete', use_pubsub_node_req=True, help=_(u'delete a Pubsub hook'))
492 self.need_loop=True 830 self.need_loop=True
493 831
494 def add_parser_options(self): 832 def add_parser_options(self):
495 self.parser.add_argument('-t', '--type', default=u'', choices=('', 'python', 'python_file', 'python_code'), help=_(u"hook type to remove, empty to remove all (DEFAULT: remove all)")) 833 self.parser.add_argument('-t', '--type', default=u'', choices=('', 'python', 'python_file', 'python_code'), help=_(u"hook type to remove, empty to remove all (default: remove all)"))
496 self.parser.add_argument('-a', '--arg', dest='hook_arg', type=base.unicode_decoder, default=u'', help=_(u"argument of the hook to remove, empty to remove all (DEFAULT: remove all)")) 834 self.parser.add_argument('-a', '--arg', dest='hook_arg', type=base.unicode_decoder, default=u'', help=_(u"argument of the hook to remove, empty to remove all (default: remove all)"))
497 835
498 def psHookRemoveCb(self, nb_deleted): 836 def psHookRemoveCb(self, nb_deleted):
499 self.disp(_(u'{nb_deleted} hook(s) have been deleted').format( 837 self.disp(_(u'{nb_deleted} hook(s) have been deleted').format(
500 nb_deleted = nb_deleted)) 838 nb_deleted = nb_deleted))
501 self.host.quit() 839 self.host.quit()
545 def __init__(self, host): 883 def __init__(self, host):
546 super(Hook, self).__init__(host, 'hook', use_profile=False, help=_('trigger action on Pubsub notifications')) 884 super(Hook, self).__init__(host, 'hook', use_profile=False, help=_('trigger action on Pubsub notifications'))
547 885
548 886
549 class Pubsub(base.CommandBase): 887 class Pubsub(base.CommandBase):
550 subcommands = (Get, Delete, Edit, Node, Affiliations, Hook, Uri) 888 subcommands = (Get, Delete, Edit, Node, Affiliations, Search, Hook, Uri)
551 889
552 def __init__(self, host): 890 def __init__(self, host):
553 super(Pubsub, self).__init__(host, 'pubsub', use_profile=False, help=_('PubSub nodes/items management')) 891 super(Pubsub, self).__init__(host, 'pubsub', use_profile=False, help=_('PubSub nodes/items management'))