jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/363748 )

Change subject: Query and grammar: add support for aliases
......................................................................


Query and grammar: add support for aliases

- allow aliases of the form A:alias_name into the grammar.
- automatically replace recursively all the aliases directly in the
  QueryBuilder, to make it completely transparent for the backends.
- In this first iteration the Direct backend do not support aliases
  because it doesn't support subgroups. It will automatically support
  them with the introduction of the multi-backend queries.

Bug: T169640
Change-Id: I3d6859b052cbafd7786c9b0b96f0abc0e965f87a
---
M README.md
M cumin/backends/__init__.py
M cumin/grammar.py
M cumin/query.py
M cumin/tests/fixtures/grammar/invalid_grammars.txt
M cumin/tests/fixtures/grammar/valid_grammars.txt
M cumin/tests/unit/test_query.py
7 files changed, 96 insertions(+), 10 deletions(-)

Approvals:
  Giuseppe Lavagetto: Looks good to me, but someone else must approve
  jenkins-bot: Verified
  Volans: Looks good to me, approved



diff --git a/README.md b/README.md
index 6939b4e..b4edde5 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,9 @@
 
 The available categories are:
 
+- `A`: for aliases. Aliases are automatically loaded from 
`{backend_name}_aliases.yaml` files, if they are present in
+  the same directory of the main configuration file. They are also 
automatically replaced with their values and nesting
+  aliases is allowed.
 - `F`: for querying facts
 - `R`: for querying resources
 
@@ -64,6 +67,7 @@
 - Category based key-value selection: `R:ResourceName = ResourceValue`
 - A complex selection for facts:
   `host10[10-42].*.domain or (not F:key1 = value1 and host10*) or (F:key2 > 
value2 and F:key3 ~ '^value[0-9]+')`
+- Alias selection: `A:group1`
 
 Backus-Naur form (BNF) of the grammar:
 
diff --git a/cumin/backends/__init__.py b/cumin/backends/__init__.py
index 120afb9..bdbb231 100644
--- a/cumin/backends/__init__.py
+++ b/cumin/backends/__init__.py
@@ -31,7 +31,7 @@
         """Add a category token to the query 'F:key = value'.
 
         Arguments:
-        category -- the category of the token, one of cumin.grammar.CATEGORIES
+        category -- the category of the token, one of cumin.grammar.CATEGORIES 
excluding the alias one.
         key      -- the key for this category
         value    -- the value to match, if not specified the key itself will 
be matched [optional, default: None]
         operator -- the comparison operator to use, one of 
cumin.grammar.OPERATORS [optional: default: =]
diff --git a/cumin/grammar.py b/cumin/grammar.py
index 95de3ef..d60759f 100644
--- a/cumin/grammar.py
+++ b/cumin/grammar.py
@@ -4,6 +4,7 @@
 
 # Available categories
 CATEGORIES = (
+    'A',  # Alias, it will be automatically replaced, the backends will never 
see it.
     'F',  # Fact
     'R',  # Resource
 )
diff --git a/cumin/query.py b/cumin/query.py
index 12fc199..a816882 100644
--- a/cumin/query.py
+++ b/cumin/query.py
@@ -6,6 +6,7 @@
 from ClusterShell.NodeSet import NodeSet
 from pyparsing import ParseResults
 
+from cumin.backends import InvalidQueryError
 from cumin.grammar import grammar
 
 
@@ -45,6 +46,7 @@
         self.logger = logger or logging.getLogger(__name__)
         self.query_string = query_string.strip()
         self.query = Query.new(config, logger=self.logger)
+        self.aliases = config.get(config['backend'], {}).get('aliases', {})
         self.level = 0  # Nesting level for sub-groups
 
     def build(self):
@@ -60,10 +62,10 @@
 
         Arguments:
         token -- a single token returned by the grammar parsing
-        level -- Nesting level in case of sub-groups in the query [optional, 
default: 0]
+        level -- nesting level in case of sub-groups in the query [optional, 
default: 0]
         """
         if not isinstance(token, ParseResults):
-            raise RuntimeError("Invalid query string syntax '{query}'. Token 
is '{token}'".format(
+            raise InvalidQueryError("Invalid query string syntax '{query}'. 
Token is '{token}'".format(
                 query=self.query_string, token=token))
 
         token_dict = token.asDict()
@@ -71,14 +73,15 @@
             for subtoken in token:
                 self._parse_token(subtoken, level=(level + 1))
         else:
