Skip to content

Commit 7fd4376

Browse files
authored
Merge pull request #72 from Cumulocity-IoT/feature/jupyter-support
Measurements and Series handling fixes and improvements
2 parents 1b4fed4 + 4a51661 commit 7fd4376

File tree

11 files changed

+318
-477
lines changed

11 files changed

+318
-477
lines changed

c8y_api/model/_base.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import logging
66
from typing import Any, Iterable, Set
7-
from urllib.parse import quote_plus, urlencode
7+
from urllib.parse import urlencode
88

99
from collections.abc import MutableMapping, MutableSequence
1010
from deprecated import deprecated
@@ -61,6 +61,9 @@ def __init__(self, dictionary: dict, on_update=None):
6161
self.__dict__['_property_items'] = dictionary
6262
self.__dict__['_property_on_update'] = on_update
6363

64+
def __repr__(self):
65+
return f'{type(self).__name__}({self.__dict__["_property_items"]})'
66+
6467
def has(self, name: str):
6568
"""Check whether a key is present in the dictionary."""
6669
return name in self.__dict__['_property_items']
@@ -108,6 +111,9 @@ def __init__(self, values: list, on_update=None):
108111
self.__dict__['_property_items'] = values
109112
self.__dict__['_property_on_update'] = on_update
110113

114+
def __repr__(self):
115+
return f'{type(self).__name__}({self.__dict__["_property_items"]})'
116+
111117
def __getitem__(self, i):
112118
item = self.__dict__['_property_items'][i]
113119
if isinstance(item, dict):
@@ -423,7 +429,7 @@ def __setitem__(self, name: str, fragment: str | bool | int | float | dict | lis
423429
(specified as nested dictionary).::
424430
425431
obj['c8y_SimpleValue'] = 14
426-
obj['c8y_ComplexValue'] = { 'x': 1, 'y': 2, 'text': 'message'}
432+
obj['c8y_ComplexValue'] = { ('x', 1, 'y': 2), 'text': 'message'}
427433
428434
Args:
429435
name (str): Name of the custom fragment.
@@ -642,7 +648,6 @@ def _map_params(
642648
name=None,
643649
fragment=None,
644650
source=None, # noqa (type)
645-
value=None,
646651
series=None,
647652
owner=None,
648653
device_id=None,
@@ -669,7 +674,7 @@ def _map_params(
669674
reverse=None,
670675
page_size=None,
671676
page_number=None, # (must not be part of the prepared query)
672-
**kwargs) -> dict:
677+
**kwargs) -> list[tuple]:
673678
assert not page_number
674679

675680
def multi(*xs):
@@ -716,6 +721,7 @@ def multi(*xs):
716721
'owner': owner,
717722
'source': source,
718723
'fragmentType': fragment,
724+
# 'series': series,
719725
'deviceId': device_id,
720726
'agentId': agent_id,
721727
'bulkId': bulk_id,
@@ -730,13 +736,19 @@ def multi(*xs):
730736
'lastUpdatedTo': updated_to,
731737
'withSourceAssets': with_source_assets,
732738
'withSourceDevices': with_source_devices,
733-
'revert': str(reverse) if reverse is not None else None,
739+
'revert': str(reverse).lower() if reverse is not None else None,
734740
'pageSize': page_size}.items() if v is not None}
735741
params.update({_StringUtil.to_pascal_case(k): v for k, v in kwargs.items() if v is not None})
736-
return params
742+
tuples = list(params.items())
743+
if series:
744+
if isinstance(series, list):
745+
tuples += [('series', s) for s in series]
746+
else:
747+
tuples.append(('series', series))
748+
return tuples
737749

738750
def _prepare_query(self, resource: str = None, expression: str = None, **kwargs):
739-
encoded = quote_plus(expression) if expression else urlencode(self._map_params(**kwargs))
751+
encoded = expression or urlencode(self._map_params(**kwargs))
740752
if not encoded:
741753
return resource or self.resource
742754
return (resource or self.resource) + '?' + encoded

