Skip to content

Commit e86978f

Browse files
authored
Merge pull request #71 from Cumulocity-IoT/feature/get-as-tuples
Added as_tuples parameter to select and get_all functions.
2 parents 75ccf67 + 4a4c08a commit e86978f

File tree

9 files changed

+197
-13
lines changed

9 files changed

+197
-13
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
* Added `as_tuples` parameter to `select` and `get_all` functions in all inventory API as well
4+
as Events and Alarms API. This parameter can be used to directly extract specific values from
5+
the results instead of parsing the JSON.
36
* Added `reload` function to inventory object classes
47
* Added `delete_tree` function to inventory object classes, implicitly using `cascade` or
58
`forceCascade` parameters depending on the use case.

c8y_api/model/_base.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,48 @@
1313
from c8y_api.model._util import _DateUtil, _StringUtil, _QueryUtil
1414

1515

16+
def get_by_path(dictionary: dict, path: str, default: Any = None) -> Any:
17+
"""Select a nested value from a dictionary by path-like expression
18+
(dot notation).
19+
20+
Args:
21+
dictionary (dict): the dictionary to extract values from
22+
path (str): a path-like expressions
23+
default (Any): default value to return if the path expression
24+
doesn't match a value in the dictionary.
25+
26+
Return:
27+
The extracted value or the specified default.
28+
"""
29+
def _get_by_path(current: dict, segments: list[str]) -> Any:
30+
if not segments:
31+
return current
32+
if segments[0] in current:
33+
return _get_by_path(current[segments[0]], segments[1:])
34+
return default
35+
36+
return _get_by_path(dictionary, path.split('.'))
37+
38+
39+
def get_all_by_path(dictionary: dict, paths: list[str] | dict[str, Any]) -> tuple:
40+
"""Select nested values from a dictionary by path-like expressions
41+
(dot notation).
42+
43+
Args:
44+
dictionary (dict): the dictionary to extract values from
45+
paths: (list or dict): a set of path-like expressions; use
46+
a dictionary to define default values for each
47+
48+
Return:
49+
The extracted values (or defaults it specified) as tuple. The
50+
number of elements in the tuple matches the length of the paths
51+
argument.
52+
"""
53+
if isinstance(paths, dict):
54+
return tuple(get_by_path(dictionary, p, d) for p, d in paths.items())
55+
return tuple(get_by_path(dictionary, p) for p in paths)
56+
57+
1658
class _DictWrapper(MutableMapping, dict):
1759

1860
def __init__(self, dictionary: dict, on_update=None):
@@ -704,7 +746,8 @@ def _iterate(self, base_query: str, page_number: int | None, limit: int | None,
704746
for result in results:
705747
if limit and num_results >= limit:
706748
return
707-
result.c8y = self.c8y # inject c8y connection into instance
749+
if hasattr(result, 'c8y'):
750+
result.c8y = self.c8y # inject c8y connection into instance
708751
yield result
709752
num_results = num_results + 1
710753
# when a specific page was specified we don't read more pages

c8y_api/model/alarms.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from __future__ import annotations
44

55
from datetime import datetime, timedelta
6-
from typing import List, Generator
6+
from typing import List, Generator, Any
77

88
from c8y_api._base_api import CumulocityRestApi
9-
from c8y_api.model._base import CumulocityResource, SimpleObject, ComplexObject
9+
from c8y_api.model._base import CumulocityResource, SimpleObject, ComplexObject, get_all_by_path
1010
from c8y_api.model._parser import ComplexObjectParser
1111
from c8y_api.model._util import _DateUtil
1212

@@ -255,6 +255,7 @@ def select(self,
255255
reverse: bool = False, limit: int = None,
256256
with_source_assets: bool = None, with_source_devices: bool = None,
257257
page_size: int = 1000, page_number: int = None,
258+
as_tuples: list[str] | dict[str, Any] = None,
258259
**kwargs) -> Generator[Alarm]:
259260
"""Query the database for alarms and iterate over the results.
260261
@@ -306,6 +307,10 @@ def select(self,
306307
parsed in one chunk). This is a performance related setting.
307308
page_number (int): Pull a specific page; this effectively disables
308309
automatic follow-up page retrieval.
310+
as_tuples: (list[str] or dict[str, Any]): Don't parse Alarms, but
311+
extract the values at certain JSON paths as tuples; If the
312+
path is not defined in a result, None is used; Specify a
313+
dictionary to define proper default values for each path.
309314
310315
Returns:
311316
Generator of Alarm objects
@@ -324,7 +329,11 @@ def select(self,
324329
min_age=min_age, max_age=max_age,
325330
reverse=reverse, page_size=page_size,
326331
**kwargs)
327-
return super()._iterate(base_query, page_number, limit, Alarm.from_json)
332+
return super()._iterate(
333+
base_query,
334+
page_number,
335+
limit,
336+
Alarm.from_json if not as_tuples else (lambda x: get_all_by_path(x, as_tuples)))
328337

329338
def get_all(
330339
self,
@@ -341,6 +350,7 @@ def get_all(
341350
with_source_assets: bool = None, with_source_devices: bool = None,
342351
reverse: bool = False, limit: int = None,
343352
page_size: int = 1000, page_number: int = None,
353+
as_tuples: list[str] | dict[str, Any] = None,
344354
**kwargs) -> List[Alarm]:
345355
"""Query the database for alarms and return the results as list.
346356
@@ -365,6 +375,7 @@ def get_all(
365375
min_age=min_age, max_age=max_age, reverse=reverse,
366376
with_source_devices=with_source_devices, with_source_assets=with_source_assets,
367377
limit=limit, page_size=page_size, page_number=page_number,
378+
as_tuples=as_tuples,
368379
**kwargs))
369380