-            self._build_token(token_dict, level)
+            if not self._replace_alias(token_dict, level):
+                self._build_token(token_dict, level)
 
     def _build_token(self, token_dict, level):
         """Build a token into the query object for the configured backend.
 
         Arguments:
         token_dict -- the dictionary of the parsed token returned by the 
grammar parsing
-        level      -- Nesting level in the query
+        level      -- nesting level in the query
         """
         keys = token_dict.keys()
 
@@ -103,3 +106,32 @@
 
         elif 'category' in keys:
             self.query.add_category(**token_dict)
+
+    def _replace_alias(self, token_dict, level):
+        """Replace any alias in the query in a recursive way, alias can 
reference other aliases.
+
+        Return True if a replacement was made, False otherwise. Raise 
InvalidQueryError on failure.
+
+        Arguments:
+        token_dict -- the dictionary of the parsed token returned by the 
grammar parsing
+        level      -- nesting level in the query
+        """
+        keys = token_dict.keys()
+        if 'category' not in keys or token_dict['category'] != 'A':
+            return False
+
+        if 'operator' in keys or 'value' in keys:
+            raise InvalidQueryError('Invalid alias syntax, aliases can be only 
of the form: A:alias_name')
+
+        alias_name = token_dict['key']
+        if alias_name not in self.aliases:
+            raise InvalidQueryError("Unable to find alias replacement for 
'{alias}' in the configuration".format(
+                alias=alias_name))
+
+        neg = 'not ' if 'neg' in keys and token_dict['neg'] else ''
+        alias = '{neg}({alias})'.format(neg=neg, 
alias=self.aliases[alias_name])
+        parsed_alias = grammar.parseString(alias, parseAll=True)
+        for token in parsed_alias:
+            self._parse_token(token, level=level)
+
+        return True
diff --git a/cumin/tests/fixtures/grammar/invalid_grammars.txt 
b/cumin/tests/fixtures/grammar/invalid_grammars.txt
index c8ce883..dfc84a9 100644
--- a/cumin/tests/fixtures/grammar/invalid_grammars.txt
+++ b/cumin/tests/fixtures/grammar/invalid_grammars.txt
@@ -11,3 +11,4 @@
 (F:key1 < 5 and () or hostname)
 (F:key1 < 5 and (hostname not F:key2 = value))
 F:key1 = value or (F:key2 < 5 and (hostname or F:key3 = (value)) and F:key4) = 
value
+Z:invalid_category
diff --git a/cumin/tests/fixtures/grammar/valid_grammars.txt 
b/cumin/tests/fixtures/grammar/valid_grammars.txt
index ba5b00f..edd74ce 100644
--- a/cumin/tests/fixtures/grammar/valid_grammars.txt
+++ b/cumin/tests/fixtures/grammar/valid_grammars.txt
@@ -31,3 +31,4 @@
 not F:key1 = value or not ( not F:key2 < 5 and not (not hostname or not F:key3 
= value)) and not F:key4 = value
 not F:key1 or not ( not F:key2 < 5 and not (not hostname or not F:key3)) and 
not F:key4 = value
 (F:key1 = value and F:key2 = "another value") or (F:key3 = Value and F:key4 > 
8.1)
+A:custom_alias
diff --git a/cumin/tests/unit/test_query.py b/cumin/tests/unit/test_query.py
index 4dbd2ee..aefe8b8 100644
--- a/cumin/tests/unit/test_query.py
+++ b/cumin/tests/unit/test_query.py
@@ -10,7 +10,7 @@
 from ClusterShell.NodeSet import NodeSet
 from pyparsing import ParseException
 
-from cumin.backends import BaseQuery
+from cumin.backends import BaseQuery, InvalidQueryError
 from cumin.query import Query, QueryBuilder
 
 
@@ -58,7 +58,15 @@
 
     query_string = 'host1 or (not F:key1 = value and R:key2 ~ regex) or host2'
     invalid_query_string = 'host1 and or not F:key1 value'
-    config = {'backend': 'test_backend'}
+    config = {
+        'backend': 'test_backend',
+        'test_backend': {
+            'aliases': {
+                'group1': 'host1 or host10[10-22]',
+                'nested_group': 'host10[40-42] or A:group1',
+            },
+        },
+    }
 
     @mock.patch('cumin.query.Query', QueryFactory)
     def test_instantiation(self):
