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.
Files changed (24) hide show
  1. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/PKG-INFO +2 -1
  2. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/README.md +1 -0
  3. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/__init__.py +1 -1
  4. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/dataflow.py +256 -41
  5. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/dataset.py +10 -14
  6. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/notebook.py +10 -12
  7. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/pipeline.py +10 -14
  8. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/graph/client.py +21 -6
  9. msdev_kit-0.2.3/msdev_kit/http.py +92 -0
  10. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/pyproject.toml +1 -1
  11. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/LICENSE +0 -0
  12. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/auth.py +0 -0
  13. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/__init__.py +0 -0
  14. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/admin.py +0 -0
  15. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/capacity.py +0 -0
  16. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/database.py +0 -0
  17. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/kql.py +0 -0
  18. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/operations.py +0 -0
  19. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/report.py +0 -0
  20. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/utilities.py +0 -0
  21. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/fabric/workspace.py +0 -0
  22. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/graph/__init__.py +0 -0
  23. {msdev_kit-0.2.2 → msdev_kit-0.2.3}/msdev_kit/sharepoint/__init__.py +0 -0
  24. {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.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. |
@@ -1,4 +1,4 @@
1
1
  from .auth import Auth
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
4
4
  __all__ = ["Auth"]
@@ -7,8 +7,11 @@ import time
7
7
  import base64
8
8
  import requests
9
9
  import pandas as pd
10
- from typing import Dict, List, Optional
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
- Initialize variables.
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(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
50
- Respects the Retry-After header when present.
63
+ Compatibility wrapper around the shared HTTP retry helper.
51
64
  """
52
- for attempt in range(max_retries + 1):
53
- response = requests.request(method, url, **kwargs)
54
- if response.status_code != 429:
55
- return response
56
-
57
- retry_after = int(response.headers.get('Retry-After', 5))
58
- print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
59
- time.sleep(retry_after)
60
-
61
- return response
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(self, workspace_id: str, dataflow_id: str) -> Dict:
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('GET', request_url, headers=self.headers)
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(self, workspace_id: str, dataflow_id: str) -> Dict:
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
- print(f"Extracting definition for dataflow {dataflow_id} from workspace {workspace_id}...")
390
- response = requests.post(api_url, headers=self.headers)
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
- print("Successfully extracted Dataflow Gen2 definition.")
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
- print(f"Error getting Dataflow Gen2 definition: {response.status_code} - {error_message}")
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(self, workspace_id: str, dataflow_id: str) -> Dict:
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
- table, destination_type, workspace_id, item_id, sql_schema,
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
- # Try CI/CD format first
1168
- cicd_result = self.get_dataflow_gen2_definition(workspace_id, dataflow_id)
1169
- is_cicd = cicd_result.get('message') == 'Success'
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
- if is_cicd:
1172
- return self._get_data_destinations_cicd(cicd_result['content'])
1173
- else:
1174
- pbi_result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
1175
- if pbi_result.get('message') == 'Success':
1176
- return self._get_data_destinations_standard(pbi_result['content'])
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
- return {'message': f'Failed to fetch dataflow definition. CI/CD: {cicd_result.get("message")}. PBI: {pbi_result.get("message")}', 'content': ''}
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
- 'table': q['name'],
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
- 'table': q['name'],
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
- 'table': q['name'],
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
- 'table': table_name,
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
- 'table': table_name,
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
- 'table': bind_match.group(1),
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
32
- Respects the Retry-After header when present.
32
+ Compatibility wrapper around the shared HTTP retry helper.
33
33
  """
34
- for attempt in range(max_retries + 1):
35
- response = requests.request(method, url, **kwargs)
36
- if response.status_code != 429:
37
- return response
38
-
39
- retry_after = int(response.headers.get("Retry-After", 5))
40
- print(
41
- f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})"
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
20
- Respects the Retry-After header when present.
20
+ Compatibility wrapper around the shared HTTP retry helper.
21
21
  """
22
- for attempt in range(max_retries + 1):
23
- response = requests.request(method, url, **kwargs)
24
- if response.status_code != 429:
25
- return response
26
-
27
- retry_after = int(response.headers.get('Retry-After', 5))
28
- print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
29
- time.sleep(retry_after)
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
27
- Respects the Retry-After header when present.
27
+ Compatibility wrapper around the shared HTTP retry helper.
28
28
  """
29
- for attempt in range(max_retries + 1):
30
- response = requests.request(method, url, **kwargs)
31
- if response.status_code != 429:
32
- return response
33
-
34
- retry_after = int(response.headers.get("Retry-After", 5))
35
- print(
36
- f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})"
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 = requests.get(
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 = requests.get(
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 = requests.get(
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 = requests.get(url, headers=self._headers(), params=params, timeout=30)
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 = requests.post(
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 = requests.delete(
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)
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "msdev-kit"
3
3
  description = "Microsoft developer toolkit: Fabric, MS Graph, and SharePoint"
4
- version = "0.2.2"
4
+ version = "0.2.3"
5
5
  requires-python = ">=3.10"
6
6
  readme = "README.md"
7
7
  license = {text = "MIT"}
File without changes
File without changes