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.
- msdev_kit/__init__.py +3 -0
- msdev_kit/auth.py +35 -0
- msdev_kit/fabric/__init__.py +11 -0
- msdev_kit/fabric/admin.py +42 -0
- msdev_kit/fabric/capacity.py +186 -0
- msdev_kit/fabric/database.py +94 -0
- msdev_kit/fabric/dataflow.py +1801 -0
- msdev_kit/fabric/dataset.py +617 -0
- msdev_kit/fabric/kql.py +66 -0
- msdev_kit/fabric/notebook.py +88 -0
- msdev_kit/fabric/operations.py +108 -0
- msdev_kit/fabric/pipeline.py +505 -0
- msdev_kit/fabric/report.py +1012 -0
- msdev_kit/fabric/utilities.py +12 -0
- msdev_kit/fabric/workspace.py +516 -0
- msdev_kit/graph/__init__.py +1 -0
- msdev_kit/graph/client.py +91 -0
- msdev_kit/sharepoint/__init__.py +1 -0
- msdev_kit/sharepoint/client.py +105 -0
- msdev_kit-0.1.0.dist-info/METADATA +269 -0
- msdev_kit-0.1.0.dist-info/RECORD +22 -0
- msdev_kit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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']}
|
msdev_kit/fabric/kql.py
ADDED
|
@@ -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
|