msdev-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,505 @@
1
+ import json
2
+ import time
3
+ import base64
4
+ import requests
5
+ from typing import Dict, List
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from .dataflow import Dataflow
8
+ from .notebook import Notebook
9
+ from .dataset import Dataset
10
+
11
+
12
+ class Pipeline:
13
+
14
+ def __init__(self, token: str):
15
+ """
16
+ Initialize variables.
17
+ """
18
+ self.fabric_api_base_url = 'https://api.fabric.microsoft.com'
19
+ self.token = token
20
+ self.headers = {'Authorization': f'Bearer {self.token}'}
21
+
22
+
23
+ def _request_with_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
24
+ """
25
+ Makes an HTTP request with automatic retry on 429 (Too Many Requests).
26
+ Respects the Retry-After header when present.
27
+ """
28
+ for attempt in range(max_retries + 1):
29
+ response = requests.request(method, url, **kwargs)
30
+ if response.status_code != 429:
31
+ return response
32
+
33
+ retry_after = int(response.headers.get('Retry-After', 5))
34
+ print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
35
+ time.sleep(retry_after)
36
+
37
+ return response
38
+
39
+
40
+ def _resolve_pipeline(self, workspace_id: str, pipeline_id_or_name: str) -> tuple:
41
+ """
42
+ Resolves a pipeline identifier that can be either an ID or a display name.
43
+ Tries as ID first via get_pipeline, falls back to listing all pipelines
44
+ and matching by display name (case-insensitive).
45
+
46
+ Args:
47
+ workspace_id (str): The workspace ID.
48
+ pipeline_id_or_name (str): The pipeline ID or display name.
49
+
50
+ Returns:
51
+ tuple: (pipeline_id, pipeline_name) or (None, None) if not found.
52
+ """
53
+ # Try as ID first
54
+ result = self.get_pipeline(workspace_id, pipeline_id_or_name)
55
+ if result.get('message') == 'Success':
56
+ content = result['content']
57
+ return content.get('id', pipeline_id_or_name), content.get('displayName', '')
58
+
59
+ # Fall back to name search
60
+ result = self.list_pipelines(workspace_id)
61
+ if result.get('message') != 'Success':
62
+ return None, None
63
+
64
+ for p in result['content']:
65
+ if p.get('displayName', '').lower() == pipeline_id_or_name.lower():
66
+ return p['id'], p['displayName']
67
+
68
+ return None, None
69
+
70
+
71
+ def _resolve_dataflow_id(self, workspace_id: str, dataflow_id_or_name: str) -> tuple:
72
+ """
73
+ Resolves a dataflow identifier that can be either an ID or a display name.
74
+ Tries as ID first via Dataflow.get_dataflow_name, falls back to listing
75
+ all dataflows and matching by name (case-insensitive).
76
+
77
+ Args:
78
+ workspace_id (str): The workspace ID.
79
+ dataflow_id_or_name (str): The dataflow ID or display name.
80
+
81
+ Returns:
82
+ tuple: (dataflow_id, dataflow_name) or (None, None) if not found.
83
+ """
84
+ dataflow = Dataflow(self.token)
85
+
86
+ # Try as ID first
87
+ name = dataflow.get_dataflow_name(workspace_id, dataflow_id_or_name)
88
+ if name:
89
+ return dataflow_id_or_name, name
90
+
91
+ # Fall back to name search
92
+ result = dataflow.list_dataflows(workspace_id)
93
+ if result.get('message') != 'Success':
94
+ return None, None
95
+
96
+ for df in result['content']:
97
+ if (df.get('name') or '').lower() == dataflow_id_or_name.lower():
98
+ return df.get('id', ''), df.get('name', '')
99
+
100
+ return None, None
101
+
102
+
103
+ def list_pipelines(self, workspace_id: str) -> Dict:
104
+ """
105
+ Lists all Fabric Data Pipelines in a workspace.
106
+
107
+ Args:
108
+ workspace_id (str): The ID of the workspace.
109
+
110
+ Returns:
111
+ Dict: 'message' and 'content' (list of pipeline dicts with id, displayName, description).
112
+ """
113
+ if workspace_id == '':
114
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
115
+
116
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataPipelines'
117
+ pipelines = []
118
+
119
+ while api_url:
120
+ response = self._request_with_retry('GET', api_url, headers=self.headers)
121
+ if response.status_code != 200:
122
+ error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
123
+ error_message = error_data.get('message', error_data.get('error', {}).get('message', response.text))
124
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
125
+
126
+ data = response.json()
127
+ pipelines.extend(data.get('value', []))
128
+ api_url = data.get('continuationUri', None)
129
+
130
+ return {'message': 'Success', 'content': pipelines}
131
+
132
+
133
+ def find_pipelines_by_dataflow(self, workspace_id: str, dataflow_id_or_name: str, max_workers: int = 5) -> Dict:
134
+ """
135
+ Finds all pipelines in a workspace that reference a specific dataflow.
136
+
137
+ Accepts either a dataflow ID or display name. Lists all pipelines, fetches
138
+ their activities concurrently, and checks which ones contain a RefreshDataflow
139
+ activity targeting the resolved dataflow ID.
140
+
141
+ Args:
142
+ workspace_id (str): The workspace ID to search pipelines in.
143
+ dataflow_id_or_name (str): The dataflow ID or display name to search for.
144
+ max_workers (int): Maximum number of concurrent requests. Defaults to 5.
145
+
146
+ Returns:
147
+ Dict: 'message' and 'content' (list of dicts with pipeline_id, pipeline_name,
148
+ and activities — the matching activity names within that pipeline).
149
+ """
150
+ if workspace_id == '':
151
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
152
+ if dataflow_id_or_name == '':
153
+ return {'message': 'Missing dataflow id or name, please check.', 'content': ''}
154
+
155
+ # Resolve dataflow ID
156
+ dataflow_id, dataflow_name = self._resolve_dataflow_id(workspace_id, dataflow_id_or_name)
157
+ if not dataflow_id:
158
+ return {'message': f'Dataflow not found: {dataflow_id_or_name}', 'content': ''}
159
+
160
+ print(f"Resolved dataflow: {dataflow_name} ({dataflow_id})")
161
+
162
+ # List all pipelines
163
+ pipelines_result = self.list_pipelines(workspace_id)
164
+ if pipelines_result.get('message') != 'Success':
165
+ return pipelines_result
166
+
167
+ pipelines = pipelines_result['content']
168
+ print(f"Found {len(pipelines)} pipelines. Scanning for dataflow {dataflow_id}...")
169
+
170
+ def _check_pipeline(p):
171
+ pipeline_id = p.get('id', '')
172
+ pipeline_name = p.get('displayName', '')
173
+
174
+ activities_result = self.get_pipeline_activities(workspace_id, pipeline_id)
175
+ if activities_result.get('message') != 'Success':
176
+ return None
177
+
178
+ matching_activities = []
179
+ for activity in activities_result['content']:
180
+ if activity['activity_type'] != 'RefreshDataflow':
181
+ continue
182
+ props = activity.get('typeProperties', {})
183
+ if props.get('dataflowId') == dataflow_id:
184
+ matching_activities.append(activity['activity_name'])
185
+
186
+ if matching_activities:
187
+ return {
188
+ 'pipeline_id': pipeline_id,
189
+ 'pipeline_name': pipeline_name,
190
+ 'activities': matching_activities
191
+ }
192
+ return None
193
+
194
+ matches = []
195
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
196
+ futures = {executor.submit(_check_pipeline, p): p for p in pipelines}
197
+ for future in as_completed(futures):
198
+ result = future.result()
199
+ if result:
200
+ matches.append(result)
201
+
202
+ matches.sort(key=lambda m: m['pipeline_name'].lower())
203
+ print(f"Found {len(matches)} pipeline(s) referencing dataflow {dataflow_id}.")
204
+ return {'message': 'Success', 'content': matches}
205
+
206
+
207
+ def update_pipeline_definition(self, workspace_id: str, pipeline_id: str, definition: Dict) -> Dict:
208
+ """
209
+ Updates an existing Fabric Data Pipeline definition.
210
+
211
+ Args:
212
+ workspace_id (str): The ID of the workspace where the pipeline resides.
213
+ pipeline_id (str): The ID of the pipeline to update.
214
+ definition (Dict): The full pipeline definition (as returned by get_pipeline_definition).
215
+
216
+ Returns:
217
+ Dict: 'message' and 'content' with the update result.
218
+ """
219
+ if workspace_id == '':
220
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
221
+ if pipeline_id == '':
222
+ return {'message': 'Missing pipeline id, please check.', 'content': ''}
223
+
224
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataPipelines/{pipeline_id}/updateDefinition'
225
+
226
+ payload = {"definition": definition['definition']}
227
+
228
+ print(f"Updating pipeline {pipeline_id} in workspace {workspace_id}...")
229
+ response = self._request_with_retry('POST', api_url, headers=self.headers, json=payload)
230
+
231
+ if response.status_code in (200, 202):
232
+ content = response.json() if response.content else {'id': pipeline_id}
233
+ print(f"Successfully updated pipeline {pipeline_id}.")
234
+ return {'message': 'Success', 'content': content}
235
+ else:
236
+ error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
237
+ error_message = error_data.get('message', error_data.get('error', {}).get('message', response.text))
238
+ print(f"Error updating pipeline: {response.status_code} - {error_message}")
239
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
240
+
241
+
242
+ def replace_dataflow_id_in_pipeline(self, workspace_id: str, pipeline_id: str,
243
+ old_dataflow_id: str, new_dataflow_id: str) -> Dict:
244
+ """
245
+ Replaces a dataflow ID in all RefreshDataflow activities of a pipeline.
246
+
247
+ Fetches the pipeline definition, finds all RefreshDataflow activities that reference
248
+ the old dataflow ID, updates them to point to the new dataflow ID, and saves the
249
+ modified definition back to Fabric.
250
+
251
+ Args:
252
+ workspace_id (str): The workspace ID where the pipeline resides.
253
+ pipeline_id (str): The pipeline ID to update.
254
+ old_dataflow_id (str): The current dataflow ID to replace.
255
+ new_dataflow_id (str): The new dataflow ID to set.
256
+
257
+ Returns:
258
+ Dict: 'message' and 'content' with the update result, including how many activities were updated.
259
+ """
260
+ if workspace_id == '':
261
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
262
+ if pipeline_id == '':
263
+ return {'message': 'Missing pipeline id, please check.', 'content': ''}
264
+
265
+ # Fetch definition
266
+ result = self.get_pipeline_definition(workspace_id, pipeline_id)
267
+ if result.get('message') != 'Success':
268
+ return result
269
+
270
+ definition = result['content']
271
+
272
+ # Find and decode pipeline-content.json
273
+ parts = definition.get('definition', {}).get('parts', [])
274
+ content_part = None
275
+ pipeline_content = None
276
+ for part in parts:
277
+ if part.get('path') == 'pipeline-content.json':
278
+ content_part = part
279
+ pipeline_content = json.loads(base64.b64decode(part['payload']).decode('utf-8'))
280
+ break
281
+
282
+ if not pipeline_content:
283
+ return {'message': 'No pipeline-content.json found in definition.', 'content': ''}
284
+
285
+ # Replace dataflow ID in matching activities
286
+ activities = pipeline_content.get('properties', {}).get('activities', [])
287
+ updated_count = 0
288
+ updated_names = []
289
+
290
+ for activity in activities:
291
+ if activity.get('type') != 'RefreshDataflow':
292
+ continue
293
+ props = activity.get('typeProperties', {})
294
+ if props.get('dataflowId') == old_dataflow_id:
295
+ props['dataflowId'] = new_dataflow_id
296
+ updated_count += 1
297
+ updated_names.append(activity.get('name', ''))
298
+
299
+ if updated_count == 0:
300
+ return {
301
+ 'message': f'No RefreshDataflow activities found with dataflow ID {old_dataflow_id}.',
302
+ 'content': ''
303
+ }
304
+
305
+ # Encode back and update
306
+ content_part['payload'] = base64.b64encode(
307
+ json.dumps(pipeline_content).encode('utf-8')
308
+ ).decode('utf-8')
309
+
310
+ print(f"Replacing dataflow ID in {updated_count} activity(ies): {', '.join(updated_names)}")
311
+ update_result = self.update_pipeline_definition(workspace_id, pipeline_id, definition)
312
+
313
+ if update_result.get('message') == 'Success':
314
+ update_result['content'] = {
315
+ 'pipeline_id': pipeline_id,
316
+ 'activities_updated': updated_count,
317
+ 'activity_names': updated_names
318
+ }
319
+
320
+ return update_result
321
+
322
+
323
+ def get_pipeline_definition(self, workspace_id: str, pipeline_id: str) -> Dict:
324
+ """
325
+ Gets the definition of a Fabric Data Pipeline from a specified workspace.
326
+
327
+ Args:
328
+ workspace_id (str): The ID of the workspace where the pipeline resides.
329
+ pipeline_id (str): The ID of the pipeline to retrieve the definition for.
330
+
331
+ Returns:
332
+ Dict: A dictionary containing the status ('Success' or error) and the pipeline definition content.
333
+ """
334
+ if workspace_id == '':
335
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
336
+ if pipeline_id == '':
337
+ return {'message': 'Missing pipeline id, please check.', 'content': ''}
338
+
339
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataPipelines/{pipeline_id}/getDefinition'
340
+
341
+ # print(f"Extracting definition for pipeline {pipeline_id} from workspace {workspace_id}...")
342
+ response = self._request_with_retry('POST', api_url, headers=self.headers)
343
+
344
+ if response.status_code == 200:
345
+ definition = response.json()
346
+ # print("Successfully extracted pipeline definition.")
347
+ return {'message': 'Success', 'content': definition}
348
+ else:
349
+ error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
350
+ error_message = error_data.get('message', error_data.get('error', {}).get('message', response.text))
351
+ print(f"Error getting pipeline definition: {response.status_code} - {error_message}")
352
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
353
+
354
+
355
+ def get_pipeline(self, workspace_id: str, pipeline_id: str) -> Dict:
356
+ """
357
+ Gets the metadata of a specific pipeline (not its definition).
358
+
359
+ Args:
360
+ workspace_id (str): The ID of the workspace.
361
+ pipeline_id (str): The ID of the pipeline.
362
+
363
+ Returns:
364
+ Dict: 'message' and 'content' (pipeline dict with id, displayName, description, etc.).
365
+ """
366
+ if workspace_id == '':
367
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
368
+ if pipeline_id == '':
369
+ return {'message': 'Missing pipeline id, please check.', 'content': ''}
370
+
371
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataPipelines/{pipeline_id}'
372
+ response = self._request_with_retry('GET', api_url, headers=self.headers)
373
+
374
+ if response.status_code == 200:
375
+ return {'message': 'Success', 'content': response.json()}
376
+ else:
377
+ error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
378
+ error_message = error_data.get('message', error_data.get('error', {}).get('message', response.text))
379
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
380
+
381
+
382
+ def get_pipeline_activities(self, workspace_id: str, pipeline_id_or_name: str, max_workers: int = 5) -> Dict:
383
+ """
384
+ Gets the list of activities from a Fabric Data Pipeline definition,
385
+ extracting name, type, typeProperties, and the resolved object name
386
+ for supported activity types.
387
+
388
+ Accepts either a pipeline ID or display name. For activities of type
389
+ RefreshDataflow, TridentNotebook, InvokePipeline, or DatasetRefresh,
390
+ the referenced object's display name is resolved using the appropriate class.
391
+
392
+ Args:
393
+ workspace_id (str): The ID of the workspace where the pipeline resides.
394
+ pipeline_id_or_name (str): The pipeline ID or display name.
395
+ max_workers (int): Maximum number of concurrent requests for name resolution. Defaults to 5.
396
+
397
+ Returns:
398
+ Dict: A dictionary with 'message' and 'content' (list of activity dicts with
399
+ pipeline_id, pipeline_name, activity_name, activity_type, typeProperties,
400
+ and object_name inside typeProperties for supported types).
401
+ """
402
+ if workspace_id == '':
403
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
404
+ if pipeline_id_or_name == '':
405
+ return {'message': 'Missing pipeline id or name, please check.', 'content': ''}
406
+
407
+ # Resolve pipeline ID and name
408
+ pipeline_id, pipeline_name = self._resolve_pipeline(workspace_id, pipeline_id_or_name)
409
+ if not pipeline_id:
410
+ return {'message': f'Pipeline not found: {pipeline_id_or_name}', 'content': ''}
411
+
412
+ result = self.get_pipeline_definition(workspace_id, pipeline_id)
413
+
414
+ if result.get('message') != 'Success':
415
+ return result
416
+
417
+ definition = result['content']
418
+
419
+ # Extract the pipeline-content.json part from the definition
420
+ pipeline_content = None
421
+ parts = definition.get('definition', {}).get('parts', [])
422
+ for part in parts:
423
+ if part.get('path') == 'pipeline-content.json':
424
+ payload = base64.b64decode(part['payload']).decode('utf-8')
425
+ pipeline_content = json.loads(payload)
426
+ break
427
+
428
+ if not pipeline_content:
429
+ return {'message': 'No pipeline-content.json found in definition.', 'content': ''}
430
+
431
+ # Map activity types to their object ID property key
432
+ activity_object_map = {
433
+ 'RefreshDataflow': 'dataflowId',
434
+ 'TridentNotebook': 'notebookId',
435
+ 'InvokePipeline': 'pipelineId',
436
+ 'DatasetRefresh': 'datasetId',
437
+ }
438
+
439
+ # Extract activities and collect unique items to resolve, grouped by type
440
+ raw_activities = pipeline_content.get('properties', {}).get('activities', [])
441
+ items_to_resolve = {} # {object_id: (target_workspace_id, activity_type)}
442
+
443
+ for activity in raw_activities:
444
+ activity_type = activity.get('type', '')
445
+ if activity_type in activity_object_map:
446
+ props = activity.get('typeProperties', {})
447
+ id_key = activity_object_map[activity_type]
448
+ object_id = props.get(id_key, '')
449
+ target_ws = props.get('workspaceId', workspace_id)
450
+ if object_id and object_id not in items_to_resolve:
451
+ items_to_resolve[object_id] = (target_ws, activity_type)
452
+
453
+ # Resolve object names concurrently using the appropriate class per type
454
+ resolved_names = {}
455
+ if items_to_resolve:
456
+ dataflow = Dataflow(self.token)
457
+ notebook = Notebook(self.token)
458
+ dataset = Dataset(self.token)
459
+
460
+ def _resolve_name(object_id, target_ws, activity_type):
461
+ if activity_type == 'RefreshDataflow':
462
+ return object_id, dataflow.get_dataflow_name(target_ws, object_id)
463
+ elif activity_type == 'TridentNotebook':
464
+ result = notebook.get_notebook(target_ws, object_id)
465
+ name = result['content'].get('displayName', '') if result.get('message') == 'Success' else ''
466
+ return object_id, name
467
+ elif activity_type == 'InvokePipeline':
468
+ result = self.get_pipeline(target_ws, object_id)
469
+ name = result['content'].get('displayName', '') if result.get('message') == 'Success' else ''
470
+ return object_id, name
471
+ elif activity_type == 'DatasetRefresh':
472
+ return object_id, dataset.get_dataset_name(target_ws, object_id)
473
+ return object_id, ''
474
+
475
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
476
+ futures = {
477
+ executor.submit(_resolve_name, obj_id, ws_id, a_type): obj_id
478
+ for obj_id, (ws_id, a_type) in items_to_resolve.items()
479
+ }
480
+ for future in as_completed(futures):
481
+ obj_id, name = future.result()
482
+ resolved_names[obj_id] = name
483
+
484
+ # Build activity list with object_name inside typeProperties as the first key
485
+ activities = []
486
+ for activity in raw_activities:
487
+ activity_type = activity.get('type', '')
488
+ type_props = activity.get('typeProperties', {})
489
+
490
+ if activity_type in activity_object_map:
491
+ object_id = type_props.get(activity_object_map[activity_type], '')
492
+ object_name = resolved_names.get(object_id, '')
493
+ type_props = {'object_name': object_name, **type_props}
494
+
495
+ entry = {
496
+ 'pipeline_id': pipeline_id,
497
+ 'pipeline_name': pipeline_name,
498
+ 'activity_name': activity.get('name', ''),
499
+ 'activity_type': activity_type,
500
+ 'typeProperties': type_props,
501
+ }
502
+
503
+ activities.append(entry)
504
+
505
+ return {'message': 'Success', 'content': activities}