Skip to content

Commit a5502dc

Browse files
authored
Merge pull request #73 from Cumulocity-IoT/feature/tuple-parsing-improvements
Feature/tuple parsing improvements
2 parents a62abe8 + 1d49b37 commit a5502dc

File tree

14 files changed

+211
-54
lines changed

14 files changed

+211
-54
lines changed

CHANGELOG.md

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

3+
* Added code coverage reporting to `test` target for _invoke_.
4+
* Updated `as_tuple` for complex objects as well as the `as_tuples` parameter for `select`
5+
and `get_all` functions to work with strings or 2-tuples. The use of a dictionary
6+
was removed as dictionaries don't define an order.
7+
* Added `as_tuples` parameter to the Measurements API `select` and `get_all` functions.
38
* Adding `c8y_tk.analytics` package with `to_numpy`, `to_series` and `to_data_frame` functions to
49
ease incorporating Cumulocity data into standard analytics pipelines.
510

c8y_api/model/_base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,11 +525,16 @@ def get(self, path: str, default=None) -> Any:
525525
segments = path.split('.')
526526
value = self
527527
for segment in segments:
528-
if segment in value:
528+
# try to drill down (assuming dict-like)
529+
try:
529530
value = value[segment]
530531
continue
532+
except (KeyError, TypeError):
533+
pass
534+
# if the segment is an actual attribute it should be the target
531535
if hasattr(value, segment):
532536
return value.__getattribute__(segment)
537+
# otherwise use the default
533538
return default
534539
return value
535540

c8y_api/model/_parser.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,42 @@
55
from typing import Set
66

77
from c8y_api.model._base import ComplexObject
8+
from c8y_api.model._util import _StringUtil
89

910

11+
def as_tuples(json_data, *path: str | tuple):
12+
"""Parse a JSON structure as tuples from paths.
13+
14+
Args:
15+
json_data (dict): A JSON structure as Python dict
16+
path (*str|tuple): Path(s) to extract from
17+
the structure; Use dots to separate JSON levels; Arrays are not
18+
supported.
19+
20+
Mote: This function automatically converts path segments from
21+
Python snake_case to pascalCase, e.g. `creation_time` will match
22+
both `creation_time` and `creationTime` fields.
23+
24+
Returns:
25+
A tuple with `len(path)` elements containing the values as-is defined
26+
in the JSON structure; an invalid path will result in None.
27+
"""
28+
def resolve(segments, default=None):
29+
json_level = json_data
30+
for segment in segments[:-1]:
31+
if segment in json_level:
32+
json_level = json_level[segment]
33+
continue
34+
pascal_segment = _StringUtil.to_pascal_case(segment)
35+
if pascal_segment in json_level:
36+
json_level = json_level[pascal_segment]
37+
continue
38+
return default
39+
return json_level.get(segments[-1], json_level.get(_StringUtil.to_pascal_case(segments[-1]), default))
40+
41+
# each p in path(s) can be a string or a tuple
42+
return tuple(resolve(p[0].split('.'), p[1]) if isinstance(p, tuple) else resolve(p.split('.')) for p in path )
43+
1044
class SimpleObjectParser(object):
1145
"""A parser for simple (without fragments) Cumulocity database objects.
1246

c8y_api/model/alarms.py

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

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

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

1313

@@ -255,7 +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,
258+
as_tuples: str | tuple | list[str | tuple] = None,
259259
**kwargs) -> Generator[Alarm]:
260260
"""Query the database for alarms and iterate over the results.
261261
@@ -307,10 +307,10 @@ def select(self,
307307
parsed in one chunk). This is a performance related setting.
308308
page_number (int): Pull a specific page; this effectively disables
309309
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.
310+
as_tuples: (*str|tuple): Don't parse objects, but directly extract
311+
the values at certain JSON paths as tuples; If the path is not
312+
defined in a result, None is used; Specify a tuple to define
313+
a proper default value for each path.
314314
315315
Returns:
316316
Generator of Alarm objects
@@ -333,7 +333,8 @@ def select(self,
333333
base_query,
334334
page_number,
335335
limit,
336-
Alarm.from_json if not as_tuples else (lambda x: get_all_by_path(x, as_tuples)))
336+
Alarm.from_json if not as_tuples else
337+
lambda x: parse_as_tuples(x, *([as_tuples] if isinstance(as_tuples, str) else as_tuples)))
337338

