msdev-kit 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/PKG-INFO +2 -1
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/README.md +1 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/__init__.py +1 -1
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/dataflow.py +256 -41
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/dataset.py +10 -14
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/notebook.py +10 -12
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/pipeline.py +10 -14
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/graph/client.py +21 -6
- msdev_kit-0.2.3/msdev_kit/http.py +92 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/pyproject.toml +1 -1
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/LICENSE +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/auth.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/__init__.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/admin.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/capacity.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/database.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/kql.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/operations.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/report.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/utilities.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/workspace.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/graph/__init__.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/sharepoint/__init__.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/sharepoint/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: msdev-kit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Microsoft developer toolkit: Fabric, MS Graph, and SharePoint
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -262,6 +262,7 @@ result = df.upgrade_to_gen2_cicd(
|
|
|
262
262
|
| `create_dataflow_gen2_from_definition(workspace_id, display_name, definition)` | Create a Dataflow Gen2 CI/CD from a definition. |
|
|
263
263
|
| `update_dataflow_gen2_from_definition(workspace_id, dataflow_id, display_name, definition)` | Update an existing Dataflow Gen2 CI/CD definition. |
|
|
264
264
|
| `get_data_destinations(workspace_id, dataflow_id)` | Get data destination details for each table in a dataflow. |
|
|
265
|
+
| `get_workspace_data_destinations(workspace_id, max_workers=4)` | Concurrently inventory every dataflow's destination tables and save a flattened workbook under `data/dataflows`. Requests are paced and retry 429 responses. |
|
|
265
266
|
| `change_data_destination(workspace_id, dataflow_id, destination_type, ...)` | Change data destination (Lakehouse/Warehouse). Modes: `preview`, `replace`, `create`. |
|
|
266
267
|
| `create_dataflow_with_new_destination(workspace_id, dataflow_id, ...)` | Create a new Gen2 CI/CD dataflow with a different data destination. |
|
|
267
268
|
| `upgrade_to_gen2_cicd(...)` | Upgrade a Gen1 or Gen2 (standard) dataflow to Gen2 CI/CD. |
|
|
@@ -229,6 +229,7 @@ result = df.upgrade_to_gen2_cicd(
|
|
|
229
229
|
| `create_dataflow_gen2_from_definition(workspace_id, display_name, definition)` | Create a Dataflow Gen2 CI/CD from a definition. |
|
|
230
230
|
| `update_dataflow_gen2_from_definition(workspace_id, dataflow_id, display_name, definition)` | Update an existing Dataflow Gen2 CI/CD definition. |
|
|
231
231
|
| `get_data_destinations(workspace_id, dataflow_id)` | Get data destination details for each table in a dataflow. |
|
|
232
|
+
| `get_workspace_data_destinations(workspace_id, max_workers=4)` | Concurrently inventory every dataflow's destination tables and save a flattened workbook under `data/dataflows`. Requests are paced and retry 429 responses. |
|
|
232
233
|
| `change_data_destination(workspace_id, dataflow_id, destination_type, ...)` | Change data destination (Lakehouse/Warehouse). Modes: `preview`, `replace`, `create`. |
|
|
233
234
|
| `create_dataflow_with_new_destination(workspace_id, dataflow_id, ...)` | Create a new Gen2 CI/CD dataflow with a different data destination. |
|
|
234
235
|
| `upgrade_to_gen2_cicd(...)` | Upgrade a Gen1 or Gen2 (standard) dataflow to Gen2 CI/CD. |
|
|
@@ -7,8 +7,11 @@ import time
|
|
|
7
7
|
import base64
|
|
8
8
|
import requests
|
|
9
9
|
import pandas as pd
|
|
10
|
-
from
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from typing import Callable, Dict, List, Optional
|
|
11
13
|
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
from msdev_kit.http import RequestPacer, request_with_retry
|
|
12
15
|
from .utilities import create_directory
|
|
13
16
|
from .workspace import Workspace
|
|
14
17
|
|
|
@@ -27,14 +30,17 @@ class ComputeEngineSettingsModel(BaseModel):
|
|
|
27
30
|
class Dataflow:
|
|
28
31
|
|
|
29
32
|
def __init__(self, token: str):
|
|
30
|
-
"""
|
|
31
|
-
|
|
33
|
+
"""Initialize the Dataflow client.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
token: Power BI or Fabric bearer token.
|
|
32
37
|
"""
|
|
33
38
|
self.main_url = 'https://api.powerbi.com/v1.0/myorg'
|
|
34
39
|
self.fabric_api_base_url = 'https://api.fabric.microsoft.com'
|
|
35
40
|
self.token = token
|
|
36
41
|
self.headers = {'Authorization': f'Bearer {self.token}'}
|
|
37
42
|
self.workspace = Workspace(self.token)
|
|
43
|
+
self._request_pacer = RequestPacer(requests_per_minute=200)
|
|
38
44
|
|
|
39
45
|
# Directories
|
|
40
46
|
self.dataflows_dir = './data/dataflows'
|
|
@@ -44,24 +50,38 @@ class Dataflow:
|
|
|
44
50
|
create_directory(dir)
|
|
45
51
|
|
|
46
52
|
|
|
47
|
-
def _request_with_retry(
|
|
53
|
+
def _request_with_retry(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
url: str,
|
|
57
|
+
max_retries: int = 3,
|
|
58
|
+
log_retries: bool = True,
|
|
59
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
60
|
+
**kwargs,
|
|
61
|
+
) -> requests.Response:
|
|
48
62
|
"""
|
|
49
|
-
|
|
50
|
-
Respects the Retry-After header when present.
|
|
63
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
51
64
|
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
return request_with_retry(
|
|
66
|
+
method,
|
|
67
|
+
url,
|
|
68
|
+
max_retries=max_retries,
|
|
69
|
+
pacer=getattr(self, '_request_pacer', None),
|
|
70
|
+
request_func=requests.request,
|
|
71
|
+
sleep=time.sleep,
|
|
72
|
+
log_retries=log_retries,
|
|
73
|
+
on_rate_limit=on_rate_limit,
|
|
74
|
+
**kwargs,
|
|
75
|
+
)
|
|
62
76
|
|
|
63
77
|
|
|
64
|
-
def _get_dataflow_pbi_definition(
|
|
78
|
+
def _get_dataflow_pbi_definition(
|
|
79
|
+
self,
|
|
80
|
+
workspace_id: str,
|
|
81
|
+
dataflow_id: str,
|
|
82
|
+
log_retries: bool = True,
|
|
83
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
84
|
+
) -> Dict:
|
|
65
85
|
"""
|
|
66
86
|
Fetches a dataflow definition from the Power BI REST API.
|
|
67
87
|
Works for Gen1 and Gen2 (standard) dataflows.
|
|
@@ -80,7 +100,13 @@ class Dataflow:
|
|
|
80
100
|
return {'message': 'Missing dataflow id, please check.', 'content': ''}
|
|
81
101
|
|
|
82
102
|
request_url = f'{self.main_url}/groups/{workspace_id}/dataflows/{dataflow_id}'
|
|
83
|
-
r = self._request_with_retry(
|
|
103
|
+
r = self._request_with_retry(
|
|
104
|
+
'GET',
|
|
105
|
+
request_url,
|
|
106
|
+
headers=self.headers,
|
|
107
|
+
log_retries=log_retries,
|
|
108
|
+
on_rate_limit=on_rate_limit,
|
|
109
|
+
)
|
|
84
110
|
|
|
85
111
|
if r.status_code == 200:
|
|
86
112
|
return {'message': 'Success', 'content': json.loads(r.content)}
|
|
@@ -166,6 +192,10 @@ class Dataflow:
|
|
|
166
192
|
if not fabric_df.empty:
|
|
167
193
|
fabric_df['name'] = fabric_df['displayName']
|
|
168
194
|
fabric_df.drop(columns=['displayName'], inplace=True)
|
|
195
|
+
if 'generation' not in fabric_df.columns:
|
|
196
|
+
fabric_df['generation'] = 2.1
|
|
197
|
+
else:
|
|
198
|
+
fabric_df['generation'] = fabric_df['generation'].fillna(2.1)
|
|
169
199
|
fabric_df['source'] = 'fabric'
|
|
170
200
|
fabric_records = fabric_df.to_dict('records')
|
|
171
201
|
|
|
@@ -371,7 +401,13 @@ class Dataflow:
|
|
|
371
401
|
|
|
372
402
|
return {'message': 'Success', 'content': response}
|
|
373
403
|
|
|
374
|
-
def get_dataflow_gen2_definition(
|
|
404
|
+
def get_dataflow_gen2_definition(
|
|
405
|
+
self,
|
|
406
|
+
workspace_id: str,
|
|
407
|
+
dataflow_id: str,
|
|
408
|
+
verbose: bool = True,
|
|
409
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
410
|
+
) -> Dict:
|
|
375
411
|
"""
|
|
376
412
|
Gets the definition of a Dataflow Gen2 (CI/CD) from a specified workspace.
|
|
377
413
|
Only Dataflow Gen2 (CI/CD / native Fabric) items support definition export.
|
|
@@ -380,18 +416,27 @@ class Dataflow:
|
|
|
380
416
|
Args:
|
|
381
417
|
workspace_id (str): The ID of the workspace where the Dataflow Gen2 resides.
|
|
382
418
|
dataflow_id (str): The ID of the Dataflow Gen2 to retrieve the definition for.
|
|
419
|
+
verbose (bool): Print per-dataflow progress. Defaults to True.
|
|
383
420
|
|
|
384
421
|
Returns:
|
|
385
422
|
Dict: A dictionary containing the status ('Success' or error) and the Dataflow Gen2 definition content.
|
|
386
423
|
"""
|
|
387
424
|
api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows/{dataflow_id}/getDefinition'
|
|
388
425
|
|
|
389
|
-
|
|
390
|
-
|
|
426
|
+
if verbose:
|
|
427
|
+
print(f"Extracting definition for dataflow {dataflow_id} from workspace {workspace_id}...")
|
|
428
|
+
response = self._request_with_retry(
|
|
429
|
+
'POST',
|
|
430
|
+
api_url,
|
|
431
|
+
headers=self.headers,
|
|
432
|
+
log_retries=verbose,
|
|
433
|
+
on_rate_limit=on_rate_limit,
|
|
434
|
+
)
|
|
391
435
|
|
|
392
436
|
if response.status_code == 200:
|
|
393
437
|
definition = response.json()
|
|
394
|
-
|
|
438
|
+
if verbose:
|
|
439
|
+
print("Successfully extracted Dataflow Gen2 definition.")
|
|
395
440
|
return {'message': 'Success', 'content': definition}
|
|
396
441
|
else:
|
|
397
442
|
# getDefinition only works for Dataflow Gen2 (CI/CD / native Fabric).
|
|
@@ -410,7 +455,8 @@ class Dataflow:
|
|
|
410
455
|
}
|
|
411
456
|
|
|
412
457
|
error_message = response.text
|
|
413
|
-
|
|
458
|
+
if verbose:
|
|
459
|
+
print(f"Error getting Dataflow Gen2 definition: {response.status_code} - {error_message}")
|
|
414
460
|
return {'message': {'error': error_message, 'status_code': response.status_code}}
|
|
415
461
|
|
|
416
462
|
|
|
@@ -1141,7 +1187,14 @@ class Dataflow:
|
|
|
1141
1187
|
return new_m_code, metadata
|
|
1142
1188
|
|
|
1143
1189
|
|
|
1144
|
-
def get_data_destinations(
|
|
1190
|
+
def get_data_destinations(
|
|
1191
|
+
self,
|
|
1192
|
+
workspace_id: str,
|
|
1193
|
+
dataflow_id: str,
|
|
1194
|
+
source: Optional[str] = None,
|
|
1195
|
+
verbose: bool = True,
|
|
1196
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
1197
|
+
) -> Dict:
|
|
1145
1198
|
"""
|
|
1146
1199
|
Gets the data destination details for each table in a dataflow.
|
|
1147
1200
|
|
|
@@ -1152,10 +1205,14 @@ class Dataflow:
|
|
|
1152
1205
|
Args:
|
|
1153
1206
|
workspace_id: The workspace ID where the dataflow resides.
|
|
1154
1207
|
dataflow_id: The dataflow ID.
|
|
1208
|
+
source: Source returned by :meth:`list_dataflows`. Uses the known
|
|
1209
|
+
API route directly when it is ``pbi`` or ``fabric``.
|
|
1210
|
+
verbose: Print definition extraction progress. Defaults to True.
|
|
1211
|
+
on_rate_limit: Called with the retry delay for each HTTP 429.
|
|
1155
1212
|
|
|
1156
1213
|
Returns:
|
|
1157
1214
|
Dict: 'message' and 'content' (list of dicts with keys:
|
|
1158
|
-
|
|
1215
|
+
name, destination_type, workspace_id, item_id, sql_schema,
|
|
1159
1216
|
mapping_type ('Automatic' or 'Manual'),
|
|
1160
1217
|
columns (list of {source, destination} dicts, empty for automatic mappings)).
|
|
1161
1218
|
"""
|
|
@@ -1164,18 +1221,176 @@ class Dataflow:
|
|
|
1164
1221
|
if dataflow_id == '':
|
|
1165
1222
|
return {'message': 'Missing dataflow id, please check.', 'content': ''}
|
|
1166
1223
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1224
|
+
cicd_result = None
|
|
1225
|
+
if source != 'pbi':
|
|
1226
|
+
cicd_result = self.get_dataflow_gen2_definition(
|
|
1227
|
+
workspace_id,
|
|
1228
|
+
dataflow_id,
|
|
1229
|
+
verbose=verbose,
|
|
1230
|
+
on_rate_limit=on_rate_limit,
|
|
1231
|
+
)
|
|
1232
|
+
if cicd_result.get('message') == 'Success':
|
|
1233
|
+
return self._get_data_destinations_cicd(cicd_result['content'])
|
|
1234
|
+
if source == 'fabric':
|
|
1235
|
+
return {
|
|
1236
|
+
'message': f'Failed to fetch Dataflow Gen2 definition: {cicd_result.get("message")}',
|
|
1237
|
+
'content': '',
|
|
1238
|
+
}
|
|
1170
1239
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1240
|
+
pbi_result = self._get_dataflow_pbi_definition(
|
|
1241
|
+
workspace_id,
|
|
1242
|
+
dataflow_id,
|
|
1243
|
+
log_retries=verbose,
|
|
1244
|
+
on_rate_limit=on_rate_limit,
|
|
1245
|
+
)
|
|
1246
|
+
if pbi_result.get('message') == 'Success':
|
|
1247
|
+
return self._get_data_destinations_standard(pbi_result['content'])
|
|
1248
|
+
|
|
1249
|
+
cicd_message = cicd_result.get('message') if cicd_result else 'not attempted'
|
|
1250
|
+
return {
|
|
1251
|
+
'message': (
|
|
1252
|
+
'Failed to fetch dataflow definition. '
|
|
1253
|
+
f'CI/CD: {cicd_message}. PBI: {pbi_result.get("message")}'
|
|
1254
|
+
),
|
|
1255
|
+
'content': '',
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def get_workspace_data_destinations(
|
|
1260
|
+
self,
|
|
1261
|
+
workspace_id: str,
|
|
1262
|
+
max_workers: int = 4,
|
|
1263
|
+
) -> Dict:
|
|
1264
|
+
"""Get destination tables for every dataflow in a workspace.
|
|
1265
|
+
|
|
1266
|
+
Uses :meth:`list_dataflows` as the source of truth for the workspace,
|
|
1267
|
+
then obtains each dataflow's destinations concurrently. Requests share
|
|
1268
|
+
the hardcoded 200 requests-per-minute pace and retry 429 responses, so
|
|
1269
|
+
increasing ``max_workers`` does not bypass API throttling.
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
workspace_id: The workspace ID to inventory.
|
|
1273
|
+
max_workers: Maximum concurrent definition lookups. Defaults to 4.
|
|
1274
|
+
|
|
1275
|
+
Returns:
|
|
1276
|
+
Dict with ``content`` containing one item per dataflow. Each item
|
|
1277
|
+
has the source ``dataflow`` record, ``tables`` with destination
|
|
1278
|
+
details, and ``error`` when that dataflow could not be inspected.
|
|
1279
|
+
The message is ``Success`` when all dataflows were inspected or
|
|
1280
|
+
``Partial success`` when one or more definition lookups failed.
|
|
1281
|
+
"""
|
|
1282
|
+
if workspace_id == '':
|
|
1283
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
1284
|
+
if max_workers < 1:
|
|
1285
|
+
return {'message': 'max_workers must be at least 1.', 'content': ''}
|
|
1286
|
+
|
|
1287
|
+
dataflows_result = self.list_dataflows(workspace_id)
|
|
1288
|
+
if dataflows_result.get('message') != 'Success':
|
|
1289
|
+
return {
|
|
1290
|
+
'message': dataflows_result.get('message'),
|
|
1291
|
+
'content': [],
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
dataflows = dataflows_result.get('content', [])
|
|
1295
|
+
results = [None] * len(dataflows)
|
|
1296
|
+
progress_lock = Lock()
|
|
1297
|
+
progress = {'completed': 0}
|
|
1298
|
+
|
|
1299
|
+
def report_rate_limit(_retry_delay: float) -> None:
|
|
1300
|
+
with progress_lock:
|
|
1301
|
+
print(
|
|
1302
|
+
f'\n{progress["completed"]} processados até então, '
|
|
1303
|
+
'aguardando rate limit...',
|
|
1304
|
+
flush=True,
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
def inspect_dataflow(index: int, dataflow: Dict) -> tuple[int, Dict]:
|
|
1308
|
+
dataflow_id = dataflow.get('id') or dataflow.get('objectId')
|
|
1309
|
+
result = {
|
|
1310
|
+
'dataflow': dataflow,
|
|
1311
|
+
'tables': [],
|
|
1312
|
+
}
|
|
1313
|
+
if not dataflow_id:
|
|
1314
|
+
result['error'] = 'Dataflow record has no id.'
|
|
1315
|
+
return index, result
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
destinations_result = self.get_data_destinations(
|
|
1319
|
+
workspace_id,
|
|
1320
|
+
dataflow_id,
|
|
1321
|
+
source=dataflow.get('source'),
|
|
1322
|
+
verbose=False,
|
|
1323
|
+
on_rate_limit=report_rate_limit,
|
|
1324
|
+
)
|
|
1325
|
+
except Exception as error:
|
|
1326
|
+
result['error'] = str(error)
|
|
1327
|
+
return index, result
|
|
1328
|
+
|
|
1329
|
+
if destinations_result.get('message') == 'Success':
|
|
1330
|
+
result['tables'] = destinations_result.get('content', [])
|
|
1177
1331
|
else:
|
|
1178
|
-
|
|
1332
|
+
result['error'] = destinations_result.get('message')
|
|
1333
|
+
return index, result
|
|
1334
|
+
|
|
1335
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1336
|
+
futures = [
|
|
1337
|
+
executor.submit(inspect_dataflow, index, dataflow)
|
|
1338
|
+
for index, dataflow in enumerate(dataflows)
|
|
1339
|
+
]
|
|
1340
|
+
for completed, future in enumerate(as_completed(futures), start=1):
|
|
1341
|
+
index, result = future.result()
|
|
1342
|
+
results[index] = result
|
|
1343
|
+
with progress_lock:
|
|
1344
|
+
progress['completed'] = completed
|
|
1345
|
+
print(
|
|
1346
|
+
f'\rExtracted definition from {completed}/{len(dataflows)} '
|
|
1347
|
+
f'dataflows from workspace {workspace_id}...',
|
|
1348
|
+
end='',
|
|
1349
|
+
flush=True,
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
if dataflows:
|
|
1353
|
+
print()
|
|
1354
|
+
|
|
1355
|
+
self._export_workspace_dataflow_destinations(workspace_id, results)
|
|
1356
|
+
failed = [result for result in results if result.get('error')]
|
|
1357
|
+
return {
|
|
1358
|
+
'message': 'Partial success' if failed else 'Success',
|
|
1359
|
+
'content': results,
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _export_workspace_dataflow_destinations(
|
|
1364
|
+
self,
|
|
1365
|
+
workspace_id: str,
|
|
1366
|
+
dataflows: List[Dict],
|
|
1367
|
+
) -> pd.DataFrame:
|
|
1368
|
+
"""Flatten workspace destination results and save them to Excel."""
|
|
1369
|
+
metadata = [
|
|
1370
|
+
['dataflow', 'id'],
|
|
1371
|
+
['dataflow', 'name'],
|
|
1372
|
+
['dataflow', 'configuredBy'],
|
|
1373
|
+
['dataflow', 'generation'],
|
|
1374
|
+
['dataflow', 'source'],
|
|
1375
|
+
]
|
|
1376
|
+
dataframe = pd.json_normalize(
|
|
1377
|
+
dataflows,
|
|
1378
|
+
record_path=['tables'],
|
|
1379
|
+
meta=metadata,
|
|
1380
|
+
record_prefix='table_',
|
|
1381
|
+
errors='ignore',
|
|
1382
|
+
)
|
|
1383
|
+
dataframe.columns = [column.replace('.', '_') for column in dataframe.columns]
|
|
1384
|
+
dataflow_columns = [
|
|
1385
|
+
column for column in dataframe.columns if column.startswith('dataflow_')
|
|
1386
|
+
]
|
|
1387
|
+
destination_columns = [
|
|
1388
|
+
column for column in dataframe.columns if not column.startswith('dataflow_')
|
|
1389
|
+
]
|
|
1390
|
+
dataframe = dataframe[dataflow_columns + destination_columns]
|
|
1391
|
+
filename = f'workspace_dataflow_destinations_{workspace_id}.xlsx'
|
|
1392
|
+
dataframe.to_excel(os.path.join(self.dataflows_dir, filename), index=False)
|
|
1393
|
+
return dataframe
|
|
1179
1394
|
|
|
1180
1395
|
|
|
1181
1396
|
def _parse_column_mappings(self, annotation: str) -> List[Dict]:
|
|
@@ -1235,7 +1450,7 @@ class Dataflow:
|
|
|
1235
1450
|
lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
|
|
1236
1451
|
for q in data_queries:
|
|
1237
1452
|
destinations.append({
|
|
1238
|
-
'
|
|
1453
|
+
'name': q['name'],
|
|
1239
1454
|
'destination_type': 'Lakehouse',
|
|
1240
1455
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1241
1456
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -1260,7 +1475,7 @@ class Dataflow:
|
|
|
1260
1475
|
if 'Lakehouse.Contents' in body:
|
|
1261
1476
|
lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
|
|
1262
1477
|
destinations.append({
|
|
1263
|
-
'
|
|
1478
|
+
'name': q['name'],
|
|
1264
1479
|
'destination_type': 'Lakehouse',
|
|
1265
1480
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1266
1481
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -1271,7 +1486,7 @@ class Dataflow:
|
|
|
1271
1486
|
elif 'Fabric.Warehouse' in body:
|
|
1272
1487
|
wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
|
|
1273
1488
|
destinations.append({
|
|
1274
|
-
'
|
|
1489
|
+
'name': q['name'],
|
|
1275
1490
|
'destination_type': 'Warehouse',
|
|
1276
1491
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1277
1492
|
'item_id': wh_match.group(1) if wh_match else '',
|
|
@@ -1312,7 +1527,7 @@ class Dataflow:
|
|
|
1312
1527
|
if 'Lakehouse.Contents' in body:
|
|
1313
1528
|
lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
|
|
1314
1529
|
destinations.append({
|
|
1315
|
-
'
|
|
1530
|
+
'name': table_name,
|
|
1316
1531
|
'destination_type': 'Lakehouse',
|
|
1317
1532
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1318
1533
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -1323,7 +1538,7 @@ class Dataflow:
|
|
|
1323
1538
|
elif 'Fabric.Warehouse' in body:
|
|
1324
1539
|
wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
|
|
1325
1540
|
destinations.append({
|
|
1326
|
-
'
|
|
1541
|
+
'name': table_name,
|
|
1327
1542
|
'destination_type': 'Warehouse',
|
|
1328
1543
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1329
1544
|
'item_id': wh_match.group(1) if wh_match else '',
|
|
@@ -1343,7 +1558,7 @@ class Dataflow:
|
|
|
1343
1558
|
bind_pattern = r'\[BindToDefaultDestination\s*=\s*true\]\s*\n\s*shared\s+(\w+)\s*='
|
|
1344
1559
|
for bind_match in re.finditer(bind_pattern, m_code):
|
|
1345
1560
|
destinations.append({
|
|
1346
|
-
'
|
|
1561
|
+
'name': bind_match.group(1),
|
|
1347
1562
|
'destination_type': 'Lakehouse',
|
|
1348
1563
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1349
1564
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -8,6 +8,7 @@ from . import report
|
|
|
8
8
|
from typing import Dict
|
|
9
9
|
from .utilities import create_directory
|
|
10
10
|
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from msdev_kit.http import request_with_retry
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class Dataset:
|
|
@@ -28,21 +29,16 @@ class Dataset:
|
|
|
28
29
|
self, method: str, url: str, max_retries: int = 3, **kwargs
|
|
29
30
|
) -> requests.Response:
|
|
30
31
|
"""
|
|
31
|
-
|
|
32
|
-
Respects the Retry-After header when present.
|
|
32
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
33
33
|
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
time.sleep(retry_after)
|
|
44
|
-
|
|
45
|
-
return response
|
|
34
|
+
return request_with_retry(
|
|
35
|
+
method,
|
|
36
|
+
url,
|
|
37
|
+
max_retries=max_retries,
|
|
38
|
+
request_func=requests.request,
|
|
39
|
+
sleep=time.sleep,
|
|
40
|
+
**kwargs,
|
|
41
|
+
)
|
|
46
42
|
|
|
47
43
|
def get_dataset_name(self, workspace_id: str, dataset_id: str) -> str:
|
|
48
44
|
"""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import time
|
|
2
2
|
import requests
|
|
3
3
|
from typing import Dict
|
|
4
|
+
from msdev_kit.http import request_with_retry
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class Notebook:
|
|
@@ -16,19 +17,16 @@ class Notebook:
|
|
|
16
17
|
|
|
17
18
|
def _request_with_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
|
18
19
|
"""
|
|
19
|
-
|
|
20
|
-
Respects the Retry-After header when present.
|
|
20
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
21
21
|
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return response
|
|
22
|
+
return request_with_retry(
|
|
23
|
+
method,
|
|
24
|
+
url,
|
|
25
|
+
max_retries=max_retries,
|
|
26
|
+
request_func=requests.request,
|
|
27
|
+
sleep=time.sleep,
|
|
28
|
+
**kwargs,
|
|
29
|
+
)
|
|
32
30
|
|
|
33
31
|
|
|
34
32
|
def list_notebooks(self, workspace_id: str) -> Dict:
|
|
@@ -7,6 +7,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
7
7
|
from .dataflow import Dataflow
|
|
8
8
|
from .notebook import Notebook
|
|
9
9
|
from .dataset import Dataset
|
|
10
|
+
from msdev_kit.http import request_with_retry
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class Pipeline:
|
|
@@ -23,21 +24,16 @@ class Pipeline:
|
|
|
23
24
|
self, method: str, url: str, max_retries: int = 3, **kwargs
|
|
24
25
|
) -> requests.Response:
|
|
25
26
|
"""
|
|
26
|
-
|
|
27
|
-
Respects the Retry-After header when present.
|
|
27
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
28
28
|
"""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
time.sleep(retry_after)
|
|
39
|
-
|
|
40
|
-
return response
|
|
29
|
+
return request_with_retry(
|
|
30
|
+
method,
|
|
31
|
+
url,
|
|
32
|
+
max_retries=max_retries,
|
|
33
|
+
request_func=requests.request,
|
|
34
|
+
sleep=time.sleep,
|
|
35
|
+
**kwargs,
|
|
36
|
+
)
|
|
41
37
|
|
|
42
38
|
def _resolve_pipeline(self, workspace_id: str, pipeline_id_or_name: str) -> tuple:
|
|
43
39
|
"""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import requests
|
|
2
2
|
from typing import Optional
|
|
3
3
|
from msdev_kit.auth import Auth
|
|
4
|
+
from msdev_kit.http import request_with_retry
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class GraphClient:
|
|
@@ -15,10 +16,20 @@ class GraphClient:
|
|
|
15
16
|
'Content-Type': 'application/json',
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
20
|
+
"""Send a Graph request through the shared retry implementation."""
|
|
21
|
+
return request_with_retry(
|
|
22
|
+
method,
|
|
23
|
+
url,
|
|
24
|
+
request_func=requests.request,
|
|
25
|
+
**kwargs,
|
|
26
|
+
)
|
|
27
|
+
|
|
18
28
|
def get_user_id(self, email: str) -> Optional[str]:
|
|
19
29
|
"""Return the Entra object ID of a user by UPN/email, or None if not found.
|
|
20
30
|
Falls back to filtering by the mail field when the UPN lookup returns 404."""
|
|
21
|
-
resp =
|
|
31
|
+
resp = self._request(
|
|
32
|
+
'GET',
|
|
22
33
|
f'{self._GRAPH_BASE}/users/{email}',
|
|
23
34
|
headers=self._headers(),
|
|
24
35
|
params={'$select': 'id'},
|
|
@@ -28,7 +39,8 @@ class GraphClient:
|
|
|
28
39
|
resp.raise_for_status()
|
|
29
40
|
return resp.json().get('id')
|
|
30
41
|
|
|
31
|
-
resp =
|
|
42
|
+
resp = self._request(
|
|
43
|
+
'GET',
|
|
32
44
|
f'{self._GRAPH_BASE}/users',
|
|
33
45
|
headers=self._headers(),
|
|
34
46
|
params={'$filter': f"mail eq '{email}'", '$select': 'id'},
|
|
@@ -40,7 +52,8 @@ class GraphClient:
|
|
|
40
52
|
|
|
41
53
|
def get_group_id(self, group_name: str) -> Optional[str]:
|
|
42
54
|
"""Return the Entra object ID of a security group by displayName, or None."""
|
|
43
|
-
resp =
|
|
55
|
+
resp = self._request(
|
|
56
|
+
'GET',
|
|
44
57
|
f'{self._GRAPH_BASE}/groups',
|
|
45
58
|
headers=self._headers(),
|
|
46
59
|
params={'$filter': f"displayName eq '{group_name}'", '$select': 'id,displayName'},
|
|
@@ -58,7 +71,7 @@ class GraphClient:
|
|
|
58
71
|
params = {'$select': 'id,displayName,mail,userPrincipalName', '$top': '999'}
|
|
59
72
|
|
|
60
73
|
while url:
|
|
61
|
-
resp =
|
|
74
|
+
resp = self._request('GET', url, headers=self._headers(), params=params, timeout=30)
|
|
62
75
|
resp.raise_for_status()
|
|
63
76
|
data = resp.json()
|
|
64
77
|
members.extend(data.get('value', []))
|
|
@@ -69,7 +82,8 @@ class GraphClient:
|
|
|
69
82
|
|
|
70
83
|
def add_group_member(self, group_id: str, user_id: str):
|
|
71
84
|
"""Add user to group. Silently ignores 'already a member' errors."""
|
|
72
|
-
resp =
|
|
85
|
+
resp = self._request(
|
|
86
|
+
'POST',
|
|
73
87
|
f'{self._GRAPH_BASE}/groups/{group_id}/members/$ref',
|
|
74
88
|
headers=self._headers(),
|
|
75
89
|
json={'@odata.id': f'{self._GRAPH_BASE}/directoryObjects/{user_id}'},
|
|
@@ -81,7 +95,8 @@ class GraphClient:
|
|
|
81
95
|
|
|
82
96
|
def remove_group_member(self, group_id: str, user_id: str):
|
|
83
97
|
"""Remove user from group. Silently ignores 404 (not a member) and 403 (insufficient privileges)."""
|
|
84
|
-
resp =
|
|
98
|
+
resp = self._request(
|
|
99
|
+
'DELETE',
|
|
85
100
|
f'{self._GRAPH_BASE}/groups/{group_id}/members/{user_id}/$ref',
|
|
86
101
|
headers=self._headers(),
|
|
87
102
|
timeout=30,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared HTTP request helpers for Microsoft service clients."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
5
|
+
from threading import Lock
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestPacer:
|
|
13
|
+
"""Thread-safe minimum interval between requests from one client instance."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, requests_per_minute: float):
|
|
16
|
+
if requests_per_minute <= 0:
|
|
17
|
+
raise ValueError('requests_per_minute must be greater than zero.')
|
|
18
|
+
self._interval = 60 / requests_per_minute
|
|
19
|
+
self._lock = Lock()
|
|
20
|
+
self._last_request_at = 0.0
|
|
21
|
+
|
|
22
|
+
def wait(self) -> None:
|
|
23
|
+
"""Wait until the next request is allowed."""
|
|
24
|
+
with self._lock:
|
|
25
|
+
elapsed = time.monotonic() - self._last_request_at
|
|
26
|
+
if elapsed < self._interval:
|
|
27
|
+
time.sleep(self._interval - elapsed)
|
|
28
|
+
self._last_request_at = time.monotonic()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def request_with_retry(
|
|
32
|
+
method: str,
|
|
33
|
+
url: str,
|
|
34
|
+
*,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
pacer: Optional[RequestPacer] = None,
|
|
37
|
+
request_func: Callable[..., requests.Response] = requests.request,
|
|
38
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
39
|
+
log_retries: bool = True,
|
|
40
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
41
|
+
**kwargs,
|
|
42
|
+
) -> requests.Response:
|
|
43
|
+
"""Send an HTTP request and retry HTTP 429 responses.
|
|
44
|
+
|
|
45
|
+
``method`` accepts any HTTP verb supported by ``requests``. When a 429
|
|
46
|
+
includes ``Retry-After``, its delta-seconds or HTTP date value is honored.
|
|
47
|
+
Otherwise retries back off exponentially from one second. A shared
|
|
48
|
+
``RequestPacer`` can be supplied by callers that issue concurrent work.
|
|
49
|
+
"""
|
|
50
|
+
normalized_method = method.strip().upper() if isinstance(method, str) else ''
|
|
51
|
+
if not normalized_method:
|
|
52
|
+
raise ValueError('method must be a non-empty HTTP method string.')
|
|
53
|
+
if max_retries < 0:
|
|
54
|
+
raise ValueError('max_retries must be zero or greater.')
|
|
55
|
+
if sleep is None:
|
|
56
|
+
sleep = time.sleep
|
|
57
|
+
|
|
58
|
+
for attempt in range(max_retries + 1):
|
|
59
|
+
if pacer is not None:
|
|
60
|
+
pacer.wait()
|
|
61
|
+
|
|
62
|
+
response = request_func(normalized_method, url, **kwargs)
|
|
63
|
+
if response.status_code != 429:
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
if attempt < max_retries:
|
|
67
|
+
retry_delay = _get_retry_delay(response, attempt)
|
|
68
|
+
if on_rate_limit is not None:
|
|
69
|
+
on_rate_limit(retry_delay)
|
|
70
|
+
if log_retries:
|
|
71
|
+
print(
|
|
72
|
+
f' Rate limited (429). Retrying in {retry_delay:g}s... '
|
|
73
|
+
f'(attempt {attempt + 1}/{max_retries})'
|
|
74
|
+
)
|
|
75
|
+
sleep(retry_delay)
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_retry_delay(response: requests.Response, attempt: int) -> float:
|
|
81
|
+
"""Return a Retry-After delay, falling back to exponential backoff."""
|
|
82
|
+
retry_after = response.headers.get('Retry-After')
|
|
83
|
+
try:
|
|
84
|
+
return max(float(retry_after), 0)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
try:
|
|
87
|
+
retry_after_at = parsedate_to_datetime(retry_after)
|
|
88
|
+
if retry_after_at.tzinfo is None:
|
|
89
|
+
retry_after_at = retry_after_at.replace(tzinfo=timezone.utc)
|
|
90
|
+
return max((retry_after_at - datetime.now(timezone.utc)).total_seconds(), 0)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return min(2 ** attempt, 60)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|