diff --git a/src/obelisk/asynchronous/core.py b/src/obelisk/asynchronous/core.py index e9231f7..0b5d7e5 100644 --- a/src/obelisk/asynchronous/core.py +++ b/src/obelisk/asynchronous/core.py @@ -42,7 +42,9 @@ from obelisk.types.core import IngestMode -DataType = Literal["number", "number[]", "json", "bool", "string", "integer", "integer[]"] +DataType = Literal[ + "number", "number[]", "json", "bool", "string", "integer", "integer[]" +] """The possible types of data Obelisk can accept""" @@ -168,7 +170,12 @@ class QueryParams(BaseModel): """List of Field Names, with their potential prefixes and suffixes, to select ordering. None user server defaults.""" dataType: DataType | None = None """Data type expected to be returned, is mandatory if the `value` field is requested in the `fields` parameter""" - filter_: Annotated[str | Filter | None, Field(serialization_alias="filter",)] = None + filter_: Annotated[ + str | Filter | None, + Field( + serialization_alias="filter", + ), + ] = None """ Obelisk CORE handles filtering in [RSQL format](https://obelisk.pages.ilabt.imec.be/obelisk-core/query.html#rsql-format), to make it easier to also programatically write these filters, we provide the `obelisk.types.core.Filter` option as well. @@ -192,7 +199,7 @@ def to_dict(self) -> dict[str, Any]: exclude_none=True, by_alias=True, mode="json", exclude={"dataset"} ) - @field_serializer('filter_', mode='plain') + @field_serializer("filter_", mode="plain") def serialize_filter(self, value: Filter | str | None) -> str | None: if value is None or isinstance(value, str): return value @@ -323,7 +330,7 @@ async def send( response = await self.http_post( f"{self.kind.root_url}/{dataset}/data/ingest", data=[x.model_dump(mode="json") for x in data], - params={"mode": ingest_mode.value} + params={"mode": ingest_mode.value}, ) if response.status_code != 204: msg = f"An error occured during data ingest. Status {response.status_code}, message: {response.text}" diff --git a/src/obelisk/types/core.py b/src/obelisk/types/core.py index bf460e0..2e6df8d 100644 --- a/src/obelisk/types/core.py +++ b/src/obelisk/types/core.py @@ -4,20 +4,21 @@ To create a filter, look at `Filter`. Example: ->>> from datetime import datetime +>>> from datetime import datetime, timezone >>> f = (Filter().add_and( ... Comparison.equal('source', 'test source'), ... Comparison.is_in('metricType', ['number', 'number[]']), ... ).add_or( -... Comparison.less('timestamp', datetime.fromtimestamp(1757422128)) +... Comparison.less('timestamp', datetime.fromtimestamp(1757422128).astimezone(timezone.utc)) ... )) >>> print(f) -(('source'=='test source';'metricType'=in=('number', 'number[]')),'timestamp'<'2025-09-09T14:48:48') +(('source'=='test source';'metricType'=in=('number', 'number[]')),'timestamp'<'2025-09-09T12:48:48+00:00') """ from __future__ import annotations from abc import ABC from datetime import datetime + from typing import Any from collections.abc import Iterable from enum import Enum @@ -55,6 +56,8 @@ class Comparison: When serializing to RSQL format, each argument is single quoted as to accept any otherwise reserved characters, and serialised using :func:`str`. + + If the right-hand value is a datetime, it _must_ be timezone-aware. """ left: FieldName @@ -77,6 +80,8 @@ def __str__(self) -> str: def _sstr(item: Any) -> str: """Smart string conversion""" if isinstance(item, datetime): + if item.tzinfo is None: + raise ValueError(f"Right side of comparison is naive datetime: {item}") return item.isoformat() return str(item) @@ -204,6 +209,7 @@ def add_or(self, *other: Item) -> Filter: class IngestMode(str, Enum): """Whether the ingested datapoints should be streamed, stored, or both (the default)""" + BOTH = "DEFAULT" STREAM = "STREAM_ONLY" STORE = "STORE_ONLY" diff --git a/src/tests/typetest/filter_test.py b/src/tests/typetest/filter_test.py index 9d547e3..96446ef 100644 --- a/src/tests/typetest/filter_test.py +++ b/src/tests/typetest/filter_test.py @@ -1,9 +1,10 @@ from obelisk.types.core import Filter, Comparison -from datetime import datetime +from datetime import datetime, timezone +import pytest def test_basic_filter(): - test_dt = datetime.now() + test_dt = datetime.now(tz=timezone.utc) f = ( Filter() .add_and( @@ -17,3 +18,9 @@ def test_basic_filter(): expected = f"((('source'=='test source'),'timestamp'<'{test_dt.isoformat()}'),'metricType'=in=('number', 'number[]'))" assert str(f) == expected + + +def test_naive_datetime(): + with pytest.raises(ValueError): + c = Comparison.less("timestamp", datetime.fromtimestamp(1879282)) + print(str(c))