Skip to content

Commit 704a651

Browse files
authored
Merge pull request #66 from Cumulocity-IoT/feature/python37-support
Feature/python37 support
2 parents 6026729 + 3f5edad commit 704a651

25 files changed

+333
-58
lines changed

CHANGELOG.md

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

3+
## Version 3.1
4+
5+
* Adding support for Python 3.7 as this is still widely used in the industry. New code can now safely used with
6+
Python 3.7 throughout Python 3.13. Added `invoke` task for docker-based tests with different Python versions.
7+
* Greatly improved _dot notation_ access to all complex Cumulocity objects (Managed Objects, Events, Alarms,
8+
Operations, etc.) This now also supports mixed access, e.g. `obj.fragment[3].sub["name"]`.
9+
* Publicly releasing a generic `get` function to complex objects which allows accessing a nested value without
10+
the need to check for null values, e.g. `obj.get('fragment.sub.name', default='N/A')`.
11+
312
## Version 3
413

514
* Unified query behaviour of all API classes (introducing potentially breaking changes as the order of parameters needed to change).

bookworm311.dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FROM python:3.11-slim-bookworm
2+
3+
RUN apt-get update && apt-get -y install git
4+
5+
COPY requirements.txt /
6+
RUN pip install --upgrade pip && pip install -r requirements.txt

buster37.dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FROM python:3.7-slim-buster
2+
3+
RUN apt-get update && apt-get -y install git
4+
5+
COPY requirements.txt /
6+
RUN pip install --upgrade pip && pip install -r requirements.txt

c8y_api/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
# Use, reproduction, transfer, publication or disclosure is prohibited except
55
# as specifically provided for in your License Agreement with Software AG.
66

7-
from importlib.metadata import version
7+
try:
8+
from importlib.metadata import version
9+
except ModuleNotFoundError:
10+
from importlib_metadata import version
811

912
from c8y_api._base_api import (
1013
ProcessingMode,

c8y_api/model/_base.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
from typing import Any, Iterable, Set
1111
from urllib.parse import quote_plus, urlencode
1212

13-
from collections.abc import MutableMapping
13+
from collections.abc import MutableMapping, MutableSequence
1414
from deprecated import deprecated
1515

1616
from c8y_api._base_api import CumulocityRestApi
1717
from c8y_api.model._util import _DateUtil, _StringUtil, _QueryUtil
1818

1919

20-
class _DictWrapper(MutableMapping):
20+
class _DictWrapper(MutableMapping, dict):
2121

2222
def __init__(self, dictionary: dict, on_update=None):
2323
self.__dict__['_property_items'] = dictionary
@@ -29,10 +29,16 @@ def has(self, name: str):
2929

3030
def __getitem__(self, name):
3131
item = self.__dict__['_property_items'][name]
32-
return item if not isinstance(item, dict) else _DictWrapper(item, self.__dict__['_property_on_update'])
32+
if isinstance(item, dict):
33+
return _DictWrapper(item, self.__dict__['_property_on_update'])
34+
if isinstance(item, list):
35+
return _ListWrapper(item, self.__dict__['_property_on_update'])
36+
return item
3337

3438
def __setitem__(self, name, value):
3539
self.__dict__['_property_items'][name] = value
40+
if self.__dict__['_property_on_update']:
41+
self.__dict__['_property_on_update']()
3642

3743
def __delitem__(self, _):
3844
raise NotImplementedError
@@ -52,13 +58,53 @@ def __getattr__(self, name):
5258
) from None
5359

5460
def __setattr__(self, name, value):
55-
if self.__dict__['_property_on_update']:
56-
self.__dict__['_property_on_update']()
5761
self[name] = value
5862

5963
def __str__(self):
6064
return self.__dict__['_property_items'].__str__()
6165

