mercuto-client 0.1.0__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.

Potentially problematic release.


This version of mercuto-client might be problematic. Click here for more details.

@@ -0,0 +1,903 @@
1
+ import base64
2
+ import contextlib
3
+ import logging
4
+ import mimetypes
5
+ import os
6
+ import time
7
+ import urllib.parse
8
+ from contextlib import nullcontext
9
+ from datetime import datetime, timedelta
10
+ from typing import (Any, BinaryIO, Iterable, Iterator, List, Literal, Mapping,
11
+ Optional, Sequence, TextIO, Type, TypeVar)
12
+
13
+ import requests
14
+ import requests.cookies
15
+
16
+ from ._util import timedelta_isoformat
17
+ from .exceptions import MercutoClientException, MercutoHTTPException
18
+ from .types import (CHANNEL_CLASSIFICATION, AlertConfiguration,
19
+ AuthHealthcheckResult, Camera, Channel, Condition,
20
+ ContactGroup, Dashboards, Datalogger, DataRequest,
21
+ DataSample, Device, DeviceType, Event, FatigueConnection,
22
+ Image, ListAlertsResponseType, NewUserApiKey, Object,
23
+ PermissionGroup, Project, RainflowConfiguration,
24
+ ScheduledReport, ScheduledReportLog,
25
+ SystemHealthcheckResult, Tenant, Units, User,
26
+ UserContactMethod, UserDetails, VerifyMeResult, Video)
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class IAuthenticationMethod:
32
+ def update_header(self, header: dict[str, str]) -> None:
33
+ return
34
+
35
+ def unique_key(self) -> str:
36
+ raise NotImplementedError(
37
+ f"unique_key not implemented for type {self.__class__.__name__}")
38
+
39
+
40
+ class ApiKeyAuthentication(IAuthenticationMethod):
41
+ def __init__(self, api_key: str) -> None:
42
+ self.api_key = api_key
43
+
44
+ def update_header(self, header: dict[str, str]) -> None:
45
+ header['X-Api-Key'] = self.api_key
46
+
47
+ def unique_key(self) -> str:
48
+ return f'api-key:{self.api_key}'
49
+
50
+
51
+ class ServiceTokenAuthentication(IAuthenticationMethod):
52
+ def __init__(self, service_token: str) -> None:
53
+ self.service_token = service_token
54
+
55
+ def update_header(self, header: dict[str, str]) -> None:
56
+ header['X-Service-Token'] = self.service_token
57
+
58
+ def unique_key(self) -> str:
59
+ return f'service-token:{self.service_token}'
60
+
61
+
62
+ class AuthorizationHeaderAuthentication(IAuthenticationMethod):
63
+ def __init__(self, authorization_header: str) -> None:
64
+ self.authorization_header = authorization_header
65
+
66
+ def update_header(self, header: dict[str, str]) -> None:
67
+ header['Authorization'] = self.authorization_header
68
+
69
+ def unique_key(self) -> str:
70
+ return f'auth-bearer:{self.authorization_header}'
71
+
72
+
73
+ class NullAuthenticationMethod(IAuthenticationMethod):
74
+ def update_header(self, header: dict[str, str]) -> None:
75
+ pass
76
+
77
+ def unique_key(self) -> str:
78
+ return 'null-authentication'
79
+
80
+
81
+ def create_authentication_method(api_key: Optional[str] = None,
82
+ service_token: Optional[str] = None,
83
+ headers: Optional[Mapping[str, str]] = None) -> IAuthenticationMethod:
84
+ if api_key is not None and service_token is not None and headers is not None:
85
+ raise MercutoClientException(
86
+ "Only one of api_key or service_token can be provided")
87
+ authorization_header = None
88
+ if headers is not None:
89
+ api_key = headers.get('X-Api-Key', None)
90
+ service_token = headers.get('X-Service-Token', None)
91
+ authorization_header = headers.get('Authorization', None)
92
+ if api_key is not None:
93
+ return ApiKeyAuthentication(api_key)
94
+ elif service_token is not None:
95
+ return ServiceTokenAuthentication(service_token)
96
+ elif authorization_header is not None:
97
+ return AuthorizationHeaderAuthentication(authorization_header)
98
+ else:
99
+ return NullAuthenticationMethod()
100
+
101
+
102
+ class Module:
103
+ def __init__(self, client: 'MercutoClient') -> None:
104
+ self._client = client
105
+
106
+ @property
107
+ def client(self) -> 'MercutoClient':
108
+ return self._client
109
+
110
+
111
+ T = TypeVar('T', bound='Module')
112
+
113
+
114
+ class MercutoClient:
115
+ def __init__(self, url: Optional[str] = None, verify_ssl: bool = True, active_session: Optional[requests.Session] = None) -> None:
116
+ if url is None:
117
+ url = os.environ.get(
118
+ 'MERCUTO_API_URL', 'https://api.rockfieldcloud.com.au')
119
+ assert isinstance(url, str)
120
+
121
+ if url.endswith('/'):
122
+ url = url[:-1]
123
+
124
+ if not url.startswith('https://'):
125
+ raise ValueError(f'Url must be https, is {url}')
126
+
127
+ self._url = url
128
+ self._verify_ssl = verify_ssl
129
+
130
+ if active_session is None:
131
+ self._current_session = requests.Session()
132
+ else:
133
+ self._current_session = active_session
134
+
135
+ self._auth_method: Optional[IAuthenticationMethod] = None
136
+ self._cookies = requests.cookies.RequestsCookieJar()
137
+
138
+ self._modules: dict[str, Module] = {}
139
+
140
+ def url(self) -> str:
141
+ return self._url
142
+
143
+ def credentials_key(self) -> str:
144
+ """
145
+ Generate a unique key that identifies the current credentials set.
146
+ """
147
+ if self._auth_method is None:
148
+ raise MercutoClientException("No credentials set")
149
+ return self._auth_method.unique_key()
150
+
151
+ def set_verify_ssl(self, verify_ssl: bool) -> None:
152
+ self._verify_ssl = verify_ssl
153
+
154
+ def copy(self) -> 'MercutoClient':
155
+ return MercutoClient(self._url, self._verify_ssl, self._current_session)
156
+
157
+ @contextlib.contextmanager
158
+ def as_credentials(self, api_key: Optional[str] = None,
159
+ service_token: Optional[str] = None,
160
+ headers: Optional[Mapping[str, str]] = None) -> Iterator['MercutoClient']:
161
+ """
162
+ Same as .connect(), but as a context manager. Will automatically logout when exiting the context.
163
+ """
164
+ # TODO: We are passing the current session along to re-use connections for speed. Will this cause security issues?
165
+ other = MercutoClient(self._url, self._verify_ssl,
166
+ self._current_session)
167
+ try:
168
+ yield other.connect(api_key=api_key, service_token=service_token, headers=headers)
169
+ finally:
170
+ other.logout()
171
+
172
+ def connect(self, *, api_key: Optional[str] = None,
173
+ service_token: Optional[str] = None,
174
+ headers: Optional[Mapping[str, str]] = None) -> 'MercutoClient':
175
+ """
176
+ Attempt to connect using any available method.
177
+ if api_key is provided, use the api_key.
178
+ if service_token is provided, use the service_token.
179
+ if headers is provided, attempt to extract either api_key or service_token from given header set.
180
+ headers should be a dictionary of headers that would be sent in a request. Useful for using existing authenation mechanism for forwarding.
181
+
182
+ """
183
+ authentication = create_authentication_method(
184
+ api_key=api_key, service_token=service_token, headers=headers)
185
+ self.login(authentication)
186
+ return self
187
+
188
+ def _update_headers(self, headers: dict[str, str]) -> dict[str, str]:
189
+ base: dict[str, str] = {}
190
+
191
+ if self._auth_method is not None:
192
+ self._auth_method.update_header(base)
193
+ base.update(headers)
194
+ return base
195
+
196
+ def build_url(self, path: str, **params: Any) -> str:
197
+ if path.startswith('/'):
198
+ path = path[1:]
199
+ if path.endswith('/'):
200
+ path = path[:-1]
201
+ return f"{self._url}/{path}/?{urllib.parse.urlencode(params)}"
202
+
203
+ def _request_json(self, method: str, url: str, *args: Any, **kwargs: Any) -> Any:
204
+ if 'timeout' not in kwargs:
205
+ kwargs['timeout'] = 10
206
+ kwargs['headers'] = self._update_headers(kwargs.get('headers', {}))
207
+
208
+ if 'verify' not in kwargs:
209
+ kwargs['verify'] = self._verify_ssl
210
+
211
+ if 'cookies' not in kwargs:
212
+ kwargs['cookies'] = self._cookies
213
+ return self._make_request(method, url, *args, **kwargs)
214
+
215
+ def _make_request(self, method: str, url: str, *args: Any, **kwargs: Any) -> Any:
216
+ if url.startswith('/'):
217
+ url = url[1:]
218
+ full_url = f"{self._url}/{url}"
219
+ start = time.time()
220
+ resp = self._current_session.request(method, full_url, *args, **kwargs)
221
+ duration = time.time() - start
222
+ logger.debug("Made request to %s %s in %.2f seconds (code=%s)",
223
+ method, full_url, duration, resp.status_code)
224
+ if not resp.ok:
225
+ raise MercutoHTTPException(resp.text, resp.status_code)
226
+ if resp.headers.get('Content-Type', '') != 'application/json':
227
+ raise MercutoClientException(f"Response is not JSON: {resp.text}")
228
+ resp.cookies.update(self._cookies)
229
+ return resp.json()
230
+
231
+ def request(self, method: str, url: str, *args: Any, **kwargs: Any) -> Any:
232
+ return self._request_json(method, url, *args, **kwargs)
233
+
234
+ def add_and_fetch_module(self, name: str, module: Type[T]) -> T:
235
+ if name not in self._modules:
236
+ self._modules[name] = module(self)
237
+ return self._modules[name] # type: ignore
238
+
239
+ def identity(self) -> 'MercutoIdentityService':
240
+ return self.add_and_fetch_module('identity', MercutoIdentityService)
241
+
242
+ def projects(self) -> 'MercutoProjectService':
243
+ return self.add_and_fetch_module('projects', MercutoProjectService)
244
+
245
+ def fatigue(self) -> 'MercutoFatigueService':
246
+ return self.add_and_fetch_module('fatigue', MercutoFatigueService)
247
+
248
+ def channels(self) -> 'MercutoChannelService':
249
+ return self.add_and_fetch_module('channels', MercutoChannelService)
250
+
251
+ def data(self) -> 'MercutoDataService':
252
+ return self.add_and_fetch_module('data', MercutoDataService)
253
+
254
+ def events(self) -> 'MercutoEventService':
255
+ return self.add_and_fetch_module('events', MercutoEventService)
256
+
257
+ def alerts(self) -> 'MercutoAlertService':
258
+ return self.add_and_fetch_module('alerts', MercutoAlertService)
259
+
260
+ def devices(self) -> 'MercutoDeviceService':
261
+ return self.add_and_fetch_module('devices', MercutoDeviceService)
262
+
263
+ def notifications(self) -> 'MercutoNotificationsService':
264
+ return self.add_and_fetch_module('notifications', MercutoNotificationsService)
265
+
266
+ def reports(self) -> 'MercutoReportingService':
267
+ return self.add_and_fetch_module('reports', MercutoReportingService)
268
+
269
+ def objects(self) -> 'MercutoObjectService':
270
+ return self.add_and_fetch_module('objects', MercutoObjectService)
271
+
272
+ def media(self) -> 'MercutoMediaService':
273
+ return self.add_and_fetch_module('media', MercutoMediaService)
274
+
275
+ def login(self, authentication: IAuthenticationMethod) -> None:
276
+ self._auth_method = authentication
277
+
278
+ def logout(self) -> None:
279
+ self._auth_method = None
280
+
281
+ def healthcheck(self) -> SystemHealthcheckResult:
282
+ return self._request_json('GET', '/healthcheck') # type: ignore[no-any-return]
283
+
284
+
285
+ class MercutoDataService(Module):
286
+ def __init__(self, client: 'MercutoClient') -> None:
287
+ super().__init__(client)
288
+
289
+ def upload_samples(self, samples: Sequence[DataSample]) -> None:
290
+ self._client._request_json(
291
+ 'POST', '/data/upload/samples', json=samples)
292
+
293
+ def upload_file(self, project: str, datatable: str, file: str | bytes | TextIO | BinaryIO, filename: Optional[str] = None) -> None:
294
+ if isinstance(file, str):
295
+ ctx = open(file, 'rb')
296
+ filename = filename or os.path.basename(file)
297
+ else:
298
+ ctx = nullcontext(file) # type: ignore
299
+ filename = filename or 'file.dat'
300
+
301
+ with ctx as f:
302
+ self._client._request_json('POST', '/files/upload/small', params={'project_code': project, 'datatable_code': datatable},
303
+ files={'file': (filename, f, 'text/csv')})
304
+
305
+ def get_data_url(
306
+ self,
307
+ project_code: str,
308
+ start_time: datetime | str | None = None,
309
+ end_time: datetime | str | None = None,
310
+ event_code: str | None = None,
311
+ channel_codes: Iterable[str] | None = None,
312
+ primary_channels: bool | None = None,
313
+ channels_like: str | None = None,
314
+ file_format: Literal['DAT', 'CSV', 'PARQUET', 'FEATHER'] = 'DAT',
315
+ frame_format: Literal['RECORDS', 'COLUMNS'] = 'COLUMNS',
316
+ channel_format: Literal['CODE', 'LABEL'] = 'LABEL',
317
+ timeout: timedelta | None = timedelta(seconds=20),
318
+ ) -> str:
319
+ request = self.get_data_request(
320
+ project_code=project_code,
321
+ start_time=start_time,
322
+ end_time=end_time,
323
+ event_code=event_code,
324
+ channel_codes=channel_codes,
325
+ primary_channels=primary_channels,
326
+ channels_like=channels_like,
327
+ file_format=file_format,
328
+ frame_format=frame_format,
329
+ channel_format=channel_format,
330
+ timeout=timeout,
331
+ )
332
+ if request['presigned_url'] is None:
333
+ raise MercutoClientException("No presigned URL available")
334
+
335
+ return request['presigned_url']
336
+
337
+ def get_data_request(
338
+ self,
339
+ project_code: str,
340
+ start_time: datetime | str | None = None,
341
+ end_time: datetime | str | None = None,
342
+ event_code: str | None = None,
343
+ channel_codes: Iterable[str] | None = None,
344
+ primary_channels: bool | None = None,
345
+ channels_like: str | None = None,
346
+ file_format: Literal['DAT', 'CSV', 'PARQUET', 'FEATHER'] = 'DAT',
347
+ frame_format: Literal['RECORDS', 'COLUMNS'] = 'COLUMNS',
348
+ channel_format: Literal['CODE', 'LABEL'] = 'LABEL',
349
+ timeout: timedelta | None = timedelta(seconds=20),
350
+ ) -> DataRequest:
351
+ params = {
352
+ 'timeout': 0
353
+ }
354
+
355
+ body = {
356
+ 'project_code': project_code,
357
+ 'start_time': start_time.isoformat() if isinstance(start_time, datetime) else start_time,
358
+ 'end_time': end_time.isoformat() if isinstance(end_time, datetime) else end_time,
359
+ 'event_code': event_code,
360
+ 'channel_codes': channel_codes,
361
+ 'primary_channels': primary_channels,
362
+ 'channels_like': channels_like,
363
+ 'data_format': file_format,
364
+ 'frame_format': frame_format,
365
+ 'channel_format': channel_format,
366
+ }
367
+
368
+ request: DataRequest = self._client._request_json(
369
+ 'POST',
370
+ '/data/requests',
371
+ params=params,
372
+ json=body)
373
+
374
+ start = time.time()
375
+ while True:
376
+ if timeout is not None and time.time() - start > timeout.total_seconds():
377
+ raise MercutoClientException(
378
+ "Timeout waiting for data request")
379
+ if request['completed_at'] is not None:
380
+ return request
381
+
382
+ time.sleep(1)
383
+ request = self._client._request_json(
384
+ 'GET', f'/data/requests/{request["code"]}')
385
+
386
+
387
+ class MercutoEventService(Module):
388
+ def __init__(self, client: 'MercutoClient') -> None:
389
+ super().__init__(client)
390
+
391
+ def list_events(self, project: str) -> list[Event]:
392
+ params: dict[str, str] = {'project_code': project}
393
+ return self._client._request_json('GET', '/events', params=params)
394
+
395
+ def get_nearest_event(
396
+ self,
397
+ project_code: str,
398
+ to: datetime | str,
399
+ maximum_delta: timedelta | None = None,
400
+ ) -> Event:
401
+ params = {
402
+ 'project_code': project_code,
403
+ 'to': to.isoformat() if isinstance(to, datetime) else to,
404
+ 'maximum_delta': timedelta_isoformat(maximum_delta) if maximum_delta is not None else None,
405
+ }
406
+
407
+ return self.client._request_json('GET', '/events/nearest', params=params)
408
+
409
+ def get_nearest_event_url(
410
+ self,
411
+ project_code: str,
412
+ to: datetime | str,
413
+ maximum_delta: timedelta | None = None,
414
+ file_format: Literal['DAT', 'CSV', 'PARQUET', 'FEATHER'] = 'DAT',
415
+ frame_format: Literal['COLUMNS', 'RECORDS'] = 'COLUMNS',
416
+ ) -> str:
417
+ event = self.get_nearest_event(project_code, to, maximum_delta)
418
+
419
+ return self.client.data().get_data_url(
420
+ project_code=project_code,
421
+ event_code=event['code'],
422
+ primary_channels=True,
423
+ file_format=file_format,
424
+ frame_format=frame_format)
425
+
426
+
427
+ class MercutoAlertService(Module):
428
+ def __init__(self, client: 'MercutoClient') -> None:
429
+ super().__init__(client)
430
+
431
+ def get_condition(self, code: str) -> Condition:
432
+ return self._client._request_json('GET', f'/alerts/conditions/{code}')
433
+
434
+ def create_condition(self, source: str, description: str, *,
435
+ lower_bound: Optional[float] = None,
436
+ upper_bound: Optional[float] = None,
437
+ neutral_position: float = 0) -> Condition:
438
+ json = {
439
+ 'source_channel_code': source,
440
+ 'description': description,
441
+ 'neutral_position': neutral_position
442
+ }
443
+ if lower_bound is not None:
444
+ json['lower_inclusive_bound'] = lower_bound
445
+ if upper_bound is not None:
446
+ json['upper_exclusive_bound'] = upper_bound
447
+ return self._client._request_json('PUT', '/alerts/conditions', json=json)
448
+
449
+ def create_configuration(self, label: str, conditions: list[str], contact_group: Optional[str] = None) -> AlertConfiguration:
450
+ json = {
451
+ 'label': label,
452
+ 'conditions': conditions,
453
+
454
+ }
455
+ if contact_group is not None:
456
+ json['contact_group'] = contact_group
457
+ return self._client._request_json('PUT', '/alerts/configurations', json=json)
458
+
459
+ def get_alert_configuration(self, code: str) -> AlertConfiguration:
460
+ return self._client._request_json('GET', f'/alerts/configurations/{code}')
461
+
462
+ def list_logs(
463
+ self,
464
+ project: str | None = None,
465
+ configuration: str | None = None,
466
+ channels: list[str] | None = None,
467
+ start_time: datetime | str | None = None,
468
+ end_time: datetime | str | None = None,
469
+ limit: int = 10,
470
+ offset: int = 0,
471
+ latest_only: bool = False,
472
+ ) -> ListAlertsResponseType:
473
+ params = {
474
+ 'project': project,
475
+ 'configuration_code': configuration,
476
+ 'channels': channels,
477
+ 'start_time': start_time.isoformat() if isinstance(start_time, datetime) else start_time,
478
+ 'end_time': end_time.isoformat() if isinstance(end_time, datetime) else end_time,
479
+ 'limit': limit,
480
+ 'offset': offset,
481
+ 'latest_only': latest_only,
482
+ }
483
+
484
+ return self._client._request_json('GET', '/alerts/logs', params=params)
485
+
486
+
487
+ class MercutoProjectService(Module):
488
+ def __init__(self, client: 'MercutoClient') -> None:
489
+ super().__init__(client)
490
+
491
+ def get_project(self, code: str) -> Project:
492
+ return self._client._request_json('GET', f'/projects/{code}')
493
+
494
+ def get_projects(self) -> list[Project]:
495
+ return self._client._request_json('GET', '/projects')
496
+
497
+ def create_project(self, name: str, project_number: str, description: str, tenant: str,
498
+ timezone: str, latitude: Optional[float] = None,
499
+ longitude: Optional[float] = None) -> Project:
500
+
501
+ return self._client._request_json('PUT', '/projects',
502
+ json={'name': name, 'project_number': project_number, 'description': description,
503
+ 'tenant_code': tenant,
504
+ 'timezone': timezone,
505
+ 'latitude': latitude,
506
+ 'longitude': longitude,
507
+ 'project_type': 1,
508
+ 'channels': []})
509
+
510
+ def ping_project(self, project: str, ip_address: str) -> None:
511
+ self._client._request_json(
512
+ 'POST', f'/projects/{project}/ping', json={'ip_address': ip_address})
513
+
514
+ def create_channel(self, project: str, label: str, sampling_period: timedelta) -> Channel:
515
+ return self._client._request_json('PUT', '/channels', json={
516
+ 'project_code': project,
517
+ 'label': label,
518
+ 'classification': 'SECONDARY',
519
+ 'sampling_period': timedelta_isoformat(sampling_period),
520
+ })
521
+
522
+ def create_dashboard(self, project_code: str, dashboards: Dashboards) -> bool:
523
+ return self._client._request_json('POST', f'/projects/{project_code}/dashboard', json=dashboards) is None
524
+
525
+
526
+ class MercutoFatigueService(Module):
527
+ def __init__(self, client: 'MercutoClient') -> None:
528
+ super().__init__(client)
529
+
530
+ def setup_rainflow(self, project: str,
531
+ max_bins: int,
532
+ bin_size: float,
533
+ multiplier: float,
534
+ channels: list[str],
535
+ reservoir_adjustment: bool = True,
536
+ enable_root_mean_cubed: bool = True,
537
+ enable_root_sum_cubed: bool = True) -> RainflowConfiguration:
538
+ return self.client._request_json('PUT', '/fatigue/rainflow/setup', json=dict(
539
+ project=project,
540
+ max_bins=max_bins,
541
+ bin_size=bin_size,
542
+ multiplier=multiplier,
543
+ reservoir_adjustment=reservoir_adjustment,
544
+ channels=channels,
545
+ enable_root_mean_cubed=enable_root_mean_cubed,
546
+ enable_root_sum_cubed=enable_root_sum_cubed
547
+ ))
548
+
549
+ def add_connection(self, project: str, label: str,
550
+ multiplier: float, c_d: float, m: float, s_0: float,
551
+ bs7608_failure_probability: float, bs7608_detail_category: str,
552
+ initial_date: datetime, initial_damage: float,
553
+ sources: list[str]) -> FatigueConnection:
554
+ """
555
+ Sources should be a list of Primary Channel codes.
556
+ """
557
+ return self.client._request_json('PUT', '/fatigue/connections', json=dict(
558
+ project=project,
559
+ label=label,
560
+ multiplier=multiplier,
561
+ c_d=c_d,
562
+ m=m,
563
+ s_0=s_0,
564
+ bs7608_failure_probability=bs7608_failure_probability,
565
+ bs7608_detail_category=bs7608_detail_category,
566
+ initial_date=initial_date.isoformat(),
567
+ initial_damage=initial_damage,
568
+ sources=sources
569
+ ))
570
+
571
+
572
+ class MercutoChannelService(Module):
573
+ def __init__(self, client: MercutoClient) -> None:
574
+ super().__init__(client)
575
+
576
+ def get_units(self) -> List[Units]:
577
+ return self._client._request_json('GET', '/channels/units')
578
+
579
+ def create_units(self, name: str, unit: str) -> Units:
580
+ return self._client._request_json('PUT', '/channels/units', json=dict(name=name, unit=unit))
581
+
582
+ def create_channel(self, project_code: str, label: str, classification: CHANNEL_CLASSIFICATION, sampling_period: timedelta) -> Channel:
583
+ return self._client._request_json('PUT', '/channels', json=dict(
584
+ project_code=project_code,
585
+ label=label,
586
+ classification=classification,
587
+ sampling_period=timedelta_isoformat(sampling_period)))
588
+
589
+ def modify_channel(self,
590
+ channel: str,
591
+ label: Optional[str] = None,
592
+ units_code: Optional[str] = None,
593
+ device_code: Optional[str] = None,
594
+ metric: Optional[str] = None,
595
+ multiplier: float = 1,
596
+ offset: float = 0) -> Channel:
597
+ data: dict = {}
598
+ if label is not None:
599
+ data['label'] = label
600
+ if units_code is not None:
601
+ data['units_code'] = units_code
602
+ if metric is not None:
603
+ data['metric'] = metric
604
+ if device_code is not None:
605
+ data['device_code'] = device_code
606
+ data['multiplier'] = multiplier
607
+ data['offset'] = offset
608
+ return self._client._request_json('PATCH', f'/channels/{channel}', json=data)
609
+
610
+ def get_channels(
611
+ self,
612
+ project_code: str | None = None,
613
+ classification: CHANNEL_CLASSIFICATION | None = None,
614
+ aggregate: str | None = None,
615
+ metric: str | None = None,
616
+ ) -> list[Channel]:
617
+ limit = 200
618
+ offset = 0
619
+ channels: list[Channel] = []
620
+
621
+ while True:
622
+ params = {
623
+ 'project_code': project_code,
624
+ 'classification': classification,
625
+ 'aggregate': aggregate,
626
+ 'metric': metric,
627
+ 'limit': limit,
628
+ 'offset': offset,
629
+ }
630
+
631
+ resp = self._client._request_json(
632
+ 'GET', '/channels', params=params)
633
+ channels.extend(resp)
634
+
635
+ if len(resp) < limit:
636
+ break
637
+
638
+ offset += limit
639
+
640
+ return channels
641
+
642
+
643
+ class MercutoDeviceService(Module):
644
+ def __init__(self, client: MercutoClient) -> None:
645
+ super().__init__(client)
646
+
647
+ def get_device_types(self) -> list[DeviceType]:
648
+ return self._client._request_json('GET', '/devices/types')
649
+
650
+ def create_device_type(self, description: str, manufacturer: str, model_number: str) -> DeviceType:
651
+ return self._client._request_json('PUT', '/devices/types', json=dict(
652
+ description=description,
653
+ manufacturer=manufacturer,
654
+ model_number=model_number))
655
+
656
+ def get_devices(self, project_code: str, limit: int, offset: int) -> list[Device]:
657
+ return self._client._request_json('GET', '/devices', params=dict(project_code=project_code, limit=limit, offset=offset))
658
+
659
+ def get_device(self, device_code: str) -> Device:
660
+ return self._client._request_json('GET', f'/devices/{device_code}')
661
+
662
+ def create_device(self,
663
+ project_code: str,
664
+ label: str,
665
+ device_type_code: str,
666
+ groups: list[str],
667
+ location_description: Optional[str] = None) -> Device:
668
+ return self._client._request_json('PUT', '/devices', json=dict(
669
+ project_code=project_code,
670
+ label=label,
671
+ device_type_code=device_type_code,
672
+ groups=groups,
673
+ location_description=location_description))
674
+
675
+ def list_dataloggers(self, project: str) -> list[Datalogger]:
676
+ return self._client._request_json('GET', '/dataloggers', params={'project_code': project})
677
+
678
+
679
+ class MercutoIdentityService(Module):
680
+ def __init__(self, client: 'MercutoClient') -> None:
681
+ super().__init__(client)
682
+
683
+ def create_tenant(self, name: str, description: str, logo_url: Optional[str] = None) -> Tenant:
684
+ return self._client._request_json('PUT', '/identity/tenants',
685
+ json={'name': name, 'description': description, 'logo_url': logo_url})
686
+
687
+ def list_tenants(self) -> list[Tenant]:
688
+ return self._client._request_json('GET', '/identity/tenants')
689
+
690
+ def get_tenant(self, code: str) -> Tenant:
691
+ return self._client._request_json('GET', f'/identity/tenants/{code}')
692
+
693
+ def create_user(self, username: str, tenant: str, description: str,
694
+ group: str, password: Optional[str] = None) -> User:
695
+ return self._client._request_json('PUT', '/identity/users',
696
+ json={'username': username, 'tenant_code': tenant, 'description': description,
697
+ 'group_code': group, 'default_password': password})
698
+
699
+ def list_users(self, project: Optional[str] = None,
700
+ tenant: Optional[str] = None) -> list[User]:
701
+ params = {}
702
+ if project is not None:
703
+ params['project'] = project
704
+ if tenant is not None:
705
+ params['tenant'] = tenant
706
+ return self._client._request_json('GET', '/identity/users', params=params)
707
+
708
+ def get_user(self, code: str) -> User:
709
+ return self._client._request_json('GET', f'/identity/users/{code}')
710
+
711
+ def get_user_details(self, code: str) -> UserDetails:
712
+ return self._client._request_json('GET', f'/identity/users/{code}/details')
713
+
714
+ def edit_user_details(self, code: str, first_name: Optional[str], last_name: Optional[str],
715
+ email_address: Optional[str], mobile_number: Optional[str]) -> UserDetails:
716
+ return self._client._request_json('PATCH', f'/identity/users/{code}/details', json={
717
+ 'first_name': first_name,
718
+ 'last_name': last_name,
719
+ 'email_address': email_address,
720
+ 'mobile_number': mobile_number
721
+ })
722
+
723
+ def generate_api_key(self, user: str, description: str) -> NewUserApiKey:
724
+ return self._client._request_json('POST', f'/identity/users/{user}/api_keys',
725
+ json={'description': description})
726
+
727
+ def grant_user_permission(self, user: str, resource: str, action: str) -> None:
728
+ return self._client._request_json('POST', f'/identity/users/{user}/grant',
729
+ json={'resource': resource, 'action': action})
730
+
731
+ def create_permission_group(self, tenant: str, label: str,
732
+ acl_json: str) -> PermissionGroup:
733
+ return self._client._request_json('PUT', '/identity/permissions', json={
734
+ 'tenant': tenant,
735
+ 'label': label,
736
+ 'acl_policy': acl_json
737
+ })
738
+
739
+ def list_permission_groups(self) -> list[PermissionGroup]:
740
+ return self._client._request_json('GET', '/identity/permissions')
741
+
742
+ def get_permission_group(self, code: str) -> PermissionGroup:
743
+ return self._client._request_json('GET', f'/identity/permissions/{code}')
744
+
745
+ def update_permission_group(self, code: str, label: str,
746
+ acl_json: str) -> None:
747
+ return self._client._request_json('PATCH', f'/identity/permissions/{code}', json={
748
+ 'label': label,
749
+ 'acl_policy': acl_json
750
+ })
751
+
752
+ def verify_me(self) -> VerifyMeResult:
753
+ return self._client._request_json('GET', '/identity/verify/me')
754
+
755
+ def healthcheck(self) -> AuthHealthcheckResult:
756
+ return self._client._request_json('GET', '/identity/healthcheck')
757
+
758
+
759
+ class MercutoNotificationsService(Module):
760
+ def __init__(self, client: 'MercutoClient') -> None:
761
+ super().__init__(client)
762
+
763
+ def list_contact_groups(self, project: Optional[str] = None) -> list[ContactGroup]:
764
+ params = {}
765
+ if project is not None:
766
+ params['project'] = project
767
+ return self._client._request_json('GET', '/notifications/contact_groups', params=params)
768
+
769
+ def get_contact_group(self, code: str) -> ContactGroup:
770
+ return self._client._request_json('GET', f'/notifications/contact_groups/{code}')
771
+
772
+ def create_contact_group(self, project: str, label: str, users: dict[str, list[UserContactMethod]]) -> ContactGroup:
773
+ return self._client._request_json('PUT', '/notifications/contact_groups',
774
+ json={
775
+ 'project': project,
776
+ 'label': label,
777
+ 'users': users
778
+ })
779
+
780
+
781
+ class MercutoReportingService(Module):
782
+ def __init__(self, client: 'MercutoClient') -> None:
783
+ super().__init__(client)
784
+
785
+ def list_reports(self, project: Optional[str] = None) -> list['ScheduledReport']:
786
+ params = {}
787
+ if project is not None:
788
+ params['project'] = project
789
+ return self._client._request_json('GET', '/reports/scheduled', params=params)
790
+
791
+ def create_report(self, project: str, label: str, schedule: str, revision: str,
792
+ api_key: Optional[str] = None, contact_group: Optional[str] = None) -> ScheduledReport:
793
+ return self._client._request_json('PUT', '/reports/scheduled', json={
794
+ 'project': project,
795
+ 'label': label,
796
+ 'schedule': schedule,
797
+ 'revision': revision,
798
+ 'execution_role_api_key': api_key,
799
+ 'contact_group': contact_group
800
+ })
801
+
802
+ def generate_report(self, report: str, timestamp: datetime, mark_as_scheduled: bool = False) -> ScheduledReportLog:
803
+ return self._client._request_json('PUT', f'/reports/scheduled/{report}/generate', json={
804
+ 'timestamp': timestamp.isoformat(),
805
+ 'mark_as_scheduled': mark_as_scheduled
806
+ })
807
+
808
+ def list_report_logs(self, report: str, project: Optional[str] = None) -> list[ScheduledReportLog]:
809
+ params: dict[str, str] = {}
810
+ if project is not None:
811
+ params['project'] = project
812
+ return self._client._request_json('GET', f'/reports/scheduled/{report}/logs', params=params)
813
+
814
+ def get_report_log(self, report: str, log: str) -> ScheduledReportLog:
815
+ return self._client._request_json('GET', f'/reports/scheduled/{report}/logs/{log}')
816
+
817
+
818
+ class MercutoObjectService(Module):
819
+ def __init__(self, client: 'MercutoClient') -> None:
820
+ super().__init__(client)
821
+
822
+ def upload_file(self, project_code: str, file: str, event_code: Optional[str] = None,
823
+ mime_type: Optional[str] = None) -> Object:
824
+ with open(file, 'rb') as f:
825
+ if mime_type is None:
826
+ mime_type = mimetypes.guess_type(file, strict=False)[0]
827
+ if mime_type is None:
828
+ raise MercutoClientException(
829
+ f"Could not determine mime type for {file}")
830
+ base64_data = base64.b64encode(f.read()).decode('utf-8')
831
+ data_url = f'data:{mime_type};base64,{base64_data}'
832
+
833
+ return self._client._request_json('POST', '/objects/upload', params={
834
+ 'project_code': project_code,
835
+ 'event_code': event_code
836
+ }, json={
837
+ 'filename': os.path.basename(file),
838
+ 'mime_type': mime_type,
839
+ 'data_url': data_url
840
+ })
841
+
842
+
843
+ class MercutoMediaService(Module):
844
+ def __init__(self, client: 'MercutoClient') -> None:
845
+ super().__init__(client)
846
+
847
+ def list_cameras(self, project: str) -> list[Camera]:
848
+ params = {}
849
+ params['project_code'] = project
850
+ return self._client._request_json('GET', '/media/cameras', params=params)
851
+
852
+ def list_videos(self, project: Optional[str] = None, event: Optional[str] = None, camera: Optional[str] = None) -> list[Video]:
853
+ params = {}
854
+ if project is not None:
855
+ params['project'] = project
856
+ if event is not None:
857
+ params['event'] = event
858
+ if camera is not None:
859
+ params['camera'] = camera
860
+ return self._client._request_json('GET', '/media/videos', params=params)
861
+
862
+ def list_images(self, project: Optional[str] = None, event: Optional[str] = None, camera: Optional[str] = None) -> list[Image]:
863
+ params = {}
864
+ if project is not None:
865
+ params['project'] = project
866
+ if event is not None:
867
+ params['event'] = event
868
+ if camera is not None:
869
+ params['camera'] = camera
870
+ return self._client._request_json('GET', '/media/images', params=params)
871
+
872
+ def get_image(self, code: str) -> Image:
873
+ return self._client._request_json('GET', f'/media/images/{code}') # type: ignore[no-any-return]
874
+
875
+ def upload_image(self, project: str, file: str, event: Optional[str] = None,
876
+ camera: Optional[str] = None, timestamp: Optional[datetime] = None,
877
+ filename: Optional[str] = None) -> Image:
878
+ if timestamp is not None and timestamp.tzinfo is None:
879
+ raise MercutoClientException("Timestamp must be timezone aware")
880
+
881
+ mimetype, _ = mimetypes.guess_type(file, strict=False)
882
+ if mimetype is None or not mimetype.startswith('image/'):
883
+ raise MercutoClientException(f"File {file} is not an image")
884
+
885
+ if os.stat(file).st_size > 5_000_000:
886
+ raise MercutoClientException(f"File {file} is too large")
887
+
888
+ if filename is None:
889
+ filename = os.path.basename(file)
890
+
891
+ params = {}
892
+ params['project'] = project
893
+ if event is not None:
894
+ params['event'] = event
895
+ if camera is not None:
896
+ params['camera'] = camera
897
+ if timestamp is not None:
898
+ params['timestamp'] = timestamp.isoformat()
899
+
900
+ with open(file, 'rb') as f:
901
+ return self._client._request_json('PUT', '/media/images', params=params, files={
902
+ 'file': (filename, f, mimetype)
903
+ })