Skip to content

Commit 9784fc6

Browse files
committed
Add strongly typed filter
This makes it easy to write RSQL filters in python, which then stringify back down to RSQL.
1 parent 16adc68 commit 9784fc6

File tree

9 files changed

+179
-14
lines changed

9 files changed

+179
-14
lines changed

docs/source/_templates/autosummary/class.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
{% for item in methods %}
1717
{% if item not in inherited_members %}
1818
.. automethod:: {{ item }}
19+
:no-index-entry:
1920
{%- endif %}
2021
{%- endfor %}
2122
{% endif %}
@@ -29,6 +30,7 @@
2930
{% for item in attributes %}
3031
{% if item not in inherited_members %}
3132
.. autoattribute:: {{ item }}
33+
:no-index-entry:
3234
{%- endif %}
3335
{%- endfor %}
3436
{% endif %}

docs/source/conf.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
'sphinx.ext.autosummary',
2929
'sphinx.ext.viewcode',
3030
'sphinx.ext.doctest',
31+
'sphinx.ext.intersphinx',
3132
]
3233
autosummary_generate = True
3334
autosummary_ignore_module_all = False
@@ -49,3 +50,8 @@
4950
}
5051
autoclass_content = 'class'
5152
autodoc_member_order = 'groupwise'
53+
54+
intersphinx_mapping = {
55+
'python': ('https://docs.python.org/3', None),
56+
'pydantic': ('https://docs.pydantic.dev/latest', None),
57+
}

src/obelisk/asynchronous/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
22
This module contains the asynchronous API to Obelisk-py.
3-
These methods all return a :any:`Coroutine`.
3+
These methods all return an :external+python:class:`collections.abc.Awaitable`.
44
55
Relevant entrance points are :class:`client.Obelisk`.
66
It can be imported from the :mod:`.client` module, or directly from this one.

src/obelisk/asynchronous/core.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
"""
1010
from obelisk.asynchronous.base import BaseClient
1111
from obelisk.exceptions import ObeliskError
12+
from obelisk.types.core import FieldName, Filter
1213

1314
from datetime import datetime, timedelta
1415
import httpx
1516
import json
16-
from pydantic import BaseModel, AwareDatetime, ValidationError, model_validator
17-
from typing import AsyncIterator, Dict, Iterator, List, Literal, Optional, Any, get_args
17+
from pydantic import BaseModel, AwareDatetime, ConfigDict, Field, ValidationError, model_validator
18+
from typing import Annotated, AsyncIterator, Dict, Iterator, List, Literal, Optional, Any, get_args
1819
from typing_extensions import Self
1920
from numbers import Number
2021

@@ -27,10 +28,6 @@
2728
"""Type of aggregation Obelisk can process"""
2829

2930

30-
FieldName = str # TODO: validate field names?
31-
"""https://obelisk.pages.ilabt.imec.be/obelisk-core/query.html#available-data-point-fields"""
32-
33-
3431
Datapoint = Dict[str, Any]
3532
"""Datapoints resulting from queries are modeled as simple dicts, as fields can come and go depending on query."""
3633

@@ -93,10 +90,13 @@ class QueryParams(BaseModel):
9390
fields: Optional[List[FieldName]] = None
9491
orderBy: Optional[List[str]] = None # More complex than just FieldName, can be prefixed with - to invert sort
9592
dataType: Optional[DataType] = None
96-
filter: Optional[str] = None # TODO: Validating this must be a nightmare
93+
filter_: Annotated[Optional[str|Filter], Field(serialization_alias='filter')] = None
94+
"""Filter in `RSQL format <https://obelisk.pages.ilabt.imec.be/obelisk-core/query.html#rsql-format>`__ Suffix to avoid collisions."""
9795
cursor: Optional[str] = None
9896
limit: int = 1000
9997

98+
model_config = ConfigDict(arbitrary_types_allowed=True)
99+
100100
@model_validator(mode='after')
101101
def check_datatype_needed(self) -> Self:
102102
if self.fields is None or 'value' in self.fields:
@@ -106,7 +106,7 @@ def check_datatype_needed(self) -> Self:
106106
return self
107107

108108
def to_dict(self) -> Dict:
109-
return self.model_dump(exclude_none=True)
109+
return self.model_dump(exclude_none=True, by_alias=True)
110110

111111

112112
class ChunkedParams(BaseModel):
@@ -116,11 +116,14 @@ class ChunkedParams(BaseModel):
116116
fields: Optional[List[FieldName]] = None
117117
orderBy: Optional[List[str]] = None # More complex than just FieldName, can be prefixed with - to invert sort
118118
dataType: Optional[DataType] = None
119-
filter: Optional[str] = None
119+
filter_: Optional[str | Filter] = None
120+
"""Underscore suffix to avoid name collisions"""
120121
start: datetime
121122
end: datetime
122123
jump: timedelta = timedelta(hours=1)
123124

125+
model_config = ConfigDict(arbitrary_types_allowed=True)
126+
124127
@model_validator(mode='after')
125128
def check_datatype_needed(self) -> Self:
126129
if self.fields is None or 'value' in self.fields:
@@ -133,9 +136,9 @@ def chunks(self) -> Iterator[QueryParams]:
133136
current_start = self.start
134137
while current_start < self.end:
135138
current_end = current_start + self.jump
136-
filter=f'timestamp>={current_start.isoformat()};timestamp<{current_end.isoformat()}'
137-
if self.filter:
138-
filter += f';{self.filter}'
139+
filter_=f'timestamp>={current_start.isoformat()};timestamp<{current_end.isoformat()}'
140+
if self.filter_:
141+
filter_ += f';{self.filter_}'
139142

