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.
@@ -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,2 @@
1
+ requests>=2.32.4
2
+ python-decouple>=3.8
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )