Source code for lmi.scripts.common.versioncheck.parser

# Copyright (C) 2013-2014 Red Hat, Inc. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and documentation are
# those of the authors and should not be interpreted as representing official
# policies, either expressed or implied, of the FreeBSD Project.
#
# Authors: Michal Minar <miminar@redhat.com>
#
"""
Parser for mini-language specifying profile and class requirements. We call
the language LMIReSpL (openLMI Requirement Specification Language).

The only thing designed for use outside this module is :py:func:`bnf_parser`.

Language is generated by BNF grammer which served as a model for parser.

Formal representation of BNF grammer is following: ::

    expr          ::= term [ op expr ]*
    term          ::= '!'? req
    req           ::= profile_cond | clsreq_cond | '(' expr ')'
    profile_cond  ::= 'profile'? [ profile | profile_quot ] cond?
    clsreq_cond   ::= 'class' [ clsname | clsname_quot] cond?
    profile_quot  ::= '"' /\w+[ +.a-zA-Z0-9_-]*/ '"'
    profile       ::= /\w+[+.a-zA-Z_-]*/
    clsname_quot  ::= '"' clsname '"'
    clsname       ::= /[a-zA-Z]+_[a-zA-Z][a-zA-Z0-9_]*/
    cond          ::= cmpop version
    cmpop         ::= /(<|=|>|!)=|<|>/
    version       ::= /[0-9]+(\.[0-9]+)*/
    op            ::= '&' | '|'

String surrounded by quotes is a literal. String enclosed with slashes is a
regular expression. Square brackets encloses a group of words and limit
the scope of some operation (like iteration).
"""
import abc
import operator
from pyparsing import Literal, Combine, Optional, ZeroOrMore, \
        Forward, Regex, Keyword, FollowedBy, LineEnd, ParseException

#: Dictionary mapping supported comparison operators to a pair. First item is a
#: function making the comparison and the second can be of two values (``all``
#: or ``any``). Former sayes that each part of first version string must be in
#: relation to corresponding part of second version string in order to satisfy
#: the condition. The latter causes the comparison to end on first satisfied
#: part.
OP_MAP = {
        '==' : operator.eq,
        '<=' : operator.le,
        '>=' : operator.ge,
        '!=' : operator.ne,
        '>'  : operator.gt,
        '<'  : operator.lt
}