@@ -76,7 +84,7 @@
         query_builder.build()
 
         query_builder.query.add_hosts.assert_has_calls(
-            [mock.call(hosts=NodeSet.fromlist(['host1'])), 
mock.call(hosts=NodeSet.fromlist(['host2']))])
+            [mock.call(hosts=NodeSet('host1')), 
mock.call(hosts=NodeSet('host2'))])
         query_builder.query.add_or.assert_has_calls([mock.call(), mock.call()])
         query_builder.query.open_subgroup.assert_called_once_with()
         query_builder.query.add_category.assert_has_calls([
@@ -86,11 +94,50 @@
         query_builder.query.close_subgroup.assert_called_once_with()
 
     @mock.patch('cumin.query.Query', QueryFactory)
+    def test_build_valid_with_aliases(self):
+        """QueryBuilder.build() should replace any aliases and build the query 
object for a valid query."""
+        query_builder = QueryBuilder('host100 or A:group1 or host2', 
self.config)
+        query_builder.build()
+
+        query_builder.query.add_hosts.assert_has_calls(
+            [mock.call(hosts=NodeSet('host100')), 
mock.call(hosts=NodeSet('host1')),
+             mock.call(hosts=NodeSet('host10[10-22]')), 
mock.call(hosts=NodeSet('host2'))])
+        query_builder.query.add_or.assert_has_calls([mock.call(), mock.call(), 
mock.call()])
+        query_builder.query.open_subgroup.assert_called_once_with()
+        query_builder.query.close_subgroup.assert_called_once_with()
+
+    @mock.patch('cumin.query.Query', QueryFactory)
+    def test_build_valid_with_nested_aliases(self):  # pylint: 
disable=invalid-name
+        """QueryBuilder.build() should replace any aliases and build the query 
object for a valid query."""
+        query_builder = QueryBuilder('host100 or A:nested_group', self.config)
+        query_builder.build()
+
+        query_builder.query.add_hosts.assert_has_calls(
+            [mock.call(hosts=NodeSet('host100')), 
mock.call(hosts=NodeSet('host10[40-42]')),
+             mock.call(hosts=NodeSet('host1')), 
mock.call(hosts=NodeSet('host10[10-22]'))])
+        query_builder.query.add_or.assert_has_calls([mock.call(), mock.call()])
+        query_builder.query.open_subgroup.assert_has_calls([mock.call(), 
mock.call()])
+
+    @mock.patch('cumin.query.Query', QueryFactory)
+    def test_build_invalid_alias_syntax(self):
+        """QueryBuilder.build() should raise InvalidQueryError if an alias has 
invalid syntax."""
+        query_builder = QueryBuilder('host1 or A:name = value', self.config)
+        with pytest.raises(InvalidQueryError, match='Invalid alias syntax, 
aliases can be only of the form'):
+            query_builder.build()
+
+    @mock.patch('cumin.query.Query', QueryFactory)
+    def test_build_missing_alias(self):
+        """QueryBuilder.build() should raise InvalidQueryError if a non 
existent alias is found."""
+        query_builder = QueryBuilder('host1 or A:non_existent_group', 
self.config)
+        with pytest.raises(InvalidQueryError, match='Unable to find alias 
replacement for'):
+            query_builder.build()
+
+    @mock.patch('cumin.query.Query', QueryFactory)
     def test_build_glob_host(self):
         """QueryBuilder.build() should parse a glob host."""
         query_builder = QueryBuilder('host1*', self.config)
         query_builder.build()
-        
query_builder.query.add_hosts.assert_called_once_with(hosts=NodeSet.fromlist(['host1*']))
+        
query_builder.query.add_hosts.assert_called_once_with(hosts=NodeSet('host1*'))
 
     @mock.patch('cumin.query.Query', QueryFactory)
     def test_build_invalid(self):
@@ -103,5 +150,5 @@
     def test__parse_token(self):
         """QueryBuilder._parse_token() should raise RuntimeError for an 
invalid token."""
         query_builder = QueryBuilder(self.invalid_query_string, self.config)
-        with pytest.raises(RuntimeError, match='Invalid query string syntax'):
+        with pytest.raises(InvalidQueryError, match='Invalid query string 
syntax'):
             query_builder._parse_token('invalid_token')  # pylint: 
disable=protected-access

-- 
To view, visit https://gerrit.wikimedia.org/r/363748
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I3d6859b052cbafd7786c9b0b96f0abc0e965f87a
Gerrit-PatchSet: 1
Gerrit-Project: operations/software/cumin
Gerrit-Branch: master
Gerrit-Owner: Volans <[email protected]>
Gerrit-Reviewer: Faidon Liambotis <[email protected]>
Gerrit-Reviewer: Gehel <[email protected]>
Gerrit-Reviewer: Giuseppe Lavagetto <[email protected]>
Gerrit-Reviewer: Muehlenhoff <[email protected]>
Gerrit-Reviewer: Volans <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to