from __future__ import annotations
import re
from dataclasses import (
dataclass,
field,
)
from typing import TYPE_CHECKING
from onyo.lib.consts import (
SORT_DESCENDING,
TAG_MAP_TYPES,
TAG_EMPTY,
TAG_UNSET,
TAG_MAP_VALUES,
)
from onyo.lib.exceptions import OnyoInvalidFilterError
from onyo.lib.items import Item
from onyo.lib.command_utils import natural_sort
if TYPE_CHECKING:
from typing import Tuple
[docs]
@dataclass
class Filter:
r"""Translate a string regular expression to a match function.
Intended for use with string patterns passed from Onyo's CLI.
This can be used along with builtin :py:func:`filter` to remove non-matching
items. For example::
repo = Repo()
f = Filter('foo=bar')
assets[:] = filter(f.match, repo.assets)
"""
_arg: str = field(repr=False)
key: str = field(init=False)
value: str = field(init=False)
operator: str = field(init=False)
def __post_init__(self) -> None:
r"""Set up a ``key=value`` conditional as a filter.
``value`` must be a valid Python regular expression.
"""
self.key, self.operator, self.value = self._format(self._arg)
@staticmethod
def _format(arg: str) -> Tuple[str, str, str]:
r"""Split filters on the first occurrence of an operator.
Valid operators are ``=``, ``!=``, ``>``, ``>=``, ``<``, and ``<=``.
Parameters
----------
arg
Raw string to split
Raises
------
OnyoInvalidFilterError
No valid operator was found.
"""
index = None
for c in ['=', '!=', '>', '<']:
if c in arg:
idx = arg.index(c)
index = idx if index is None or idx < index else index
if index is None:
raise OnyoInvalidFilterError(
'Filters must be formatted as `key=value`')
match arg[index:index + 2]:
case "<=":
operator = "<="
case ">=":
operator = ">="
case "!=":
operator = "!="
case _:
operator = arg[index]
if index in (0, len(arg) - len(operator)):
raise OnyoInvalidFilterError(
'Filters must be formatted as `key=value`')
key, value = arg.split(operator, 1)
return key, operator, value
@staticmethod
def _re_match(text: str,
r: str) -> bool:
r"""Does a whole string fully match a regular expression pattern.
Parameters
----------
text
String to match
r
Regular expression Pattern
"""
try:
return True if re.compile(r).fullmatch(text) else False
except re.error:
return False
def _tags_or_types_match(self,
item: Item) -> bool:
r"""Whether the tags or types of ``self.value`` equals ``Item[self.key]``.
Parameters
----------
item
Item to compare against.
Raises
------
KeyError
``self.key`` is not in ``item`` (and ``self.value`` is not ``<unset>``)
ValueError
``self.key`` is not a tag or empty literal.
"""
if self.value == TAG_UNSET:
# match if the key is not present
return self.key not in item
if self.key not in item:
# we can't match anything other than TAG_UNSET, if key is not present
raise KeyError
item_value = item[self.key]
# onyo type representation match (<list>, <dict>, etc)
if self.value in TAG_MAP_TYPES:
return isinstance(item_value, TAG_MAP_TYPES[self.value])
# onyo value representation match (<false>, <null>, <true>, etc)
if self.value in TAG_MAP_VALUES:
return item_value is TAG_MAP_VALUES[self.value]
# <empty> is special
if self.value == TAG_EMPTY:
return any(item_value == x for x in [None, '', [], {}])
# literal empty structure representations
match self.value:
case '[]' | '{}':
return str(item_value) == self.value
case '""' | "''":
return str(item_value) == ''
raise ValueError
def _match_equal(self,
item: Item) -> bool:
r"""Whether ``self.value`` equals ``Item[self.key]``.
Parameters
----------
item
Item to compare against.
"""
try:
return self._tags_or_types_match(item)
except ValueError:
# not a tag or empty type literal
return item[self.key] == self.value
def _match_equal_with_re(self,
item: Item) -> bool:
r"""Whether ``self.value`` equals ``Item[self.key]``.
Regex is supported.
Parameters
----------
item
Item to compare against.
"""
try:
return self._tags_or_types_match(item)
except ValueError:
# not a tag or empty type literal
item_value = item[self.key]
return item_value == self.value or self._re_match(str(item_value), self.value)
def _match_not_equal(self,
item: Item) -> bool:
r"""Whether ``self.value`` does not equal ``Item[self.key]``.
Regex is supported.
Parameters
----------
item
Item to compare against.
"""
return not self._match_equal_with_re(item)
def _match_greater_than(self,
item: Item) -> bool:
r"""Whether ``self.value`` is > ``Item[self.key]``.
A natural sort is used (i.e. '300' > '5').
Parameters
----------
item
Item to compare against.
Raises
------
KeyError
``self.key`` is not in ``item`` (and ``self.value`` is not ``<unset>``)
ValueError
Comparison is not possible (e.g. across types/tags)
"""
if self.value in [*TAG_MAP_TYPES, *TAG_MAP_VALUES, TAG_EMPTY, TAG_UNSET]:
# type comparisons are not possible
raise ValueError
if self.key not in item:
# comparison with nothing is not possible
raise KeyError
item_value = item[self.key]
# literal empty structures ([], {})
empty_structs = {'[]': list, '{}': dict, '""': str, "''": str}
if self.value in empty_structs:
if isinstance(item_value, empty_structs[self.value]):
# an non-empty dict/list/string is indeed greater than an empty one
return bool(item_value)
else:
# comparison is not possible
raise ValueError
# ``item`` is intentionally put second in the list, so that it will stay
# in place when the compared key values match, and only move up when
# it's indeed greater.
item_list = [
Item({self.key: self.value}),
item,
]
keys = {
self.key: SORT_DESCENDING,
}
sorted_item_list = natural_sort(item_list, keys=keys) # pyre-ignore[6]
return sorted_item_list[0] == item
def _match_greater_than_or_equal(self,
item: Item) -> bool:
r"""Whether ``self.value`` is >= ``Item[self.key]``.
A natural sort is used (i.e. '300' > '5').
Parameters
----------
item
Item to compare against.
Raises
------
ValueError
Comparison is not possible (e.g. across types/tags)
"""
if self.value in [*TAG_MAP_TYPES, *TAG_MAP_VALUES, TAG_EMPTY, TAG_UNSET]:
# type comparisons are not possible
raise ValueError
if self._match_equal(item):
return True
return self._match_greater_than(item)
def _match_less_than(self,
item: Item) -> bool:
r"""Whether ``self.value`` is < ``Item[self.key]``.
A natural sort is used (i.e. '5' < '300').
Parameters
----------
item
Item to compare against.
"""
return not self._match_greater_than_or_equal(item)
def _match_less_than_or_equal(self,
item: Item) -> bool:
r"""Whether ``self.value`` is <= ``Item[self.key]``.
A natural sort is used (i.e. '5' < '300').
Parameters
----------
item
Item to compare against.
Raises
------
ValueError
Comparison is not possible (e.g. across types/tags)
"""
if self.value in [*TAG_MAP_TYPES, *TAG_MAP_VALUES, TAG_EMPTY, TAG_UNSET]:
# type comparisons are not possible
raise ValueError
if self._match_equal(item):
return True
return not self._match_greater_than(item)
[docs]
def match(self,
item: Item) -> bool:
r"""Does ``item`` match this ``Filter``.
Parameters
----------
item
Item to match against.
Raises
------
OnyoInvalidFilterError
No valid operator was found.
"""
try:
match self.operator:
case "=":
return self._match_equal_with_re(item)
case "!=":
return self._match_not_equal(item)
case ">":
return self._match_greater_than(item)
case ">=":
return self._match_greater_than_or_equal(item)
case "<":
return self._match_less_than(item)
case "<=":
return self._match_less_than_or_equal(item)
case _:
raise OnyoInvalidFilterError
except (KeyError, ValueError):
# when the question makes no sense, return False
return False