338339
def get_all(
339340
self,
@@ -350,7 +351,7 @@ def get_all(
350351
with_source_assets: bool = None, with_source_devices: bool = None,
351352
reverse: bool = False, limit: int = None,
352353
page_size: int = 1000, page_number: int = None,
353-
as_tuples: list[str] | dict[str, Any] = None,
354+
as_tuples: str | tuple | list[str|tuple] = None,
354355
**kwargs) -> List[Alarm]:
355356
"""Query the database for alarms and return the results as list.
356357

c8y_api/model/events.py

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

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

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

1616

@@ -264,7 +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,
267+
as_tuples: str | tuple | list[str|tuple] = None,
268268
**kwargs) -> Generator[Event]:
269269
"""Query the database for events and iterate over the results.
270270
@@ -315,10 +315,10 @@ def select(self,
315315
parsed in one chunk). This is a performance related setting.
316316
page_number (int): Pull a specific page; this effectively disables
317317
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.
318+
as_tuples: (*str|tuple): Don't parse objects, but directly extract
319+
the values at certain JSON paths as tuples; If the path is not
320+
defined in a result, None is used; Specify a tuple to define
321+
a proper default value for each path.
322322
323323
Returns:
324324
Generator for Event objects
@@ -344,7 +344,8 @@ def select(self,
344344
base_query,
345345
page_number,
346346
limit,
347-
Event.from_json if not as_tuples else (lambda x: get_all_by_path(x, as_tuples)))
347+
Event.from_json if not as_tuples else
348+
lambda x: parse_as_tuples(x, *([as_tuples] if isinstance(as_tuples, str) else as_tuples)))
348349