[docs]def cmp_version(fst, snd, opsign='<'): """ Compare two version specifications. Each version string shall contain digits delimited with dots. Empty string is also valid version. It will be replaced with -1. :param str fst: First version string. :param str snd: Second version string. :param str opsign: Sign denoting operation to be used. Supported signs are present in :py:attr:`OP_MAP`. :returns: ``True`` if the relation denoted by particular operation exists between two operands. :rtype: boolean """ def splitver(ver): """ Converts version string to a tuple of integers. """ return tuple(int(p) if p else -1 for p in ver.split('.')) aparts = splitver(fst) bparts = splitver(snd) op = OP_MAP[opsign] for ap, bp in zip(aparts, bparts): if not op(ap, bp): if ap != bp: return False if ap != bp: return op(ap, bp) return op(len(aparts), len(bparts))
[docs]class SemanticGroup(object): """ Base class for non-terminals. Just a minimal set of non-terminals is represented by objects the rest is represented by strings. All subclasses need to define their own :py:meth:`evaluate` method. The parser builds a tree of these non-terminals with single non-terminal being a root node. This node's *evaluate* method returns a boolean saying whether the condition is satisfied. Root node is always an object of :py:class:`Expr`. """ __metaclass__ = abc.ABCMeta def __call__(self): return self.evaluate() @abc.abstractmethod
[docs] def evaluate(self): """ :returns: ``True`` if the sub-condition represented by this non-terminal is satisfied. :rtype: boolean """ pass
[docs]class Expr(SemanticGroup): """ Initial non-terminal. Object of this class (or one of its subclasses) is a result of parsing. :param term: An object of :py:class:`Term` non-terminal. """ def __init__(self, term): assert isinstance(term, Term) self.fst = term def evaluate(self): return self.fst() def __str__(self): return str(self.fst)
[docs]class And(Expr): """ Represents logical *AND* of two expressions. Short-circuit evaluation is being exploited here. :param fst: An object of :py:class:`Term` non-terminal. :param snd: An object of :py:class:`Term` non-terminal. """ def __init__(self, fst, snd): assert isinstance(snd, (Term, Expr)) Expr.__init__(self, fst) self.snd = snd def evaluate(self): if self.fst(): return self.snd() return False def __str__(self): return "%s & %s" % (self.fst, self.snd)
[docs]class Or(Expr): """ Represents logical *OR* of two expressions. Short-circuit evaluation is being exploited here. :param fst: An object of :py:class:`Term` non-terminal. :param snd: An object of :py:class:`Term` non-terminal. """ def __init__(self, fst, snd): assert isinstance(snd, (Term, Expr)) Expr.__init__(self, fst) self.snd = snd def evaluate(self): if self.fst(): return True return self.snd() def __str__(self): return "%s | %s" % (self.fst, self.snd)
[docs]class Term(SemanticGroup): """ Represents possible negation of expression. :param req: An object of :py:class:`Req`. :param boolean negate: Whether the result of children shall be negated. """ def __init__(self, req, negate): assert isinstance(req, Req) self.req = req self.negate = negate def evaluate(self): res = self.req() return not res if self.negate else res def __str__(self): if self.negate: return '!' + str(self.req) return str(self.req)
[docs]class Req(SemanticGroup): """ Represents one of following subexpressions: * single requirement on particular profile * single requirement on particular class * a subexpression """ pass
[docs]class ReqCond(Req): """ Represents single requirement on particular class or profile. :param str kind: Name identifying kind of thing this belongs. For example ``'class'`` or ``'profile'``. :param callable version_getter: Is a function called to get version of either profile or CIM class. It must return corresponding version string if the profile or class is registered and ``None`` otherwise. Version string is read from ``RegisteredVersion`` property of ``CIM_RegisteredProfile``. If a class is being queried, version shall be taken from ``Version`` qualifier of given class. :param str name: Name of profile or CIM class to check for. In case of a profile, it is compared to ``RegisteredName`` property of ``CIM_RegisteredProfile``. If any instance of this class has matching name, it's version will be checked. If no matching instance is found, instances of ``CIM_RegisteredSubProfile`` are queried the same way. Failing to find it results in ``False``. :param str cond: Is a version requirement. Check the grammer above for ``cond`` non-terminal. """ def __init__(self, kind, version_getter, name, cond=None): assert isinstance(kind, basestring) assert callable(version_getter) assert isinstance(name, basestring) assert cond is None or (isinstance(cond, tuple) and len(cond) == 2) self.kind = kind self.version_getter = version_getter self.name = name self.cond = cond def evaluate(self): version = self.version_getter(self.name) return version and (not self.cond or self._check_version(version)) def _check_version(self, version): """ Checks whether the version of profile or class satisfies the requirement. Version strings are first split into a list of integers (that were delimited with a dot) and then they are compared in descending order from the most signigicant down. :param str version: Version of profile or class to check. """ opsign, cmpver = self.cond return cmp_version(version, cmpver, opsign) def __str__(self): return '{%s "%s"%s}' % ( self.kind, self.name, ' %s %s' % self.cond if self.cond else '')
[docs]class Subexpr(Req): """ Represents a subexpression originally enclosed in brackets. """ def __init__(self, expr): assert isinstance(expr, Expr) self.expr = expr def evaluate(self): return self.expr() def __str__(self): return "(%s)" % self.expr
[docs]class TreeBuilder(object): """ A stack interface for parser. It defines methods modifying the stack with additional checks. """ def __init__(self, stack, profile_version_getter, class_version_getter): if not isinstance(stack, list): raise TypeError("stack needs to be empty!") if stack: stack[:] = [] self.stack = stack self.profile_version_getter = profile_version_getter self.class_version_getter = class_version_getter
[docs] def expr(self, strg, loc, toks): """ Operates upon a stack. It takes either one or two *terms* there and makes an expression object out of them. Terms need to be delimited with logical operator. """ assert len(self.stack) > 0 if not isinstance(self.stack[-1], (Term, Expr)): raise ParseException("Invalid expression (stopped at char %d)." % loc) if len(self.stack) >= 3 and self.stack[-2] in ('&', '|'): assert isinstance(self.stack[-3], Term) if self.stack[-2] == '&': expr = And(self.stack[-3], self.stack[-1]) else: expr = Or(self.stack[-3], self.stack[-1]) self.stack.pop() self.stack.pop() elif not isinstance(self.stack[-1], Expr): expr = Expr(self.stack[-1]) else: expr = self.stack[-1] self.stack[-1] = expr
[docs] def term(self, strg, loc, toks): """ Creates a ``term`` out of requirement (``req`` non-terminal). """ assert len(self.stack) > 0 assert isinstance(self.stack[-1], Req) self.stack[-1] = Term(self.stack[-1], toks[0] == '!')
[docs] def subexpr(self, strg, loc, toks): """ Operates upon a stack. It creates an instance of :py:class:`Subexpr` out of :py:class:`Expr` which is enclosed in brackets. """ assert len(self.stack) > 1 assert self.stack[-2] == '(' assert isinstance(self.stack[-1], Expr) assert len(toks) > 0 and toks[-1] == ')' self.stack[-2] = Subexpr(self.stack[-1]) self.stack.pop()
[docs] def push_class(self, strg, loc, toks): """ Handles ``clsreq_cond`` non-terminal in one go. It extracts corresponding tokens and pushes an object of :py:class:`ReqCond` to a stack. """ assert toks[0] == 'class' assert len(toks) >= 2 name = toks[1] condition = None if len(toks) > 2 and toks[2] in OP_MAP: assert len(toks) >= 4 condition = toks[2], toks[3] self.stack.append(ReqCond('class', self.class_version_getter, name, condition))
[docs] def push_profile(self, strg, loc, toks): """ Handles ``profile_cond`` non-terminal in one go. It behaves in the same way as :py:meth:`push_profile`. """ index = 0 if toks[0] == 'profile': index = 1 assert len(toks) > index name = toks[index] index += 1 condition = None if len(toks) > index and toks[index] in OP_MAP: assert len(toks) >= index + 2 condition = toks[index], toks[index + 1] self.stack.append(ReqCond('profile', self.profile_version_getter, name, condition))
[docs] def push_literal(self, strg, loc, toks): """ Pushes operators to a stack. """ assert toks[0] in ('&', '|', '(') if toks[0] == '(': assert not self.stack or self.stack[-1] in ('&', '|') else: assert len(self.stack) > 0 assert isinstance(self.stack[-1], Term) self.stack.append(toks[0])
[docs]def bnf_parser(stack, profile_version_getter, class_version_getter): """ Builds a parser operating on provided stack. :param list stack: Stack to operate on. It will contain the resulting :py:class:`Expr` object when the parsing is successfully over - it will be the only item in the list. It needs to be initially empty. :param callable profile_version_getter: Function returning version of registered profile or ``None`` if not present. :param callable class_version_getter: Fucntion returning version of registered class or ``None`` if not present. :returns: Parser object. :rtype: :py:class:`pyparsing,ParserElement` """ if not isinstance(stack, list): raise TypeError("stack must be a list!") builder = TreeBuilder(stack, profile_version_getter, class_version_getter) ntop = ((Literal('&') | Literal('|')) + FollowedBy(Regex('["a-zA-Z\(!]'))) \ .setName('op').setParseAction(builder.push_literal) ntversion = Regex(r'[0-9]+(\.[0-9]+)*').setName('version') ntcmpop = Regex(r'(<|=|>|!)=|<|>(?=\s*\d)').setName('cmpop') ntcond = (ntcmpop + ntversion).setName('cond') ntclsname = Regex(r'[a-zA-Z]+_[a-zA-Z][a-zA-Z0-9_]*').setName('clsname') ntclsname_quot = Combine( Literal('"').suppress() + ntclsname + Literal('"').suppress()).setName('clsname_quot') ntprofile_quot = Combine( Literal('"').suppress() + Regex(r'\w+[ +.a-zA-Z0-9_-]*') + Literal('"').suppress()).setName('profile_quot') ntprofile = Regex(r'\w+[+.a-zA-Z0-9_-]*').setName('profile') ntclsreq_cond = ( Keyword('class') + (ntclsname_quot | ntclsname) + Optional(ntcond)).setName('clsreq_cond').setParseAction( builder.push_class) ntprofile_cond = ( Optional(Keyword('profile')) + (ntprofile_quot | ntprofile) + Optional(ntcond)).setName('profile_cond').setParseAction( builder.push_profile) ntexpr = Forward().setName('expr') bracedexpr = ( Literal('(').setParseAction(builder.push_literal) + ntexpr + Literal(')')).setParseAction(builder.subexpr) ntreq = (bracedexpr | ntclsreq_cond | ntprofile_cond).setName('req') ntterm = (Optional(Literal("!")) + ntreq + FollowedBy(Regex('[\)&\|]') | LineEnd()))\ .setParseAction(builder.term) ntexpr << ntterm + ZeroOrMore(ntop + ntexpr).setParseAction(builder.expr) return ntexpr
if __name__ == '__main__': def get_class_version(class_name): try: version = { 'lmi_logicalfile' : '0.1.2' , 'lmi_softwareidentity' : '3.2.1' , 'pg_computersystem' : '1.1.1' }[class_name.lower()] except KeyError: version = None return version def get_profile_version(profile_name): try: version = { 'openlmi software' : '0.1.2' , 'openlmi-software' : '1.3.4' , 'openlmi hardware' : '1.1.1' , 'openlmi-hardware' : '0.2.3' }[profile_name.lower()] except KeyError: version = None return version def test(s, expected): stack = [] parser = bnf_parser(stack, get_profile_version, get_class_version) results = parser.parseString(s, parseAll=True) if len(stack) == 1: evalresult = stack[0]() if expected == evalresult: print "%s\t=>\tOK" % s else: print "%s\t=>\tFAILED" % s else: print "%s\t=>\tFAILED" % s print " stack: [%s]" % ', '.join(str(i) for i in stack) test( 'class LMI_SoftwareIdentity == 0.2.0', False) test( '"OpenLMI-Software" == 0.1.2 & "OpenLMI-Hardware" < 0.1.3', False) test( 'OpenLMI-Software<1|OpenLMI-Hardware!=1.2.4', True) test( '"OpenLMI Software" & profile "OpenLMI Hardware"' ' | ! class LMI_LogicalFile', True) test( 'profile OpenLMI-Software > 0.1.2 & !(class "PG_ComputerSystem"' ' == 2.3.4 | "OpenLMI Hardware")', False) test( 'OpenLMI-Software > 1.3 & OpenLMI-Software >= 1.3.4' ' & OpenLMI-Software < 1.3.4.1 & OpenLMI-Software <= 1.3.4' ' & OpenLMI-Software == 1.3.4', True) test( 'OpenLMI-Software < 1.3.4 | OpenLMI-Software > 1.3.4' ' | OpenLMI-Software != 1.3.4', False) test( '(! OpenLMI-Software == 1.3.4 | OpenLMI-Software <= 1.3.4.1)' ' & !(openlmi-software > 1.3.4 | Openlmi-software != 1.3.4)', True) for badexpr in ( 'OpenLMI-Software > & OpenLMI-Hardware', 'classs LMI_SoftwareIdentity', 'OpenLMI-Software > 1.2.3 | == 5.4.3', '', '"OpenLMI-Software', 'OpenLMI-Software < > OpenLMI-Hardware', 'OpenlmiSoftware & (openLMI-Hardware ', 'OpenLMISoftware & ) OpenLMI-Hardare (', 'OpenLMISoftware | OpenlmiSoftware > "1.2.3"' ): try: test(badexpr, None) except ParseException: print "%s\t=>\tOK" % badexpr