aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2023-06-03 19:54:26 +0200
committerHugo Hörnquist <hugo@lysator.liu.se>2023-06-03 19:54:26 +0200
commitb2898df70651910389665643aa20ce1c15c3a838 (patch)
tree8ef7960a5be5135311d06d300cdb58914d6144dc
parentMinor test cleanup. (diff)
downloadmuppet-strings-b2898df70651910389665643aa20ce1c15c3a838.tar.gz
muppet-strings-b2898df70651910389665643aa20ce1c15c3a838.tar.xz
Introduce lookup.
-rw-r--r--muppet/lookup.py200
-rw-r--r--tests/test_lookup.py46
2 files changed, 246 insertions, 0 deletions
diff --git a/muppet/lookup.py b/muppet/lookup.py
new file mode 100644
index 0000000..0abb644
--- /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 0000000..4ad12d9
--- /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