370381
def count(

c8y_api/model/events.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
from __future__ import annotations
77

88
from datetime import datetime, timedelta
9-
from typing import Generator, List, BinaryIO
9+
from typing import Generator, List, BinaryIO, Any
1010

1111
from c8y_api._base_api import CumulocityRestApi
12-
from c8y_api.model._base import CumulocityResource, SimpleObject, ComplexObject
12+
from c8y_api.model._base import CumulocityResource, SimpleObject, ComplexObject, get_all_by_path
1313
from c8y_api.model._parser import ComplexObjectParser
1414
from c8y_api.model._util import _DateUtil
1515

@@ -264,6 +264,7 @@ def select(self,
264264
reverse: bool = False, limit: int = None,
265265
with_source_assets: bool = None, with_source_devices: bool = None,
266266
page_size: int = 1000, page_number: int = None,
267+
as_tuples: list[str] | dict[str, Any] = None,
267268
**kwargs) -> Generator[Event]:
268269
"""Query the database for events and iterate over the results.
269270
@@ -314,6 +315,10 @@ def select(self,
314315
parsed in one chunk). This is a performance related setting.
315316
page_number (int): Pull a specific page; this effectively disables
316317
automatic follow-up page retrieval.
318+
as_tuples: (list[str] or dict[str, Any]): Don't parse Events, but
319+
extract the values at certain JSON paths as tuples; If the
320+
path is not defined in a result, None is used; Specify a
321+
dictionary to define proper default values for each path.
317322
318323
Returns:
319324
Generator for Event objects
@@ -335,7 +340,11 @@ def select(self,
335340
with_source_devices=with_source_devices, with_source_assets=with_source_assets,
336341
page_size=page_size,
337342
**kwargs)
338-
return super()._iterate(base_query, page_number, limit, Event.from_json)
343+
return super()._iterate(
344+
base_query,
345+
page_number,
346+
limit,
347+
Event.from_json if not as_tuples else (lambda x: get_all_by_path(x, as_tuples)))
339348

340349
def get_all(
341350
self,
@@ -352,6 +361,7 @@ def get_all(
352361
reverse: bool = False, limit: int = None,
353362
with_source_assets: bool = None, with_source_devices: bool = None,
354363
page_size: int = 1000, page_number: int = None,
364+
as_tuples: list[str] | dict[str, Any] = None,
355365
**kwargs) -> List[Event]:
356366
"""Query the database for events and return the results as list.
357367
@@ -377,6 +387,7 @@ def get_all(
377387
reverse=reverse,
378388
with_source_devices=with_source_devices, with_source_assets=with_source_assets,
379389
limit=limit, page_size=page_size, page_number=page_number,
390+
as_tuples=as_tuples,
380391
**kwargs
381392
))
382393

