Skip to content

Commit 7b1467d

Browse files
committed
Make filters combinable with & and | operators
1 parent 4aa8ce4 commit 7b1467d

File tree

2 files changed

+182
-8
lines changed

2 files changed

+182
-8
lines changed

faculty/clients/experiment/_models.py

+73-8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from collections import namedtuple
1717
from enum import Enum
1818

19+
from attr import attrs, attrib
20+
1921

2022
class LifecycleStage(Enum):
2123
ACTIVE = "active"
@@ -81,21 +83,84 @@ class ComparisonOperator(Enum):
8183
GREATER_THAN_OR_EQUAL_TO = "ge"
8284

8385

84-
ProjectIdFilter = namedtuple("ProjectIdFilter", ["operator", "value"])
85-
ExperimentIdFilter = namedtuple("ExperimentIdFilter", ["operator", "value"])
86-
RunIdFilter = namedtuple("RunIdFilter", ["operator", "value"])
87-
DeletedAtFilter = namedtuple("DeletedAtFilter", ["operator", "value"])
88-
TagFilter = namedtuple("TagFilter", ["key", "operator", "value"])
89-
ParamFilter = namedtuple("ParamFilter", ["key", "operator", "value"])
90-
MetricFilter = namedtuple("MetricFilter", ["key", "operator", "value"])
86+
def _matching_compound(filter, operator):
87+
return isinstance(filter, CompoundFilter) and filter.operator == operator
88+
89+
90+
def _combine_filters(first, second, op):
91+
if _matching_compound(first, op) and _matching_compound(second, op):
92+
conditions = first.conditions + second.conditions
93+
elif _matching_compound(first, op):
94+
conditions = first.conditions + [second]
95+
elif _matching_compound(second, op):
96+
conditions = [first] + second.conditions
97+
else:
98+
conditions = [first, second]
99+
return CompoundFilter(op, conditions)
100+
101+
102+
class BaseFilter(object):
103+
def __and__(self, other):
104+
return _combine_filters(self, other, LogicalOperator.AND)
105+
106+
def __or__(self, other):
107+
return _combine_filters(self, other, LogicalOperator.OR)
108+
109+
110+
@attrs
111+
class ProjectIdFilter(BaseFilter):
112+
operator = attrib()
113+
value = attrib()
114+
115+
116+
@attrs
117+
class ExperimentIdFilter(BaseFilter):
118+
operator = attrib()
119+
value = attrib()
120+
121+
122+
@attrs
123+
class RunIdFilter(BaseFilter):
124+
operator = attrib()
125+
value = attrib()
126+
127+
128+
@attrs
129+
class DeletedAtFilter(BaseFilter):
130+
operator = attrib()
131+
value = attrib()
132+
133+
134+
@attrs
135+
class TagFilter(BaseFilter):
136+
key = attrib()
137+
operator = attrib()
138+
value = attrib()
139+
140+
141+
@attrs
142+
class ParamFilter(BaseFilter):
143+
key = attrib()
144+
operator = attrib()
145+
value = attrib()
146+
147+
148+
@attrs
149+
class MetricFilter(BaseFilter):
150+
key = attrib()
151+
operator = attrib()
152+
value = attrib()
91153

92154

93155
class LogicalOperator(Enum):
94156
AND = "and"
95157
OR = "or"
96158

97159

98-
CompoundFilter = namedtuple("CompoundFilter", ["operator", "conditions"])
160+
@attrs
161+
class CompoundFilter(BaseFilter):
162+
operator = attrib()
163+
conditions = attrib()
99164

100165

