msdev-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msdev_kit/__init__.py +3 -0
- msdev_kit/auth.py +35 -0
- msdev_kit/fabric/__init__.py +11 -0
- msdev_kit/fabric/admin.py +42 -0
- msdev_kit/fabric/capacity.py +186 -0
- msdev_kit/fabric/database.py +94 -0
- msdev_kit/fabric/dataflow.py +1801 -0
- msdev_kit/fabric/dataset.py +617 -0
- msdev_kit/fabric/kql.py +66 -0
- msdev_kit/fabric/notebook.py +88 -0
- msdev_kit/fabric/operations.py +108 -0
- msdev_kit/fabric/pipeline.py +505 -0
- msdev_kit/fabric/report.py +1012 -0
- msdev_kit/fabric/utilities.py +12 -0
- msdev_kit/fabric/workspace.py +516 -0
- msdev_kit/graph/__init__.py +1 -0
- msdev_kit/graph/client.py +91 -0
- msdev_kit/sharepoint/__init__.py +1 -0
- msdev_kit/sharepoint/client.py +105 -0
- msdev_kit-0.1.0.dist-info/METADATA +269 -0
- msdev_kit-0.1.0.dist-info/RECORD +22 -0
- msdev_kit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,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
|
+
|