c8y_api/model/inventory.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from typing import Any, Generator, List
77

8-
from c8y_api.model._base import CumulocityResource
8+
from c8y_api.model._base import CumulocityResource, get_all_by_path
99
from c8y_api.model._util import _QueryUtil
1010
from c8y_api.model.managedobjects import ManagedObjectUtil, ManagedObject, Device, Availability, DeviceGroup
1111

@@ -61,6 +61,7 @@ def get_all(
6161
reverse: bool = None,
6262
limit: int = None,
6363
page_size: int = 1000,
64+
as_tuples: list[str] | dict[str, Any] = None,
6465
**kwargs) -> List[ManagedObject]:
6566
""" Query the database for managed objects and return the results
6667
as list.
@@ -93,6 +94,7 @@ def get_all(
9394
reverse=reverse,
9495
limit=limit,
9596
page_size=page_size,
97+
as_tuples=as_tuples,
9698
**kwargs))
9799

98100
def get_count(
@@ -154,6 +156,7 @@ def select(
154156
limit: int = None,
155157
page_size: int = 1000,
156158
page_number: int = None,
159+
as_tuples: list[str] | dict[str, Any] = None,
157160
**kwargs) -> Generator[ManagedObject]:
158161
""" Query the database for managed objects and iterate over the
159162
results.
@@ -205,6 +208,10 @@ def select(
205208
parsed in one chunk). This is a performance related setting.
206209
page_number (int): Pull a specific page; this effectively disables
207210
automatic follow-up page retrieval.
211+
as_tuples: (list[str] or dict[str, Any]): Don't parse ManagedObjects,
212+
but extract the values at certain JSON paths as tuples; If the path
213+
is not defined in a result, None is used; Specify a dictionary to
214+
define proper default values for each path.
208215
209216
Returns:
210217
Generator for ManagedObject instances
@@ -234,6 +241,7 @@ def select(
234241
limit=limit,
235242
page_size=page_size,
236243
page_number=page_number,
244+
as_tuples=as_tuples,
237245
**kwargs)
238246