101166
class SortOrder(Enum):
+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright 2018-2019 Faculty Science Limited
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import uuid
17+
18+
import pytest
19+
20+
from faculty.clients.experiment._models import (
21+
ComparisonOperator,
22+
CompoundFilter,
23+
DeletedAtFilter,
24+
ExperimentIdFilter,
25+
LogicalOperator,
26+
MetricFilter,
27+
ParamFilter,
28+
ProjectIdFilter,
29+
RunIdFilter,
30+
TagFilter,
31+
)
32+
33+
34+
SINGLE_FILTERS = [
35+
ProjectIdFilter(ComparisonOperator.EQUAL_TO, uuid.uuid4()),
36+
ExperimentIdFilter(ComparisonOperator.NOT_EQUAL_TO, 4),
37+
RunIdFilter(ComparisonOperator.EQUAL_TO, uuid.uuid4()),
38+
DeletedAtFilter(ComparisonOperator.DEFINED, False),
39+
TagFilter("key", ComparisonOperator.EQUAL_TO, "value"),
40+
ParamFilter("key", ComparisonOperator.NOT_EQUAL_TO, "value"),
41+
ParamFilter("key", ComparisonOperator.GREATER_THAN, 0.3),
42+
MetricFilter("key", ComparisonOperator.LESS_THAN_OR_EQUAL_TO, 0.6),
43+
]
44+
AND_FILTER = CompoundFilter(
45+
LogicalOperator.AND,
46+
[
47+
ExperimentIdFilter(ComparisonOperator.EQUAL_TO, 4),
48+
ParamFilter("key", ComparisonOperator.GREATER_THAN_OR_EQUAL_TO, 0.4),
49+
],
50+
)
51+
OR_FILTER = CompoundFilter(
52+
LogicalOperator.OR,
53+
[
54+
ExperimentIdFilter(ComparisonOperator.EQUAL_TO, 4),
55+
ExperimentIdFilter(ComparisonOperator.EQUAL_TO, 5),
56+
],
57+
)
58+
59+
60+
@pytest.mark.parametrize("left", SINGLE_FILTERS + [OR_FILTER])
61+
@pytest.mark.parametrize("right", SINGLE_FILTERS + [OR_FILTER])
62+
def test_non_mergable_and(left, right):
63+
assert (left & right) == CompoundFilter(LogicalOperator.AND, [left, right])
64+
65+
66+
@pytest.mark.parametrize("left", SINGLE_FILTERS + [AND_FILTER])
67+
@pytest.mark.parametrize("right", SINGLE_FILTERS + [AND_FILTER])
68+
def test_non_mergable_or(left, right):
69+
assert (left | right) == CompoundFilter(LogicalOperator.OR, [left, right])
70+
71+
72+
@pytest.mark.parametrize("right", SINGLE_FILTERS)
73+
def test_left_mergeable_and(right):
74+
assert (AND_FILTER & right) == CompoundFilter(
75+
LogicalOperator.AND, AND_FILTER.conditions + [right]
76+
)
77+
78+
79+
@pytest.mark.parametrize("right", SINGLE_FILTERS)
80+
def test_left_mergeable_or(right):
81+
assert (OR_FILTER | right) == CompoundFilter(
82+
LogicalOperator.OR, OR_FILTER.conditions + [right]
83+
)
84+
85+
86+
@pytest.mark.parametrize("left", SINGLE_FILTERS)
87+
def test_right_mergeable_and(left):
88+
assert (left & AND_FILTER) == CompoundFilter(
89+
LogicalOperator.AND, [left] + AND_FILTER.conditions
90+
)
91+
92+
93+
@pytest.mark.parametrize("left", SINGLE_FILTERS)
94+
def test_right_mergeable_or(left):
95+
assert (left | OR_FILTER) == CompoundFilter(
96+
LogicalOperator.OR, [left] + OR_FILTER.conditions
97+
)
98+
99+
100+
def test_fully_mergable_and():
101+
assert (AND_FILTER & AND_FILTER) == CompoundFilter(
102+
LogicalOperator.AND, AND_FILTER.conditions + AND_FILTER.conditions
103+
)
104+
105+
106+
def test_fully_mergable_or():
107+
assert (OR_FILTER | OR_FILTER) == CompoundFilter(
108+
LogicalOperator.OR, OR_FILTER.conditions + OR_FILTER.conditions
109+
)

0 commit comments

Comments
 (0)