msdev-kit 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.
@@ -0,0 +1,617 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import requests
5
+ import pandas as pd
6
+ from . import workspace
7
+ from . import report
8
+ from typing import Dict
9
+ from .utilities import create_directory
10
+ from concurrent.futures import ThreadPoolExecutor
11
+
12
+
13
+ class Dataset:
14
+
15
+ def __init__(self, token: str):
16
+ """
17
+ Initialize variables.
18
+ """
19
+ self.main_url = 'https://api.powerbi.com/v1.0/myorg'
20
+ self.fabric_api_base_url = 'https://api.fabric.microsoft.com'
21
+ self.token = token
22
+ self.headers = {'Authorization': f'Bearer {self.token}'}
23
+ self.data_dir = './data/datasets'
24
+
25
+ create_directory(self.data_dir)
26
+
27
+
28
+ def _request_with_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
29
+ """
30
+ Makes an HTTP request with automatic retry on 429 (Too Many Requests).
31
+ Respects the Retry-After header when present.
32
+ """
33
+ for attempt in range(max_retries + 1):
34
+ response = requests.request(method, url, **kwargs)
35
+ if response.status_code != 429:
36
+ return response
37
+
38
+ retry_after = int(response.headers.get('Retry-After', 5))
39
+ print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
40
+ time.sleep(retry_after)
41
+
42
+ return response
43
+
44
+
45
+ def get_dataset_name(self, workspace_id: str, dataset_id: str) -> str:
46
+ """
47
+ Resolves the display name of a dataset (semantic model) by its ID.
48
+ Tries the Power BI API first, then falls back to the Fabric API.
49
+
50
+ Args:
51
+ workspace_id (str): The workspace ID.
52
+ dataset_id (str): The dataset ID.
53
+
54
+ Returns:
55
+ str: The dataset display name, or empty string if not found.
56
+ """
57
+ # Try PBI API first
58
+ request_url = f'{self.main_url}/groups/{workspace_id}/datasets/{dataset_id}'
59
+ response = self._request_with_retry('GET', request_url, headers=self.headers)
60
+ if response.status_code == 200:
61
+ return response.json().get('name', '')
62
+
63
+ # Fall back to Fabric API
64
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/semanticModels/{dataset_id}'
65
+ response = self._request_with_retry('GET', api_url, headers=self.headers)
66
+ if response.status_code == 200:
67
+ return response.json().get('displayName', '')
68
+
69
+ return ''
70
+
71
+
72
+ def get_dataset_details(
73
+ self,
74
+ workspace_id: str = '',
75
+ dataset_id: str = '') -> Dict:
76
+ """
77
+ Get details of a specific dataset.
78
+
79
+ Args:
80
+ workspace_id (str, optional): workspace id to search datasets from.
81
+ dataset_id (str, optional): dataset id to search details from.
82
+
83
+ Returns:
84
+ Dict: status message and content.
85
+ """
86
+
87
+ # Main URL
88
+ request_url = f'{self.main_url}/groups/{workspace_id}/datasets/{dataset_id}'
89
+
90
+ # If workspace ID was not informed, return error message...
91
+ if workspace_id == '':
92
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
93
+ if dataset_id == '':
94
+ return {'message': 'Missing dataset id, please check.', 'content': ''}
95
+
96
+ # If workspace ID was informed...
97
+ else:
98
+ filename = f'datasets_{workspace_id}_{dataset_id}.xlsx'
99
+
100
+ # Make the request
101
+ r = requests.get(url=request_url, headers=self.headers)
102
+
103
+ # Get HTTP status and content
104
+ status = r.status_code
105
+ response = json.loads(r.content)
106
+
107
+ # If success...
108
+ if status == 200:
109
+ # Save to Excel file
110
+ df = pd.DataFrame([response])
111
+ df.to_excel(f'{self.data_dir}/{filename}', index=False)
112
+
113
+ return {'message': 'Success', 'content': response}
114
+
115
+ else:
116
+ # If any error happens, return message.
117
+ response = json.loads(r.content)
118
+ try:
119
+ error_message = response['error']['message']
120
+ except KeyError as e:
121
+ error_message = response['error']['pbi.error']
122
+
123
+ return {'message': {'error': error_message, 'content': response}}
124
+
125
+
126
+ def list_datasets(
127
+ self,
128
+ workspace_id: str = '') -> Dict:
129
+ """
130
+ List all datasets on a specific workspace_id that the user has access to.
131
+
132
+ Args:
133
+ workspace_id (str, optional): workspace id to search datasets from.
134
+
135
+ Returns:
136
+ Dict: status message and content.
137
+ """
138
+
139
+ # Main URL
140
+ request_url = f'{self.main_url}/groups/{workspace_id}/datasets'
141
+
142
+ # If workspace ID was not informed, return error message...
143
+ if workspace_id == '':
144
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
145
+
146
+ # If workspace ID was informed...
147
+ else:
148
+ filename = f'datasets_{workspace_id}.xlsx'
149
+
150
+ # Make the request
151
+ r = requests.get(url=request_url, headers=self.headers)
152
+
153
+ # Get HTTP status and content
154
+ status = r.status_code
155
+ response = json.loads(r.content).get('value', '')
156
+
157
+ # If success...
158
+ if status == 200:
159
+ # Save to Excel file
160
+ df = pd.DataFrame(response)
161
+ df.to_excel(f'{self.data_dir}/{filename}', index=False)
162
+
163
+ return {'message': 'Success', 'content': response}
164
+
165
+ else:
166
+ # If any error happens, return message.
167
+ response = json.loads(r.content)
168
+ error_message = response['error']['message']
169
+
170
+ return {'message': {'error': error_message, 'content': response}}
171
+
172
+
173
+ def _post_query(self, workspace_id: str, dataset_id: str, query: str) -> requests.Response:
174
+ """
175
+ Send a DAX query to the Power BI executeQueries API.
176
+
177
+ Args:
178
+ workspace_id (str): workspace id.
179
+ dataset_id (str): dataset id.
180
+ query (str): DAX query string.
181
+
182
+ Returns:
183
+ requests.Response: raw response from the API.
184
+ """
185
+ request_url = self.main_url + f'/groups/{workspace_id}/datasets/{dataset_id}/executeQueries'
186
+ headers = {'Authorization': f'Bearer {self.token}'}
187
+ data = {
188
+ "queries": [{"query": query}],
189
+ "serializerSettings": {"includeNulls": 'true'}
190
+ }
191
+ return requests.post(url=request_url, headers=headers, json=data)
192
+
193
+ @staticmethod
194
+ def _extract_table_expression(query: str) -> str:
195
+ """
196
+ Extract the table expression from a DAX EVALUATE query.
197
+
198
+ Args:
199
+ query (str): full DAX query starting with EVALUATE.
200
+
201
+ Returns:
202
+ str: the table expression after EVALUATE.
203
+ """
204
+ import re
205
+ match = re.search(r'\bEVALUATE\b\s+(.*)', query, re.IGNORECASE | re.DOTALL)
206
+ if match:
207
+ return match.group(1).strip()
208
+ return ''
209
+
210
+ def execute_query(
211
+ self,
212
+ workspace_id: str = '',
213
+ dataset_id: str = '',
214
+ query: str = '',
215
+ impersonated_username: str = '') -> Dict:
216
+ """
217
+ Execute a DAX query against a dataset.
218
+
219
+ Before running the actual query, a COUNTROWS pre-check is performed to
220
+ detect whether the API row/value limits would truncate the result.
221
+
222
+ API limits:
223
+ - Max 100,000 rows per query.
224
+ - Max 1,000,000 values (rows x columns) per query.
225
+ - Max 15 MB of data per query.
226
+ - Whichever limit is hit first applies.
227
+
228
+ Args:
229
+ workspace_id (str): workspace id.
230
+ dataset_id (str): dataset id.
231
+ query (str): DAX query (must start with EVALUATE).
232
+ impersonated_username (str, optional): effective username for RLS.
233
+
234
+ Returns:
235
+ Dict: status message, content (parsed rows), and truncation metadata.
236
+ """
237
+
238
+ if (query == '') or (workspace_id == '') or (dataset_id == ''):
239
+ return {'message': 'Missing parameters, please check.'}
240
+
241
+ # --- Step 1: COUNTROWS pre-check ---
242
+ table_expression = self._extract_table_expression(query)
243
+ total_rows = None
244
+
245
+ if table_expression:
246
+ count_query = f'EVALUATE ROW("_count", COUNTROWS({table_expression}))'
247
+ count_response = self._post_query(workspace_id, dataset_id, count_query)
248
+
249
+ if count_response.status_code == 200:
250
+ count_result = json.loads(count_response.content)
251
+ try:
252
+ total_rows = count_result['results'][0]['tables'][0]['rows'][0]['[_count]']
253
+ except (KeyError, IndexError):
254
+ total_rows = None
255
+
256
+ # --- Step 2: Execute the actual query ---
257
+ r = self._post_query(workspace_id, dataset_id, query)
258
+ status = r.status_code
259
+
260
+ if status == 200:
261
+ result = json.loads(r.content)
262
+
263
+ try:
264
+ rows = result['results'][0]['tables'][0]['rows']
265
+ except (KeyError, IndexError):
266
+ rows = []
267
+
268
+ # Determine column count from the first row
269
+ num_columns = len(rows[0]) if rows else 0
270
+ rows_returned = len(rows)
271
+
272
+ # Calculate the effective row limit based on the 1M values cap
273
+ if num_columns > 0:
274
+ max_rows = min(100_000, 1_000_000 // num_columns)
275
+ else:
276
+ max_rows = 100_000
277
+
278
+ # Determine if data was truncated
279
+ truncated = False
280
+ if total_rows is not None and total_rows > max_rows:
281
+ truncated = True
282
+ elif rows_returned >= max_rows:
283
+ truncated = True
284
+
285
+ return {
286
+ 'message': 'Success',
287
+ 'content': rows,
288
+ 'total_rows': total_rows,
289
+ 'rows_returned': rows_returned,
290
+ 'num_columns': num_columns,
291
+ 'max_rows_allowed': max_rows,
292
+ 'truncated': truncated
293
+ }
294
+
295
+ else:
296
+ return {'message': 'Error', 'content': r.content}
297
+
298
+
299
+ def list_users(
300
+ self,
301
+ workspace_id: str = '',
302
+ dataset_id: str = '') -> Dict:
303
+ """
304
+ List all datasets on a specific workspace_id that the user has access to.
305
+
306
+ Args:
307
+ workspace_id (str, optional): workspace id to search datasets from.
308
+
309
+ Returns:
310
+ Dict: status message and content.
311
+ """
312
+
313
+ # Main URL
314
+ request_url = f'{self.main_url}/groups/{workspace_id}/datasets/{dataset_id}/users'
315
+
316
+ # If workspace ID was not informed, return error message...
317
+ if workspace_id == '':
318
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
319
+
320
+ # If workspace ID was informed...
321
+ else:
322
+ filename = f'datasets_{workspace_id}.xlsx'
323
+
324
+ # Make the request
325
+ r = requests.get(url=request_url, headers=self.headers)
326
+
327
+ # Get HTTP status and content
328
+ status = r.status_code
329
+ response = json.loads(r.content).get('value', '')
330
+
331
+ # If success...
332
+ if status == 200:
333
+ # Save to Excel file
334
+ df = pd.DataFrame(response)
335
+ df.to_excel(f'{self.data_dir}/{filename}', index=False)
336
+
337
+ return {'message': 'Success', 'content': response}
338
+
339
+ else:
340
+ # If any error happens, return message.
341
+ response = json.loads(r.content)
342
+ error_message = response['error']['message']
343
+
344
+ return {'message': {'error': error_message, 'content': response}}
345
+
346
+
347
+ def add_user(
348
+ self,
349
+ user_principal_name: str = '',
350
+ workspace_id: str = '',
351
+ dataset_id: str = '',
352
+ access_right: str = 'Read',
353
+ user_type: str = 'User') -> Dict:
354
+ """
355
+ Grants an user access to a specific dataset.
356
+
357
+ Args:
358
+ user_principal_name (str): user e-mail or identifier of service principal.
359
+ workspace_id (str): workspace id to add the user to.
360
+ dataset_id (str): dataset id to grant access to.
361
+ access_right (str, optional): access right type. Defaults to 'Member'.
362
+ user_type (str, optional): user type, 'SP' for service accounts. Defaults to 'user'.
363
+
364
+ Returns:
365
+ Dict: status message.
366
+ """
367
+
368
+ # If both, user, workspace and dataset are provided...
369
+ if (user_principal_name != '') & (workspace_id != '') & (dataset_id != ''):
370
+
371
+ request_url = self.main_url + f'/groups/{workspace_id}/datasets/{dataset_id}/users'
372
+
373
+ headers = {'Authorization': f'Bearer {self.token}'}
374
+
375
+ # Add user to dataset with the specified access right.
376
+ # https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/post-dataset-user-in-group
377
+ data = {
378
+ "identifier": user_principal_name,
379
+ "principalType": user_type,
380
+ "datasetUserAccessRight": access_right
381
+ }
382
+
383
+ # Make the request
384
+ r = requests.post(url=request_url, headers=headers, json=data)
385
+
386
+ # Get HTTP status and content
387
+ status = r.status_code
388
+
389
+ # If success...
390
+ if status == 200:
391
+ return {'message': 'Success'}
392
+
393
+ else:
394
+ # If any error happens, return message.
395
+ response = json.loads(r.content)
396
+ error_message = response['error']['details']['message']
397
+
398
+ return {'message': {'error': error_message, 'content': response}}
399
+
400
+ else:
401
+ return {'message': 'Missing parameters, please check.', 'content': ''}
402
+
403
+
404
+ def update_user(
405
+ self,
406
+ user_principal_name: str = '',
407
+ workspace_id: str = '',
408
+ dataset_id: str = '',
409
+ access_right: str = 'Read',
410
+ user_type: str = 'User') -> Dict:
411
+ """
412
+ Update an user access to a specific dataset.
413
+
414
+ Args:
415
+ user_principal_name (str): user e-mail or identifier of service principal.
416
+ workspace_id (str): workspace id to add the user to.
417
+ dataset_id (str): dataset id to grant access to.
418
+ access_right (str, optional): access right type. Defaults to 'Member'.
419
+ user_type (str, optional): user type, 'SP' for service accounts. Defaults to 'user'.
420
+
421
+ Returns:
422
+ Dict: status message.
423
+ """
424
+
425
+ # If both, user, workspace and dataset are provided...
426
+ if (user_principal_name != '') & (workspace_id != '') & (dataset_id != ''):
427
+
428
+ request_url = self.main_url + f'/groups/{workspace_id}/datasets/{dataset_id}/users'
429
+
430
+ headers = {'Authorization': f'Bearer {self.token}'}
431
+
432
+ # Add user to dataset with the specified access right.
433
+ # https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/post-dataset-user-in-group
434
+ data = {
435
+ "identifier": user_principal_name,
436
+ "principalType": user_type,
437
+ "datasetUserAccessRight": access_right
438
+ }
439
+
440
+ # Make the request
441
+ r = requests.put(url=request_url, headers=headers, json=data)
442
+
443
+ # Get HTTP status and content
444
+ status = r.status_code
445
+
446
+ # If success...
447
+ if status == 200:
448
+ return {'message': 'Success'}
449
+
450
+ else:
451
+ # If any error happens, return message.
452
+ response = json.loads(r.content)
453
+ error_message = response['error']['code']
454
+
455
+ return {'message': {'error': {'status': status, 'description': error_message}, 'content': response}}
456
+
457
+ else:
458
+ return {'message': 'Missing parameters, please check.', 'content': ''}
459
+
460
+
461
+ def remove_user(
462
+ self,
463
+ user_principal_name: str = '',
464
+ workspace_id: str = '',
465
+ dataset_id: str = '',
466
+ user_type: str = 'User') -> Dict:
467
+ """
468
+ Removes an user access to a specific dataset.
469
+
470
+ Args:
471
+ user_principal_name (str): user e-mail or identifier of service principal.
472
+ workspace_id (str): workspace id to add the user to.
473
+ dataset_id (str): dataset id to grant access to.
474
+ access_right (str, optional): access right type. Defaults to 'Member'.
475
+ user_type (str, optional): user type, 'SP' for service accounts. Defaults to 'user'.
476
+
477
+ Returns:
478
+ Dict: status message.
479
+ """
480
+
481
+ # If both, user, workspace and dataset are provided...
482
+ if (user_principal_name != '') & (workspace_id != '') & (dataset_id != ''):
483
+
484
+ request_url = self.main_url + f'/groups/{workspace_id}/datasets/{dataset_id}/users'
485
+
486
+ headers = {'Authorization': f'Bearer {self.token}'}
487
+
488
+ # Add user to dataset with the specified access right.
489
+ # https://learn.microsoft.com/en-us/rest/api/power-bi/datasets/post-dataset-user-in-group
490
+ data = {
491
+ "identifier": user_principal_name,
492
+ "principalType": user_type,
493
+ "datasetUserAccessRight": "None"
494
+ }
495
+
496
+ # Make the request
497
+ r = requests.put(url=request_url, headers=headers, json=data)
498
+
499
+ # Get HTTP status and content
500
+ status = r.status_code
501
+
502
+ # If success...
503
+ if status == 200:
504
+ return {'message': 'Success'}
505
+
506
+ # Too many requests
507
+ elif status == 429:
508
+ return {'message': {'error': {'status': 429, 'description': 'too many requests'}, 'content': ''}}
509
+
510
+ # Cannot change admin access
511
+ elif status == 401:
512
+ return {'message': {'error': {'status': 401, 'description': 'not authorized'}, 'content': ''}}
513
+ else:
514
+ # If any error happens, return message.
515
+ response = json.loads(r.content)
516
+ error_message = response['error']['code']
517
+ return {'message': {'error': {'status': status, 'description': error_message}, 'content': ''}}
518
+
519
+ else:
520
+ return {'message': 'Missing parameters, please check.', 'content': ''}
521
+
522
+
523
+ def list_dataset_related_reports(
524
+ self,
525
+ workspace_id: str = '',
526
+ dataset_id: str = '',
527
+ workspace: workspace.Workspace = None) -> Dict:
528
+ """
529
+ List all reports related to a specific dataset.
530
+
531
+ Args:
532
+ workspace_id (str): workspace id where dataset is published.
533
+ dataset_id (str): dataset id to search reports from.
534
+ workspace (Workspace): workspace object to list reports.
535
+
536
+ Returns:
537
+ Dict: status message and content.
538
+ """
539
+
540
+ dataset_reports = []
541
+ filename = f'dataset_reports_{dataset_id}.xlsx'
542
+ file_path = f'{self.data_dir}/reports'
543
+
544
+ os.makedirs(file_path, exist_ok=True)
545
+
546
+ try:
547
+
548
+ workspace_reports = workspace.list_reports(workspace_id=workspace_id)
549
+
550
+ for report in workspace_reports['content']:
551
+ if report['datasetId'] == dataset_id:
552
+ dataset_reports.append(report)
553
+
554
+ # Save to Excel file
555
+ df = pd.DataFrame(dataset_reports)
556
+ df.to_excel(f'{self.data_dir}/reports/{filename}', index=False)
557
+
558
+ return {'message': 'Success', 'content': dataset_reports}
559
+
560
+ except Exception as error_message:
561
+ return {'message': {'error': error_message}, 'content': dataset_reports}
562
+
563
+
564
+ def export_dataset_related_reports(
565
+ self,
566
+ workspace_id: str = '',
567
+ dataset_id: str = '',
568
+ replace_existing: bool = False,
569
+ workspace: workspace.Workspace = None,
570
+ report: report.Report = None) -> Dict:
571
+ """
572
+ Export all reports related to a specific dataset.
573
+
574
+ Args:
575
+ workspace_id (str): workspace id where dataset is published.
576
+ dataset_id (str): dataset id to search reports from.
577
+ replace_existing (bool, optional): replace existing files. Defaults to False.
578
+ workspace (Workspace): workspace object to list reports.
579
+
580
+ Returns:
581
+ Dict: status message and content.
582
+ """
583
+
584
+ print('Getting workspace name...')
585
+ workspace_details = workspace.get_workspace_details(workspace_id=workspace_id)
586
+ workspace_name = workspace_details.get('content', []).get('name', 'unknown workspace')
587
+
588
+ print('Getting dataset name...')
589
+ dataset_details = self.get_dataset_details(workspace_id=workspace_id, dataset_id=dataset_id)
590
+ dataset_name = dataset_details.get('content', []).get('name', 'unknown dataset')
591
+
592
+ print(f'Getting {dataset_name} reports list on {workspace_name}...')
593
+ dataset_reports_list = self.list_dataset_related_reports(workspace_id=workspace_id, dataset_id=dataset_id, workspace=workspace)
594
+
595
+ if 'error' in dataset_reports_list['message']:
596
+ return {'message': {'error': dataset_reports_list}}
597
+
598
+ reports_to_export = []
599
+ for report_data in dataset_reports_list['content']:
600
+ if report_data['name'] != dataset_name:
601
+ reports_to_export.append(report_data)
602
+
603
+ print(f'Downloading reports connected to {dataset_name}.\n\nWorkspace: {workspace_name}\nTotal reports: {len(reports_to_export)}\n')
604
+
605
+ def _export(report_data):
606
+ return report.export_report(
607
+ workspace_id=workspace_id,
608
+ workspace_name=workspace_name,
609
+ dataset_name=dataset_name,
610
+ report_id=report_data['id'],
611
+ report_name=report_data['name'],
612
+ replace_existing=replace_existing)
613
+
614
+ with ThreadPoolExecutor() as executor:
615
+ list(executor.map(_export, reports_to_export))
616
+
617
+ return {'message': 'Success', 'content': dataset_reports_list['content']}
@@ -0,0 +1,66 @@
1
+ import re
2
+ import pandas as pd
3
+ from pandas.core.frame import DataFrame
4
+ from .utilities import create_directory
5
+ from azure.kusto.data import KustoClient, KustoConnectionStringBuilder
6
+ from azure.kusto.data.exceptions import KustoServiceError, KustoMultiApiError
7
+
8
+
9
+ class KQLDatabase:
10
+
11
+ def __init__(self, kusto_uri: str, database_name: str, client_id: str, client_secret: str, tenant_id: str):
12
+ """
13
+ Initialize variables.
14
+ """
15
+ self.kusto_uri = kusto_uri
16
+ self.database_name = database_name
17
+
18
+ # Create a connection string for authentication
19
+ kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
20
+ kusto_uri, client_id, client_secret, tenant_id
21
+ )
22
+
23
+ # Create a Kusto client
24
+ self.client = KustoClient(kcsb)
25
+ self.data_dir = './data/monitoring'
26
+
27
+ create_directory(self.data_dir)
28
+
29
+
30
+ def query_kql_database(self, kql_query: str, sort_by: str = None) -> DataFrame:
31
+ """
32
+ Connects to a Kusto (KQL) database and executes a query.
33
+
34
+ Args:
35
+ kql_query (str): The KQL query to execute.
36
+
37
+ Returns:
38
+ pandas.DataFrame: A DataFrame containing the query results, or None if an error occurs.
39
+ """
40
+ try:
41
+ # Execute the query
42
+ response = self.client.execute(self.database_name, kql_query)
43
+ print(response)
44
+
45
+ # Convert the response to a pandas DataFrame
46
+ last_parameters_string = kql_query.rsplit('| project', maxsplit=1)[1].strip()
47
+ project_string = last_parameters_string.split('|', maxsplit=1)[0].strip().split(',')
48
+ columns = [re.sub(r'\s+', '', s) for s in project_string]
49
+ df = pd.DataFrame(response.primary_results[0], columns=columns, dtype=str)
50
+ if sort_by:
51
+ df.sort_values(by=sort_by, inplace=True, ascending=False)
52
+
53
+ return df
54
+
55
+ except KustoServiceError as error:
56
+ if 'E_QUERY_RESULT_SET_TOO_LARGE' in str(error):
57
+ print('ERROR: Query set too large, try adding some more filters!')
58
+ else:
59
+ print(f"An error occurred: {error}")
60
+ return None
61
+ except KustoMultiApiError as error:
62
+ print(f"An error occurred: {error}")
63
+ return None
64
+ except Exception as e:
65
+ print(f"An unexpected error occurred: {e}")
66
+ return None