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,1801 @@
1
+ import os
2
+ import re
3
+ import copy
4
+ import json
5
+ import uuid
6
+ import time
7
+ import base64
8
+ import requests
9
+ import pandas as pd
10
+ from typing import Dict, List
11
+ from .utilities import create_directory
12
+ from .workspace import Workspace
13
+
14
+
15
+ class Dataflow:
16
+
17
+ def __init__(self, token: str):
18
+ """
19
+ Initialize variables.
20
+ """
21
+ self.main_url = 'https://api.powerbi.com/v1.0/myorg'
22
+ self.fabric_api_base_url = 'https://api.fabric.microsoft.com'
23
+ self.token = token
24
+ self.headers = {'Authorization': f'Bearer {self.token}'}
25
+ self.workspace = Workspace(self.token)
26
+
27
+ # Directories
28
+ self.dataflows_dir = './data/dataflows'
29
+ self.directories = [self.dataflows_dir]
30
+
31
+ for dir in self.directories:
32
+ create_directory(dir)
33
+
34
+
35
+ def _request_with_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
36
+ """
37
+ Makes an HTTP request with automatic retry on 429 (Too Many Requests).
38
+ Respects the Retry-After header when present.
39
+ """
40
+ for attempt in range(max_retries + 1):
41
+ response = requests.request(method, url, **kwargs)
42
+ if response.status_code != 429:
43
+ return response
44
+
45
+ retry_after = int(response.headers.get('Retry-After', 5))
46
+ print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
47
+ time.sleep(retry_after)
48
+
49
+ return response
50
+
51
+
52
+ def _get_dataflow_pbi_definition(self, workspace_id: str, dataflow_id: str) -> Dict:
53
+ """
54
+ Fetches a dataflow definition from the Power BI REST API.
55
+ Works for Gen1 and Gen2 (standard) dataflows.
56
+
57
+ Args:
58
+ workspace_id (str): The workspace ID.
59
+ dataflow_id (str): The dataflow ID.
60
+
61
+ Returns:
62
+ Dict: A dictionary with 'message' ('Success' or error) and 'content' (full API response).
63
+ """
64
+ if workspace_id == '':
65
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
66
+
67
+ if dataflow_id == '':
68
+ return {'message': 'Missing dataflow id, please check.', 'content': ''}
69
+
70
+ request_url = f'{self.main_url}/groups/{workspace_id}/dataflows/{dataflow_id}'
71
+ r = self._request_with_retry('GET', request_url, headers=self.headers)
72
+
73
+ if r.status_code == 200:
74
+ return {'message': 'Success', 'content': json.loads(r.content)}
75
+ else:
76
+ try:
77
+ response = json.loads(r.content)
78
+ error_message = response['error']['message']
79
+ except Exception:
80
+ error_message = r.text
81
+ return {'message': {'error': error_message, 'status_code': r.status_code}}
82
+
83
+
84
+ def get_dataflow_name(self, workspace_id: str, dataflow_id: str) -> str:
85
+ """
86
+ Resolves the display name of a dataflow by its ID.
87
+ Tries the Power BI API first (Gen1 + Gen2 standard), then falls back
88
+ to the Fabric API (Gen2 CI/CD).
89
+
90
+ Args:
91
+ workspace_id (str): The workspace ID.
92
+ dataflow_id (str): The dataflow ID.
93
+
94
+ Returns:
95
+ str: The dataflow display name, or empty string if not found.
96
+ """
97
+ # Try PBI API first (covers Gen1 and Gen2 non-CI/CD)
98
+ result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
99
+ if result.get('message') == 'Success':
100
+ return result['content'].get('name', '')
101
+
102
+ # Fall back to Fabric API (Gen2 CI/CD)
103
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows/{dataflow_id}'
104
+ response = self._request_with_retry('GET', api_url, headers=self.headers)
105
+ if response.status_code == 200:
106
+ return response.json().get('displayName', '')
107
+
108
+ return ''
109
+
110
+
111
+ def list_dataflows(self, workspace_id: str = '') -> Dict:
112
+ """
113
+ List all dataflows in a workspace, including Gen1, Gen2 (standard), and Gen2 CI/CD (Fabric native).
114
+
115
+ Fetches from both the Power BI REST API (Gen1 and Gen2 standard) and the Fabric API
116
+ (Gen2 CI/CD). Results are merged and deduplicated by dataflow ID, with a 'source' column
117
+ indicating the origin ('pbi', 'fabric', or 'both').
118
+
119
+ Args:
120
+ workspace_id (str): The workspace ID to list dataflows from.
121
+
122
+ Returns:
123
+ Dict: status message and content (list of dataflow records).
124
+ """
125
+ if workspace_id == '':
126
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
127
+
128
+ workspace_name = self.workspace.get_workspace_details(workspace_id).get('content', {}).get('name', 'notFound')
129
+ filename = f'dataflows_{workspace_name}.xlsx'
130
+
131
+ # Fetch from PBI API (Gen1 + Gen2 standard)
132
+ pbi_url = f'{self.main_url}/groups/{workspace_id}/dataflows'
133
+ pbi_response = self._request_with_retry('GET', pbi_url, headers=self.headers)
134
+
135
+ pbi_records = []
136
+ if pbi_response.status_code == 200:
137
+ pbi_data = json.loads(pbi_response.content).get('value', [])
138
+ pbi_df = pd.DataFrame(pbi_data)
139
+ if not pbi_df.empty:
140
+ pbi_df['source'] = 'pbi'
141
+ # Normalize ID column name
142
+ if 'objectId' in pbi_df.columns:
143
+ pbi_df = pbi_df.rename(columns={'objectId': 'id'})
144
+ pbi_records = pbi_df.to_dict('records')
145
+
146
+ # Fetch from Fabric API (Gen2 CI/CD)
147
+ fabric_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows'
148
+ fabric_response = self._request_with_retry('GET', fabric_url, headers=self.headers)
149
+
150
+ fabric_records = []
151
+ if fabric_response.status_code == 200:
152
+ fabric_data = json.loads(fabric_response.content).get('value', [])
153
+ fabric_df = pd.json_normalize(fabric_data)
154
+ if not fabric_df.empty:
155
+ fabric_df['name'] = fabric_df['displayName']
156
+ fabric_df.drop(columns=['displayName'], inplace=True)
157
+ fabric_df['source'] = 'fabric'
158
+ fabric_records = fabric_df.to_dict('records')
159
+
160
+ # Check if both APIs failed
161
+ if pbi_response.status_code != 200 and fabric_response.status_code != 200:
162
+ try:
163
+ error_message = json.loads(pbi_response.content)['error']['message']
164
+ except Exception:
165
+ error_message = pbi_response.text
166
+ return {'message': {'error': error_message, 'content': ''}}
167
+
168
+ # Merge and deduplicate by ID
169
+ pbi_ids = {r['id'] for r in pbi_records if 'id' in r}
170
+ fabric_ids = {r['id'] for r in fabric_records if 'id' in r}
171
+ both_ids = pbi_ids & fabric_ids
172
+
173
+ merged = []
174
+ for r in pbi_records:
175
+ if r.get('id') in both_ids:
176
+ r['source'] = 'both'
177
+ merged.append(r)
178
+ for r in fabric_records:
179
+ if r.get('id') not in pbi_ids:
180
+ merged.append(r)
181
+
182
+ df = pd.json_normalize(merged)
183
+ if 'name' in df.columns:
184
+ df = df.sort_values(by='name', key=lambda s: s.str.lower()).reset_index(drop=True)
185
+ df.to_excel(f'{self.dataflows_dir}/{filename}', index=False)
186
+ result = json.loads(df.to_json(orient='records'))
187
+
188
+ return {'message': 'Success', 'content': result}
189
+
190
+
191
+ def get_dataflow_details(self, workspace_id: str = '', dataflow_id: str = '', folder_name: str = '') -> Dict:
192
+ """
193
+ Get all details from a specific dataflow in a workspace and save to a JSON file.
194
+
195
+ Args:
196
+ workspace_id (str, optional): workspace id to search for.
197
+ dataflow_id (str, optional): dataflow id to get the details.
198
+ folder_name (str, optional): folder name to save the JSON file. If not provided, uses the workspace name.
199
+
200
+ Returns:
201
+ Dict: status message and content.
202
+ """
203
+ result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
204
+
205
+ if result.get('message') != 'Success':
206
+ return result
207
+
208
+ response = result['content']
209
+
210
+ if folder_name == '':
211
+ folder_name = self.workspace.get_workspace_details(workspace_id).get('content', {}).get('name', 'notFound')
212
+
213
+ # Save to json file
214
+ filepath = f'{self.dataflows_dir}/{folder_name}/dataflows'
215
+ filename = f'{filepath}/{response.get("name", "")}.json'
216
+ os.makedirs(filepath, exist_ok=True)
217
+
218
+ with open(filename, mode='w', encoding='utf-8-sig') as f:
219
+ json.dump(response, f, ensure_ascii=True, indent=4)
220
+
221
+ return result
222
+
223
+
224
+ def create_dataflow(
225
+ self,
226
+ workspace_id: str = '',
227
+ dataflow_content: Dict = '') -> Dict:
228
+ """
229
+ Creates a new Power BI dataflow in a specified workspace.
230
+
231
+ Args:
232
+ workspace_id (str): workspace id where dataflow will be created.
233
+ dataflow_content (Dict): dataflow json with all details from it.
234
+
235
+ Returns:
236
+ Dict: status message.
237
+ """
238
+
239
+ # If both, user and workspace if are provided...
240
+ if (dataflow_content != '') & (workspace_id != ''):
241
+
242
+ request_url = self.main_url + f'/groups/{workspace_id}/imports?datasetDisplayName=model.json&nameConflit=Ignore'
243
+
244
+ body = {
245
+ 'value': json.dumps(dataflow_content, ensure_ascii=True)
246
+ }
247
+
248
+ # Make the request
249
+ r = requests.post(url=request_url, headers=self.headers, files=body)
250
+
251
+ # Get HTTP status and content
252
+ status = r.status_code
253
+
254
+ # If success...
255
+ if status in (200, 202):
256
+ return {'message': 'Success'}
257
+
258
+ else:
259
+
260
+ try:
261
+ # If any error happens, return message.
262
+ response = json.loads(r.content)
263
+ error_message = response['error']
264
+
265
+ except:
266
+ return {'message': 'Error reading JSON response'}
267
+
268
+ return {'message': {'error': error_message, 'content': response}}
269
+
270
+ else:
271
+ return {'message': 'Missing parameters, please check.'}
272
+
273
+
274
+ def delete_dataflow(
275
+ self,
276
+ workspace_id: str = '',
277
+ dataflow_id: str = '',
278
+ type: str = 'pbi') -> Dict:
279
+ """
280
+ Deletes a Power BI dataflow from a specified workspace.
281
+
282
+ Args:
283
+ workspace_id (str): workspace id where dataflow will be deleted.
284
+ dataflow_id (str): dataflow id to be deleted.
285
+
286
+ Returns:
287
+ Dict: status message.
288
+ """
289
+ # If workspace ID was not informed, return error message...
290
+ if workspace_id == '':
291
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
292
+
293
+ # If dataflow ID was not informed, return error message...
294
+ if dataflow_id == '':
295
+ return {'message': 'Missing dataflow id, please check.', 'content': ''}
296
+
297
+
298
+ if type not in ('pbi', 'fabric'):
299
+ return {'message': 'Type must be "pbi" or "fabric".', 'content': ''}
300
+
301
+ # Main URL
302
+ elif type == 'pbi':
303
+ request_url = f'{self.main_url}/groups/{workspace_id}/dataflows/{dataflow_id}'
304
+ elif type == 'fabric':
305
+ request_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows/{dataflow_id}'
306
+ else:
307
+ return {'message': 'Type must be "pbi" or "fabric".', 'content': ''} # Just as fallback, it won't reach here.
308
+
309
+
310
+ # Make the request
311
+ r = requests.delete(url=request_url, headers=self.headers)
312
+
313
+ # Get HTTP status and content
314
+ status = r.status_code
315
+
316
+ # If success...
317
+ if status in (200, 202):
318
+ return {'message': 'Success'}
319
+
320
+ else:
321
+
322
+ try:
323
+ # If any error happens, return message.
324
+ print(r.text)
325
+ response = json.loads(r.content)
326
+ error_message = response['error']
327
+
328
+ except:
329
+ return {'message': 'Error reading JSON response'}
330
+
331
+ return {'message': {'error': error_message, 'content': response}}
332
+
333
+
334
+ def export_dataflow_json(self, workspace_id: str = '', dataflow_id: str = '', dataflow_name: str = '') -> Dict:
335
+ """
336
+ Exports the JSON definition of a Power BI dataflow.
337
+
338
+ Args:
339
+ workspace_id (str, optional): workspace id where the dataflow resides.
340
+ dataflow_id (str, optional): dataflow id to get the details.
341
+ dataflow_name (str, optional): name to save the exported JSON file.
342
+
343
+ Returns:
344
+ Dict: status message and content.
345
+ """
346
+ result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
347
+
348
+ if result.get('message') != 'Success':
349
+ return result
350
+
351
+ response = result['content']
352
+ response['pbi:mashup']['allowNativeQueries'] = False
353
+
354
+ # Save to json file
355
+ filename = f'{self.dataflows_dir}/prod backup/{dataflow_name}.json'
356
+
357
+ with open(filename, mode='w', encoding='utf-8-sig') as f:
358
+ json.dump(response, f, ensure_ascii=True, indent=4)
359
+
360
+ return {'message': 'Success', 'content': response}
361
+
362
+ def get_dataflow_gen2_definition(self, workspace_id: str, dataflow_id: str) -> Dict:
363
+ """
364
+ Gets the definition of a Dataflow Gen2 (CI/CD) from a specified workspace.
365
+ Only Dataflow Gen2 (CI/CD / native Fabric) items support definition export.
366
+ Standard Dataflow Gen2 items are not supported.
367
+
368
+ Args:
369
+ workspace_id (str): The ID of the workspace where the Dataflow Gen2 resides.
370
+ dataflow_id (str): The ID of the Dataflow Gen2 to retrieve the definition for.
371
+
372
+ Returns:
373
+ Dict: A dictionary containing the status ('Success' or error) and the Dataflow Gen2 definition content.
374
+ """
375
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows/{dataflow_id}/getDefinition'
376
+
377
+ print(f"Extracting definition for dataflow {dataflow_id} from workspace {workspace_id}...")
378
+ response = requests.post(api_url, headers=self.headers)
379
+
380
+ if response.status_code == 200:
381
+ definition = response.json()
382
+ print("Successfully extracted Dataflow Gen2 definition.")
383
+ return {'message': 'Success', 'content': definition}
384
+ else:
385
+ # getDefinition only works for Dataflow Gen2 (CI/CD / native Fabric).
386
+ # Standard Dataflow Gen2 items return an error here.
387
+ error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
388
+ error_code = error_data.get('errorCode', '')
389
+
390
+ if response.status_code == 400 or error_code == 'UnknownError':
391
+ return {
392
+ 'message': {
393
+ 'error': 'This dataflow does not support definition export. '
394
+ 'Only Dataflow Gen2 (CI/CD) items created via the native Fabric experience support this operation. '
395
+ 'Standard Dataflow Gen2 items are not supported.',
396
+ 'status_code': response.status_code
397
+ }
398
+ }
399
+
400
+ error_message = response.text
401
+ print(f"Error getting Dataflow Gen2 definition: {response.status_code} - {error_message}")
402
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
403
+
404
+
405
+ def create_dataflow_gen2_from_definition(self, workspace_id: str, display_name: str, definition: Dict) -> Dict:
406
+ """
407
+ Creates a new Dataflow Gen2 in a specified workspace from a given definition.
408
+
409
+ Args:
410
+ workspace_id (str): The ID of the target workspace where the Dataflow Gen2 will be created.
411
+ display_name (str): The display name for the new Dataflow Gen2.
412
+ definition (Dict): The complete JSON definition of the Dataflow Gen2 (obtained from get_dataflow_gen2_definition).
413
+
414
+ Returns:
415
+ Dict: A dictionary containing the status ('Success' or error) and the details of the newly created Dataflow Gen2.
416
+ """
417
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows'
418
+
419
+ payload = {
420
+ "displayName": display_name,
421
+ "description": "",
422
+ "definition": definition['definition']
423
+ }
424
+
425
+ print(f"Creating Dataflow Gen2 '{display_name}' in workspace {workspace_id}...")
426
+ response = requests.post(api_url, headers=self.headers, json=payload)
427
+
428
+ if response.status_code == 201:
429
+ new_item = response.json()
430
+ print(f"Successfully created Dataflow Gen2. New Item ID: {new_item['id']}")
431
+ return {'message': 'Success', 'content': new_item}
432
+ elif response.status_code == 400 and 'ItemDisplayNameAlreadyInUse' in response.text:
433
+ error_message = response.text
434
+ print(f"Error creating Dataflow Gen2: {response.status_code} - {error_message}")
435
+ print('Use update method instead.')
436
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
437
+ else:
438
+ error_message = response.text
439
+ print(f"Error creating Dataflow Gen2: {response.status_code} - {error_message}")
440
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
441
+
442
+
443
+ def update_dataflow_gen2_from_definition(self, workspace_id: str, dataflow_id: str, display_name: str, definition: Dict) -> Dict:
444
+ """
445
+ Updates an existing Dataflow Gen2 in a specified workspace with a new definition.
446
+
447
+ Args:
448
+ workspace_id (str): The ID of the workspace where the Dataflow Gen2 resides.
449
+ dataflow_id (str): The ID of the Dataflow Gen2 to update.
450
+ display_name (str): The new display name for the Dataflow Gen2 (can be the same as current).
451
+ definition (Dict): The complete JSON definition of the Dataflow Gen2 (obtained from get_dataflow_gen2_definition).
452
+
453
+ Returns:
454
+ Dict: A dictionary containing the status ('Success' or error) and the details of the updated Dataflow Gen2.
455
+ """
456
+ api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows/{dataflow_id}/updateDefinition?updateMetadata=true'
457
+
458
+ payload = {
459
+ "definition": definition['definition']
460
+ }
461
+
462
+ print(f"Updating Dataflow Gen2 '{display_name}' (ID: {dataflow_id}) in workspace {workspace_id}...")
463
+ response = requests.post(api_url, headers=self.headers, json=payload)
464
+
465
+ if response.status_code in (200, 202):
466
+ if response.content:
467
+ updated_item = response.json()
468
+ else:
469
+ updated_item = {'id': dataflow_id, 'displayName': display_name}
470
+ print(f"Successfully updated Dataflow Gen2. Item ID: {updated_item['id']}")
471
+ return {'message': 'Success', 'content': updated_item}
472
+ else:
473
+ error_message = response.text
474
+ print(f"Error updating Dataflow Gen2: {response.status_code} - {error_message}")
475
+ return {'message': {'error': error_message, 'status_code': response.status_code}}
476
+
477
+
478
+ def _rewrite_data_destination_queries(self, m_code: str, destination_type: str,
479
+ destination_workspace_id: str,
480
+ destination_item_id: str) -> str:
481
+ """
482
+ Rewrites all _DataDestination queries in M code to point to a new destination type.
483
+ Preserves the table name from each existing _DataDestination query.
484
+ Works for both standard format (with NavigationTable.CreateTableOnDemand)
485
+ and CI/CD format (without wrapper).
486
+
487
+ Args:
488
+ m_code: The M (Power Query) code string.
489
+ destination_type: Target destination - 'Lakehouse' or 'Warehouse'.
490
+ destination_workspace_id: Workspace ID of the target destination.
491
+ destination_item_id: Item ID of the target (lakehouseId or warehouseId).
492
+
493
+ Returns:
494
+ Modified M code with rewritten _DataDestination queries.
495
+ """
496
+ def replace_match(match):
497
+ full_match = match.group(0)
498
+ query_name = match.group(1)
499
+
500
+ # Extract table name from existing query
501
+ # Use word boundaries to avoid matching workspaceId, lakehouseId, warehouseId, ItemKind
502
+ table_match = re.search(r'\b(?:Id|Item)\b\s*=\s*"([^"]+)"', full_match)
503
+ table_name = table_match.group(1) if table_match else query_name
504
+
505
+ if destination_type.lower() == 'warehouse':
506
+ return (
507
+ f'shared {query_name}_DataDestination = let\r\n'
508
+ f' Pattern = Fabric.Warehouse([CreateNavigationProperties = false, HierarchicalNavigation = null]),\r\n'
509
+ f' Navigation_1 = Pattern{{[workspaceId = "{destination_workspace_id}"]}}[Data],\r\n'
510
+ f' Navigation_2 = Navigation_1{{[warehouseId = "{destination_item_id}"]}}[Data],\r\n'
511
+ f' TableNavigation = Navigation_2{{[Item = "{table_name}", Schema = "dbo"]}}?[Data]?\r\n'
512
+ f'in\r\n'
513
+ f' TableNavigation;\r\n'
514
+ )
515
+ else: # lakehouse
516
+ return (
517
+ f'shared {query_name}_DataDestination = let\r\n'
518
+ f' Pattern = Lakehouse.Contents([CreateNavigationProperties = false, EnableFolding = false, HierarchicalNavigation = null]),\r\n'
519
+ f' Navigation_1 = Pattern{{[workspaceId = "{destination_workspace_id}"]}}[Data],\r\n'
520
+ f' Navigation_2 = Navigation_1{{[lakehouseId = "{destination_item_id}"]}}[Data],\r\n'
521
+ f' TableNavigation = Navigation_2{{[Id = "{table_name}", ItemKind = "Table"]}}?[Data]?\r\n'
522
+ f'in\r\n'
523
+ f' TableNavigation;\r\n'
524
+ )
525
+
526
+ pattern = r'shared\s+(\w+)_DataDestination\s*=\s*let[\s\S]*?;\r?\n'
527
+ return re.sub(pattern, replace_match, m_code)
528
+
529
+
530
+ def _update_destination_connections(self, connections: list, destination_type: str) -> list:
531
+ """
532
+ Updates connection entries to match the new destination type.
533
+ Replaces Lakehouse/Warehouse connection entries with the target type.
534
+
535
+ Args:
536
+ connections: List of connection dicts (connectionOverrides, trustedConnections, or CI/CD connections).
537
+ destination_type: Target destination - 'Lakehouse' or 'Warehouse'.
538
+
539
+ Returns:
540
+ Updated connections list.
541
+ """
542
+ source_types = ('Lakehouse', 'Warehouse')
543
+ target = 'Lakehouse' if destination_type.lower() == 'lakehouse' else 'Warehouse'
544
+
545
+ updated = []
546
+ for conn in connections:
547
+ conn = dict(conn) # Shallow copy to avoid mutating original
548
+ if conn.get('kind') in source_types:
549
+ conn['kind'] = target
550
+ conn['path'] = target
551
+ if 'connectionName' in conn:
552
+ conn['connectionName'] = json.dumps({"kind": target, "path": target})
553
+ updated.append(conn)
554
+ return updated
555
+
556
+
557
+ def _get_lakehouse_table_columns(self, workspace_id: str, lakehouse_id: str, table_name: str) -> List[str]:
558
+ """
559
+ Fetch column names for a table in a Fabric Lakehouse via REST API.
560
+
561
+ Args:
562
+ workspace_id: Workspace ID where the Lakehouse resides.
563
+ lakehouse_id: Lakehouse ID.
564
+ table_name: Name of the table.
565
+
566
+ Returns:
567
+ List of column names, or empty list if the table is not found.
568
+ """
569
+ url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/lakehouses/{lakehouse_id}/tables'
570
+ r = requests.get(url=url, headers=self.headers)
571
+
572
+ if r.status_code == 200:
573
+ response = r.json()
574
+ tables = response.get('data', response.get('value', []))
575
+ for table in tables:
576
+ if table.get('name') == table_name:
577
+ return [col['name'] for col in table.get('columns', [])]
578
+ return []
579
+
580
+
581
+ def _parse_cicd_mashup(self, m_code: str) -> Dict:
582
+ """
583
+ Parse CI/CD mashup.pq into structured components.
584
+
585
+ Returns:
586
+ Dict with: header, section, data_queries, source_queries, dest_queries.
587
+ Each query is a dict with: name, body, annotation.
588
+ Returns None if the M code cannot be parsed.
589
+ """
590
+ # Extract header and section line
591
+ section_match = re.search(r'(section\s+\w+;)\r?\n', m_code)
592
+ if not section_match:
593
+ return None
594
+
595
+ header_line = m_code[:section_match.start()].strip()
596
+ section_line = section_match.group(1)
597
+ queries_text = m_code[section_match.end():]
598
+
599
+ # Match query blocks: optional annotation line + shared query_name = let ... in ... ;
600
+ block_pattern = r'(\[[^\n]*\]\r?\n)?(shared\s+(\w+)\s*=\s*let\b[\s\S]*?;\r?\n?)'
601
+ matches = list(re.finditer(block_pattern, queries_text))
602
+
603
+ data_queries = []
604
+ source_queries = []
605
+ dest_queries = []
606
+
607
+ for match in matches:
608
+ annotation = match.group(1) or ''
609
+ full_query = match.group(2)
610
+ query_name = match.group(3)
611
+
612
+ if query_name == 'DefaultDestination' or query_name.endswith('_DataDestination'):
613
+ dest_queries.append({'name': query_name, 'body': full_query, 'annotation': annotation})
614
+ elif 'BindToDefaultDestination' in annotation or 'DataDestinations' in annotation:
615
+ data_queries.append({'name': query_name, 'body': full_query, 'annotation': annotation})
616
+ else:
617
+ source_queries.append({'name': query_name, 'body': full_query, 'annotation': annotation})
618
+
619
+ return {
620
+ 'header': header_line,
621
+ 'section': section_line,
622
+ 'data_queries': data_queries,
623
+ 'source_queries': source_queries,
624
+ 'dest_queries': dest_queries
625
+ }
626
+
627
+
628
+ def _extract_current_destination_info(self, dest_queries: list) -> Dict:
629
+ """
630
+ Extract current destination type and IDs from destination queries.
631
+
632
+ Returns:
633
+ Dict with: type ('lakehouse', 'warehouse', or 'unknown'), workspace_id, item_id.
634
+ """
635
+ for dq in dest_queries:
636
+ body = dq['body']
637
+ ws_match = re.search(r'workspaceId\s*=\s*"([^"]+)"', body)
638
+
639
+ if 'Lakehouse.Contents' in body:
640
+ lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
641
+ return {
642
+ 'type': 'lakehouse',
643
+ 'workspace_id': ws_match.group(1) if ws_match else '',
644
+ 'item_id': lh_match.group(1) if lh_match else ''
645
+ }
646
+ elif 'Fabric.Warehouse' in body:
647
+ wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
648
+ dn_match = re.search(r'displayName\s*=\s*"([^"]+)"', body)
649
+ return {
650
+ 'type': 'warehouse',
651
+ 'workspace_id': ws_match.group(1) if ws_match else '',
652
+ 'item_id': wh_match.group(1) if wh_match else (dn_match.group(1) if dn_match else '')
653
+ }
654
+
655
+ return {'type': 'unknown', 'workspace_id': '', 'item_id': ''}
656
+
657
+
658
+ def _build_warehouse_annotation(self, query_name: str, columns: List[str]) -> str:
659
+ """Build the [DataDestinations = {...}] M annotation for a warehouse destination with manual column mappings."""
660
+ mappings = ', '.join(
661
+ f'[SourceColumnName = "{col}", DestinationColumnName = "{col}"]'
662
+ for col in columns
663
+ )
664
+ return (
665
+ f'[DataDestinations = {{[Definition = [Kind = "Reference", '
666
+ f'QueryName = "{query_name}_DataDestination", IsNewTarget = true], '
667
+ f'Settings = [Kind = "Manual", AllowCreation = true, '
668
+ f'ColumnSettings = [Mappings = {{{mappings}}}], '
669
+ f'DynamicSchema = false, UpdateMethod = [Kind = "Replace"], '
670
+ f'TypeSettings = [Kind = "Table"]]]}}]'
671
+ )
672
+
673
+
674
+ def _build_warehouse_dest_query(self, query_name: str, destination_workspace_id: str,
675
+ destination_item_id: str) -> str:
676
+ """Build a shared X_DataDestination M query for Warehouse destination."""
677
+ return (
678
+ f'shared {query_name}_DataDestination = let\r\n'
679
+ f' Pattern = Fabric.Warehouse([CreateNavigationProperties = false, HierarchicalNavigation = null]),\r\n'
680
+ f' Navigation_1 = Pattern{{[workspaceId = "{destination_workspace_id}"]}}[Data],\r\n'
681
+ f' Navigation_2 = Navigation_1{{[warehouseId = "{destination_item_id}"]}}[Data],\r\n'
682
+ f' TableNavigation = Navigation_2{{[Item = "{query_name}", Schema = "dbo"]}}?[Data]?\r\n'
683
+ f'in\r\n'
684
+ f' TableNavigation;\r\n'
685
+ )
686
+
687
+
688
+ def _build_lakehouse_default_dest(self, destination_workspace_id: str,
689
+ destination_item_id: str) -> str:
690
+ """Build the shared DefaultDestination M query for Lakehouse destination."""
691
+ return (
692
+ f'shared DefaultDestination = let\r\n'
693
+ f' Source = Lakehouse.Contents([CreateNavigationProperties = false, EnableFolding = false]),\r\n'
694
+ f' #"Navigation 1" = Source{{[workspaceId = "{destination_workspace_id}"]}}[Data],\r\n'
695
+ f' #"Navigation 2" = #"Navigation 1"{{[lakehouseId = "{destination_item_id}"]}}[Data]\r\n'
696
+ f'in\r\n'
697
+ f' #"Navigation 2";\r\n'
698
+ )
699
+
700
+
701
+ def _change_data_destination(self, definition: Dict, destination_type: str,
702
+ destination_workspace_id: str, destination_item_id: str) -> Dict:
703
+ """
704
+ Internal method that changes the data destination of a dataflow definition dict.
705
+
706
+ Handles both CI/CD (Fabric API) and standard (PBI API) formats.
707
+ For CI/CD format, supports both DefaultDestination and per-query _DataDestination patterns.
708
+
709
+ When target is Lakehouse: outputs DefaultDestination pattern (automatic mapping).
710
+ When target is Warehouse: outputs per-query _DataDestination pattern (manual column mappings).
711
+
712
+ Args:
713
+ definition: The dataflow definition dict (standard or CI/CD format).
714
+ destination_type: Target destination type - 'Lakehouse' or 'Warehouse'.
715
+ destination_workspace_id: Workspace ID where the target Lakehouse/Warehouse resides.
716
+ destination_item_id: The ID of the target Lakehouse or Warehouse.
717
+
718
+ Returns:
719
+ Dict: A deep copy of the definition with updated data destinations.
720
+ """
721
+ if destination_type.lower() not in ('lakehouse', 'warehouse'):
722
+ return {'message': 'destination_type must be "Lakehouse" or "Warehouse".', 'content': ''}
723
+
724
+ # Detect format
725
+ is_cicd = 'definition' in definition and 'parts' in definition.get('definition', {})
726
+ is_standard = 'pbi:mashup' in definition
727
+
728
+ if not is_cicd and not is_standard:
729
+ return {'message': 'Unrecognized definition format. Expected standard (PBI API) or CI/CD (Fabric API) format.', 'content': ''}
730
+
731
+ if is_standard:
732
+ return self._change_standard_data_destination(
733
+ definition, destination_type, destination_workspace_id, destination_item_id
734
+ )
735
+ else:
736
+ return self._change_cicd_data_destination(
737
+ definition, destination_type, destination_workspace_id, destination_item_id
738
+ )
739
+
740
+
741
+ def _change_standard_data_destination(self, definition: Dict, destination_type: str,
742
+ destination_workspace_id: str, destination_item_id: str) -> Dict:
743
+ """Handle data destination change for standard (PBI API) format definitions."""
744
+ m_code = definition.get('pbi:mashup', {}).get('document', '')
745
+
746
+ # Check if already set to target
747
+ if m_code:
748
+ current_type, current_item_id = self._detect_current_dest_from_mcode(m_code)
749
+ if current_type == destination_type.lower() and current_item_id == destination_item_id:
750
+ return {'message': f'Data destination is already set to {destination_type} with item ID {destination_item_id}. No changes needed.', 'content': definition}
751
+
752
+ result = copy.deepcopy(definition)
753
+ mashup = result.get('pbi:mashup', {})
754
+ document = mashup.get('document', '')
755
+
756
+ if not document:
757
+ return {'message': 'No mashup document found in definition.', 'content': ''}
758
+
759
+ mashup['document'] = self._rewrite_data_destination_queries(
760
+ document, destination_type, destination_workspace_id, destination_item_id
761
+ )
762
+ if 'connectionOverrides' in mashup:
763
+ mashup['connectionOverrides'] = self._update_destination_connections(
764
+ mashup['connectionOverrides'], destination_type
765
+ )
766
+ if 'trustedConnections' in mashup:
767
+ mashup['trustedConnections'] = self._update_destination_connections(
768
+ mashup['trustedConnections'], destination_type
769
+ )
770
+ return result
771
+
772
+
773
+ def _detect_current_dest_from_mcode(self, m_code: str):
774
+ """Detect current destination type and item ID from M code. Returns (type, item_id)."""
775
+ if re.search(r'Fabric\.Warehouse\(', m_code):
776
+ id_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', m_code)
777
+ return 'warehouse', id_match.group(1) if id_match else None
778
+ elif re.search(r'Lakehouse\.Contents\(', m_code):
779
+ id_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', m_code)
780
+ return 'lakehouse', id_match.group(1) if id_match else None
781
+ return None, None
782
+
783
+
784
+ def _change_cicd_data_destination(self, definition: Dict, destination_type: str,
785
+ destination_workspace_id: str, destination_item_id: str) -> Dict:
786
+ """
787
+ Handle data destination change for CI/CD (Fabric API) format definitions.
788
+
789
+ Supports both DefaultDestination and per-query _DataDestination input patterns.
790
+ Outputs DefaultDestination for Lakehouse, per-query _DataDestination for Warehouse.
791
+ """
792
+ result = copy.deepcopy(definition)
793
+ parts = result['definition']['parts']
794
+
795
+ # Extract mashup.pq and queryMetadata.json
796
+ m_code = ''
797
+ metadata = {}
798
+ for part in parts:
799
+ if part['path'] == 'mashup.pq':
800
+ m_code = base64.b64decode(part['payload']).decode('utf-8')
801
+ elif part['path'] == 'queryMetadata.json':
802
+ metadata = json.loads(base64.b64decode(part['payload']).decode('utf-8'))
803
+
804
+ if not m_code:
805
+ return {'message': 'No mashup.pq found in definition.', 'content': ''}
806
+
807
+ # Parse the M code into components
808
+ parsed = self._parse_cicd_mashup(m_code)
809
+ if not parsed:
810
+ return {'message': 'Could not parse mashup.pq.', 'content': ''}
811
+
812
+ data_queries = parsed['data_queries']
813
+ source_queries = parsed['source_queries']
814
+ dest_queries = parsed['dest_queries']
815
+
816
+ if not data_queries:
817
+ return {'message': 'No data queries with destinations found in mashup.pq.', 'content': ''}
818
+
819
+ # Get current destination info and check if already the same
820
+ current_dest = self._extract_current_destination_info(dest_queries)
821
+ if current_dest['type'] == destination_type.lower() and current_dest['item_id'] == destination_item_id:
822
+ return {
823
+ 'message': f'Data destination is already set to {destination_type} with item ID {destination_item_id}. No changes needed.',
824
+ 'content': definition
825
+ }
826
+
827
+ # Extract staging definition from current header
828
+ staging_match = re.search(r'StagingDefinition\s*=\s*\[[^\]]*\]', parsed['header'])
829
+ staging_def = staging_match.group(0) if staging_match else 'StagingDefinition = [Kind = "FastCopy"]'
830
+
831
+ # Build new M code and update queryMetadata based on target type
832
+ if destination_type.lower() == 'warehouse':
833
+ new_m_code, metadata = self._build_warehouse_cicd(
834
+ parsed, metadata, staging_def, current_dest,
835
+ destination_workspace_id, destination_item_id
836
+ )
837
+ else:
838
+ new_m_code, metadata = self._build_lakehouse_cicd(
839
+ parsed, metadata, staging_def,
840
+ destination_workspace_id, destination_item_id
841
+ )
842
+
843
+ # Encode and update parts
844
+ for part in parts:
845
+ if part['path'] == 'mashup.pq':
846
+ part['payload'] = base64.b64encode(new_m_code.encode('utf-8')).decode('utf-8')
847
+ elif part['path'] == 'queryMetadata.json':
848
+ part['payload'] = base64.b64encode(
849
+ json.dumps(metadata, indent=2).encode('utf-8')
850
+ ).decode('utf-8')
851
+
852
+ return result
853
+
854
+
855
+ def _build_warehouse_cicd(self, parsed: Dict, metadata: Dict, staging_def: str,
856
+ current_dest: Dict, destination_workspace_id: str,
857
+ destination_item_id: str):
858
+ """
859
+ Build warehouse CI/CD mashup.pq and update queryMetadata for warehouse destination.
860
+
861
+ Returns:
862
+ Tuple of (new_m_code, updated_metadata).
863
+ """
864
+ data_queries = parsed['data_queries']
865
+ source_queries = parsed['source_queries']
866
+
867
+ # Get column names for each data query table
868
+ columns_per_query = {}
869
+ if current_dest['type'] == 'lakehouse' and current_dest['workspace_id'] and current_dest['item_id']:
870
+ print("Fetching table columns from current Lakehouse destination...")
871
+ for q in data_queries:
872
+ columns = self._get_lakehouse_table_columns(
873
+ current_dest['workspace_id'], current_dest['item_id'], q['name']
874
+ )
875
+ if columns:
876
+ columns_per_query[q['name']] = columns
877
+ else:
878
+ print(f" Warning: Could not fetch columns for table '{q['name']}'. Skipping column mappings.")
879
+ elif current_dest['type'] == 'warehouse':
880
+ # Extract column mappings from existing DataDestinations annotations
881
+ for q in data_queries:
882
+ cols = re.findall(r'SourceColumnName\s*=\s*"([^"]+)"', q['annotation'])
883
+ if cols:
884
+ columns_per_query[q['name']] = cols
885
+
886
+ # Build header (no DefaultOutputDestinationSettings for warehouse)
887
+ new_header = f'[{staging_def}]'
888
+
889
+ # Build M code
890
+ new_m_code = new_header + '\r\n'
891
+ new_m_code += parsed['section'] + '\r\n'
892
+
893
+ # Data queries with DataDestinations annotations
894
+ for q in data_queries:
895
+ columns = columns_per_query.get(q['name'], [])
896
+ if columns:
897
+ annotation = self._build_warehouse_annotation(q['name'], columns)
898
+ new_m_code += annotation + '\r\n'
899
+ new_m_code += q['body']
900
+ if not q['body'].endswith('\n'):
901
+ new_m_code += '\r\n'
902
+
903
+ # Source queries (preserve as-is)
904
+ for q in source_queries:
905
+ if q['annotation']:
906
+ new_m_code += q['annotation']
907
+ new_m_code += q['body']
908
+ if not q['body'].endswith('\n'):
909
+ new_m_code += '\r\n'
910
+
911
+ # _DataDestination queries for each data query
912
+ for q in data_queries:
913
+ new_m_code += self._build_warehouse_dest_query(
914
+ q['name'], destination_workspace_id, destination_item_id
915
+ )
916
+
917
+ # Update queryMetadata
918
+ queries_meta = metadata.get('queriesMetadata', {})
919
+
920
+ # Remove DefaultDestination entry if present
921
+ queries_meta.pop('DefaultDestination', None)
922
+
923
+ # Remove loadEnabled from data queries (warehouse doesn't use it)
924
+ for q in data_queries:
925
+ if q['name'] in queries_meta:
926
+ queries_meta[q['name']].pop('loadEnabled', None)
927
+
928
+ # Add _DataDestination entries for each data query
929
+ for q in data_queries:
930
+ dest_name = f"{q['name']}_DataDestination"
931
+ if dest_name not in queries_meta:
932
+ queries_meta[dest_name] = {
933
+ 'queryId': str(uuid.uuid4()),
934
+ 'queryName': dest_name,
935
+ 'isHidden': True,
936
+ 'loadEnabled': False
937
+ }
938
+
939
+ # Update connections: ensure Warehouse connection exists
940
+ connections = metadata.get('connections', [])
941
+ has_warehouse = any(c.get('kind') == 'Warehouse' for c in connections)
942
+ if not has_warehouse:
943
+ connections.append({'path': 'Warehouse', 'kind': 'Warehouse'})
944
+ # Remove Lakehouse connection if switching from Lakehouse
945
+ connections = [c for c in connections if c.get('kind') != 'Lakehouse']
946
+ metadata['connections'] = connections
947
+ metadata['queriesMetadata'] = queries_meta
948
+
949
+ return new_m_code, metadata
950
+
951
+
952
+ def _build_lakehouse_cicd(self, parsed: Dict, metadata: Dict, staging_def: str,
953
+ destination_workspace_id: str, destination_item_id: str):
954
+ """
955
+ Build lakehouse CI/CD mashup.pq and update queryMetadata for lakehouse destination.
956
+
957
+ Returns:
958
+ Tuple of (new_m_code, updated_metadata).
959
+ """
960
+ data_queries = parsed['data_queries']
961
+ source_queries = parsed['source_queries']
962
+
963
+ # Build header with DefaultOutputDestinationSettings
964
+ new_header = (
965
+ f'[DefaultOutputDestinationSettings = [DestinationDefinition = '
966
+ f'[Kind = "Reference", QueryName = "DefaultDestination", IsNewTarget = true], '
967
+ f'UpdateMethod = [Kind = "Replace"], DestinationTypeSettings = [Kind = "Table"]], '
968
+ f'{staging_def}]'
969
+ )
970
+
971
+ # Build M code
972
+ new_m_code = new_header + '\r\n'
973
+ new_m_code += parsed['section'] + '\r\n'
974
+
975
+ # Data queries with BindToDefaultDestination
976
+ for q in data_queries:
977
+ new_m_code += '[BindToDefaultDestination = true]\r\n'
978
+ new_m_code += q['body']
979
+ if not q['body'].endswith('\n'):
980
+ new_m_code += '\r\n'
981
+
982
+ # Source queries (preserve as-is)
983
+ for q in source_queries:
984
+ if q['annotation']:
985
+ new_m_code += q['annotation']
986
+ new_m_code += q['body']
987
+ if not q['body'].endswith('\n'):
988
+ new_m_code += '\r\n'
989
+
990
+ # DefaultDestination query
991
+ new_m_code += self._build_lakehouse_default_dest(
992
+ destination_workspace_id, destination_item_id
993
+ )
994
+
995
+ # Update queryMetadata
996
+ queries_meta = metadata.get('queriesMetadata', {})
997
+
998
+ # Remove _DataDestination entries
999
+ dest_names = [f"{q['name']}_DataDestination" for q in data_queries]
1000
+ for name in dest_names:
1001
+ queries_meta.pop(name, None)
1002
+
1003
+ # Set data queries loadEnabled to false
1004
+ for q in data_queries:
1005
+ if q['name'] in queries_meta:
1006
+ queries_meta[q['name']]['loadEnabled'] = False
1007
+
1008
+ # Add DefaultDestination entry
1009
+ if 'DefaultDestination' not in queries_meta:
1010
+ queries_meta['DefaultDestination'] = {
1011
+ 'queryId': str(uuid.uuid4()),
1012
+ 'queryName': 'DefaultDestination',
1013
+ 'isHidden': True,
1014
+ 'loadEnabled': False
1015
+ }
1016
+
1017
+ # Update connections: ensure Lakehouse connection, remove Warehouse
1018
+ connections = metadata.get('connections', [])
1019
+ connections = [c for c in connections if c.get('kind') != 'Warehouse']
1020
+ has_lakehouse = any(c.get('kind') == 'Lakehouse' for c in connections)
1021
+ if not has_lakehouse:
1022
+ connections.append({'path': 'Lakehouse', 'kind': 'Lakehouse'})
1023
+ metadata['connections'] = connections
1024
+ metadata['queriesMetadata'] = queries_meta
1025
+
1026
+ return new_m_code, metadata
1027
+
1028
+
1029
+ def get_data_destinations(self, workspace_id: str, dataflow_id: str) -> Dict:
1030
+ """
1031
+ Gets the data destination details for each table in a dataflow.
1032
+
1033
+ Fetches the dataflow definition (CI/CD first, then standard) and extracts
1034
+ which tables have a data destination configured, the destination type
1035
+ (Lakehouse or Warehouse), and the column mappings (for Warehouse with manual mappings).
1036
+
1037
+ Args:
1038
+ workspace_id: The workspace ID where the dataflow resides.
1039
+ dataflow_id: The dataflow ID.
1040
+
1041
+ Returns:
1042
+ Dict: 'message' and 'content' (list of dicts with keys:
1043
+ table, destination_type, workspace_id, item_id, sql_schema,
1044
+ mapping_type ('Automatic' or 'Manual'),
1045
+ columns (list of {source, destination} dicts, empty for automatic mappings)).
1046
+ """
1047
+ if workspace_id == '':
1048
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
1049
+ if dataflow_id == '':
1050
+ return {'message': 'Missing dataflow id, please check.', 'content': ''}
1051
+
1052
+ # Try CI/CD format first
1053
+ cicd_result = self.get_dataflow_gen2_definition(workspace_id, dataflow_id)
1054
+ is_cicd = cicd_result.get('message') == 'Success'
1055
+
1056
+ if is_cicd:
1057
+ return self._get_data_destinations_cicd(cicd_result['content'])
1058
+ else:
1059
+ pbi_result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
1060
+ if pbi_result.get('message') == 'Success':
1061
+ return self._get_data_destinations_standard(pbi_result['content'])
1062
+ else:
1063
+ return {'message': f'Failed to fetch dataflow definition. CI/CD: {cicd_result.get("message")}. PBI: {pbi_result.get("message")}', 'content': ''}
1064
+
1065
+
1066
+ def _parse_column_mappings(self, annotation: str) -> List[Dict]:
1067
+ """Extract column mappings from a DataDestinations annotation string.
1068
+
1069
+ Returns:
1070
+ List of dicts with 'source' and 'destination' keys.
1071
+ """
1072
+ mappings = []
1073
+ for m in re.finditer(
1074
+ r'\[SourceColumnName\s*=\s*"([^"]+)",\s*DestinationColumnName\s*=\s*"([^"]+)"\]',
1075
+ annotation
1076
+ ):
1077
+ mappings.append({'source': m.group(1), 'destination': m.group(2)})
1078
+ return mappings
1079
+
1080
+ def _parse_mapping_type(self, annotation: str) -> str:
1081
+ """Extract mapping type (Manual or Automatic) from a DataDestinations annotation."""
1082
+ kind_match = re.search(r'Settings\s*=\s*\[Kind\s*=\s*"(\w+)"', annotation)
1083
+ return kind_match.group(1) if kind_match else 'Automatic'
1084
+
1085
+ def _get_data_destinations_cicd(self, definition: Dict) -> Dict:
1086
+ """Extract data destination info from a CI/CD dataflow definition."""
1087
+ parts = definition.get('definition', {}).get('parts', [])
1088
+
1089
+ m_code = ''
1090
+ for part in parts:
1091
+ if part['path'] == 'mashup.pq':
1092
+ m_code = base64.b64decode(part['payload']).decode('utf-8')
1093
+ break
1094
+
1095
+ if not m_code:
1096
+ return {'message': 'No mashup.pq found in definition.', 'content': ''}
1097
+
1098
+ parsed = self._parse_cicd_mashup(m_code)
1099
+ if not parsed:
1100
+ return {'message': 'Could not parse mashup.pq.', 'content': ''}
1101
+
1102
+ data_queries = parsed['data_queries']
1103
+ dest_queries = parsed['dest_queries']
1104
+
1105
+ if not data_queries:
1106
+ return {'message': 'Success', 'content': []}
1107
+
1108
+ destinations = []
1109
+
1110
+ # Check for DefaultDestination pattern (Lakehouse)
1111
+ default_dest = None
1112
+ for dq in dest_queries:
1113
+ if dq['name'] == 'DefaultDestination':
1114
+ default_dest = dq
1115
+ break
1116
+
1117
+ if default_dest:
1118
+ body = default_dest['body']
1119
+ ws_match = re.search(r'workspaceId\s*=\s*"([^"]+)"', body)
1120
+ lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
1121
+ for q in data_queries:
1122
+ destinations.append({
1123
+ 'table': q['name'],
1124
+ 'destination_type': 'Lakehouse',
1125
+ 'workspace_id': ws_match.group(1) if ws_match else '',
1126
+ 'item_id': lh_match.group(1) if lh_match else '',
1127
+ 'sql_schema': None,
1128
+ 'mapping_type': 'Automatic',
1129
+ 'columns': []
1130
+ })
1131
+ else:
1132
+ # Per-query _DataDestination pattern
1133
+ dest_map = {dq['name']: dq for dq in dest_queries}
1134
+ for q in data_queries:
1135
+ dest_key = q['name'] + '_DataDestination'
1136
+ if dest_key not in dest_map:
1137
+ continue
1138
+ body = dest_map[dest_key]['body']
1139
+ annotation = q.get('annotation', '')
1140
+ ws_match = re.search(r'workspaceId\s*=\s*"([^"]+)"', body)
1141
+ schema_match = re.search(r'Schema\s*=\s*"([^"]+)"', body)
1142
+ mapping_type = self._parse_mapping_type(annotation)
1143
+ columns = self._parse_column_mappings(annotation)
1144
+
1145
+ if 'Lakehouse.Contents' in body:
1146
+ lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
1147
+ destinations.append({
1148
+ 'table': q['name'],
1149
+ 'destination_type': 'Lakehouse',
1150
+ 'workspace_id': ws_match.group(1) if ws_match else '',
1151
+ 'item_id': lh_match.group(1) if lh_match else '',
1152
+ 'sql_schema': schema_match.group(1) if schema_match else None,
1153
+ 'mapping_type': mapping_type,
1154
+ 'columns': columns
1155
+ })
1156
+ elif 'Fabric.Warehouse' in body:
1157
+ wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
1158
+ destinations.append({
1159
+ 'table': q['name'],
1160
+ 'destination_type': 'Warehouse',
1161
+ 'workspace_id': ws_match.group(1) if ws_match else '',
1162
+ 'item_id': wh_match.group(1) if wh_match else '',
1163
+ 'sql_schema': schema_match.group(1) if schema_match else 'dbo',
1164
+ 'mapping_type': mapping_type,
1165
+ 'columns': columns
1166
+ })
1167
+
1168
+ return {'message': 'Success', 'content': destinations}
1169
+
1170
+
1171
+ def _get_data_destinations_standard(self, definition: Dict) -> Dict:
1172
+ """Extract data destination info from a standard (PBI API) dataflow definition."""
1173
+ m_code = definition.get('pbi:mashup', {}).get('document', '')
1174
+ if not m_code:
1175
+ return {'message': 'No mashup document found in definition.', 'content': ''}
1176
+
1177
+ destinations = []
1178
+
1179
+ # Find _DataDestination queries and their associated data query annotations
1180
+ # First build a map of annotation per data query
1181
+ annotation_pattern = r'(\[DataDestinations[^\n]*\])\r?\n\s*shared\s+(\w+)\s*='
1182
+ annotation_map = {}
1183
+ for ann_match in re.finditer(annotation_pattern, m_code):
1184
+ annotation_map[ann_match.group(2)] = ann_match.group(1)
1185
+
1186
+ # Find _DataDestination queries
1187
+ dest_pattern = r'shared\s+(\w+)_DataDestination\s*=\s*let\b([\s\S]*?);\s*'
1188
+ for match in re.finditer(dest_pattern, m_code):
1189
+ table_name = match.group(1)
1190
+ body = match.group(2)
1191
+ ws_match = re.search(r'workspaceId\s*=\s*"([^"]+)"', body)
1192
+ schema_match = re.search(r'Schema\s*=\s*"([^"]+)"', body)
1193
+ annotation = annotation_map.get(table_name, '')
1194
+ mapping_type = self._parse_mapping_type(annotation)
1195
+ columns = self._parse_column_mappings(annotation)
1196
+
1197
+ if 'Lakehouse.Contents' in body:
1198
+ lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
1199
+ destinations.append({
1200
+ 'table': table_name,
1201
+ 'destination_type': 'Lakehouse',
1202
+ 'workspace_id': ws_match.group(1) if ws_match else '',
1203
+ 'item_id': lh_match.group(1) if lh_match else '',
1204
+ 'sql_schema': schema_match.group(1) if schema_match else None,
1205
+ 'mapping_type': mapping_type,
1206
+ 'columns': columns
1207
+ })
1208
+ elif 'Fabric.Warehouse' in body:
1209
+ wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
1210
+ destinations.append({
1211
+ 'table': table_name,
1212
+ 'destination_type': 'Warehouse',
1213
+ 'workspace_id': ws_match.group(1) if ws_match else '',
1214
+ 'item_id': wh_match.group(1) if wh_match else '',
1215
+ 'sql_schema': schema_match.group(1) if schema_match else 'dbo',
1216
+ 'mapping_type': mapping_type,
1217
+ 'columns': columns
1218
+ })
1219
+
1220
+ # Check for DefaultDestination (Lakehouse with BindToDefaultDestination)
1221
+ if not destinations:
1222
+ default_match = re.search(r'shared\s+DefaultDestination\s*=\s*let\b([\s\S]*?);', m_code)
1223
+ if default_match:
1224
+ body = default_match.group(1)
1225
+ ws_match = re.search(r'workspaceId\s*=\s*"([^"]+)"', body)
1226
+ lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
1227
+
1228
+ bind_pattern = r'\[BindToDefaultDestination\s*=\s*true\]\s*\n\s*shared\s+(\w+)\s*='
1229
+ for bind_match in re.finditer(bind_pattern, m_code):
1230
+ destinations.append({
1231
+ 'table': bind_match.group(1),
1232
+ 'destination_type': 'Lakehouse',
1233
+ 'workspace_id': ws_match.group(1) if ws_match else '',
1234
+ 'item_id': lh_match.group(1) if lh_match else '',
1235
+ 'sql_schema': None,
1236
+ 'mapping_type': 'Automatic',
1237
+ 'columns': []
1238
+ })
1239
+
1240
+ return {'message': 'Success', 'content': destinations}
1241
+
1242
+
1243
+ def change_data_destination(self, workspace_id: str, dataflow_id: str, destination_type: str,
1244
+ destination_workspace_id: str, destination_item_id: str,
1245
+ mode: str = 'preview', compute_engine_settings: Dict = None) -> Dict:
1246
+ """
1247
+ Changes the data destination of a dataflow, keeping everything else as-is.
1248
+
1249
+ Fetches the dataflow definition automatically (trying CI/CD format first, then standard),
1250
+ then rewrites all _DataDestination queries to point to a new destination type
1251
+ (e.g., switch from Lakehouse to Warehouse or vice versa).
1252
+ Only queries with an existing data destination (_DataDestination suffix) are affected.
1253
+
1254
+ Args:
1255
+ workspace_id: The workspace ID where the dataflow resides.
1256
+ dataflow_id: The dataflow ID.
1257
+ destination_type: Target destination type - 'Lakehouse' or 'Warehouse'.
1258
+ destination_workspace_id: Workspace ID where the target Lakehouse/Warehouse resides.
1259
+ destination_item_id: The ID of the target Lakehouse or Warehouse.
1260
+ mode: Controls save behavior. One of:
1261
+ - 'preview' (default): Returns the modified definition without saving.
1262
+ - 'replace': Saves changes back to Fabric, replacing the existing dataflow.
1263
+ CI/CD Gen2: Updates in-place. Standard Gen2: Deletes original and
1264
+ creates a new CI/CD dataflow with the same name.
1265
+ - 'create': Creates a new CI/CD dataflow with '_cicd' suffix, keeping the
1266
+ original dataflow untouched.
1267
+ compute_engine_settings: Compute engine settings for the CI/CD dataflow.
1268
+ Only used when mode is 'replace' or 'create' and converting from standard to CI/CD.
1269
+
1270
+ Returns:
1271
+ Dict: When mode='preview', a deep copy of the definition with updated data destinations.
1272
+ When mode='replace' or 'create', the API response from updating/creating the dataflow.
1273
+ Returns error dict if the definition cannot be fetched or format is unrecognized.
1274
+ """
1275
+ if workspace_id == '':
1276
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
1277
+ if dataflow_id == '':
1278
+ return {'message': 'Missing dataflow id, please check.', 'content': ''}
1279
+ if destination_type.lower() not in ('lakehouse', 'warehouse'):
1280
+ return {'message': 'destination_type must be "Lakehouse" or "Warehouse".', 'content': ''}
1281
+ if mode not in ('preview', 'replace', 'create'):
1282
+ return {'message': "mode must be 'preview', 'replace', or 'create'.", 'content': ''}
1283
+
1284
+ # Try CI/CD format first (Fabric API)
1285
+ print(f"Checking if dataflow {dataflow_id} is Gen2 CI/CD...")
1286
+ cicd_result = self.get_dataflow_gen2_definition(workspace_id, dataflow_id)
1287
+ is_cicd = cicd_result.get('message') == 'Success'
1288
+
1289
+ if is_cicd:
1290
+ definition = cicd_result['content']
1291
+ else:
1292
+ # Fall back to standard format (PBI API)
1293
+ print("Dataflow is standard Gen2. Fetching definition via PBI API...")
1294
+ pbi_result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
1295
+ if pbi_result.get('message') == 'Success':
1296
+ definition = pbi_result['content']
1297
+ else:
1298
+ return {'message': f'Failed to fetch dataflow definition. CI/CD: {cicd_result.get("message")}. PBI: {pbi_result.get("message")}', 'content': ''}
1299
+
1300
+ # Change data destination
1301
+ print(f"Changing data destination to {destination_type}...")
1302
+ modified = self._change_data_destination(definition, destination_type, destination_workspace_id, destination_item_id)
1303
+
1304
+ if mode == 'preview':
1305
+ return modified
1306
+
1307
+ # Extract display name
1308
+ if is_cicd:
1309
+ display_name = 'dataflow'
1310
+ for part in modified.get('definition', {}).get('parts', []):
1311
+ if part['path'] == '.platform':
1312
+ platform = json.loads(base64.b64decode(part['payload']).decode('utf-8'))
1313
+ display_name = platform.get('metadata', {}).get('displayName', 'dataflow')
1314
+ break
1315
+ else:
1316
+ display_name = definition.get('name', 'dataflow')
1317
+
1318
+ if mode == 'replace':
1319
+ if is_cicd:
1320
+ print(f"Updating Dataflow Gen2 CI/CD '{display_name}' (ID: {dataflow_id}) in-place...")
1321
+ return self.update_dataflow_gen2_from_definition(
1322
+ workspace_id, dataflow_id, display_name, modified
1323
+ )
1324
+ else:
1325
+ # Standard Gen2 — convert to CI/CD, delete original, create new with same name
1326
+ print("Converting to CI/CD format...")
1327
+ cicd_definition = self._convert_gen2_to_cicd_definition(modified, display_name, compute_engine_settings)
1328
+
1329
+ if cicd_definition is None:
1330
+ return {'message': {'error': 'Could not extract mashup document from dataflow.', 'content': ''}}
1331
+
1332
+ print(f"Deleting original standard dataflow '{display_name}' (ID: {dataflow_id})...")
1333
+ delete_result = self.delete_dataflow(workspace_id, dataflow_id, type='pbi')
1334
+ if delete_result.get('message') != 'Success':
1335
+ print(f"Warning: Could not delete original dataflow: {delete_result}")
1336
+ return {'message': {'error': f'Failed to delete original dataflow before recreating: {delete_result}', 'content': ''}}
1337
+
1338
+ print(f"Creating Dataflow Gen2 CI/CD '{display_name}' in workspace {workspace_id}...")
1339
+ return self.create_dataflow_gen2_from_definition(workspace_id, display_name, cicd_definition)
1340
+
1341
+ elif mode == 'create':
1342
+ # Create a new dataflow with _cicd suffix, keep original untouched
1343
+ new_name = display_name + '_cicd'
1344
+
1345
+ if is_cicd:
1346
+ # Update display name in .platform for the new copy
1347
+ for part in modified['definition']['parts']:
1348
+ if part['path'] == '.platform':
1349
+ platform = json.loads(base64.b64decode(part['payload']).decode('utf-8'))
1350
+ platform['metadata']['displayName'] = new_name
1351
+ part['payload'] = base64.b64encode(
1352
+ json.dumps(platform, indent=2).encode('utf-8')
1353
+ ).decode('utf-8')
1354
+ break
1355
+
1356
+ print(f"Creating new Dataflow Gen2 CI/CD '{new_name}' in workspace {workspace_id}...")
1357
+ return self.create_dataflow_gen2_from_definition(workspace_id, new_name, modified)
1358
+ else:
1359
+ print("Converting to CI/CD format...")
1360
+ cicd_definition = self._convert_gen2_to_cicd_definition(modified, new_name, compute_engine_settings)
1361
+
1362
+ if cicd_definition is None:
1363
+ return {'message': {'error': 'Could not extract mashup document from dataflow.', 'content': ''}}
1364
+
1365
+ print(f"Creating new Dataflow Gen2 CI/CD '{new_name}' in workspace {workspace_id}...")
1366
+ return self.create_dataflow_gen2_from_definition(workspace_id, new_name, cicd_definition)
1367
+
1368
+
1369
+ def create_dataflow_with_new_destination(
1370
+ self,
1371
+ workspace_id: str,
1372
+ dataflow_id: str,
1373
+ destination_type: str,
1374
+ destination_workspace_id: str,
1375
+ destination_item_id: str,
1376
+ display_name: str = '',
1377
+ target_workspace_id: str = '',
1378
+ compute_engine_settings: Dict = None) -> Dict:
1379
+ """
1380
+ Creates a new Dataflow Gen2 CI/CD from an existing Gen2 dataflow (standard or CI/CD),
1381
+ changing the data destination to a new Lakehouse or Warehouse.
1382
+
1383
+ Auto-detects whether the source is standard (PBI API) or CI/CD (Fabric API):
1384
+ - Standard Gen2: Fetches via PBI API, changes destination, converts to CI/CD, creates.
1385
+ - CI/CD Gen2: Fetches via Fabric API, changes destination, creates.
1386
+
1387
+ Only queries with an existing data destination are affected.
1388
+
1389
+ Args:
1390
+ workspace_id (str): Workspace ID where the source dataflow resides.
1391
+ dataflow_id (str): ID of the source dataflow.
1392
+ destination_type (str): Target destination type - 'Lakehouse' or 'Warehouse'.
1393
+ destination_workspace_id (str): Workspace ID where the target Lakehouse/Warehouse resides.
1394
+ destination_item_id (str): The ID of the target Lakehouse or Warehouse.
1395
+ display_name (str, optional): Display name for the new dataflow.
1396
+ If not provided, uses the original name with '_cicd' suffix.
1397
+ target_workspace_id (str, optional): Workspace where the new dataflow will be created.
1398
+ If not provided, creates in the same workspace as the source.
1399
+ compute_engine_settings (Dict, optional): Compute engine settings for the new CI/CD dataflow.
1400
+ Only used when converting from standard to CI/CD format.
1401
+
1402
+ Returns:
1403
+ Dict: A dictionary containing the status ('Success' or error) and the new dataflow details.
1404
+ """
1405
+ if workspace_id == '':
1406
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
1407
+ if dataflow_id == '':
1408
+ return {'message': 'Missing dataflow id, please check.', 'content': ''}
1409
+ if destination_type.lower() not in ('lakehouse', 'warehouse'):
1410
+ return {'message': 'destination_type must be "Lakehouse" or "Warehouse".', 'content': ''}
1411
+
1412
+ create_workspace_id = target_workspace_id if target_workspace_id != '' else workspace_id
1413
+
1414
+ # Try Fabric API first (CI/CD format)
1415
+ print(f"Checking if dataflow {dataflow_id} is Gen2 CI/CD...")
1416
+ cicd_result = self.get_dataflow_gen2_definition(workspace_id, dataflow_id)
1417
+
1418
+ if cicd_result.get('message') == 'Success':
1419
+ # CI/CD dataflow
1420
+ cicd_content = cicd_result['content']
1421
+
1422
+ if display_name == '':
1423
+ # Extract name from .platform
1424
+ for part in cicd_content.get('definition', {}).get('parts', []):
1425
+ if part['path'] == '.platform':
1426
+ platform = json.loads(base64.b64decode(part['payload']).decode('utf-8'))
1427
+ display_name = platform.get('metadata', {}).get('displayName', 'dataflow') + '_cicd'
1428
+ break
1429
+
1430
+ # Change data destination
1431
+ print(f"Changing data destination to {destination_type}...")
1432
+ modified = self._change_data_destination(
1433
+ cicd_content, destination_type, destination_workspace_id, destination_item_id
1434
+ )
1435
+
1436
+ # Update display name in .platform
1437
+ for part in modified['definition']['parts']:
1438
+ if part['path'] == '.platform':
1439
+ platform = json.loads(base64.b64decode(part['payload']).decode('utf-8'))
1440
+ platform['metadata']['displayName'] = display_name
1441
+ part['payload'] = base64.b64encode(
1442
+ json.dumps(platform, indent=2).encode('utf-8')
1443
+ ).decode('utf-8')
1444
+ break
1445
+
1446
+ print(f"Creating Dataflow Gen2 CI/CD '{display_name}' in workspace {create_workspace_id}...")
1447
+ return self.create_dataflow_gen2_from_definition(create_workspace_id, display_name, modified)
1448
+
1449
+ # Standard Gen2 - fetch from PBI API
1450
+ print("Dataflow is standard Gen2. Fetching definition via PBI API...")
1451
+ pbi_result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
1452
+
1453
+ if pbi_result.get('message') != 'Success':
1454
+ return pbi_result
1455
+
1456
+ pbi_content = pbi_result['content']
1457
+
1458
+ if display_name == '':
1459
+ display_name = pbi_content.get('name', 'dataflow') + '_cicd'
1460
+
1461
+ # Change data destination on the standard definition
1462
+ print(f"Changing data destination to {destination_type}...")
1463
+ modified = self._change_data_destination(
1464
+ pbi_content, destination_type, destination_workspace_id, destination_item_id
1465
+ )
1466
+
1467
+
1468
+ # Convert to CI/CD format
1469
+ print("Converting to CI/CD format...")
1470
+ definition = self._convert_gen2_to_cicd_definition(modified, display_name, compute_engine_settings)
1471
+
1472
+ if definition is None:
1473
+ return {'message': {'error': 'Could not extract mashup document from dataflow.', 'content': ''}}
1474
+
1475
+ print(f"Creating Dataflow Gen2 CI/CD '{display_name}' in workspace {create_workspace_id}...")
1476
+ return self.create_dataflow_gen2_from_definition(create_workspace_id, display_name, definition)
1477
+
1478
+
1479
+ def _transform_mashup_to_cicd(self, document: str, gen2_content: Dict) -> str:
1480
+ """
1481
+ Transforms a Gen2 standard M document into the CI/CD mashup.pq format.
1482
+
1483
+ Key transformations:
1484
+ - Adds [StagingDefinition] header if fastCopy is enabled.
1485
+ - Adds [DataDestinations] annotations before queries that write to destinations.
1486
+ - Removes internal pipeline queries (DefaultStaging, FastCopyStaging, *_WriteToDataDestination, *_TransformForWriteToDataDestination).
1487
+ - Removes [Staging = "..."] annotations.
1488
+ - Simplifies DataDestination queries by removing NavigationTable.CreateTableOnDemand wrapper.
1489
+ """
1490
+ result = document
1491
+
1492
+ # 1. Add StagingDefinition if fastCopy is enabled
1493
+ if gen2_content.get('ppdf:fastCopy', False):
1494
+ result = '[StagingDefinition = [Kind = "FastCopy"]]\n' + result
1495
+
1496
+ # 2. Identify queries with data destinations (those that have _WriteToDataDestination counterparts)
1497
+ dest_queries = re.findall(r'shared\s+(\w+)_WriteToDataDestination\s*=', result)
1498
+
1499
+ # 3. Remove [Staging = "..."] annotations
1500
+ result = re.sub(r'\[Staging\s*=\s*"[^"]*"\]\r?\n', '', result)
1501
+
1502
+ # 4. Remove internal pipeline queries
1503
+ internal_queries = ['DefaultStaging', 'FastCopyStaging']
1504
+ for qname in dest_queries:
1505
+ internal_queries.append(f'{qname}_WriteToDataDestination')
1506
+ internal_queries.append(f'{qname}_TransformForWriteToDataDestination')
1507
+
1508
+ for query_name in internal_queries:
1509
+ pattern = rf'shared\s+{re.escape(query_name)}\s*=\s*let[\s\S]*?;\r?\n'
1510
+ result = re.sub(pattern, '', result)
1511
+
1512
+ # 5. Add [DataDestinations] annotation before queries that have destinations
1513
+ for query_name in dest_queries:
1514
+ dd_annotation = (
1515
+ f'[DataDestinations = {{[Definition = [Kind = "Reference", '
1516
+ f'QueryName = "{query_name}_DataDestination", IsNewTarget = true], '
1517
+ f'Settings = [Kind = "Automatic", TypeSettings = [Kind = "Table"]]]}}]\n'
1518
+ )
1519
+ result = result.replace(f'shared {query_name} =', f'{dd_annotation}shared {query_name} =')
1520
+
1521
+ # 6. Simplify DataDestination queries - remove NavigationTable.CreateTableOnDemand wrapper
1522
+ result = re.sub(
1523
+ r',\r?\n\s*Table\s*=\s*NavigationTable\.CreateTableOnDemand\([^\n]*\)\r?\nin\r?\n\s*Table;',
1524
+ '\r\nin\r\n TableNavigation;\r\n',
1525
+ result
1526
+ )
1527
+
1528
+ return result
1529
+
1530
+
1531
+ def _build_query_metadata(self, gen2_content: Dict, compute_engine_settings: Dict = None) -> Dict:
1532
+ """
1533
+ Builds the queryMetadata.json content for CI/CD from Gen2 standard PBI API response.
1534
+
1535
+ Args:
1536
+ gen2_content (Dict): Full response from PBI API.
1537
+ compute_engine_settings (Dict, optional): Override compute engine settings.
1538
+ If not provided, derives allowFastCopy from ppdf:fastCopy.
1539
+ """
1540
+ mashup = gen2_content.get('pbi:mashup', {})
1541
+ queries_metadata = mashup.get('queriesMetadata', {})
1542
+ annotations = gen2_content.get('annotations', [])
1543
+
1544
+ # Internal queries to exclude
1545
+ internal_suffixes = ('_WriteToDataDestination', '_TransformForWriteToDataDestination')
1546
+ internal_names = ('DefaultStaging', 'FastCopyStaging')
1547
+
1548
+ # Filter and transform queriesMetadata
1549
+ cicd_queries = {}
1550
+ for name, meta in queries_metadata.items():
1551
+ if name in internal_names or any(name.endswith(s) for s in internal_suffixes):
1552
+ continue
1553
+ entry = {
1554
+ 'queryId': meta.get('queryId', ''),
1555
+ 'queryName': meta.get('queryName', name),
1556
+ 'loadEnabled': False
1557
+ }
1558
+ if meta.get('queryGroupId'):
1559
+ entry['queryGroupId'] = meta['queryGroupId']
1560
+ if name.endswith('_DataDestination'):
1561
+ entry['isHidden'] = True
1562
+ cicd_queries[name] = entry
1563
+
1564
+ # Extract query groups from annotations
1565
+ query_groups = []
1566
+ for ann in annotations:
1567
+ if ann.get('name') == 'pbi:QueryGroups':
1568
+ raw_groups = json.loads(ann['value'])
1569
+ for g in raw_groups:
1570
+ group = {
1571
+ 'id': g['Id'],
1572
+ 'name': g['Name'],
1573
+ 'description': g.get('Description', '')
1574
+ }
1575
+ if g.get('Order') is not None:
1576
+ group['order'] = g['Order']
1577
+ query_groups.append(group)
1578
+ break
1579
+
1580
+ # Build connections from connectionOverrides
1581
+ connections = []
1582
+ for conn in mashup.get('connectionOverrides', []):
1583
+ connections.append({
1584
+ 'path': conn['path'],
1585
+ 'kind': conn['kind']
1586
+ })
1587
+
1588
+ # Build computeEngineSettings
1589
+ # Note: "Allow combining data from multiple sources" (pbi:mashup.fastCombine) is not
1590
+ # part of the CI/CD definition format. It must be configured via the Fabric portal UI.
1591
+ if compute_engine_settings is not None:
1592
+ engine_settings = compute_engine_settings
1593
+ else:
1594
+ engine_settings = {}
1595
+ fast_copy = gen2_content.get('ppdf:fastCopy', False)
1596
+ if not fast_copy:
1597
+ engine_settings['allowFastCopy'] = False
1598
+
1599
+ return {
1600
+ 'formatVersion': '202502',
1601
+ 'computeEngineSettings': engine_settings,
1602
+ 'name': gen2_content.get('name', ''),
1603
+ 'queryGroups': query_groups,
1604
+ 'documentLocale': gen2_content.get('culture', 'en-US'),
1605
+ 'queriesMetadata': cicd_queries,
1606
+ 'connections': connections
1607
+ }
1608
+
1609
+
1610
+ def _convert_gen2_to_cicd_definition(self, gen2_content: Dict, display_name: str, compute_engine_settings: Dict = None) -> Dict:
1611
+ """
1612
+ Converts a Gen2 standard dataflow definition (from PBI API) to Gen2 CI/CD definition format (Fabric API).
1613
+
1614
+ Builds three definition parts:
1615
+ - mashup.pq: Transformed Power Query M script.
1616
+ - queryMetadata.json: Query metadata, groups, connections.
1617
+ - .platform: Platform metadata with display name.
1618
+
1619
+ Args:
1620
+ gen2_content (Dict): Full response from PBI API GET /groups/{ws}/dataflows/{df}.
1621
+ display_name (str): Display name for the new CI/CD dataflow.
1622
+ compute_engine_settings (Dict, optional): Override compute engine settings
1623
+ (e.g. allowFastCopy, allowPartitionedCompute, allowModernEvaluationEngine).
1624
+ If not provided, derives from source properties.
1625
+
1626
+ Returns:
1627
+ Dict: CI/CD definition payload ready for create_dataflow_gen2_from_definition, or None if conversion fails.
1628
+ """
1629
+ mashup = gen2_content.get('pbi:mashup', {})
1630
+ document = mashup.get('document', '')
1631
+
1632
+ if not document:
1633
+ return None
1634
+
1635
+ # Build mashup.pq
1636
+ mashup_pq = self._transform_mashup_to_cicd(document, gen2_content)
1637
+
1638
+ # Build queryMetadata.json
1639
+ query_metadata = self._build_query_metadata(gen2_content, compute_engine_settings)
1640
+
1641
+ # Build .platform
1642
+ platform = {
1643
+ "$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
1644
+ "metadata": {
1645
+ "type": "Dataflow",
1646
+ "displayName": display_name
1647
+ },
1648
+ "config": {
1649
+ "version": "2.0",
1650
+ "logicalId": "00000000-0000-0000-0000-000000000000"
1651
+ }
1652
+ }
1653
+
1654
+ # Encode all parts as base64
1655
+ mashup_pq_b64 = base64.b64encode(mashup_pq.encode('utf-8')).decode('utf-8')
1656
+ query_metadata_b64 = base64.b64encode(json.dumps(query_metadata, indent=2).encode('utf-8')).decode('utf-8')
1657
+ platform_b64 = base64.b64encode(json.dumps(platform, indent=2).encode('utf-8')).decode('utf-8')
1658
+
1659
+ return {
1660
+ "definition": {
1661
+ "parts": [
1662
+ {"path": "queryMetadata.json", "payload": query_metadata_b64, "payloadType": "InlineBase64"},
1663
+ {"path": "mashup.pq", "payload": mashup_pq_b64, "payloadType": "InlineBase64"},
1664
+ {"path": ".platform", "payload": platform_b64, "payloadType": "InlineBase64"}
1665
+ ]
1666
+ }
1667
+ }
1668
+
1669
+
1670
+ def upgrade_to_gen2_cicd(
1671
+ self,
1672
+ workspace_id: str,
1673
+ dataflow_id: str,
1674
+ display_name: str = '',
1675
+ description: str = '',
1676
+ destination_workspace_id: str = '',
1677
+ include_schedule: bool = False,
1678
+ compute_engine_settings: Dict = None,
1679
+ source_type: str = 'gen1') -> Dict:
1680
+ """
1681
+ Upgrades a Dataflow Gen1 or Gen2 (standard) to Dataflow Gen2 CI/CD (native Fabric).
1682
+
1683
+ For Gen1: Uses the Power BI saveAsNativeArtifact API (preview) to convert directly.
1684
+ Handles connection format updates, sensitivity labels, and optionally migrates refresh schedules.
1685
+
1686
+ For Gen2 (standard): Fetches the definition via PBI API and converts it to the CI/CD format
1687
+ (mashup.pq, queryMetadata.json, .platform), then creates a new Dataflow Gen2 CI/CD via Fabric API.
1688
+ If the dataflow is already CI/CD, it re-creates it with the given display name.
1689
+
1690
+ Note: This method creates a NEW Dataflow Gen2 CI/CD item. The original dataflow is NOT
1691
+ deleted automatically. You can use delete_dataflow() to remove the original after verifying
1692
+ the new dataflow works correctly.
1693
+
1694
+ Args:
1695
+ workspace_id (str): The ID of the workspace where the source dataflow resides.
1696
+ dataflow_id (str): The ID of the source dataflow to upgrade.
1697
+ display_name (str, optional): The display name for the new Dataflow Gen2 CI/CD.
1698
+ If not provided, for Gen1 the API auto-generates a name (e.g. original_name_copy1).
1699
+ For Gen2, uses the original dataflow name with '_cicd' suffix.
1700
+ description (str, optional): Description for the new artifact. If not provided,
1701
+ copies the description from the source dataflow (Gen1 only).
1702
+ destination_workspace_id (str, optional): The ID of the workspace where the new dataflow
1703
+ will be created. If not provided, creates in the same workspace as the source.
1704
+ include_schedule (bool, optional): Whether to migrate the refresh schedule from the source
1705
+ dataflow (Gen1 only). The schedule is copied in disabled state. Defaults to False.
1706
+ compute_engine_settings (Dict, optional): Compute engine settings for the new CI/CD dataflow
1707
+ (Gen2 only). Supported keys: allowFastCopy (bool), allowPartitionedCompute (bool),
1708
+ allowModernEvaluationEngine (bool). If not provided, derives allowFastCopy from the
1709
+ source dataflow's ppdf:fastCopy setting.
1710
+ source_type (str): Type of source dataflow - 'gen1' or 'gen2'. Defaults to 'gen1'.
1711
+
1712
+ Returns:
1713
+ Dict: A dictionary containing the status ('Success' or error) and the details of the newly created Dataflow Gen2 CI/CD.
1714
+ """
1715
+ if workspace_id == '':
1716
+ return {'message': 'Missing workspace id, please check.', 'content': ''}
1717
+
1718
+ if dataflow_id == '':
1719
+ return {'message': 'Missing dataflow id, please check.', 'content': ''}
1720
+
1721
+ if source_type not in ('gen1', 'gen2'):
1722
+ return {'message': 'source_type must be "gen1" or "gen2".', 'content': ''}
1723
+
1724
+ # If no destination workspace provided, use the source workspace
1725
+ target_workspace_id = destination_workspace_id if destination_workspace_id != '' else workspace_id
1726
+
1727
+ if source_type == 'gen1':
1728
+ # Use the dedicated saveAsNativeArtifact API for Gen1 → Gen2 CI/CD conversion
1729
+ request_url = f'{self.main_url}/groups/{workspace_id}/dataflows/{dataflow_id}/saveAsNativeArtifact'
1730
+
1731
+ body = {
1732
+ 'includeSchedule': include_schedule
1733
+ }
1734
+
1735
+ if display_name != '':
1736
+ body['displayName'] = display_name
1737
+
1738
+ if description != '':
1739
+ body['description'] = description
1740
+
1741
+ if target_workspace_id != workspace_id:
1742
+ body['targetWorkspaceId'] = target_workspace_id
1743
+
1744
+ print(f"Converting Gen1 dataflow {dataflow_id} to Gen2 CI/CD via saveAsNativeArtifact...")
1745
+ r = requests.post(url=request_url, headers=self.headers, json=body)
1746
+
1747
+ if r.status_code == 200:
1748
+ response = json.loads(r.content)
1749
+ artifact = response.get('artifactMetadata', {})
1750
+ errors = response.get('errors', [])
1751
+
1752
+ if errors:
1753
+ print(f"Migration completed with warnings: {errors}")
1754
+
1755
+ print(f"Successfully created Gen2 CI/CD. New artifact ID: {artifact.get('objectId', 'N/A')}")
1756
+ return {'message': 'Success', 'content': response, 'warnings': errors}
1757
+ else:
1758
+ try:
1759
+ response = json.loads(r.content)
1760
+ error_message = response.get('error', {}).get('message', r.text)
1761
+ except Exception:
1762
+ error_message = r.text
1763
+ print(f"Error converting Gen1 dataflow: {r.status_code} - {error_message}")
1764
+ return {'message': {'error': error_message, 'status_code': r.status_code}}
1765
+
1766
+ elif source_type == 'gen2':
1767
+ # For Gen2: first check if already CI/CD via Fabric API
1768
+ print(f"Checking if dataflow {dataflow_id} is already Gen2 CI/CD...")
1769
+ gen2_definition = self.get_dataflow_gen2_definition(workspace_id, dataflow_id)
1770
+
1771
+ if gen2_definition.get('message') == 'Success':
1772
+ # Already a CI/CD dataflow - re-create with the definition
1773
+ if display_name == '':
1774
+ display_name = gen2_definition['content'].get('displayName', 'dataflow') + '_cicd'
1775
+
1776
+ print(f"Dataflow is already Gen2 CI/CD. Creating copy as '{display_name}' in workspace {target_workspace_id}...")
1777
+ return self.create_dataflow_gen2_from_definition(target_workspace_id, display_name, gen2_definition['content'])
1778
+
1779
+ # Standard Gen2 - fetch from PBI API and convert
1780
+ print("Dataflow is standard Gen2. Fetching definition via PBI API for conversion...")
1781
+ pbi_result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
1782
+
1783
+ if pbi_result.get('message') != 'Success':
1784
+ return pbi_result
1785
+
1786
+ pbi_content = pbi_result['content']
1787
+
1788
+ # If display_name not provided, use original name with _cicd suffix
1789
+ if display_name == '':
1790
+ display_name = pbi_content.get('name', 'dataflow') + '_cicd'
1791
+
1792
+ # Convert PBI API definition to CI/CD format
1793
+ definition = self._convert_gen2_to_cicd_definition(pbi_content, display_name, compute_engine_settings)
1794
+
1795
+ if definition is None:
1796
+ return {'message': {'error': 'Could not extract mashup document from dataflow. The dataflow may not contain any queries.', 'content': ''}}
1797
+
1798
+ # Create the new Gen2 CI/CD dataflow
1799
+ print(f"Creating Dataflow Gen2 CI/CD '{display_name}' in workspace {target_workspace_id}...")
1800
+ return self.create_dataflow_gen2_from_definition(target_workspace_id, display_name, definition)
1801
+