c8y_api/model/measurements.py

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -716,19 +716,11 @@ def get_series(
716716
717717
See also: https://cumulocity.com/api/core/#operation/getMeasurementSeriesResource
718718
"""
719-
# The 'series' parameter has to be added through a hack; it
720-
# may be a list and because 'series' is by default converted to
721-
# the 'valueFragmentSeries' parameter
722-
723-
if series:
724-
series = series if isinstance(series, str) else ','.join(series)
725-
726719
base_query = self._prepare_query(
727720
resource=f'{self.resource}/series',
728721
expression=expression,
729722
source=source,
730-
# this is a non-mapped parameter
731-
aggregationType=aggregation,
723+
aggregationType=aggregation, # this is a non-mapped parameter
732724
series=series,
733725
before=before,
734726
after=after,
@@ -738,14 +730,81 @@ def get_series(
738730
**kwargs)
739731
return Series(self.c8y.get(base_query))
740732

733+
def collect_series(
734+
self,
735+
expression: str = None,
736+
source: str = None,
737+
aggregation: str = None,
738+
series: str | Sequence[str] = None,
739+
before: str | datetime = None,
740+
after: str | datetime = None,
741+
min_age: timedelta = None,
742+
max_age: timedelta = None,
743+
reverse: bool = None,
744+
value: str = None,
745+
timestamps: bool|str = None,
746+
**kwargs
747+
):
748+
"""Query the database for series values.
749+
750+
This function is functionally the same as using the `get_series` function
751+
with an immediate `collect` on the result.
752+
753+
Args:
754+
expression (str): Arbitrary filter expression which will be
755+
passed to Cumulocity without change; all other filters
756+
are ignored if this is provided
757+
source (str): Database ID of a source device
758+
aggregation (str): Aggregation type
759+
series (str|Sequence[str]): Series' to query and collect; If
760+
multiple series are collected each element in the result will
761+
be a tuple. If omitted, all available series are collected.
762+
before (datetime|str): Datetime object or ISO date/time string.
763+
Only measurements assigned to a time before this date are
764+
included.
765+
after (datetime|str): Datetime object or ISO date/time string.
766+
Only measurements assigned to a time after this date are
767+
included.
768+
min_age (timedelta): Timedelta object. Only measurements of
769+
at least this age are included.
770+
max_age (timedelta): Timedelta object. Only measurements with
771+
at most this age are included.
772+
reverse (bool): Invert the order of results, starting with the
773+
most recent one.
774+
value (str): Which value (min/max) to collect. If omitted, both
775+
values will be collected, grouped as 2-tuples.
776+
timestamps (bool|str): Whether each element in the result list will
777+
be prepended with the corresponding timestamp. If True, the
778+
timestamp string will be included; Use 'datetime' or 'epoch' to
779+
parse the timestamp string.
780+
781+
Returns:
782+
A simple list or list of tuples (potentially nested) depending on the
783+
parameter combination.
784+
785+
See also: https://cumulocity.com/api/core/#operation/getMeasurementSeriesResource
786+
"""
787+
result = self.get_series(
788+
expression=expression,
789+
source=source,
790+
aggregation=aggregation,
791+
series=series,
792+
before=before,
793+
after=after,
794+
min_age=min_age,
795+
max_age=max_age,
796+
reverse=reverse,
797+
**kwargs)
798+
return result.collect(
799+
series=series,
800+
value=value,
801+
timestamps=timestamps)
802+
741803
def delete_by(
742804
self,
743805
expression: str = None,
744806
type: str = None,
745807
source: str | int = None,
746-
# value_fragment_type: str = None,
747-
# value_fragment_series: str = None,
748-
# series: str = None,
749808
date_from: str | datetime = None,
750809
date_to: str | datetime = None,
751810
before: str | datetime = None,
@@ -785,9 +844,6 @@ def delete_by(
785844
expression=expression,
786845
type=type,
787846
source=source,
788-
# value_fragment_type=value_fragment_type,
789-
# value_fragment_series=value_fragment_series,
790-
# series=series,
791847
date_from=date_from,
792848
date_to=date_to,
793849
before=before,

integration_tests/test_measurements.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,37 @@ def test_collect_multiple_series(series_fixture, request):
261261
for i in range(0, len(series_names)):
262262
t = type(values[0][i])
263263
assert all(isinstance(v[i], t) for v in values if v[i])
264+
265+
266+
def test_get_and_collect_series(live_c8y, sample_series_device):
267+
"""Verify that get & collect works as expected."""
268+
series = live_c8y.measurements.get_series(
269+
source=sample_series_device.id,
270+
series=sample_series_device.c8y_SupportedSeries,
271+
aggregation=Measurements.AggregationType.HOURLY,
272+
after='1970-01-01',
273+
before='now'
274+
)
275+
276+
# multiple series
277+
collected = series.collect(sample_series_device.c8y_SupportedSeries)
278+
directly_collected = live_c8y.measurements.collect_series(
279+
source=sample_series_device.id,
280+
series=sample_series_device.c8y_SupportedSeries,
281+
aggregation=Measurements.AggregationType.HOURLY,
282+
after='1970-01-01',
283+
before='now'
284+
)
285+
assert collected == directly_collected
286+
287+
# single series
288+
for series_name in sample_series_device.c8y_SupportedSeries:
289+
collected = series.collect(series_name)
290+
directly_collected = live_c8y.measurements.collect_series(
291+
source=sample_series_device.id,
292+
series=series_name,
293+
aggregation=Measurements.AggregationType.HOURLY,
294+
after='1970-01-01',
295+
before='now'
296+
)
297+
assert collected == directly_collected

integration_tests/test_notification2.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,3 @@ async def receive_notification(m:AsyncListener.Message):
418418
# (99) cleanup
419419
await asyncio.gather(*[l.close() for l in listeners])
420420
await asyncio.wait(listener_tasks)
421-
await asyncio.all_tasks()

0 commit comments

Comments
 (0)