diff --git a/muppet/lookup.py b/muppet/lookup.py new file mode 100644 index 0000000000000000000000000000000000000000..0abb64479bd05c9e58f7d64dddd813e139f68f99 --- /dev/null +++ b/muppet/lookup.py @@ -0,0 +1,200 @@ +""" +[Jq(1)](https://jqlang.github.io/jq/) like expressions for python. + +Something similar to Jq, but built on python objects. +All procedures eventually return the expecetd value, or a +user-supplied default value. + + +Example +------- + lookup(i) \ + .ref('docstring') \ + .ref('tags') \ + .select(Ref('tag_name') == 'summary')) \ + .idx(0) + .ref('text') \ + .exec() + +TODO +---- +- `select` + Selects all values from a list which matches a given expression. + This would however require us to manage multiple values at once. +""" + +from typing import Any, Union + + +class _Expression: + """ + A test expression. + + x.find(Ref("key") == "summary") + Would focus in on the first list element which has the key "key" + with a value of "summary". + + This is the root-class, and doesn't make sense to initialize directly. + """ + + def run(self, value: Any) -> bool: + return False + + +class _RefEqExpr(_Expression): + """ + Equality expression. + + Assumes that the left part is a _RefExpr and the right part is a value. + + Checks that the left reference exists in the given value, and that + it's value is equal to the right one. + """ + + def __init__(self, left: '_RefExpr', right: Any): + self.left = left + self.right = right + + def run(self, value: Any) -> bool: + if self.left.key not in value: + return False + else: + return bool(value[self.left.key] == self.right) + + +class _RefExpr(_Expression): + """ + A key reference expression. + + By itself, checks if the given key exists in the given value. + Intended to be used for dictionaries, but will work on anything + implementing `in`. + """ + + def __init__(self, key: str): + self.key = key + + def __eq__(self, other: Any) -> '_RefEqExpr': # type: ignore + """ + Return a new expression checking equality between left and right. + + Left side will be ourself, while the right side can in theory + be anything (see _RefEqExpr for details). + + Typing is removed here, since the base object specifies the type as + def __eq__(self, x: Any) -> bool: + ... + Which we aren't allowed to deviate from according to the + Liskov substitution principle. However, at least sqlalchemy + uses the exact same "trick" for the exact same effect. So + there is a president. + + """ + return _RefEqExpr(self, other) + + def run(self, value: Any) -> bool: + return self.key in value + + +class _NullLookup: + """ + A failed lookup. + + Shares the same interface as true "true" lookup class, but all + methods imidiattely propagate the failure state. + + This saves us from null in the code. + """ + + def get(self, _: str) -> '_NullLookup': + """Propagate null.""" + return self + + def ref(self, _: str) -> '_NullLookup': + """Propagate null.""" + return self + + def idx(self, _: int) -> '_NullLookup': + """Propagate null.""" + return self + + def find(self, _: _Expression) -> '_NullLookup': + """Propagate null.""" + return self + + def value(self, dflt: Any = None) -> Any: + """Return the default value.""" + return dflt + + +class _TrueLookup: + """Easily lookup values in nested data structures.""" + + def __init__(self, object: Any): + self.object = object + + def get(self, key: str) -> Union['_TrueLookup', '_NullLookup']: + """Select object field by name.""" + try: + return _TrueLookup(getattr(self.object, key)) + except Exception: + return _NullLookup() + + def ref(self, key: str) -> Union['_TrueLookup', '_NullLookup']: + """Select object by dictionary key.""" + try: + return _TrueLookup(self.object[key]) + except TypeError: + # Not a dictionary + return _NullLookup() + except KeyError: + # Key not in dictionary + return _NullLookup() + + def idx(self, idx: int) -> Union['_TrueLookup', '_NullLookup']: + """Select array index.""" + try: + return _TrueLookup(self.object[idx]) + except TypeError: + # Not a list + return _NullLookup() + except IndexError: + # Index out of range + return _NullLookup() + + def find(self, expr: _Expression) -> Union['_TrueLookup', '_NullLookup']: + """Find the first element in list matching expression.""" + for item in self.object: + if expr.run(item): + return _TrueLookup(item) + return _NullLookup() + + def value(self, dflt: Any = None) -> Any: + """ + Return the found value. + + If no value is found, either return None, or the second argument. + """ + return self.object + + +# Implemented as a union between our two different types, since the +# split is an implementation detail to easier handle null values. +Lookup = Union[_TrueLookup, _NullLookup] +"""Lookup type.""" + + +def lookup(base: Any) -> Lookup: + """ + Create a new lookup base object. + + All queries should start here. + + Parameters + ---------- + base - Can be anything which has meaningful subfields. + """ + return _TrueLookup(base) + + +Ref = _RefExpr diff --git a/tests/test_lookup.py b/tests/test_lookup.py new file mode 100644 index 0000000000000000000000000000000000000000..4ad12d95a92b05b40ef88a1d5cc68645d330d1bc --- /dev/null +++ b/tests/test_lookup.py @@ -0,0 +1,46 @@ +"""Unit tests for lookup.""" + +from muppet.lookup import lookup, Ref + + +def test_simple_lookup(): + assert lookup(str).get('split').value() == str.split + assert lookup({'a': 'b'}).ref('a').value() == 'b' + assert lookup("Hello").idx(1).value() == 'e' + + +def test_simple_failing_lookups(): + assert lookup(str).get('missing').value() is None + assert lookup(str).get('missing').get('x').value() is None + assert lookup(str).get('missing').ref('x').value() is None + assert lookup(str).get('missing').idx(0).value() is None + assert lookup(str).get('missing').find('Anything can go here').value() is None + + +def test_expressions(): + # Missing field + assert not Ref('field').run({}) + # Present field + assert Ref('field').run({'field': 'anything'}) + # Equality on missing field + assert not (Ref('field') == 'anything').run({'not': 'else'}) + # Equality on present field with different value + assert not (Ref('field') == 'anything').run({'field': 'else'}) + # Equality on present field with expected value + assert (Ref('field') == 'anything').run({'field': 'anything'}) + + +def test_find(): + assert lookup([{'something': 'else'}, {'key': 'value'}]) \ + .find(Ref('key')) \ + .value() == {'key': 'value'} + + assert lookup([{'something': 'else'}, {'key': 'value'}, {'key': '2'}]) \ + .find(Ref('key') == '2') \ + .ref('key') \ + .value() == '2' + + assert lookup([{'something': 'else'}, {'key': 'value'}]) \ + .find(Ref('key') == '2') \ + .ref('key') \ + .value() is None