volue-insight-timeseries 2.0.2__py3-none-any.whl

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,127 @@
1
+ import contextlib
2
+ import json
3
+ import time
4
+
5
+ import sseclient
6
+ import threading
7
+ import queue
8
+
9
+ from . import curves, util
10
+
11
+
12
+ class EventListener:
13
+ def __init__(self, session, curve_list, start_time=None, timeout=None):
14
+ self.curve_cache = {}
15
+ ids = []
16
+ if not hasattr(curve_list, '__iter__') or isinstance(curve_list, str):
17
+ curve_list = [curve_list]
18
+ for curve in curve_list:
19
+ if isinstance(curve, curves.BaseCurve):
20
+ ids.append(curve.id)
21
+ self.curve_cache[curve.id] = curve
22
+ else:
23
+ ids.append(curve)
24
+ args = [util.make_arg('id', ids)]
25
+ if start_time is not None:
26
+ args.append(util.make_arg('start_time', start_time))
27
+ self.url = '/api/events?{}'.format('&'.join(args))
28
+ self.session = session
29
+ self.timeout = timeout
30
+ self.retry = 3000 # Retry time in milliseconds
31
+ self.client = None
32
+ self.queue = queue.Queue()
33
+ self.do_shutdown = False
34
+ self.worker = threading.Thread(target=self.fetch_events)
35
+ self.worker.daemon = True
36
+ self.worker.start()
37
+
38
+ def get(self):
39
+ try:
40
+ val = self.queue.get(timeout=self.timeout)
41
+ if isinstance(val, EventError):
42
+ raise val.exception
43
+ return val
44
+ except queue.Empty:
45
+ return EventTimeout()
46
+
47
+ def fetch_events(self):
48
+ while not self.do_shutdown:
49
+ try:
50
+ with self.session.data_request("GET", self.session.urlbase, self.url, stream=True) as stream:
51
+ self.client = sseclient.SSEClient(stream)
52
+ for sse_event in self.client.events():
53
+ if sse_event.event == 'curve_event':
54
+ event = CurveEvent(sse_event)
55
+ else:
56
+ event = DefaultEvent(sse_event)
57
+ if hasattr(event, 'id') and event.id in self.curve_cache:
58
+ event.curve = self.curve_cache[event.id]
59
+ self.queue.put(event)
60
+ if sse_event.retry is not None:
61
+ with contextlib.suppress(ValueError, TypeError):
62
+ self.retry = int(sse_event.retry)
63
+ if self.do_shutdown:
64
+ break
65
+ # Session was closed by server/network, wait for retry before looping.
66
+ time.sleep(self.retry / 1000.0)
67
+ except Exception as e:
68
+ self.queue.put(EventError(e))
69
+ break
70
+
71
+ def close(self, timeout=1):
72
+ self.do_shutdown = True
73
+ if self.client is not None:
74
+ self.client.close()
75
+ self.worker.join(timeout)
76
+
77
+ def __iter__(self):
78
+ return self
79
+
80
+ def __next__(self):
81
+ return self.get()
82
+
83
+ def __enter__(self):
84
+ return self
85
+
86
+ def __exit__(self, exc_type, exc_val, exc_tb):
87
+ self.close()
88
+
89
+
90
+ class EventError:
91
+ def __init__(self, exception):
92
+ self.exception = exception
93
+
94
+ def __str__(self):
95
+ return "{}".format(self.exception)
96
+
97
+
98
+ class EventTimeout:
99
+ """Returned on timeout, etc."""
100
+ pass
101
+
102
+
103
+ class DefaultEvent(object):
104
+ def __init__(self, sse_event):
105
+ self._raw_event = sse_event
106
+ try:
107
+ self.json_data = json.loads(sse_event.data)
108
+ except (json.JSONDecodeError, TypeError):
109
+ self.json_data = None
110
+
111
+
112
+ class CurveEvent(DefaultEvent):
113
+ def __init__(self, sse_event):
114
+ super(CurveEvent, self).__init__(sse_event)
115
+ self.id = self.json_data['id']
116
+ self.curve = None
117
+ self.created = util.parsetime(self.json_data['created'])
118
+ self.operation = self.json_data['operation']
119
+ self.tag = None
120
+ self.issue_date = None
121
+ self.range = None
122
+ if 'tag' in self.json_data:
123
+ self.tag = self.json_data['tag']
124
+ if 'issue_date' in self.json_data:
125
+ self.issue_date = util.parsetime(self.json_data['issue_date'])
126
+ if 'range' in self.json_data:
127
+ self.range = util.parserange(self.json_data['range'])
@@ -0,0 +1,521 @@
1
+ try:
2
+ from urllib.parse import urljoin
3
+ except ImportError:
4
+ from urlparse import urljoin
5
+
6
+ import requests
7
+ import json
8
+ import time
9
+ import warnings
10
+ import configparser
11
+
12
+ from . import auth, curves, events, util
13
+ from .util import CurveException
14
+
15
+
16
+ RETRY_COUNT = 4 # Number of times to retry
17
+ RETRY_DELAY = 0.5 # Delay between retried calls, in seconds.
18
+ TIMEOUT = 300 # Default timeout for web calls, in seconds.
19
+ API_URLBASE = 'https://api.volueinsight.com'
20
+ AUTH_URLBASE = 'https://auth.volueinsight.com'
21
+
22
+
23
+ class ConfigException(Exception):
24
+ pass
25
+
26
+
27
+ class MetadataException(Exception):
28
+ pass
29
+
30
+
31
+ class Session(object):
32
+ """ Establish a connection to Wattsight API
33
+
34
+ Creates an object that holds the state which is needed when talking to the
35
+ Wattsight data center. To establish a session, you have to provide
36
+ suthentication information either directly by using a ```client_id` and
37
+ ``client_secret`` or using a ``config_file`` .
38
+
39
+ See https://api.volueinsight.com/#documentation for information how to get
40
+ your authentication data.
41
+
42
+ Parameters
43
+ ----------
44
+
45
+ urlbase: url
46
+ Location of Wattsight service
47
+ config_file: path
48
+ path to the config.ini file which contains your authentication
49
+ information.
50
+ client_id: str
51
+ Your client ID
52
+ client_secret:
53
+ Your client secret.
54
+ auth_urlbase: url
55
+ Location of Wattsight authentication service
56
+ timeout: float
57
+ Timeout for REST calls, in seconds
58
+
59
+ Returns
60
+ -------
61
+ session: :class:`volue_insight_timeseries.session.Session` object
62
+
63
+ """
64
+
65
+ def __init__(self, urlbase=None, config_file=None, client_id=None, client_secret=None,
66
+ auth_urlbase=None, timeout=None, retry_update_auth=False):
67
+ self.urlbase = API_URLBASE
68
+ self.auth = None
69
+ self.timeout = TIMEOUT
70
+ self._session = requests.Session()
71
+ self.retry_update_auth = retry_update_auth
72
+ if config_file is not None:
73
+ self.read_config_file(config_file)
74
+ elif client_id is not None and client_secret is not None:
75
+ self.configure(client_id, client_secret, auth_urlbase)
76
+ if urlbase is not None:
77
+ self.urlbase = urlbase
78
+ if timeout is not None:
79
+ self.timeout = timeout
80
+
81
+ def read_config_file(self, config_file):
82
+ """Set up according to configuration file with hosts and access details"""
83
+ if self.auth is not None:
84
+ raise ConfigException('Session configuration is already done')
85
+ config = configparser.RawConfigParser()
86
+ # Support being given a file-like object or a file path:
87
+ if hasattr(config_file, 'read'):
88
+ config.read_file(config_file)
89
+ else:
90
+ files_read = config.read(config_file)
91
+ if not files_read:
92
+ raise ConfigException('Configuration file with name {} '
93
+ 'was not found.'.format(config_file))
94
+ urlbase = config.get('common', 'urlbase', fallback=None)
95
+ if urlbase is not None:
96
+ self.urlbase = urlbase
97
+ auth_type = config.get('common', 'auth_type')
98
+ if auth_type == 'OAuth':
99
+ client_id = config.get(auth_type, 'id')
100
+ client_secret = config.get(auth_type, 'secret')
101
+ auth_urlbase = config.get(auth_type, 'auth_urlbase', fallback=AUTH_URLBASE)
102
+ self.auth = auth.OAuth(self, client_id, client_secret, auth_urlbase)
103
+ timeout = config.get('common', 'timeout', fallback=None)
104
+ if timeout is not None:
105
+ self.timeout = float(timeout)
106
+
107
+ def configure(self, client_id, client_secret, auth_urlbase=None):
108
+ """Programmatically set authentication parameters"""
109
+ if self.auth is not None:
110
+ raise ConfigException('Session configuration is already done')
111
+ if auth_urlbase is None:
112
+ auth_urlbase = AUTH_URLBASE
113
+ self.auth = auth.OAuth(self, client_id, client_secret, auth_urlbase)
114
+
115
+ def get_curve(self, id=None, name=None):
116
+ """Getting a curve object
117
+
118
+ Return a curve object of the correct type. Name should be specified.
119
+ While it is possible to get a curve by id, this is not guaranteed to be
120
+ long-term stable and will be removed in future versions.
121
+
122
+ Parameters
123
+ ----------
124
+
125
+ id: int
126
+ curve id (deprecated)
127
+ name: str
128
+ curve name
129
+
130
+ Returns
131
+ -------
132
+ curve object
133
+ Curve objects, can be one of:
134
+ :class:`~volue_insight_timeseries.curves.TimeSeriesCurve`,
135
+ :class:`~volue_insight_timeseries.curves.TaggedCurve`,
136
+ :class:`~volue_insight_timeseries.curves.InstanceCurve`,
137
+ :class:`~volue_insight_timeseries.curves.TaggedInstanceCurve`.
138
+ """
139
+ if id is not None:
140
+ warnings.warn("Looking up a curve by ID will be removed in the future.", FutureWarning, stacklevel=2)
141
+ if id is None and name is None:
142
+ raise MetadataException('No curve specified')
143
+
144
+ if id is not None:
145
+ arg = util.make_arg('id', id)
146
+ else:
147
+ arg = util.make_arg('name', name)
148
+ response = self.data_request('GET', self.urlbase, '/api/curves/get?{}'.format(arg))
149
+ return self.handle_single_curve_response(response)
150
+
151
+ def search(self, query=None, id=None, name=None, commodity=None, category=None, area=None, station=None,
152
+ source=None, scenario=None, unit=None, time_zone=None, version=None, frequency=None, data_type=None,
153
+ curve_state=None, modified_since=None, only_accessible=None):
154
+ """
155
+ Search for a curve matching various metadata.
156
+
157
+ This function searches for curves that matches the given search
158
+ parameters and returns a list of 0 or more curve objects.
159
+ A curve object can be a
160
+ :class:`~volue_insight_timeseries.curves.TimeSeriesCurve`,
161
+ :class:`~volue_insight_timeseries.curves.TaggedCurve`,
162
+ :class:`~volue_insight_timeseries.curves.InstanceCurve` or a
163
+ :class:`~volue_insight_timeseries.curves.TaggedInstanceCurve` object.
164
+
165
+ The search will return those curves matching all supplied parameters
166
+ (logical AND). For most parameters, a list of values may be supplied.
167
+ The search will match any of these values (logical OR). If a single
168
+ value contains a string with comma-separated values, these will be
169
+ treated as a list but will match with logical AND. (This only makes
170
+ sense for parameters where a curve may have multiple values:
171
+ area (border curves), category, source and scenario.)
172
+
173
+ For more details, see the REST documentation.
174
+
175
+ Parameters
176
+ ----------
177
+
178
+ query: str
179
+ A query string used for a language-aware text search on both names
180
+ and descriptions of the various attributes in the curve.
181
+
182
+ id: int or lits of int
183
+ search for one or more specific id's (deprecated)
184
+
185
+ name: str or list of str
186
+ search for one or more curve names, you can use the ``*`` as
187
+ a wildcard for patter matching.
188
+
189
+ commodity: str or list of str
190
+ search for curves that match the given ``commodity`` attribute.
191
+ Get valid values for this attribute with
192
+ :meth:`volue_insight_timeseries.session.Session.get_commodities`
193
+
194
+ category: str or list of str
195
+ search for curves that match the given ``category`` attribute.
196
+ Get valid values for this attribute with
197
+ :meth:`volue_insight_timeseries.session.Session.get_categories`
198
+
199
+ area: str or list of str
200
+ search for curves that match the given ``area`` attribute.
201
+ Get valid values for this attribute with
202
+ :meth:`volue_insight_timeseries.session.Session.get_areas`
203
+
204
+ station: str or list of str
205
+ search for curves that match the given ``station`` attribute.
206
+ Get valid values for this attribute with
207
+ :meth:`volue_insight_timeseries.session.Session.get_stations`
208
+
209
+ source: str or list of str
210
+ search for curves that match the given ``source`` attribute.
211
+ Get valid values for this attribute with
212
+ :meth:`volue_insight_timeseries.session.Session.get_sources`
213
+
214
+ scenario: str or list of str
215
+ search for curves that match the given ``scenario`` attribute.
216
+ Get valid values for this attribute with
217
+ :meth:`volue_insight_timeseries.session.Session.get_scenarios`
218
+
219
+ unit: str or list of str
220
+ search for curves that match the given ``unit`` attribute.
221
+ Get valid values for this attribute with
222
+ :meth:`volue_insight_timeseries.session.Session.get_units`
223
+
224
+ time_zone: str or list of str
225
+ search for curves that match the given ``time_zone`` attribute.
226
+ Get valid values for this attribute with
227
+ :meth:`volue_insight_timeseries.session.Session.get_time_zones`
228
+
229
+ version: str or list of str
230
+ search for curves that match the given ``version`` attribute.
231
+ Get valid values for this attribute with
232
+ :meth:`volue_insight_timeseries.session.Session.get_versions`
233
+
234
+ frequency: str or list of str
235
+ search for curves that match the given ``frequency`` attribute.
236
+ Get valid values for this attribute with
237
+ :meth:`volue_insight_timeseries.session.Session.get_frequencies`
238
+
239
+ data_type: str or list of str
240
+ search for curves that match the given ``data_type`` attribute.
241
+ Get valid values for this attribute with
242
+ :meth:`volue_insight_timeseries.session.Session.get_data_types`
243
+
244
+ curve_state: str or list of str
245
+ search for curves that match the given ``curve_state`` attribute.
246
+ Get valid values for this attribute with
247
+ :meth:`volue_insight_timeseries.session.Session.get_curve_state`
248
+
249
+ modified_since: datestring, pandas.Timestamp or datetime.datetime
250
+ only return curves that where modified after given datetime.
251
+
252
+ only_accessible: bool
253
+ If True, only return curves you have (some) access to.
254
+
255
+ Returns
256
+ -------
257
+ curves: list
258
+ list of curve objects, can be one of:
259
+ :class:`~volue_insight_timeseries.curves.TimeSeriesCurve`,
260
+ :class:`~volue_insight_timeseries.curves.TaggedCurve`,
261
+ :class:`~volue_insight_timeseries.curves.InstanceCurve`,
262
+ :class:`~volue_insight_timeseries.curves.TaggedInstanceCurve`.
263
+ """
264
+ search_terms = {
265
+ 'query': query,
266
+ 'id': id,
267
+ 'name': name,
268
+ 'commodity': commodity,
269
+ 'category': category,
270
+ 'area': area,
271
+ 'station': station,
272
+ 'source': source,
273
+ 'scenario': scenario,
274
+ 'unit': unit,
275
+ 'time_zone': time_zone,
276
+ 'version': version,
277
+ 'frequency': frequency,
278
+ 'data_type': data_type,
279
+ 'curve_state': curve_state,
280
+ 'modified_since': modified_since,
281
+ 'only_accessible': only_accessible,
282
+ }
283
+ if id is not None:
284
+ warnings.warn("Searching for curves by ID will be removed in the future.", FutureWarning, stacklevel=2)
285
+ args = []
286
+ astr = ''
287
+ for key, val in search_terms.items():
288
+ if val is None:
289
+ continue
290
+ args.append(util.make_arg(key, val))
291
+ if len(args):
292
+ astr = "?{}".format("&".join(args))
293
+ # Now run the search, and try to produce a list of curves
294
+ response = self.data_request('GET', self.urlbase, '/api/curves{}'.format(astr))
295
+ return self.handle_multi_curve_response(response)
296
+
297
+ def make_curve(self, id, curve_type):
298
+ """Return a mostly uninitialized curve object of the correct type.
299
+ This is generally a bad idea, use get_curve or search when possible."""
300
+ if curve_type in self._curve_types:
301
+ return self._curve_types[curve_type](id, None, self)
302
+ raise CurveException('Bad curve type requested')
303
+
304
+ def events(self, curve_list, start_time=None, timeout=None):
305
+ """Get an event listener for a list of curves."""
306
+ return events.EventListener(self, curve_list, start_time=start_time, timeout=timeout)
307
+
308
+ _attributes = {'commodities', 'categories', 'areas', 'stations', 'sources', 'scenarios',
309
+ 'units', 'time_zones', 'versions', 'frequencies', 'data_types',
310
+ 'curve_states', 'curve_types', 'functions', 'filters'}
311
+
312
+ def get_commodities(self):
313
+ """
314
+ Get valid values for the commodity attribute
315
+ """
316
+ return self.get_attribute('commodities')
317
+
318
+ def get_categories(self):
319
+ """
320
+ Get valid values for the category attribute
321
+ """
322
+ return self.get_attribute('categories')
323
+
324
+ def get_areas(self):
325
+ """
326
+ Get valid values for the area attribute
327
+ """
328
+ return self.get_attribute('areas')
329
+
330
+ def get_stations(self):
331
+ """
332
+ Get valid values for the station attribute
333
+ """
334
+ return self.get_attribute('stations')
335
+
336
+ def get_sources(self):
337
+ """
338
+ Get valid values for the source attribute
339
+ """
340
+ return self.get_attribute('sources')
341
+
342
+ def get_scenarios(self):
343
+ """
344
+ Get valid values for the scenarios attribute
345
+ """
346
+ return self.get_attribute('scenarios')
347
+
348
+ def get_units(self):
349
+ """
350
+ Get valid values for the unit attribute
351
+ """
352
+ return self.get_attribute('units')
353
+
354
+ def get_time_zones(self):
355
+ """
356
+ Get valid values for the time zone attribute
357
+ """
358
+ return self.get_attribute('time_zones')
359
+
360
+ def get_versions(self):
361
+ """
362
+ Get valid values for the version attribute
363
+ """
364
+ return self.get_attribute('versions')
365
+
366
+ def get_frequencies(self):
367
+ """
368
+ Get valid values for the frequency attribute
369
+ """
370
+ return self.get_attribute('frequencies')
371
+
372
+ def get_data_types(self):
373
+ """
374
+ Get valid values for the data_type attribute
375
+ """
376
+ return self.get_attribute('data_types')
377
+
378
+ def get_curve_states(self):
379
+ """
380
+ Get valid values for the curve_state attribute
381
+ """
382
+ return self.get_attribute('curve_states')
383
+
384
+ def get_curve_types(self):
385
+ """
386
+ Get valid values for the curve_type attribute
387
+ """
388
+ return self.get_attribute('curve_types')
389
+
390
+ def get_functions(self):
391
+ """
392
+ Get valid values for the function attribute
393
+ """
394
+ return self.get_attribute('functions')
395
+
396
+ def get_filters(self):
397
+ """
398
+ Get valid values for the filter attribute
399
+ """
400
+ return self.get_attribute('filters')
401
+
402
+ def get_attribute(self, attribute):
403
+ """Get valid values for an attribute."""
404
+ if attribute not in self._attributes:
405
+ raise MetadataException('Attribute {} is not valid'.format(attribute))
406
+ response = self.data_request('GET', self.urlbase, '/api/{}'.format(attribute))
407
+ if response.status_code == 200:
408
+ return response.json()
409
+ elif response.status_code == 204:
410
+ return None
411
+ raise MetadataException('Failed loading {}: {}'.format(attribute,
412
+ response.content.decode()))
413
+
414
+ _curve_types = {
415
+ util.TIME_SERIES: curves.TimeSeriesCurve,
416
+ util.TAGGED: curves.TaggedCurve,
417
+ util.INSTANCES: curves.InstanceCurve,
418
+ util.TAGGED_INSTANCES: curves.TaggedInstanceCurve,
419
+ }
420
+
421
+ _meta_keys = ('id', 'name', 'frequency', 'time_zone', 'curve_type')
422
+
423
+ def _build_curve(self, metadata):
424
+ for key in self._meta_keys:
425
+ if key not in metadata:
426
+ raise MetadataException('Mandatory key {} not found in metadata'.format(key))
427
+ curve_id = int(metadata['id'])
428
+ if('curve_state' in metadata and metadata['curve_state'] == 'DEPRECATED'):
429
+ warnings.warn("Deprecation warning for curve: {}".format(metadata['name']), DeprecationWarning, stacklevel=4)
430
+ if metadata['curve_type'] in self._curve_types:
431
+ c = self._curve_types[metadata['curve_type']](curve_id, metadata, self)
432
+ return c
433
+ raise CurveException('Unknown curve type ({})'.format(metadata['curve_type']))
434
+
435
+ def _get_auth_header_with_retry(self, databytes, retries=RETRY_COUNT):
436
+ try:
437
+ self.auth.validate_auth()
438
+ return self.auth.get_headers(databytes)
439
+ except Exception as e:
440
+ if retries <= 0:
441
+ raise e
442
+ if RETRY_DELAY > 0:
443
+ time.sleep(RETRY_DELAY)
444
+ return self._get_auth_header_with_retry(databytes, retries - 1)
445
+
446
+ def _validate_auth(self, data, rawdata):
447
+ headers = {}
448
+
449
+ databytes = None
450
+ if data is not None:
451
+ headers['content-type'] = 'application/json'
452
+ if isinstance(data, str):
453
+ databytes = data.encode()
454
+ else:
455
+ databytes = json.dumps(data).encode()
456
+ if data is None and rawdata is not None:
457
+ databytes = rawdata
458
+ if self.auth is not None:
459
+ # Beta-feature: Only update auth with retry if explicitly requested
460
+ if self.retry_update_auth:
461
+ auth_header = self._get_auth_header_with_retry(databytes)
462
+ headers.update(auth_header)
463
+ else:
464
+ self.auth.validate_auth()
465
+ headers.update(self.auth.get_headers(databytes))
466
+
467
+ return headers
468
+
469
+ def send_data_request(self, req_type, urlbase, url, data=None, rawdata=None, headers=None, authval=None,
470
+ stream=False, retries=RETRY_COUNT):
471
+ if not urlbase:
472
+ urlbase = self.urlbase
473
+ longurl = urljoin(urlbase, url)
474
+
475
+ databytes = None
476
+ if data is not None:
477
+ if isinstance(data, str):
478
+ databytes = data.encode()
479
+ else:
480
+ databytes = json.dumps(data).encode()
481
+ if data is None and rawdata is not None:
482
+ databytes = rawdata
483
+ timeout = None
484
+ try:
485
+ res = self._session.request(method=req_type, url=longurl, data=databytes,
486
+ headers=headers, auth=authval, stream=stream, timeout=self.timeout)
487
+ except requests.exceptions.Timeout as e:
488
+ timeout = e
489
+ res = None
490
+ if (timeout is not None or (500 <= res.status_code < 600) or res.status_code == 408) and retries > 0:
491
+ if RETRY_DELAY > 0:
492
+ time.sleep(RETRY_DELAY)
493
+ return self.send_data_request(req_type, urlbase, url, data, rawdata, headers, authval, stream, retries-1)
494
+ if timeout is not None:
495
+ raise timeout
496
+ return res
497
+
498
+ def data_request(self, req_type, urlbase, url, data=None, rawdata=None, authval=None,
499
+ stream=False, retries=RETRY_COUNT):
500
+ """Run a call to the backend, dealing with authentication etc."""
501
+ headers = self._validate_auth(data, rawdata)
502
+ res = self.send_data_request(req_type, urlbase, url, data, rawdata, headers, authval, stream, retries)
503
+ return res
504
+
505
+ def handle_single_curve_response(self, response):
506
+ if not response.ok:
507
+ raise MetadataException('Failed to load curve: {}'
508
+ .format(response.content.decode()))
509
+ metadata = response.json()
510
+ return self._build_curve(metadata)
511
+
512
+ def handle_multi_curve_response(self, response):
513
+ if not response.ok:
514
+ raise MetadataException('Curve search failed: {}'
515
+ .format(response.content.decode()))
516
+ metadata_list = response.json()
517
+
518
+ result = []
519
+ for metadata in metadata_list:
520
+ result.append(self._build_curve(metadata))
521
+ return result