66+
class _ListWrapper(MutableSequence, list):
67+
68+
def __init__(self, values: list, on_update=None):
69+
self.__dict__['_property_items'] = values
70+
self.__dict__['_property_on_update'] = on_update
71+
72+
def __getitem__(self, i):
73+
item = self.__dict__['_property_items'][i]
74+
if isinstance(item, dict):
75+
return _DictWrapper(item, self.__dict__['_property_on_update'])
76+
if isinstance(item, list):
77+
return _ListWrapper(item, self.__dict__['_property_on_update'])
78+
return item
79+
80+
def __setitem__(self, i, value):
81+
self.__dict__['_property_items'][i] = value
82+
if self.__dict__['_property_on_update']:
83+
self.__dict__['_property_on_update']()
84+
85+
def __delitem__(self, i):
86+
del self.__dict__['_property_items'][i]
87+
if self.__dict__['_property_on_update']:
88+
self.__dict__['_property_on_update']()
89+
90+
def __len__(self):
91+
return len(self.__dict__['_property_items'])
92+
93+
# def append(self, value):
94+
# self.__dict__['_property_items'].append(value)
95+
# if self.__dict__['_property_on_update']:
96+
# self.__dict__['_property_on_update']()
97+
98+
def insert(self, i, value):
99+
self.__dict__['_property_items'].insert(i, value)
100+
if self.__dict__['_property_on_update']:
101+
self.__dict__['_property_on_update']()
102+
103+
# def extend(self, other):
104+
# self.__dict__['_property_items'].extend(other)
105+
# if self.__dict__['_property_on_update']:
106+
# self.__dict__['_property_on_update']()
107+
62108

63109
class CumulocityObject:
64110
"""Base class for all Cumulocity database objects."""
@@ -355,10 +401,13 @@ def __getitem__(self, name: str):
355401
# it is ensured that the same access behaviour is ensured on all levels.
356402
# All updated anywhere within the dictionary tree will be reported as an update
357403
# to this instance.
358-
# If the element is not a dictionary, it can be returned directly
404+
# If the element is not a dictionary or a list, it can be returned directly
359405
item = self.fragments[name]
360-
return item if not isinstance(item, dict) else \
361-
_DictWrapper(self.fragments[name], lambda: self._signal_updated_fragment(name))
406+
if isinstance(item, dict):
407+
return _DictWrapper(self.fragments[name], lambda: self._signal_updated_fragment(name))
408+
if isinstance(item, list):
409+
return _ListWrapper(self.fragments[name], lambda: self._signal_updated_fragment(name))
410+
return item
362411

363412
def __getattr__(self, name: str):
364413
""" Get the value of a custom fragment.
@@ -372,10 +421,12 @@ def __getattr__(self, name: str):
372421
if name in self:
373422
return self[name]
374423
pascal_name = _StringUtil.to_pascal_case(name)
424+
if pascal_name == name:
425+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'.") from None
375426
if pascal_name in self:
376427
return self[pascal_name]
377428
raise AttributeError(
378-
f"'{type(self).__name__}' object has no attribute '{name}' or '{pascal_name}'"
429+
f"'{type(self).__name__}' object has no attribute '{name}' or '{pascal_name}'."
379430
) from None
380431

381432
def _setattr_(self, name, value):
@@ -574,14 +625,14 @@ def multi(*xs):
574625
'q': q,
575626
'query': query,
576627
'type': type,
577-
'name': f"'{_QueryUtil.encode_odata_query_value(name)}'" if name else None,
628+
'name': _QueryUtil.encode_odata_text_value(name)if name else None,
578629
'owner': owner,
579630
'source': source,
580631
'fragmentType': fragment,
581632
'deviceId': device_id,
582633
'agentId': agent_id,
583634
'bulkId': bulk_id,
584-
'text': f"'{_QueryUtil.encode_odata_query_value(text)}'" if text else None,
635+
'text': _QueryUtil.encode_odata_text_value(text) if text else None,
585636
'ids': ','.join(str(i) for i in ids) if ids else None,
586637
'bulkOperationId': bulk_id,
587638
'dateFrom': date_from,

c8y_api/model/_util.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# and/or its subsidiaries and/or its affiliates and/or their licensors.
44
# Use, reproduction, transfer, publication or disclosure is prohibited except
55
# as specifically provided for in your License Agreement with Software AG.
6+
67
import re
78
from datetime import datetime, timedelta, timezone
89
from dateutil import parser
@@ -32,6 +33,14 @@ def encode_odata_query_value(value):
3233
# single quotes escaped through single quote
3334
return sub('\'', '\'\'', value)
3435

36+
@staticmethod
37+
def encode_odata_text_value(value):
38+
"""Encode value strings according to OData query rules.
39+
http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_URLParsing
40+
http://docs.oasis-open.org/odata/odata/v4.01/cs01/abnf/odata-abnf-construction-rules.txt """
41+
# single quotes escaped through single quote
42+
encoded_quotes = sub('\'', '\'\'', value)
43+
return encoded_quotes if " " not in encoded_quotes else f"'{encoded_quotes}'"
3544

