Skip to content

Commit

Permalink
Merge pull request #6 from telefonicasc/embed-pyjexl
Browse files Browse the repository at this point in the history
ADD allow to use context for transformations arguments
  • Loading branch information
arcosa authored Mar 8, 2024
2 parents 83e31b6 + 261ae9c commit 4974cdb
Show file tree
Hide file tree
Showing 12 changed files with 759 additions and 3 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

This is a wrapper of the [pyjexl](https://pypi.org/project/pyjexl/) library, including a set of default transformations (detailed in [this section](#included-transformations))

Current version of the tcjexl library embeds the pyjexl code (as in [0.3.0 release](https://files.pythonhosted.org/packages/ab/1d/757ac4c9ae2da97cbb2c844fb70395990b5bbacccff5c0297ceefd670c62/pyjexl-0.3.0.tar.gz)) in order to apply some fixes. Ideally, the fix should be applied on the upstream library (in this sense, ve have created [this PR](https://github.com/mozilla/pyjexl/pull/30)) but it hasn't been merged yet at the moment of writing this by pyjexl mantainers. Hopefully, if at some moment the fix is applied on pyjexl we could simplify this (it would be a matter of rollback [this PR](https://github.com/telefonicasc/tcjexl/pull/6) and set the proper pyjexl dependency, e.g. `pyjexl==0.4.0`)

Example:

```python
Expand Down Expand Up @@ -141,6 +143,8 @@ upload process you will be prompted to provide your user and password.

## Changelog

- Add: allow to use context for transformations arguments (using a patched version of pyjexl)

0.1.0 (March 6th, 2024)

- Initial release
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@

# Required packages. They will be installed if not already installed
INSTALL_REQUIRES = [
'pyjexl==0.3.0'
'parsimonious==0.10.0',
'future==1.0.0'
]

setup(
Expand Down
4 changes: 2 additions & 2 deletions tcjexl/jexl.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# along with IoT orchestrator. If not, see http://www.gnu.org/licenses/.


import pyjexl
from .pyjexl.jexl import JEXL as pyJEXL
import random
import datetime
import math
Expand All @@ -25,7 +25,7 @@
from datetime import timezone
from .expression_functions import linearInterpolator, linearSelector, randomLinearInterpolator, zipStrList, valueResolver

class JEXL(pyjexl.JEXL):
class JEXL(pyJEXL):
def __init__(self, context=None, now=datetime.datetime.now(timezone.utc)):
super().__init__(context=context)

Expand Down
23 changes: 23 additions & 0 deletions tcjexl/pyjexl/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Copyright (c) 2016 Michael Kelly.

bin/build-package.sh and CI configurations were taken from
https://github.com/atom/ci and are Copyright (c) 2015 GitHub Inc.

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3 changes: 3 additions & 0 deletions tcjexl/pyjexl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# flake8: noqa
from .exceptions import EvaluationError, JEXLException, MissingTransformError
from .jexl import JEXL
24 changes: 24 additions & 0 deletions tcjexl/pyjexl/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class JEXLAnalyzer(object):
def __init__(self, jexl_config):
self.config = jexl_config

def visit(self, expression):
method = getattr(self, 'visit_' + type(expression).__name__, self.generic_visit)
return method(expression)

def generic_visit(self, expression):
raise NotImplementedError()


class ValidatingAnalyzer(JEXLAnalyzer):
def visit_Transform(self, transform):
if transform.name not in self.config.transforms:
yield "The `{name}` transform is undefined.".format(name=transform.name)
for t in self.generic_visit(transform):
yield t

def generic_visit(self, expression):
for child in expression.children:
assert child is not None
for c in self.visit(child):
yield c
115 changes: 115 additions & 0 deletions tcjexl/pyjexl/evaluator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
try:
from collections.abc import MutableMapping
except ImportError:
# Python 2.7 compat
# TODO: Decide if we stop supporting 2.7
# as it's been EOL for a while now
from collections import MutableMapping

from .exceptions import MissingTransformError


class Context(MutableMapping):
def __init__(self, context_data=None):
self.data = context_data or {}
self.relative_value = {}

def __getitem__(self, key):
return self.data[key]

def __setitem__(self, key, value):
self.data[key] = value

def __delitem__(self, key):
del self.data[key]

def __iter__(self):
return iter(self.data)

def __len__(self):
return len(self.data)

def with_relative(self, relative_value):
new_context = Context(self.data)
new_context.relative_value = relative_value
return new_context


class Evaluator(object):
def __init__(self, jexl_config):
self.config = jexl_config

def evaluate(self, expression, context=None):
method = getattr(self, 'visit_' + type(expression).__name__, self.generic_visit)
context = context or Context()
return method(expression, context)

def visit_BinaryExpression(self, exp, context):
return exp.operator.do_evaluate(
lambda: self.evaluate(exp.left, context),
lambda: self.evaluate(exp.right, context)
)

def visit_UnaryExpression(self, exp, context):
return exp.operator.do_evaluate(lambda: self.evaluate(exp.right, context))

def visit_Literal(self, literal, context):
return literal.value

def visit_Identifier(self, identifier, context):
if identifier.relative:
subject = context.relative_value
elif identifier.subject:
subject = self.evaluate(identifier.subject, context)
else:
subject = context

return subject.get(identifier.value, None)

def visit_ObjectLiteral(self, object_literal, context):
return dict(
(key, self.evaluate(value, context))
for key, value in object_literal.value.items()
)

def visit_ArrayLiteral(self, array_literal, context):
return [self.evaluate(value, context) for value in array_literal.value]

def visit_Transform(self, transform, context):
try:
transform_func = self.config.transforms[transform.name]
except KeyError:
raise MissingTransformError(
'No transform found with the name "{name}"'.format(name=transform.name)
)

args = [self.evaluate(arg, context) for arg in transform.args]
return transform_func(self.evaluate(transform.subject, context), *args)

def visit_FilterExpression(self, filter_expression, context):
values = self.evaluate(filter_expression.subject, context)
if filter_expression.relative:
return [
value for value in values
if self.evaluate(filter_expression.expression, context.with_relative(value))
]
else:
filter_value = self.evaluate(filter_expression.expression, context)
if filter_value is True:
return values
elif filter_value is False:
return None
else:
try:
return values[filter_value]
except (IndexError, KeyError):
return None

def visit_ConditionalExpression(self, conditional, context):
if self.evaluate(conditional.test, context):
return self.evaluate(conditional.consequent, context)
else:
return self.evaluate(conditional.alternate, context)

def generic_visit(self, expression, context):
raise ValueError('Could not evaluate expression: ' + repr(expression))
18 changes: 18 additions & 0 deletions tcjexl/pyjexl/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class JEXLException(Exception):
"""Base class for all pyjexl exceptions."""


class ParseError(JEXLException):
"""An error during the parsing of an expression."""


class InvalidOperatorError(ParseError):
"""An invalid operator was used."""


class EvaluationError(JEXLException):
"""An error during evaluation of a parsed expression."""


class MissingTransformError(EvaluationError):
"""An unregistered transform was used."""
93 changes: 93 additions & 0 deletions tcjexl/pyjexl/jexl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from builtins import str
from collections import namedtuple
from functools import wraps

from parsimonious.exceptions import ParseError as ParsimoniousParseError

from .analysis import ValidatingAnalyzer
from .evaluator import Context, Evaluator
from .exceptions import ParseError
from .operators import default_binary_operators, default_unary_operators, Operator
from .parser import jexl_grammar, Parser


#: Encapsulates the variable parts of JEXL that affect parsing and
#: evaluation.
JEXLConfig = namedtuple('JEXLConfig', ['transforms', 'unary_operators', 'binary_operators'])


def invalidates_grammar(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
self._grammar = None
return func(self, *args, **kwargs)
return wrapper


class JEXL(object):
def __init__(self, context=None):
self.context = Context(context or {})
self.config = JEXLConfig(
transforms={},
unary_operators=default_unary_operators.copy(),
binary_operators=default_binary_operators.copy()
)

self._grammar = None

@property
def grammar(self):
if not self._grammar:
self._grammar = jexl_grammar(self.config)
return self._grammar

@invalidates_grammar
def add_binary_operator(self, operator, precedence, func):
self.config.binary_operators[operator] = Operator(operator, precedence, func)

@invalidates_grammar
def remove_binary_operator(self, operator):
del self.config.binary_operators[operator]

@invalidates_grammar
def add_unary_operator(self, operator, func):
self.config.unary_operators[operator] = Operator(operator, 1000, func)

@invalidates_grammar
def remove_unary_operator(self, operator):
del self.config.unary_operators[operator]

def add_transform(self, name, func):
self.config.transforms[name] = func

def remove_transform(self, name):
del self.config.transforms[name]

def transform(self, name=None):
def wrapper(func):
self.config.transforms[name or func.__name__] = func
return func
return wrapper

def parse(self, expression):
try:
return Parser(self.config).visit(self.grammar.parse(expression))
except ParsimoniousParseError:
raise ParseError('Could not parse expression: ' + expression)

def analyze(self, expression, AnalyzerClass):
parsed_expression = self.parse(expression)
visitor = AnalyzerClass(self.config)
return visitor.visit(parsed_expression)

def validate(self, expression):
try:
for res in self.analyze(expression, ValidatingAnalyzer):
yield res
except ParseError as err:
yield str(err)

def evaluate(self, expression, context=None):
parsed_expression = self.parse(expression)
context = Context(context) if context is not None else self.context
return Evaluator(self.config).evaluate(parsed_expression, context)
51 changes: 51 additions & 0 deletions tcjexl/pyjexl/operators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import operator


class Operator(object):
__slots__ = ('symbol', 'precedence', 'evaluate', '_evaluate_lazy')

def __init__(self, symbol, precedence, evaluate, evaluate_lazy=False):
"""Operator definition.
If evaluate_lazy is set to True, the `evaluate()` method will receive
it's parameters as a lambda expression that needs to be called to
receive the value of the expression. Otherwise, the values will
already be evaluated.
"""
self.symbol = symbol
self.precedence = precedence
self.evaluate = evaluate
self._evaluate_lazy = evaluate_lazy

def do_evaluate(self, *args):
if self._evaluate_lazy:
return self.evaluate(*args)
return self.evaluate(*(arg() for arg in args))

def __repr__(self):
return 'Operator({})'.format(repr(self.symbol))


default_binary_operators = {
'+': Operator('+', 30, operator.add),
'-': Operator('-', 30, operator.sub),
'*': Operator('*', 40, operator.mul),
'//': Operator('//', 40, operator.floordiv),
'/': Operator('/', 40, operator.truediv),
'%': Operator('%', 50, operator.mod),
'^': Operator('^', 50, operator.pow),
'==': Operator('==', 20, operator.eq),
'!=': Operator('!=', 20, operator.ne),
'>=': Operator('>=', 20, operator.ge),
'>': Operator('>', 20, operator.gt),
'<=': Operator('<=', 20, operator.le),
'<': Operator('<', 20, operator.lt),
'&&': Operator('&&', 10, lambda a, b: a() and b(), evaluate_lazy=True),
'||': Operator('||', 10, lambda a, b: a() or b(), evaluate_lazy=True),
'in': Operator('in', 20, lambda a, b: a in b),
}


default_unary_operators = {
'!': Operator('!', 1000, operator.not_),
}
Loading

0 comments on commit 4974cdb

Please sign in to comment.