140143
yield QueryParams(
141144
dataset=self.dataset,
@@ -144,7 +147,7 @@ def chunks(self) -> Iterator[QueryParams]:
144147
fields=self.fields,
145148
orderBy=self.orderBy,
146149
dataType=self.dataType,
147-
filter=filter
150+
filter_=filter_
148151
)
149152

150153
current_start += self.jump
File renamed without changes.

src/obelisk/types/core.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations
2+
from abc import ABC
3+
from datetime import datetime
4+
from typing import Any, Iterable, List
5+
6+
7+
FieldName = str # TODO: validate field names?
8+
"""https://obelisk.pages.ilabt.imec.be/obelisk-core/query.html#available-data-point-fields"""
9+
10+
11+
class Constraint(ABC):
12+
pass
13+
14+
15+
class Comparison():
16+
left: FieldName
17+
right: Any
18+
op: str
19+
20+
def __init__(self, left: FieldName, right: Any, op: str):
21+
self.left = left
22+
self.right = right
23+
self.op = op
24+
25+
def __str__(self) -> str:
26+
right = self._sstr(self.right)
27+
if not right.startswith('('):
28+
right = f"'{right}'"
29+
30+
return f"('{self.left}'{self.op}{right})"
31+
32+
@staticmethod
33+
def _sstr(item: Any):
34+
"""Smart string conversion"""
35+
if isinstance(item, datetime):
36+
return item.isoformat()
37+
return str(item)
38+
39+
@staticmethod
40+
def _iterable_to_group(iter: Iterable[Any]) -> str:
41+
"""Produces a group of the form ("a","b")"""
42+
return str(tuple([Comparison._sstr(x) for x in iter]))
43+
44+
@classmethod
45+
def equal(cls, left: FieldName, right: Any) -> Comparison:
46+
return Comparison(left, right, "==")
47+
48+
@classmethod
49+
def not_equal(cls, left: FieldName, right: Any) -> Comparison:
50+
return Comparison(left, right, "!=")
51+
52+
@classmethod
53+
def less(cls, left: FieldName, right: Any) -> Comparison:
54+
return Comparison(left, right, "<")
55+
56+
@classmethod
57+
def less_equal(cls, left: FieldName, right: Any) -> Comparison:
58+
return Comparison(left, right, "<=")
59+
60+
@classmethod
61+
def greater(cls, left: FieldName, right: Any) -> Comparison:
62+
return Comparison(left, right, ">")
63+
64+
@classmethod
65+
def greater_equal(cls, left: FieldName, right: Any) -> Comparison:
66+
return Comparison(left, right, ">=")
67+
68+
@classmethod
69+
def is_in(cls, left: FieldName, right: Iterable[Any]) -> Comparison:
70+
return Comparison(left, cls._iterable_to_group(right), "=in=")
71+
72+
@classmethod
73+
def is_not_in(cls, left: FieldName, right: Iterable[Any]) -> Comparison:
74+
return Comparison(left, cls._iterable_to_group(right), "=out=")
75+
76+
@classmethod
77+
def null(cls, left: FieldName) -> Comparison:
78+
return Comparison(left, "", "=null=")
79+
80+
@classmethod
81+
def not_null(cls, left: FieldName) -> Comparison:
82+
return Comparison(left, "", "=notnull=")
83+
84+
85+
Item = Constraint | Comparison
86+
87+
88+
class And(Constraint):
89+
content: List[Item]
90+
91+
def __init__(self, *args: Item):
92+
self.content = list(args)
93+
94+
def __str__(self) -> str:
95+
return "(" + ";".join([str(x) for x in self.content]) + ")"
96+
97+
98+
class Or(Constraint):
99+
content: List[Item]
100+
101+
def __init__(self, *args: Item):
102+
self.content = list(args)
103+
104+
def __str__(self) -> str:
105+
return "(" + ",".join([str(x) for x in self.content]) + ")"
106+
107+
108+
class Filter():
109+
content: Item | None = None
110+
111+
def __init__(self, content: Constraint | None = None):
112+
self.content = content
113+
114+
def __str__(self) -> str:
115+
return str(self.content)
116+
117+
def add_and(self, *other: Item) -> Filter:
118+
if self.content is None:
119+
self.content = And(*other)
120+
else:
121+
self.content = And(self.content, *other)
122+
return self
123+
124+
def add_or(self, *other: Item) -> Filter:
125+
if self.content is None:
126+
self.content = Or(*other)
127+
else:
128+
self.content = Or(self.content, *other)
129+
return self
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from obelisk.asynchronous.core import QueryParams
2+
3+
def test_query_param_serialize():
4+
q = QueryParams(dataset="83989232", filter_="(metric=='smartphone.application::string')", dataType='string')
5+
dump = q.to_dict()
6+
assert "filter" in dump

src/tests/typetest/__init__.py

Whitespace-only changes.

src/tests/typetest/filter_test.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from obelisk.types.core import Filter, Comparison
2+
from datetime import datetime
3+
4+
5+
def test_basic_filter():
6+
test_dt = datetime.now()
7+
f = Filter() \
8+
.add_and(
9+
Comparison.equal('source', 'test source'),
10+
)\
11+
.add_or(
12+
Comparison.less('timestamp', test_dt)
13+
)\
14+
.add_or(
15+
Comparison.is_in('metricType', ['number', 'number[]']),
16+
)
17+
18+
expected = f"(((('source'=='test source')),('timestamp'<'{test_dt.isoformat()}')),('metricType'=in=('number', 'number[]')))"
19+
assert str(f) == expected

0 commit comments

Comments
 (0)