349350
def get_all(
350351
self,
@@ -361,7 +362,7 @@ def get_all(
361362
reverse: bool = False, limit: int = None,
362363
with_source_assets: bool = None, with_source_devices: bool = None,
363364
page_size: int = 1000, page_number: int = None,
364-
as_tuples: list[str] | dict[str, Any] = None,
365+
as_tuples: str | tuple | list[str|tuple] = None,
365366
**kwargs) -> List[Event]:
366367
"""Query the database for events and return the results as list.
367368

c8y_api/model/inventory.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from typing import Any, Generator, List
77

8-
from c8y_api.model._base import CumulocityResource, get_all_by_path
8+
from c8y_api.model._base import CumulocityResource
9+
from c8y_api.model._parser import as_tuples as parse_as_tuples
910
from c8y_api.model._util import _QueryUtil
1011
from c8y_api.model.managedobjects import ManagedObjectUtil, ManagedObject, Device, Availability, DeviceGroup
1112

@@ -61,7 +62,7 @@ def get_all(
6162
reverse: bool = None,
6263
limit: int = None,
6364
page_size: int = 1000,
64-
as_tuples: list[str] | dict[str, Any] = None,
65+
as_tuples: str | tuple | list[str|tuple] = None,
6566
**kwargs) -> List[ManagedObject]:
6667
""" Query the database for managed objects and return the results
6768
as list.
@@ -156,7 +157,7 @@ def select(
156157
limit: int = None,
157158
page_size: int = 1000,
158159
page_number: int = None,
159-
as_tuples: list[str] | dict[str, Any] = None,
160+
as_tuples: str | tuple | list[str|tuple] = None,
160161
**kwargs) -> Generator[ManagedObject]:
161162
""" Query the database for managed objects and iterate over the
162163
results.
@@ -208,10 +209,10 @@ def select(
208209
parsed in one chunk). This is a performance related setting.
209210
page_number (int): Pull a specific page; this effectively disables
210211
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.
212+
as_tuples: (*str|tuple): Don't parse objects, but directly extract
213+
the values at certain JSON paths as tuples; If the path is not
214+
defined in a result, None is used; Specify a tuple to define
215+
a proper default value for each path.
215216
216217
Returns:
217218
Generator for ManagedObject instances
@@ -351,7 +352,8 @@ def _select(self, parse_fun, device_mode: bool, page_number, limit, as_tuples, *
351352
base_query,
352353
page_number,
353354
limit,
354-
parse_fun if not as_tuples else (lambda x: get_all_by_path(x, as_tuples)))
355+
parse_fun if not as_tuples else
356+
lambda x: parse_as_tuples(x, *([as_tuples] if isinstance(as_tuples, str) else as_tuples)))
355357

356358
def create(self, *objects: ManagedObject):
357359
"""Create managed objects within the database.
@@ -501,7 +503,7 @@ def select( # noqa (order)
501503
limit: int = None,
502504
page_size: int = 100,
503505
page_number: int = None,
504-
as_tuples: list[str] | dict[str, Any] = None,
506+
as_tuples: str | tuple | list[str|tuple] = None,
505507
**kwargs,) -> Generator[Device]:
506508
# pylint: disable=arguments-differ, arguments-renamed
507509
""" Query the database for devices and iterate over the results.
@@ -556,10 +558,10 @@ def select( # noqa (order)
556558
parsed in one chunk). This is a performance related setting.
557559
page_number (int): Pull a specific page; this effectively disables
558560
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.
561+
as_tuples: (*str|tuple): Don't parse objects, but directly extract
562+
the values at certain JSON paths as tuples; If the path is not
563+
defined in a result, None is used; Specify a tuple to define
564+
a proper default value for each path.
563565
564566
Returns:
565567
Generator for Device objects
@@ -614,7 +616,7 @@ def get_all( # noqa (changed signature)
614616
limit: int = None,
615617
page_size: int = 100,
616618
page_number: int = None,
617-
as_tuples: list[str] | dict[str, Any] = None,
619+
as_tuples: str | tuple | list[str|tuple] = None,
618620
**kwargs) -> List[Device]:
619621
# pylint: disable=arguments-differ, arguments-renamed
620622
""" Query the database for devices and return the results as list.
@@ -754,7 +756,7 @@ def select( # noqa (changed signature)
754756
limit: int = None,
755757
page_size: int = 100,
756758
page_number: int = None,
757-
as_tuples: list[str] | dict[str, Any] = None,
759+
as_tuples: str | tuple | list[str|tuple] = None,
758760
**kwargs) -> Generator[DeviceGroup]:
759761
# pylint: disable=arguments-differ, arguments-renamed
760762
""" Select device groups by various parameters.
@@ -812,11 +814,10 @@ def select( # noqa (changed signature)
812814
parsed in one chunk). This is a performance related setting.
813815
page_number (int): Pull a specific page; this effectively disables
814816
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.
817+
as_tuples: (*str|tuple): Don't parse objects, but directly extract
818+
the values at certain JSON paths as tuples; If the path is not
819+
defined in a result, None is used; Specify a tuple to define
820+
a proper default value for each path.
820821
821822
Returns:
822823
Generator of DeviceGroup instances
@@ -923,7 +924,7 @@ def get_all( # noqa (changed signature)
923924
limit: int = None,
924925
page_size: int = 100,
925926
page_number: int = None,
926-
as_tuples: list[str] | dict[str, Any] = None,
927+
as_tuples: str | tuple | list[str|tuple] = None,
927928
**kwargs ) -> List[DeviceGroup]:
928929
# pylint: disable=arguments-differ, arguments-renamed
929930
""" Select managed objects by various parameters.

c8y_api/model/measurements.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from c8y_api._base_api import CumulocityRestApi
1010

1111
from c8y_api.model._base import CumulocityResource, ComplexObject
12-
from c8y_api.model._parser import ComplexObjectParser
12+
from c8y_api.model._parser import as_tuples as parse_as_tuples, ComplexObjectParser
1313
from c8y_api.model._base import _DictWrapper
1414
from c8y_api.model._util import _DateUtil
1515

@@ -495,6 +495,7 @@ def select(
495495
limit: int = None,
496496
page_size: int = 1000,
497497
page_number: int = None,
498+
as_tuples: str | tuple | list[str|tuple] = None,
498499
**kwargs
499500
) -> Generator[Measurement]:
500501
""" Query the database for measurements and iterate over the results.
@@ -538,6 +539,10 @@ def select(
538539
related setting.
539540
page_number (int): Pull a specific page; this effectively disables
540541
automatic follow-up page retrieval.
542+
as_tuples: (*str|tuple): Don't parse objects, but directly extract
543+
the values at certain JSON paths as tuples; If the path is not
544+
defined in a result, None is used; Specify a tuple to define
545+
a proper default value for each path.
541546
542547
Returns:
543548
Generator[Measurement]: Iterable of matching Measurement objects
@@ -559,7 +564,12 @@ def select(
559564
page_size=page_size,
560565
**kwargs
561566
)
562-
return super()._iterate(base_query, page_number, limit, Measurement.from_json)
567+
return super()._iterate(
568+
base_query,
569+
page_number,
570+
limit,
571+
Measurement.from_json if not as_tuples else
572+
lambda x: parse_as_tuples(x, *([as_tuples] if isinstance(as_tuples, str) else as_tuples)))
563573

564574
def get_all(
565575
self,
@@ -579,6 +589,7 @@ def get_all(
579589
limit: int = None,
580590
page_size: int = 1000,
581591
page_number: int = None,
592+
as_tuples: str | tuple | list[str|tuple] = None,
582593
**kwargs
583594
) -> List[Measurement]:
584595
""" Query the database for measurements and return the results
@@ -607,6 +618,7 @@ def get_all(
607618
limit=limit,
608619
page_size=page_size,
609620
page_number=page_number,
621+
as_tuples=as_tuples,
610622
**kwargs))
611623

612624
def get_last(

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ requests
66
Deprecated
77
pytest
88
pytest-asyncio
9+
pytest-cov
910
responses
1011
python-dotenv
1112
invoke

0 commit comments

Comments
 (0)