3645
class _DateUtil(object):
3746

c8y_api/model/measurements.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,9 @@ def indexes_by_name():
322322
def parse_timestamp(t):
323323
"""Parse timestamps."""
324324
if timestamps == 'datetime':
325-
return datetime.fromisoformat(t)
325+
return _DateUtil.to_datetime(t)
326326
if timestamps == 'epoch':
327-
return datetime.fromisoformat(t).timestamp()
327+
return _DateUtil.to_datetime(t).timestamp()
328328
return t
329329

330330
# use all series if no series provided

c8y_api/model/tenant_options.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from __future__ import annotations
88

9-
from typing import Generator, List
9+
from typing import Generator, List, Dict
1010

1111
from c8y_api._base_api import CumulocityRestApi
1212
from c8y_api.model._base import SimpleObject, CumulocityResource
@@ -181,7 +181,7 @@ def get_all(self, category: str = None, limit: int = None,
181181
"""
182182
return list(self.select(category=category, limit=limit, page_size=page_size, page_number=page_number))
183183

184-
def get_all_mapped(self, category: str = None) -> dict[str, str]:
184+
def get_all_mapped(self, category: str = None) -> Dict[str, str]:
185185
""" Query the database for tenant options and return the results
186186
as a dictionary.
187187
@@ -262,7 +262,7 @@ def update(self, *options: TenantOption) -> None:
262262
for o in options:
263263
self.c8y.put(self.build_object_path(o.category, o.key), json=o.to_diff_json(), accept=None)
264264

265-
def update_by(self, category: str, options: dict[str, str]) -> None:
265+
def update_by(self, category: str, options: Dict[str, str]) -> None:
266266
""" Update options within the database.
267267
268268
Args:

c8y_tk/interactive/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import getpass
99
import os
1010
import time
11+
from typing import Dict
1112
from urllib.parse import urlparse
1213

1314
from c8y_api import CumulocityApi, UnauthorizedError, MissingTfaError, HTTPBearerAuth, CumulocityRestApi, HttpError
@@ -30,7 +31,7 @@ class CumulocityContext(CumulocityApi):
3031
```
3132
"""
3233

33-
_cached_passwords: dict[str, str] = {}
34+
_cached_passwords: Dict[str, str] = {}
3435

3536
@staticmethod
3637
def _read_variable(env_name: str, prompt: str = None, secret: bool = False):

integration_tests/test_applications.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def test_select_owner(live_c8y: CumulocityApi):
2929

3030
@pytest.mark.parametrize('param, param_func', [
3131
('type', lambda x: 'HOSTED'),
32-
('user', lambda x: x.username),
32+
('user', lambda x: 'service_sms-gateway'),
3333
('tenant', lambda x: x.tenant_id),
3434
('subscriber', lambda x: x.tenant_id),
3535
('provided_for', lambda x: x.tenant_id),

0 commit comments

Comments
 (0)