Skip to content

Commit

Permalink
Fix: optional sort param is handled incorrectly causing an api erro…
Browse files Browse the repository at this point in the history
…r in filter() method on assets, events, sequences, time_series apis (#1387)

Co-authored-by: Anders Albert <[email protected]>
Co-authored-by: Håkon V. Treider <[email protected]>
  • Loading branch information
3 people authored Oct 4, 2023
1 parent 312eb17 commit ae47d95
Show file tree
Hide file tree
Showing 14 changed files with 91 additions and 29 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Changes are grouped as follows
- `Fixed` for any bug fixes.
- `Security` in case of vulnerabilities.

## [6.29.2] - 2023-10-04
### Fixed
- Calling some of the methods `assets.filter()`, `events.filter()`, `sequences.filter()`, `time_series.filter()` without a `sort` parameter could cause a `CogniteAPIError` with a 400 code. This is now fixed.

## [6.29.1] - 2023-10-04
### Added
- Convenience method `to_text` on the `FunctionCallLog` class which simplifies printing out function call logs.
Expand Down
9 changes: 3 additions & 6 deletions cognite/client/_api/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
from cognite.client.utils._concurrency import classify_error, get_priority_executor
from cognite.client.utils._identifier import IdentifierSequence
from cognite.client.utils._text import to_camel_case
from cognite.client.utils._validation import process_asset_subtree_ids, process_data_set_ids
from cognite.client.utils._validation import prepare_filter_sort, process_asset_subtree_ids, process_data_set_ids

if TYPE_CHECKING:
from concurrent.futures import Future
Expand Down Expand Up @@ -907,10 +907,7 @@ def filter(
"""
self._validate_filter(filter)
if sort is None:
sort = []
elif not isinstance(sort, list):
sort = [sort]

if aggregated_properties:
aggregated_properties_camel = [to_camel_case(prop) for prop in aggregated_properties]
else:
Expand All @@ -922,7 +919,7 @@ def filter(
method="POST",
limit=limit,
advanced_filter=filter.dump(camel_case=True) if isinstance(filter, Filter) else filter,
sort=[AssetSort.load(item).dump(camel_case=True) for item in sort],
sort=prepare_filter_sort(sort, AssetSort),
other_params={"aggregatedProperties": aggregated_properties_camel} if aggregated_properties_camel else {},
)

Expand Down
8 changes: 2 additions & 6 deletions cognite/client/_api/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from cognite.client.data_classes.events import EventPropertyLike, EventSort, SortableEventProperty
from cognite.client.data_classes.filters import Filter, _validate_filter
from cognite.client.utils._identifier import IdentifierSequence
from cognite.client.utils._validation import process_asset_subtree_ids, process_data_set_ids
from cognite.client.utils._validation import prepare_filter_sort, process_asset_subtree_ids, process_data_set_ids

SortSpec: TypeAlias = Union[
EventSort,
Expand Down Expand Up @@ -697,18 +697,14 @@ def filter(
... sort=(SortableEventProperty.start_time, "desc"))
"""
self._validate_filter(filter)
if sort is None:
sort = []
elif not isinstance(sort, list):
sort = [sort]

return self._list(
list_cls=EventList,
resource_cls=Event,
method="POST",
limit=limit,
advanced_filter=filter.dump(camel_case=True) if isinstance(filter, Filter) else filter,
sort=[EventSort.load(item).dump(camel_case=True) for item in sort],
sort=prepare_filter_sort(sort, EventSort),
)

def _validate_filter(self, filter: Filter | dict | None) -> None:
Expand Down
8 changes: 2 additions & 6 deletions cognite/client/_api/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from cognite.client.data_classes.shared import TimestampRange
from cognite.client.utils._identifier import Identifier, IdentifierSequence
from cognite.client.utils._text import convert_all_keys_to_camel_case
from cognite.client.utils._validation import process_asset_subtree_ids, process_data_set_ids
from cognite.client.utils._validation import prepare_filter_sort, process_asset_subtree_ids, process_data_set_ids

if TYPE_CHECKING:
import pandas
Expand Down Expand Up @@ -767,18 +767,14 @@ def filter(
"""
self._validate_filter(filter)
if sort is None:
sort = []
elif not isinstance(sort, list):
sort = [sort]

return self._list(
list_cls=SequenceList,
resource_cls=Sequence,
method="POST",
limit=limit,
advanced_filter=filter.dump(camel_case=True) if isinstance(filter, Filter) else filter,
sort=[SequenceSort.load(item).dump(camel_case=True) for item in sort],
sort=prepare_filter_sort(sort, SequenceSort),
api_subversion="beta",
)

Expand Down
9 changes: 2 additions & 7 deletions cognite/client/_api/time_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from cognite.client.data_classes.filters import Filter, _validate_filter
from cognite.client.data_classes.time_series import SortableTimeSeriesProperty, TimeSeriesProperty, TimeSeriesSort
from cognite.client.utils._identifier import IdentifierSequence
from cognite.client.utils._validation import process_asset_subtree_ids, process_data_set_ids
from cognite.client.utils._validation import prepare_filter_sort, process_asset_subtree_ids, process_data_set_ids

if TYPE_CHECKING:
from cognite.client import CogniteClient
Expand Down Expand Up @@ -683,19 +683,14 @@ def filter(
>>> res = c.time_series.filter(filter=is_numeric, sort=SortableTimeSeriesProperty.external_id)
"""
self._validate_filter(filter)
if sort is None:
sort = []
elif not isinstance(sort, list):
sort = [sort]

return self._list(
list_cls=TimeSeriesList,
resource_cls=TimeSeries,
method="POST",
limit=limit,
advanced_filter=filter.dump(camel_case=True) if isinstance(filter, Filter) else filter,
sort=[TimeSeriesSort.load(item).dump(camel_case=True) for item in sort],
api_subversion="beta",
sort=prepare_filter_sort(sort, TimeSeriesSort),
)

def _validate_filter(self, filter: Filter | dict | None) -> None:
Expand Down
2 changes: 1 addition & 1 deletion cognite/client/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from __future__ import annotations

__version__ = "6.29.1"
__version__ = "6.29.2"
__api_subversion__ = "V20220125"
23 changes: 22 additions & 1 deletion cognite/client/utils/_validation.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
from __future__ import annotations

import functools
from typing import TYPE_CHECKING, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Callable, Literal, Mapping, Sequence, Tuple, Union

from typing_extensions import TypeAlias

from cognite.client.data_classes._base import T_CogniteSort
from cognite.client.utils._identifier import Identifier, IdentifierSequence

if TYPE_CHECKING:
from cognite.client.utils._identifier import T_ID


SortSpec: TypeAlias = Union[
T_CogniteSort,
str,
Tuple[str, Literal["asc", "desc"]],
Tuple[str, Literal["asc", "desc"], Literal["auto", "first", "last"]],
]


def validate_user_input_dict_with_identifier(dct: Mapping, required_keys: set[str]) -> dict[str, T_ID]:
if not isinstance(dct, Mapping):
raise TypeError(f"Expected dict-like object, got {type(dct)}")
Expand Down Expand Up @@ -45,3 +56,13 @@ def _process_identifiers(
process_asset_subtree_ids: Callable[
[int | Sequence[int] | None, str | Sequence[str] | None], list[dict[str, int | str]] | None
] = functools.partial(_process_identifiers, id_name="asset_subtree")


def prepare_filter_sort(
sort: SortSpec | list[SortSpec] | None, sort_type: type[T_CogniteSort]
) -> list[dict[str, Any]] | None:
if sort is not None:
if not isinstance(sort, list):
sort = [sort]
return [sort_type.load(item).dump(camel_case=True) for item in sort]
return None
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
[tool.poetry]
name = "cognite-sdk"

version = "6.29.1"
version = "6.29.2"
description = "Cognite Python SDK"
readme = "README.md"
documentation = "https://cognite-sdk-python.readthedocs-hosted.com"
Expand Down
15 changes: 15 additions & 0 deletions tests/tests_integration/test_api/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,21 @@ def test_filter_on_metadata_key(self, cognite_client: CogniteClient, asset_list:
assert len(result) == 1, "Expected only one asset to match the filter"
assert result[0].external_id == "integration_test:asset2"

def test_filter_without_sort(self, cognite_client: CogniteClient, asset_list: AssetList) -> None:
# Arrange
f = filters
is_integration_test = f.Prefix("external_id", "integration_test:")
in_europe = f.Prefix(AssetProperty.metadata_key("timezone"), "Europe")

# Act
result = cognite_client.assets.filter(
f.And(is_integration_test, in_europe), aggregated_properties=["child_count"], sort=None
)

# Assert
assert len(result) == 1, "Expected only one asset to match the filter"
assert result[0].external_id == "integration_test:asset2"

def test_aggregate_count(self, cognite_client: CogniteClient, asset_list: AssetList) -> None:
f = filters
is_integration_test = f.Prefix("externalId", "integration_test:")
Expand Down
10 changes: 10 additions & 0 deletions tests/tests_integration/test_api/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ def test_filter_search(self, cognite_client: CogniteClient, event_list: EventLis
assert len(result) == 1, "Expected only one event to match the filter"
assert result[0].external_id == "integration_test:event1_lorem_ipsum"

def test_filter_search_without_sort(self, cognite_client: CogniteClient, event_list: EventList) -> None:
f = filters
is_integration_test = f.Prefix(EventProperty.external_id, "integration_test:")
has_lorem_ipsum = f.Search(EventProperty.description, "lorem ipsum")

result = cognite_client.events.filter(f.And(is_integration_test, has_lorem_ipsum), sort=None)

assert len(result) == 1, "Expected only one event to match the filter"
assert result[0].external_id == "integration_test:event1_lorem_ipsum"

def test_aggregate_count(self, cognite_client: CogniteClient, event_list: EventList) -> None:
f = filters
is_integration_test = f.Prefix(EventProperty.external_id, "integration_test:")
Expand Down
1 change: 1 addition & 0 deletions tests/tests_integration/test_api/test_iam.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def test_create_and_delete(self, cognite_client):


class TestSessionsAPI:
@pytest.mark.skip("Bad test: CogniteAPIError: There can be only 10000 sessions")
@pytest.mark.skipif(
os.getenv("LOGIN_FLOW") == "client_certificate", reason="Sessions do not work with client_certificate"
)
Expand Down
15 changes: 15 additions & 0 deletions tests/tests_integration/test_api/test_sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,21 @@ def test_filter_equals(self, cognite_client: CogniteClient, sequence_list: Seque
assert len(result) == 1, "Expected only one sequence in subtree"
assert result[0].external_id == sequence_list[0].external_id

def test_filter_without_sort(
self, cognite_client: CogniteClient, sequence_list: SequenceList, root_asset: Asset
) -> None:
# Arrange
f = filters
is_integration_test = f.Prefix(SequenceProperty.external_id, "integration_test:")
is_asset = f.Equals(SequenceProperty.asset_id, root_asset.id)

# Act
result = cognite_client.sequences.filter(f.And(is_integration_test, is_asset), sort=None)

# Assert
assert len(result) == 1
assert result[0].external_id == sequence_list[0].external_id

def test_aggregate_count(self, cognite_client: CogniteClient, sequence_list: SequenceList) -> None:
f = filters
is_integration_test = f.Prefix("externalId", "integration_test:")
Expand Down
12 changes: 12 additions & 0 deletions tests/tests_integration/test_api/test_time_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ def test_filter_is_numeric(self, cognite_client: CogniteClient, test_tss: TimeSe
# Assert
assert result, "There should be at least one numeric time series"

def test_filter_without_sort(self, cognite_client: CogniteClient, test_tss: TimeSeriesList) -> None:
# Arrange
f = filters
is_integration_test = f.Prefix(TimeSeriesProperty.external_id, "PYSDK integration test")
is_numeric = f.Equals(TimeSeriesProperty.is_string, False)

# Act
result = cognite_client.time_series.filter(f.And(is_integration_test, is_numeric), sort=None)

# Assert
assert result, "There should be at least one numeric time series"

def test_aggregate_count(self, cognite_client: CogniteClient, time_series_list: TimeSeriesList) -> None:
f = filters
is_integration_test = f.Prefix("externalId", "integration_test:")
Expand Down
1 change: 1 addition & 0 deletions tests/tests_integration/test_api/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ def test_retrieve_non_existing_workflow_execution(self, cognite_client: CogniteC

assert non_existing is None

@pytest.mark.skip("Bad test: CogniteAPIError: There can be only 10000 sessions")
def test_trigger_retrieve_detailed_update_update_task(
self, cognite_client: CogniteClient, add_multiply_workflow: WorkflowVersion
) -> None:
Expand Down

0 comments on commit ae47d95

Please sign in to comment.