diff options
Diffstat (limited to 'lmi/scripts/common/versioncheck')
-rw-r--r-- | lmi/scripts/common/versioncheck/__init__.py | 146 | ||||
-rw-r--r-- | lmi/scripts/common/versioncheck/parser.py | 521 |
2 files changed, 667 insertions, 0 deletions
diff --git a/lmi/scripts/common/versioncheck/__init__.py b/lmi/scripts/common/versioncheck/__init__.py new file mode 100644 index 0000000..c7e595f --- /dev/null +++ b/lmi/scripts/common/versioncheck/__init__.py @@ -0,0 +1,146 @@ +# 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> +# +""" +Package with utilities for checking availability of profiles or CIM classes. +Version requirements can also be specified. +""" + +import functools +from pyparsing import ParseException + +from lmi.scripts.common import Configuration +from lmi.scripts.common import errors +from lmi.scripts.common.versioncheck import parser + +def cmp_profiles(fst, snd): + """ + Compare two profiles by their version. + + :returns: + * -1 if the *fst* profile has lower version than *snd* + * 0 if their versions are equal + * 1 otherwise + :rtype: int + """ + fstver = fst.RegisteredVersion + sndver = snd.RegisteredVersion + if fstver == sndver: + return 0 + return -1 if parser.cmp_version(fstver, sndver) else 1 + +def get_profile_version(conn, name, cache=None): + """ + Get version of registered profile on particular broker. Queries + ``CIM_RegisteredProfile`` and ``CIM_RegisteredSubProfile``. The latter + comes in question only when ``CIM_RegisteredProfile`` does not yield any + matching result. + + :param conn: Connection object. + :param string name: Name of the profile which must match value of *RegisteredName* + property. + :param dictionary cache: Optional cache where the result will be stored for + later use. This greatly speeds up evaluation of several expressions refering + to same profiles or classes. + :returns: Version of matching profile found. If there were more of them, + the highest version will be returned. ``None`` will be returned when no matching + profile or subprofile is found. + :rtype: string + """ + if cache and name in cache: + return cache[(conn.uri, name)] + insts = conn.root.interop.wql('SELECT * FROM CIM_RegisteredProfile' + ' WHERE RegisteredName=\"%s\"' % name) + regular = set(i for i in insts if i.classname.endswith('RegisteredProfile')) + if regular: # select instances of PG_RegisteredProfile if available + insts = regular + else: # otherwise fallback to PG_RegisteredSubProfile instances + insts = set(i for i in insts if i not in regular) + if not insts: + ret = None + else: + ret = sorted(insts, cmp=cmp_profiles)[-1].RegisteredVersion + if cache is not None: + cache[(conn.uri, name)] = ret + return ret + +def get_class_version(conn, name, namespace=None, cache=None): + """ + Query broker for version of particular CIM class. Version is stored in + ``Version`` qualifier of particular CIM class. + + :param conn: Connection object. + :param string name: Name of class to query. + :param string namespace: Optional CIM namespace. Defaults to configured namespace. + :param dictionary cache: Optional cache used to speed up expression prrocessing. + :returns: Version of CIM matching class. Empty string if class is registered but + is missing ``Version`` qualifier and ``None`` if it is not registered. + :rtype: string + """ + if namespace is None: + namespace = Configuration.get_instance().namespace + if cache and (namespace, name) in cache: + return cache[(conn.uri, namespace, name)] + ns = conn.get_namespace(namespace) + cls = getattr(ns, name, None) + if not cls: + ret = None + else: + quals = cls.wrapped_object.qualifiers + if 'Version' not in quals: + ret = '' + else: + ret = quals['Version'].value + if cache is not None: + cache[(conn.uri, namespace, name)] = ret + return ret + +def eval_respl(expr, conn, namespace=None, cache=None): + """ + Evaluate LMIReSpL expression on particular broker. + + :param string expr: Expression to evaluate. + :param conn: Connection object. + :param string namespace: Optional CIM namespace where CIM classes will be + searched. + :param dictionary cache: Optional cache speeding up evaluation. + :returns: ``True`` if requirements in expression are satisfied. + :rtype: boolean + """ + if namespace is None: + namespace = Configuration.get_instance().namespace + stack = [] + pvget = functools.partial(get_profile_version, conn, cache=cache) + cvget = functools.partial(get_class_version, conn, + namespace=namespace, cache=cache) + pr = parser.bnf_parser(stack, pvget, cvget) + pr.parseString(expr, parseAll=True) + # Now evaluate starting non-terminal created on stack. + return stack[0]() + diff --git a/lmi/scripts/common/versioncheck/parser.py b/lmi/scripts/common/versioncheck/parser.py new file mode 100644 index 0000000..b5f9116 --- /dev/null +++ b/lmi/scripts/common/versioncheck/parser.py @@ -0,0 +1,521 @@ +# 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, all), + '<=' : (operator.le, all), + '>=' : (operator.ge, all), + '!=' : (operator.ne, any), + '>' : (operator.gt, any), + '<' : (operator.lt, any) +} + +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, which = OP_MAP[opsign] + if which is all: + for ap, bp in zip(aparts, bparts): + if not op(ap, bp): + return False + else: + for ap, bp in zip(aparts, bparts): + if op(ap, bp): + return True + if ap != bp: + return False + return op(len(aparts), len(bparts)) + +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 + def evaluate(self): + """ + :returns: ``True`` if the sub-condition represented by this non-terminal + is satisfied. + :rtype: boolean + """ + pass + +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) + +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) + +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) + +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) + +class Req(SemanticGroup): + """ + Represents one of following subexpressions: + + * single requirement on particular profile + * single requirement on particular class + * a subexpression + """ + pass + +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 '') + +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 + +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 + + 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 + + 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] == '!') + + 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() + + 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)) + + 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)) + + 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]) + +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 |