239247
def _prepare_inventory_query(
@@ -336,10 +344,14 @@ def filter_none(**xs):
336344

337345
return {query_key: query, **kwargs}
338346

339-
def _select(self, parse_fun, device_mode: bool, page_number, limit, **kwargs) -> Generator[Any]:
347+
def _select(self, parse_fun, device_mode: bool, page_number, limit, as_tuples, **kwargs) -> Generator[Any]:
340348
"""Generic select function to be used by derived classes as well."""
341349
base_query = self._prepare_inventory_query(device_mode, **kwargs)
342-
return super()._iterate(base_query, page_number, limit, parse_fun)
350+
return super()._iterate(
351+
base_query,
352+
page_number,
353+
limit,
354+
parse_fun if not as_tuples else (lambda x: get_all_by_path(x, as_tuples)))
343355

344356
def create(self, *objects: ManagedObject):
345357
"""Create managed objects within the database.
@@ -489,6 +501,7 @@ def select( # noqa (order)
489501
limit: int = None,
490502
page_size: int = 100,
491503
page_number: int = None,
504+
as_tuples: list[str] | dict[str, Any] = None,
492505
**kwargs,) -> Generator[Device]:
493506
# pylint: disable=arguments-differ, arguments-renamed
494507
""" Query the database for devices and iterate over the results.
@@ -543,6 +556,10 @@ def select( # noqa (order)
543556
parsed in one chunk). This is a performance related setting.
544557
page_number (int): Pull a specific page; this effectively disables
545558
automatic follow-up page retrieval.
559+
as_tuples: (list[str] or dict[str, Any]): Don't parse Device objects,
560+
but extract the values at certain JSON paths as tuples; If the path
561+
is not defined in a result, None is used; Specify a dictionary to
562+
define proper default values for each path.
546563
547564
Returns:
548565
Generator for Device objects
@@ -571,6 +588,7 @@ def select( # noqa (order)
571588
limit=limit,
572589
page_size=page_size,
573590
page_number=page_number,
591+
as_tuples=as_tuples,
574592
**kwargs)
575593

576594
def get_all( # noqa (changed signature)
@@ -596,6 +614,7 @@ def get_all( # noqa (changed signature)
596614
limit: int = None,
597615
page_size: int = 100,
598616
page_number: int = None,
617+
as_tuples: list[str] | dict[str, Any] = None,
599618
**kwargs) -> List[Device]:
600619
# pylint: disable=arguments-differ, arguments-renamed
601620
""" Query the database for devices and return the results as list.
@@ -628,6 +647,7 @@ def get_all( # noqa (changed signature)
628647
limit=limit,
629648
page_size=page_size,
630649
page_number=page_number,
650+
as_tuples=as_tuples,
631651
**kwargs))
632652

633653
def get_count( # noqa (changed signature)
@@ -734,6 +754,7 @@ def select( # noqa (changed signature)
734754
limit: int = None,
735755
page_size: int = 100,
736756
page_number: int = None,
757+
as_tuples: list[str] | dict[str, Any] = None,
737758
**kwargs) -> Generator[DeviceGroup]:
738759
# pylint: disable=arguments-differ, arguments-renamed
739760
""" Select device groups by various parameters.
@@ -791,6 +812,11 @@ def select( # noqa (changed signature)
791812
parsed in one chunk). This is a performance related setting.
792813
page_number (int): Pull a specific page; this effectively disables
793814
automatic follow-up page retrieval.
815+
as_tuples: (list[str] or dict[str, Any]): Don't parse DeviceGroup
816+
objects, but extract the values at certain JSON paths as
817+
tuples; If the path is not defined in a result, None is used;
818+
Specify a dictionary to define proper default values for each
819+
path.
794820
795821
Returns:
796822
Generator of DeviceGroup instances
@@ -827,6 +853,7 @@ def select( # noqa (changed signature)
827853
limit=limit,
828854
page_size=page_size,
829855
page_number=page_number,
856+
as_tuples=as_tuples,
830857
**kwargs)
831858

832859
def get_count( # noqa (changed signature)
@@ -896,7 +923,8 @@ def get_all( # noqa (changed signature)
896923
limit: int = None,
897924
page_size: int = 100,
898925
page_number: int = None,
899-
**kwargs) -> List[DeviceGroup]:
926+
as_tuples: list[str] | dict[str, Any] = None,
927+
**kwargs ) -> List[DeviceGroup]:
900928
# pylint: disable=arguments-differ, arguments-renamed
901929
""" Select managed objects by various parameters.
902930
@@ -927,6 +955,7 @@ def get_all( # noqa (changed signature)
927955
limit=limit,
928956
page_size=page_size,
929957
page_number=page_number,
958+
as_tuples=as_tuples,
930959
**kwargs))
931960

932961
def create(self, *groups):

tests/model/test_alarms.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ def test_select(fun, params, expected, not_expected):
8484
assert ne not in resource
8585

8686

87+
def test_select_as_tuples():
88+
"""Verify that select as tuples works as expected."""
89+
jsons = [
90+
{'type': 'type1', 'text': 'text1', 'source': 'source1', 'test_Fragment': {'key': 'value1', 'key2': 'value2'}},
91+
{'type': 'type2', 'text': 'text2', 'source': 'source2', 'test_Fragment': {'key': 'value2'}},
92+
]
93+
94+
api = Alarms(c8y=Mock())
95+
api.c8y.get = Mock(side_effect=[{'alarms': jsons}, {'alarms': []}])
96+
result = api.get_all(as_tuples=['type', 'text', 'test_Fragment.key', 'test_Fragment.key2'])
97+
assert result == [
98+
('type1', 'text1', 'value1', 'value2'),
99+
('type2', 'text2', 'value2', None),
100+
]
101+
102+
api.c8y.get = Mock(side_effect=[{'alarms': jsons}, {'alarms': []}])
103+
result = api.get_all(as_tuples={'type': None, 'text': None, 'test_Fragment.key': None, 'test_Fragment.key2': '-'})
104+
assert result == [
105+
('type1', 'text1', 'value1', 'value2'),
106+
('type2', 'text2', 'value2', '-'),
107+
]
108+
109+
87110
@pytest.mark.parametrize('fun', [
88111
Alarms.get_all,
89112
Alarms.count,

0 commit comments

Comments
 (0)