bn-byneuron 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bn_byneuron-0.0.1/PKG-INFO +31 -0
- bn_byneuron-0.0.1/README.md +14 -0
- bn_byneuron-0.0.1/bn_byneuron.egg-info/PKG-INFO +31 -0
- bn_byneuron-0.0.1/bn_byneuron.egg-info/SOURCES.txt +10 -0
- bn_byneuron-0.0.1/bn_byneuron.egg-info/dependency_links.txt +1 -0
- bn_byneuron-0.0.1/bn_byneuron.egg-info/requires.txt +2 -0
- bn_byneuron-0.0.1/bn_byneuron.egg-info/top_level.txt +1 -0
- bn_byneuron-0.0.1/byneuron/__init__.py +1 -0
- bn_byneuron-0.0.1/byneuron/byneuron.py +761 -0
- bn_byneuron-0.0.1/pyproject.toml +16 -0
- bn_byneuron-0.0.1/setup.cfg +4 -0
- bn_byneuron-0.0.1/setup.py +26 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bn_byneuron
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: API for byneuron backend
|
|
5
|
+
Home-page: https://gitlab.com/bynubian/bynode/python_packages/byneuron
|
|
6
|
+
Author: jovi
|
|
7
|
+
Author-email: jo.vinckier@bynubian.com
|
|
8
|
+
License: UNKNOWN
|
|
9
|
+
Platform: UNKNOWN
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Byneuron
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## About
|
|
20
|
+
byneuron API tool that works via backend endpoints
|
|
21
|
+
Handles entities and eventdata.
|
|
22
|
+
|
|
23
|
+
## get started
|
|
24
|
+
provide environment variables, e.g. .ENV file in root with
|
|
25
|
+
-> BYNEURON_URL
|
|
26
|
+
-> KEYCLOAK_TOKEN_URL
|
|
27
|
+
-> OAUTH2_CLIENT_ID
|
|
28
|
+
-> OAUTH2_CLIENT_SECRET
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Byneuron
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## About
|
|
5
|
+
byneuron API tool that works via backend endpoints
|
|
6
|
+
Handles entities and eventdata.
|
|
7
|
+
|
|
8
|
+
## get started
|
|
9
|
+
provide environment variables, e.g. .ENV file in root with
|
|
10
|
+
-> BYNEURON_URL
|
|
11
|
+
-> KEYCLOAK_TOKEN_URL
|
|
12
|
+
-> OAUTH2_CLIENT_ID
|
|
13
|
+
-> OAUTH2_CLIENT_SECRET
|
|
14
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bn-byneuron
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: API for byneuron backend
|
|
5
|
+
Home-page: https://gitlab.com/bynubian/bynode/python_packages/byneuron
|
|
6
|
+
Author: jovi
|
|
7
|
+
Author-email: jo.vinckier@bynubian.com
|
|
8
|
+
License: UNKNOWN
|
|
9
|
+
Platform: UNKNOWN
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Byneuron
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## About
|
|
20
|
+
byneuron API tool that works via backend endpoints
|
|
21
|
+
Handles entities and eventdata.
|
|
22
|
+
|
|
23
|
+
## get started
|
|
24
|
+
provide environment variables, e.g. .ENV file in root with
|
|
25
|
+
-> BYNEURON_URL
|
|
26
|
+
-> KEYCLOAK_TOKEN_URL
|
|
27
|
+
-> OAUTH2_CLIENT_ID
|
|
28
|
+
-> OAUTH2_CLIENT_SECRET
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
bn_byneuron.egg-info/PKG-INFO
|
|
5
|
+
bn_byneuron.egg-info/SOURCES.txt
|
|
6
|
+
bn_byneuron.egg-info/dependency_links.txt
|
|
7
|
+
bn_byneuron.egg-info/requires.txt
|
|
8
|
+
bn_byneuron.egg-info/top_level.txt
|
|
9
|
+
byneuron/__init__.py
|
|
10
|
+
byneuron/byneuron.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
byneuron
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .byneuron import Byneuron
|
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import uuid
|
|
3
|
+
import requests
|
|
4
|
+
import json
|
|
5
|
+
from decouple import config
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger('byneuron')
|
|
9
|
+
|
|
10
|
+
class Byneuron:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.api = f'{config("BYNEURON_URL")}/api/v1'
|
|
13
|
+
self._token = ''
|
|
14
|
+
self._token_expire = datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
|
|
15
|
+
self._publicIdTypes = None
|
|
16
|
+
self._isc = None
|
|
17
|
+
# load indexSets for user and set one as default
|
|
18
|
+
self.indexSets = self.get_indexsets()
|
|
19
|
+
self.indexSetActive = self.indexSets[0]
|
|
20
|
+
|
|
21
|
+
def __enter__(self):
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
25
|
+
if exc_type:
|
|
26
|
+
log.error("Error Type: {}, \nError: {}\n".format(exc_type.__name__, exc_val))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def now(self):
|
|
30
|
+
"""timezone aware utc now"""
|
|
31
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def headers(self):
|
|
35
|
+
"""default header for API"""
|
|
36
|
+
return {'Authorization': 'Bearer {}'.format(self.token)}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def token(self):
|
|
40
|
+
"""triggers login if needed"""
|
|
41
|
+
if self.now > self._token_expire:
|
|
42
|
+
data = self._login()
|
|
43
|
+
if data:
|
|
44
|
+
log.debug('set _token from data \n%s ', json.dumps(data, indent=2))
|
|
45
|
+
self._token = data.get('access_token', '')
|
|
46
|
+
self._token_expire = self.now + datetime.timedelta(seconds=data.get('expires_in', 0))
|
|
47
|
+
return self._token
|
|
48
|
+
|
|
49
|
+
def url(self, url, headers=None, data=None, as_json=True):
|
|
50
|
+
log.info('requests url %s', url)
|
|
51
|
+
if headers is None:
|
|
52
|
+
headers = self.headers
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
if as_json:
|
|
55
|
+
log.debug('request.post json \n%s \n%s \n%s', url, headers, data)
|
|
56
|
+
r = requests.post(url, headers=headers, json=data)
|
|
57
|
+
else:
|
|
58
|
+
r = requests.post(url, headers=headers, data=data)
|
|
59
|
+
log.debug('request.post data \n%s \n%s \n%s', url, headers, data)
|
|
60
|
+
else:
|
|
61
|
+
r = requests.get(url, headers=headers)
|
|
62
|
+
log.debug('request.get \n%s \n%s', url, headers)
|
|
63
|
+
|
|
64
|
+
if r.status_code == 200:
|
|
65
|
+
return r.json() # returns a dict
|
|
66
|
+
else:
|
|
67
|
+
r.raise_for_status()
|
|
68
|
+
|
|
69
|
+
def set_indexset(self, e, verbose=True):
|
|
70
|
+
"""analogy of selecting a tenant in frontend"""
|
|
71
|
+
if isinstance(e, Entity) and e.entity_type == 'IndexSet':
|
|
72
|
+
if verbose:
|
|
73
|
+
log.info('Welcome to tenant %s', e.name)
|
|
74
|
+
self.indexSetActive = e
|
|
75
|
+
|
|
76
|
+
def iter_indexset(self):
|
|
77
|
+
for e in self.indexSets:
|
|
78
|
+
self.set_indexset(e, True)
|
|
79
|
+
yield self.indexSetActive
|
|
80
|
+
|
|
81
|
+
def publicids(self, entitytype):
|
|
82
|
+
if self._publicIdTypes is None:
|
|
83
|
+
self._publicIdTypes = [e.entity_type_ref for e in self.get_entities('PublicId') if isinstance(e, Entity)]
|
|
84
|
+
log.info('publicids', self._publicIdTypes)
|
|
85
|
+
return entitytype in self._publicIdTypes
|
|
86
|
+
|
|
87
|
+
### endpoints ##
|
|
88
|
+
def _login(self):
|
|
89
|
+
login_headers = {
|
|
90
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
91
|
+
}
|
|
92
|
+
data = {
|
|
93
|
+
"client_id": config('OAUTH2_CLIENT_ID'),
|
|
94
|
+
"client_secret": config('OAUTH2_CLIENT_SECRET'),
|
|
95
|
+
"grant_type": "client_credentials"
|
|
96
|
+
}
|
|
97
|
+
url = config('KEYCLOAK_TOKEN_URL')
|
|
98
|
+
r = self.url(url=url, headers=login_headers, data=data, as_json=False)
|
|
99
|
+
return r
|
|
100
|
+
|
|
101
|
+
def _datamodel(self):
|
|
102
|
+
url = f'{self.api}/backend/rest/datamodel'
|
|
103
|
+
return self.url(url=url)
|
|
104
|
+
|
|
105
|
+
def _graphql(self, query, references=None):
|
|
106
|
+
"""
|
|
107
|
+
graphql query array eg ["CodeType:?codeTypes entity:type CodeType."]
|
|
108
|
+
default retrieve ?indexSet
|
|
109
|
+
:returns {'codeTypes':{'id':{object}}}
|
|
110
|
+
"""
|
|
111
|
+
query = "\n".join(query) if isinstance(query, list) else f'{query}'
|
|
112
|
+
references = references if isinstance(references, dict) else {}
|
|
113
|
+
data = {
|
|
114
|
+
"query": query,
|
|
115
|
+
"references": references
|
|
116
|
+
}
|
|
117
|
+
url = f'{self.api}/backend/rest/datamodel/_graphql'
|
|
118
|
+
log.info('_graphql %s', data)
|
|
119
|
+
return self.url(url=url, data=data)
|
|
120
|
+
|
|
121
|
+
def _graphmodel(self, entities=None, events=None):
|
|
122
|
+
""" create / edit entities and events into the model"""
|
|
123
|
+
nodes = {e.key: e.wrap() for e in entities if isinstance(e, Entity)} \
|
|
124
|
+
if isinstance(entities, list) else {}
|
|
125
|
+
events = [e.wrap() for e in events if isinstance(e, Numberevent)] \
|
|
126
|
+
if isinstance(events, list) else []
|
|
127
|
+
data = {
|
|
128
|
+
"entityDataModel": {"nodes": nodes},
|
|
129
|
+
"numberEventDataModel": {"events": events}
|
|
130
|
+
}
|
|
131
|
+
log.info('graphmodel, data: \n%s', json.dumps(data, indent=2))
|
|
132
|
+
url = f'{self.api}/backend/tasks/entities/graphmodel'
|
|
133
|
+
return self.url(url=url, data=data)
|
|
134
|
+
|
|
135
|
+
def _publicid(self, entitytype, indexset=None):
|
|
136
|
+
if self.publicids(entitytype):
|
|
137
|
+
data = {
|
|
138
|
+
"indexSet": indexset.key if indexset else self.indexSetActive.key,
|
|
139
|
+
"entityType": f'com.bynubian.shared.entities.{entitytype}',
|
|
140
|
+
"fieldName": 'publicId',
|
|
141
|
+
"date": self.now.strftime('%Y-%m-%d')
|
|
142
|
+
}
|
|
143
|
+
url = f'{self.api}/backend/rest/publicids/generate'
|
|
144
|
+
r = self.url(url=url, data=data)
|
|
145
|
+
if r:
|
|
146
|
+
return r.get('publicId')
|
|
147
|
+
|
|
148
|
+
def _numberevents(self, size=100, order="DESC", deleted=False, indexsets=None, filters=None):
|
|
149
|
+
"""
|
|
150
|
+
:param size:
|
|
151
|
+
:param order:
|
|
152
|
+
:param deleted:
|
|
153
|
+
:param indexsets: list of indexSet or entity keys, else active indexSet
|
|
154
|
+
:param filters:
|
|
155
|
+
:return:
|
|
156
|
+
"""
|
|
157
|
+
if isinstance(indexsets, list):
|
|
158
|
+
if all([isinstance(i, Entity) for i in indexsets]):
|
|
159
|
+
indexsets = [i.key for i in indexsets]
|
|
160
|
+
else:
|
|
161
|
+
indexsets = indexsets
|
|
162
|
+
else:
|
|
163
|
+
indexsets = [self.indexSetActive.key]
|
|
164
|
+
filters = [] if not isinstance(filters, list) else filters
|
|
165
|
+
data = {
|
|
166
|
+
"indexSets": indexsets, # list of indexSet keys
|
|
167
|
+
"order": order, # "ASC","DESC"
|
|
168
|
+
"size": int(size),
|
|
169
|
+
"deleted": bool(deleted),
|
|
170
|
+
"filters": filters,
|
|
171
|
+
"esDocumentType": "NumberEvent"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
url = f'{self.api}/backend/rest/numberevents/_query'
|
|
175
|
+
return self.url(url=url, data=data)
|
|
176
|
+
|
|
177
|
+
### endpoint handlers ###
|
|
178
|
+
def get_numberevents(self, item, size):
|
|
179
|
+
""" last 100 events for item in {datetime:value} format """
|
|
180
|
+
items = item if isinstance(item, list) else [item]
|
|
181
|
+
f = {
|
|
182
|
+
"type": "eventitemid",
|
|
183
|
+
"values": [i.key if isinstance(i, Entity) else i for i in items],
|
|
184
|
+
"invertFilter": False,
|
|
185
|
+
"operator": "or"
|
|
186
|
+
}
|
|
187
|
+
indexsets = list({i.indexset for i in items if isinstance(i, Entity)})
|
|
188
|
+
data = self._numberevents(indexsets=indexsets, filters=[f], size=size)
|
|
189
|
+
if data:
|
|
190
|
+
for i in data:
|
|
191
|
+
yield Numberevent(node=i)
|
|
192
|
+
|
|
193
|
+
def get_numberevents_dict(self, item, size):
|
|
194
|
+
return {e.datetime: e.value for e in self.get_numberevents(item=item, size=size)}
|
|
195
|
+
|
|
196
|
+
def query(self, query, keys):
|
|
197
|
+
"""
|
|
198
|
+
Query a nuql expression and extract the entities for the requested variable
|
|
199
|
+
note behaviour on result:
|
|
200
|
+
keys = 'x' > result = {entities_for_x} ;
|
|
201
|
+
keys = ['x'] > result = {'x': {entities_for_x}}
|
|
202
|
+
keys = ['x','y'] > result = {'x': {entities_for_x}, 'y': {entities_for_y}}
|
|
203
|
+
careful for large requests, use limit and offset is advised
|
|
204
|
+
:param query: [] of lines for graphql
|
|
205
|
+
:param keys: the [] of str variable(s)
|
|
206
|
+
:return: {variable: {entityKey: Entity}} or {entityKey: Entity}
|
|
207
|
+
"""
|
|
208
|
+
result = {}
|
|
209
|
+
result_with_keys = isinstance(keys, list)
|
|
210
|
+
data = self._graphql(query)
|
|
211
|
+
if data:
|
|
212
|
+
log.info('time _graphql %sms', data.get('time'))
|
|
213
|
+
variables = data.get('variables', {})
|
|
214
|
+
nodes = data.get('nodes', {})
|
|
215
|
+
for k in keys if result_with_keys else [keys]:
|
|
216
|
+
result.update({k: {}})
|
|
217
|
+
for entityKey in variables.get(f"?{k}", []):
|
|
218
|
+
e = nodes.get(entityKey, {})
|
|
219
|
+
result[k].update({entityKey: Entity(node=e)})
|
|
220
|
+
return result if result_with_keys else result.get(keys)
|
|
221
|
+
|
|
222
|
+
def get_entities(self, entitytype, indexset=None, **kwargs):
|
|
223
|
+
"""
|
|
224
|
+
iterates entities using a filter e.g. get_entities('Item', 'hydrobox', 'externalId':'regex:0/1/.*'}
|
|
225
|
+
constructs the required nuql query
|
|
226
|
+
:param entitytype: eg Item, Device, ..
|
|
227
|
+
:param indexset:
|
|
228
|
+
True, search all indexSets;
|
|
229
|
+
None, search selected indexSet;
|
|
230
|
+
string or [str], search indexSets by attribute-name
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def from_kwarg(kwarg_key):
|
|
234
|
+
v = kwargs.get(kwarg_key)
|
|
235
|
+
if isinstance(v, str):
|
|
236
|
+
return f'regex:"{v[6:]}"' if v.startswith('regex:') else f'"{v}"'
|
|
237
|
+
elif isinstance(v, list):
|
|
238
|
+
return f'{[i for i in v]}'
|
|
239
|
+
elif v is True:
|
|
240
|
+
return f'value:any'
|
|
241
|
+
elif v is None:
|
|
242
|
+
return f'value:none'
|
|
243
|
+
|
|
244
|
+
if indexset is True:
|
|
245
|
+
entity_list = self.indexSets # all indexSets
|
|
246
|
+
elif isinstance(indexset, list):
|
|
247
|
+
entity_list = [i for i in indexset if isinstance(i, Entity)] # specify multiple indexSets
|
|
248
|
+
elif isinstance(indexset, str):
|
|
249
|
+
entity_list = [i for i in self.indexSets if i.name == indexset or i.key == indexset]
|
|
250
|
+
else:
|
|
251
|
+
entity_list = [self.indexSetActive] # default to active indexSet
|
|
252
|
+
query = [
|
|
253
|
+
f"IndexSet:?indexSet entity:key {[i.key for i in entity_list]}.",
|
|
254
|
+
f"{entitytype}:?e link:{'isAssignedTo' if entitytype == 'Gateway' else 'isDefinedIn'} ?indexSet."
|
|
255
|
+
]
|
|
256
|
+
filter_list = []
|
|
257
|
+
for k in ['key']:
|
|
258
|
+
if k in kwargs:
|
|
259
|
+
filter_list.append(f'entity:{k} {from_kwarg(k)}')
|
|
260
|
+
for k in ['name', 'externalId', 'publicId', 'codeType']:
|
|
261
|
+
if k in kwargs:
|
|
262
|
+
filter_list.append(f'attribute:{k} {from_kwarg(k)}')
|
|
263
|
+
if filter_list:
|
|
264
|
+
query.append(f"{entitytype}:?e {'; '.join(filter_list)}.")
|
|
265
|
+
offset, limit = 0, 20
|
|
266
|
+
while True:
|
|
267
|
+
query.append(f"{entitytype}:?e limit {limit}; offset {offset}.")
|
|
268
|
+
entities = self.query(query, 'e')
|
|
269
|
+
for e in entities.values():
|
|
270
|
+
if isinstance(e, Entity):
|
|
271
|
+
yield e
|
|
272
|
+
if len(entities) < limit:
|
|
273
|
+
break
|
|
274
|
+
query.pop() # remove limit
|
|
275
|
+
offset += limit
|
|
276
|
+
|
|
277
|
+
def get_entity(self, entitytype, **kwargs):
|
|
278
|
+
for i in self.get_entities(entitytype, **kwargs):
|
|
279
|
+
return i
|
|
280
|
+
|
|
281
|
+
def get_indexsets(self):
|
|
282
|
+
q = ['IndexSet:?indexSet attribute:name value:any.']
|
|
283
|
+
r = self.query(q, 'indexSet')
|
|
284
|
+
return [e for e in r.values() if isinstance(e, Entity)]
|
|
285
|
+
|
|
286
|
+
def create_entity(self, entity):
|
|
287
|
+
log.info('create_entity %s', entity)
|
|
288
|
+
entity.set_context('create')
|
|
289
|
+
log.info('create_entity %s %s %s', entity.entity_type, entity.role, entity.type)
|
|
290
|
+
if not entity.entity_type == 'Gateway':
|
|
291
|
+
|
|
292
|
+
# indexSetCode only for entity that are defined in indexSet
|
|
293
|
+
if entity.role:
|
|
294
|
+
for role in entity.role:
|
|
295
|
+
self.set_isc(f'{entity.entity_type.lower()}Role', role)
|
|
296
|
+
if entity.type:
|
|
297
|
+
self.set_isc(f'{entity.entity_type.lower()}Type', entity.type)
|
|
298
|
+
|
|
299
|
+
data = self._graphmodel(entities=[entity])
|
|
300
|
+
if data:
|
|
301
|
+
log.info('created entity data %s', data)
|
|
302
|
+
result = data.get('result', {}).get('entityDataModel', {}).get('nodes', {}).get(entity.key, {})
|
|
303
|
+
log.info('created entity %s', result)
|
|
304
|
+
return Entity(node=result)
|
|
305
|
+
|
|
306
|
+
def update_entity(self, entity):
|
|
307
|
+
entity.set_context('update')
|
|
308
|
+
data = self._graphmodel(entities=[entity])
|
|
309
|
+
if data:
|
|
310
|
+
result = data.get('result', {}).get('entityDataModel', {}).get('nodes', {}).get(entity.key, {})
|
|
311
|
+
return Entity(node=result)
|
|
312
|
+
|
|
313
|
+
def create_item(self, **kwargs):
|
|
314
|
+
|
|
315
|
+
# to filter the keywords we can allow, and to set required fields
|
|
316
|
+
item_config = {
|
|
317
|
+
'description': kwargs.get('description'),
|
|
318
|
+
'name': kwargs.get('name'),
|
|
319
|
+
'externalId': kwargs.get('externalId'),
|
|
320
|
+
'publicId': self._publicid(entitytype="Item"),
|
|
321
|
+
'active': True,
|
|
322
|
+
'valid': True,
|
|
323
|
+
'valueType': 'Double',
|
|
324
|
+
'unit': kwargs.get('unit'), # optional
|
|
325
|
+
'data': kwargs.get('data'), # optional
|
|
326
|
+
'metaKeywords': kwargs.get('metaKeywords'), # optional
|
|
327
|
+
'metaBooleans': kwargs.get('metaBooleans'), # optional
|
|
328
|
+
'roles': kwargs.get('roles', ['DEFAULT']),
|
|
329
|
+
'type': kwargs.get('type', 'DEFAULT')
|
|
330
|
+
}
|
|
331
|
+
e = Entity().create('Item', self.indexSetActive, **{k: v for k, v in item_config.items() if v is not None})
|
|
332
|
+
return self.create_entity(e)
|
|
333
|
+
|
|
334
|
+
def create_device(self, **kwargs):
|
|
335
|
+
device_config = {
|
|
336
|
+
'description': kwargs.get('description'),
|
|
337
|
+
'name': kwargs.get('name'),
|
|
338
|
+
'externalId': kwargs.get('externalId'),
|
|
339
|
+
'publicId': self._publicid(entitytype="Device"),
|
|
340
|
+
'active': True,
|
|
341
|
+
'valid': True,
|
|
342
|
+
'data': kwargs.get('data'), # optional
|
|
343
|
+
'metaKeywords': kwargs.get('metaKeywords'), # optional
|
|
344
|
+
'metaBooleans': kwargs.get('metaBooleans'), # optional
|
|
345
|
+
'roles': kwargs.get('roles', ['DEFAULT']),
|
|
346
|
+
'type': kwargs.get('type', 'DEFAULT')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
e = Entity().create('Device', self.indexSetActive, **{k: v for k, v in device_config.items() if v is not None})
|
|
350
|
+
if kwargs.get('definition'):
|
|
351
|
+
# add Definition using Exid, if not present, duplicate from different indexSet
|
|
352
|
+
local = self.get_entity('DeviceDefinition', externalId=kwargs.get('definition'))
|
|
353
|
+
if local is None:
|
|
354
|
+
|
|
355
|
+
remote = self.get_entity('DeviceDefinition', indexSet=True, externalId=kwargs.get('definition'))
|
|
356
|
+
if isinstance(remote, Entity):
|
|
357
|
+
log.info('duplicate definition %s', kwargs.get('definition'))
|
|
358
|
+
local = self.duplicate(remote, self.indexSetActive)
|
|
359
|
+
if local:
|
|
360
|
+
e.set_link('isImplementationOf', local)
|
|
361
|
+
# todo add profile, assetModel, status
|
|
362
|
+
return self.create_entity(e)
|
|
363
|
+
|
|
364
|
+
def create_code(self, codetype, code):
|
|
365
|
+
e = Entity().create('IndexSetCode', self.indexSetActive, codeType=codetype, code=code, key=f'{codetype}#{code}')
|
|
366
|
+
self.create_entity(e)
|
|
367
|
+
|
|
368
|
+
def create_codetype(self, codetype, parent_codetype=None):
|
|
369
|
+
e = Entity().create('IndexSetCodeType', self.indexSetActive, key=codetype)
|
|
370
|
+
if parent_codetype:
|
|
371
|
+
pct = self.get_entity('IndexSetCodeType', key=parent_codetype)
|
|
372
|
+
if isinstance(pct, Entity):
|
|
373
|
+
e.set_link('hasParent', pct)
|
|
374
|
+
self.create_entity(e)
|
|
375
|
+
|
|
376
|
+
def set_features(self, parent, children):
|
|
377
|
+
"""
|
|
378
|
+
:param parent: Entity of type Device, Site to create hasFeature-relation in
|
|
379
|
+
:param children: [] of Entity of type Item to create hasFeature-relation to
|
|
380
|
+
:return: parent after update
|
|
381
|
+
"""
|
|
382
|
+
for child in children:
|
|
383
|
+
parent.set_link('hasFeature', child)
|
|
384
|
+
return self.update_entity(parent)
|
|
385
|
+
|
|
386
|
+
def duplicate(self, entity, indexset):
|
|
387
|
+
"""
|
|
388
|
+
duplicate an entity to a different indexSet
|
|
389
|
+
:param entity: Entity to copy
|
|
390
|
+
:param indexset: indexSet (Entity) to copy to
|
|
391
|
+
:return:
|
|
392
|
+
"""
|
|
393
|
+
e = Entity().create(
|
|
394
|
+
entity.entity_type,
|
|
395
|
+
indexset,
|
|
396
|
+
description=entity.description,
|
|
397
|
+
name=entity.name,
|
|
398
|
+
externalId=entity.external_id,
|
|
399
|
+
data=entity.data,
|
|
400
|
+
metaKeywords=entity.meta_keywords,
|
|
401
|
+
metaBooleans=entity.meta_booleans)
|
|
402
|
+
log.info('new entity %s from copy %s', e, entity)
|
|
403
|
+
return self.create_entity(e)
|
|
404
|
+
|
|
405
|
+
def set_isc(self, codetype, code):
|
|
406
|
+
"""
|
|
407
|
+
routine to make sure codetypes#code are available in entity creation
|
|
408
|
+
buffers codes per codetype
|
|
409
|
+
todo crete codetype relation to root > reference-data
|
|
410
|
+
:param codetype: eg itemRole
|
|
411
|
+
:param code: eg DEFAULT
|
|
412
|
+
:return:
|
|
413
|
+
"""
|
|
414
|
+
if self._isc is None:
|
|
415
|
+
self._isc = {}
|
|
416
|
+
if self.indexSetActive != self._isc.get('indexSet'):
|
|
417
|
+
log.info('initiate indexSetBuffer')
|
|
418
|
+
self._isc = {'indexSet': self.indexSetActive}
|
|
419
|
+
for e in self.get_entities('IndexSetCodeType'):
|
|
420
|
+
self._isc.update({e.key: None})
|
|
421
|
+
|
|
422
|
+
if codetype not in self._isc:
|
|
423
|
+
self.create_codetype(codetype, 'reference-data')
|
|
424
|
+
self._isc.update({codetype: None})
|
|
425
|
+
# create codetype
|
|
426
|
+
# buffer the code e.g. DEFAULT for the codetype e.g. itemRole
|
|
427
|
+
if not self._isc.get(codetype):
|
|
428
|
+
# buffer existing codes
|
|
429
|
+
self._isc.update({codetype: [e.code for e in self.get_entities('IndexSetCode', codeType=codetype)]})
|
|
430
|
+
if code not in self._isc.get(codetype, []):
|
|
431
|
+
self.create_code(codetype, code)
|
|
432
|
+
self._isc[codetype].append(code)
|
|
433
|
+
# create code
|
|
434
|
+
|
|
435
|
+
log.info('buffered indexSetCodetypes %s', self._isc)
|
|
436
|
+
# buffer existing codes
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class Entity:
|
|
440
|
+
def __init__(self, node=None):
|
|
441
|
+
self._datamodel = {} if not isinstance(node, dict) else node
|
|
442
|
+
|
|
443
|
+
def __repr__(self):
|
|
444
|
+
return f'{self.entity_type} {self.name}'
|
|
445
|
+
|
|
446
|
+
def wrap(self):
|
|
447
|
+
return self._datamodel
|
|
448
|
+
|
|
449
|
+
def _prop(self, key):
|
|
450
|
+
return self._datamodel.get(key)
|
|
451
|
+
|
|
452
|
+
def _attr(self, name):
|
|
453
|
+
for a in self._datamodel.get('attributes', []):
|
|
454
|
+
if a.get('name') == name:
|
|
455
|
+
for k in a:
|
|
456
|
+
if k.endswith('Value') or k.endswith('Values'):
|
|
457
|
+
return a.get(k)
|
|
458
|
+
|
|
459
|
+
def _link(self, linktype):
|
|
460
|
+
for link in self._datamodel.get('links', []):
|
|
461
|
+
if link.get('linktype') == linktype:
|
|
462
|
+
return link.get('entityKey')
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def tail(s):
|
|
466
|
+
seperator = ['#', '.']
|
|
467
|
+
if isinstance(s, str):
|
|
468
|
+
for i in seperator:
|
|
469
|
+
if s.count(i):
|
|
470
|
+
return s.split(i)[-1]
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def key(self):
|
|
474
|
+
return self._prop('key')
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def id(self):
|
|
478
|
+
return self._prop('id')
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def entity_type(self):
|
|
482
|
+
return self.tail(self._prop('type'))
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def entity_type_ref(self):
|
|
486
|
+
# for publicId
|
|
487
|
+
return self.tail(self._attr('entityTypeRef'))
|
|
488
|
+
|
|
489
|
+
@property
|
|
490
|
+
def entity_type_long(self):
|
|
491
|
+
return self._prop('type')
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def description(self):
|
|
495
|
+
return self._prop('description')
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def name(self):
|
|
499
|
+
return self._attr('name')
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def external_id(self):
|
|
503
|
+
return self._attr('externalId')
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def public_id(self):
|
|
507
|
+
return self._attr('publicId')
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def unit(self):
|
|
511
|
+
return self._attr('unit')
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def codetype(self): # for indexSetCode
|
|
515
|
+
return self._attr('codeType')
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def code(self): # for indexSetCode
|
|
519
|
+
return self._attr('code')
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def data(self):
|
|
523
|
+
try:
|
|
524
|
+
return json.loads(self._attr('data'))
|
|
525
|
+
except json.JSONDecoder:
|
|
526
|
+
log.exception('decode entity data')
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def meta_keywords(self):
|
|
530
|
+
meta = self._attr('metaKeywords')
|
|
531
|
+
if isinstance(meta, list):
|
|
532
|
+
return {i.get('key'): i.get('value') for i in meta}
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def meta_booleans(self):
|
|
536
|
+
meta = self._attr('metaBooleans')
|
|
537
|
+
if isinstance(meta, list):
|
|
538
|
+
return {i.get('key'): bool(i.get('value')) for i in meta}
|
|
539
|
+
|
|
540
|
+
@property
|
|
541
|
+
def role(self):
|
|
542
|
+
roles = self._attr(f'{self.entity_type.lower()}Role')
|
|
543
|
+
if isinstance(roles, list):
|
|
544
|
+
return [self.tail(i) for i in roles]
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def roles(self):
|
|
548
|
+
return self.role
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def type(self):
|
|
552
|
+
t = self._attr(f'{self.entity_type.lower()}Type')
|
|
553
|
+
if t:
|
|
554
|
+
return self.tail(t)
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def indexset(self):
|
|
558
|
+
if self.entity_type == 'Gateway':
|
|
559
|
+
return self._link('isAssignedTo')
|
|
560
|
+
return self._link('isDefinedIn')
|
|
561
|
+
|
|
562
|
+
def create(self, entitytype, indexset, **kwargs):
|
|
563
|
+
new_id = str(uuid.uuid4())
|
|
564
|
+
key = kwargs.get('key', new_id) # method to overwrite key, eg for indexSetCodeType
|
|
565
|
+
self._datamodel = {
|
|
566
|
+
"type": f'com.bynubian.shared.entities.{entitytype}',
|
|
567
|
+
"id": new_id,
|
|
568
|
+
"key": key,
|
|
569
|
+
"links": [],
|
|
570
|
+
"attributes": []
|
|
571
|
+
}
|
|
572
|
+
self.set_context('create')
|
|
573
|
+
linktype = 'isAssignedTo' if self.entity_type == 'Gateway' else 'isDefinedIn'
|
|
574
|
+
self.set_link(linktype, indexset)
|
|
575
|
+
for k, v in kwargs.items():
|
|
576
|
+
if v is None:
|
|
577
|
+
continue
|
|
578
|
+
elif k in ['description']:
|
|
579
|
+
self._datamodel.update({k: v})
|
|
580
|
+
else:
|
|
581
|
+
self.set_attribute(k, v)
|
|
582
|
+
return self
|
|
583
|
+
|
|
584
|
+
def set_context(self, action):
|
|
585
|
+
if 'entityContext' not in self._datamodel:
|
|
586
|
+
self._datamodel.update({'entityContext': {}})
|
|
587
|
+
if 'action' not in self._datamodel['entityContext']:
|
|
588
|
+
self._datamodel['entityContext'].update({"action": action})
|
|
589
|
+
|
|
590
|
+
def set_link(self, linktype, entity):
|
|
591
|
+
def _index():
|
|
592
|
+
for position, _link in enumerate(self._datamodel['links']):
|
|
593
|
+
if _link.get('entityKey') == entity.key:
|
|
594
|
+
return position
|
|
595
|
+
|
|
596
|
+
if 'links' not in self._datamodel:
|
|
597
|
+
self._datamodel.update({'links': []})
|
|
598
|
+
i = _index()
|
|
599
|
+
if linktype is None: # method to remove
|
|
600
|
+
if i is not None:
|
|
601
|
+
self._datamodel['links'].pop(i)
|
|
602
|
+
elif isinstance(entity, Entity) and entity.key:
|
|
603
|
+
log.info('setlink %s with %s / %s ', linktype, entity, entity.indexset)
|
|
604
|
+
link = {
|
|
605
|
+
"linkType": linktype, # hasFeature
|
|
606
|
+
"label": f'{linktype}{entity.entity_type}', # hasFeatureItem
|
|
607
|
+
'entityKey': entity.key,
|
|
608
|
+
'entityId': entity.id,
|
|
609
|
+
'entityType': entity.entity_type_long,
|
|
610
|
+
'entityIndexSetKey': entity._link('isDefinedIn'),
|
|
611
|
+
'entityIndexSetId': entity._link('isDefinedIn')
|
|
612
|
+
}
|
|
613
|
+
link = {k: v for k, v in link.items() if
|
|
614
|
+
v is not None} # this will remove entityIndexSetKey for gateway and indexset entities
|
|
615
|
+
if i is None:
|
|
616
|
+
self._datamodel['links'].append(link)
|
|
617
|
+
else:
|
|
618
|
+
self._datamodel['links'][i] = link
|
|
619
|
+
|
|
620
|
+
def set_attribute(self, name, value):
|
|
621
|
+
def _index(attr_name):
|
|
622
|
+
for position, _attr in enumerate(self._datamodel.get('attributes', [])):
|
|
623
|
+
if _attr.get('name') == attr_name:
|
|
624
|
+
return position
|
|
625
|
+
|
|
626
|
+
def _attribute(attr_name, attr_value, attr_entity, attr_type):
|
|
627
|
+
return { # , "com.bynubian.shared.Boolean", "numberValue", name, int(bool(value)))
|
|
628
|
+
"name": attr_name,
|
|
629
|
+
"type": f'com.bynubian.shared.{attr_entity}',
|
|
630
|
+
attr_type: attr_value
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if 'attributes' not in self._datamodel:
|
|
634
|
+
self._datamodel.update({'attributes': []})
|
|
635
|
+
# set value to None to have the attribute deleted
|
|
636
|
+
if value is None:
|
|
637
|
+
i = _index(name)
|
|
638
|
+
if i is not None:
|
|
639
|
+
self._datamodel['attributes'].pop(i)
|
|
640
|
+
return
|
|
641
|
+
try:
|
|
642
|
+
if name in ['name', 'externalId', 'publicId', 'code']:
|
|
643
|
+
attr = _attribute(name, str(value), 'Keyword', 'keywordValue')
|
|
644
|
+
elif name in ['active', 'valid']:
|
|
645
|
+
attr = _attribute(name, int(bool(value)), 'Boolean', 'numberValue')
|
|
646
|
+
elif name in ['valueType']:
|
|
647
|
+
attr = _attribute(name, str(value), 'ValueType', 'keywordValue')
|
|
648
|
+
elif name in ['data']:
|
|
649
|
+
attr = _attribute(name, json.dumps(value), 'Json', 'textValue')
|
|
650
|
+
elif name in ['unit']:
|
|
651
|
+
attr = _attribute(name, str(value), 'Unit', 'keywordValue')
|
|
652
|
+
elif name in ['metaKeywords']:
|
|
653
|
+
list_values = [{'key': k, 'value': str(v)} for k, v in value.items()] \
|
|
654
|
+
if isinstance(value, dict) else []
|
|
655
|
+
attr = _attribute(name, list_values, 'ExtensionKeyword', 'extensionKeywordValues')
|
|
656
|
+
elif name in ['metaBooleans']:
|
|
657
|
+
list_values = [{'key': k, 'value': int(bool(v))} for k, v in value.items()] \
|
|
658
|
+
if isinstance(value, dict) else []
|
|
659
|
+
attr = _attribute(name, list_values, 'ExtensionBoolean', 'extensionNumberValues')
|
|
660
|
+
elif name in ['codeType']:
|
|
661
|
+
attr = _attribute(name, str(value), 'IndexSetCodeType', 'keywordValue')
|
|
662
|
+
elif name.endswith('Role'):
|
|
663
|
+
# this is used in relations with single roles
|
|
664
|
+
# e.g. name itemRole, value DEFAULT
|
|
665
|
+
attr = _attribute(name, f'{name}#{value}', 'IndexSetCode', 'keywordValue')
|
|
666
|
+
elif name in ['roles']:
|
|
667
|
+
name = f'{self.entity_type.lower()}Role'
|
|
668
|
+
list_roles = [f'{name}#{role}' for role in value]
|
|
669
|
+
attr = _attribute(name, list_roles, 'IndexSetCode', 'keywordValues')
|
|
670
|
+
elif name in ['type']:
|
|
671
|
+
name = f'{self.entity_type.lower()}Type'
|
|
672
|
+
type_value = f'{name}#{value}'
|
|
673
|
+
attr = _attribute(name, type_value, 'IndexSetCode', 'keywordValue')
|
|
674
|
+
else:
|
|
675
|
+
return
|
|
676
|
+
i = _index(name)
|
|
677
|
+
if i is None:
|
|
678
|
+
self._datamodel['attributes'].append(attr)
|
|
679
|
+
else:
|
|
680
|
+
self._datamodel['attributes'][i] = attr
|
|
681
|
+
|
|
682
|
+
except (Exception,):
|
|
683
|
+
log.exception('set_attribute')
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class Numberevent:
|
|
687
|
+
|
|
688
|
+
def __init__(self, node=None):
|
|
689
|
+
if isinstance(node, dict):
|
|
690
|
+
for k, v in node.items():
|
|
691
|
+
setattr(self, k, v)
|
|
692
|
+
|
|
693
|
+
def __repr__(self):
|
|
694
|
+
return f'Numberevent {self.datetime} {self.value}'
|
|
695
|
+
|
|
696
|
+
def wrap(self):
|
|
697
|
+
return vars(self)
|
|
698
|
+
|
|
699
|
+
def create(self, value, dt, item):
|
|
700
|
+
"""
|
|
701
|
+
create the required attributes to populate the Event
|
|
702
|
+
:param value: the numberValue that will be floated
|
|
703
|
+
:param dt: datetime, either tz-aware or utc
|
|
704
|
+
:param item: the Item-entity to link the event with
|
|
705
|
+
:return:
|
|
706
|
+
"""
|
|
707
|
+
|
|
708
|
+
def timestamp():
|
|
709
|
+
# if not timezone aware; force UTC
|
|
710
|
+
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
|
711
|
+
dt.replace(tzinfo=datetime.timezone.utc)
|
|
712
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%S.%fZ')
|
|
713
|
+
|
|
714
|
+
node = {
|
|
715
|
+
"timestamp": timestamp(),
|
|
716
|
+
"id": str(uuid.uuid4()), # todo is this needed
|
|
717
|
+
"itemId": item.key,
|
|
718
|
+
"documentType": "NumberEvent",
|
|
719
|
+
"eventContext": {
|
|
720
|
+
"action": "create"
|
|
721
|
+
},
|
|
722
|
+
"indexSetKey": item.indexset,
|
|
723
|
+
"numberValue": float(value)
|
|
724
|
+
}
|
|
725
|
+
for key, value in node.items():
|
|
726
|
+
setattr(self, key, value)
|
|
727
|
+
|
|
728
|
+
@property
|
|
729
|
+
def datetime(self):
|
|
730
|
+
if hasattr(self, 'timestamp'):
|
|
731
|
+
return datetime.datetime.strptime(self.timestamp, '%Y-%m-%dT%H:%M:%S.%f%z')
|
|
732
|
+
|
|
733
|
+
@property
|
|
734
|
+
def value(self):
|
|
735
|
+
if hasattr(self, 'numberValue'):
|
|
736
|
+
return float(self.numberValue)
|
|
737
|
+
|
|
738
|
+
@property
|
|
739
|
+
def now(self):
|
|
740
|
+
"""timezone aware utc now"""
|
|
741
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def example01():
|
|
745
|
+
logging.basicConfig(level=logging.INFO)
|
|
746
|
+
log.info('list tenants')
|
|
747
|
+
# list tenants
|
|
748
|
+
with Byneuron() as r:
|
|
749
|
+
print(f'IndexSets in this environment: {[e.name for e in r.indexSets]}')
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def example02():
|
|
753
|
+
logging.basicConfig(level=logging.INFO)
|
|
754
|
+
log.info('create item')
|
|
755
|
+
# crete item 'test' in indexSet 0
|
|
756
|
+
with Byneuron() as r:
|
|
757
|
+
r.create_item(name='test')
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
if __name__ == '__main__':
|
|
761
|
+
example02()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "<my_package>" # Will be dynamically replaced by CI/CD pipeline
|
|
7
|
+
version = "<1.0.0>" # Will be dynamically replaced by CI/CD pipeline
|
|
8
|
+
description = "<byneuron endpoint API>"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "<Jo Vinckier>", email = "<jo.vinckier@byneuron.com>"},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
"Homepage" = "<https://gitlab.com/my_package>" # Will be replaced with actual project URL
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# setup.py
|
|
2
|
+
from setuptools import setup, find_packages
|
|
3
|
+
|
|
4
|
+
setup(
|
|
5
|
+
name='bn_byneuron', #only lowercase and dashes
|
|
6
|
+
version='0.0.1',
|
|
7
|
+
author='jovi',
|
|
8
|
+
author_email='jo.vinckier@bynubian.com',
|
|
9
|
+
description='API for byneuron backend',
|
|
10
|
+
long_description=open('README.md').read(),
|
|
11
|
+
long_description_content_type='text/markdown',
|
|
12
|
+
url='https://gitlab.com/bynubian/bynode/python_packages/byneuron',
|
|
13
|
+
packages=find_packages(),
|
|
14
|
+
classifiers=[
|
|
15
|
+
'Programming Language :: Python :: 3',
|
|
16
|
+
'License :: OSI Approved :: MIT License',
|
|
17
|
+
'Operating System :: OS Independent',
|
|
18
|
+
],
|
|
19
|
+
python_requires='>=3.7',
|
|
20
|
+
install_requires=[
|
|
21
|
+
'requests>=2.32.4',
|
|
22
|
+
'python-decouple>=3.8'
|
|
23
|
+
# dependencies: other packages required
|
|
24
|
+
# eg 'numpy>=1.11.1'
|
|
25
|
+
]
|
|
26
|
+
)
|