Skip to content

Commit e53b160

Browse files
author
Christoffer Bäckström
committed
added shortcut smart diff feature
1 parent 60d0e67 commit e53b160

5 files changed

Lines changed: 257 additions & 8 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
kind: added
2+
body: Add feature flag for shortcut smart diff publishing
3+
time: 2026-04-01T13:51:49.2071983+02:00
4+
custom:
5+
Author: bckstrm
6+
AuthorLink: https://github.com/bckstrm
7+
Issue: "904"
8+
IssueLink: https://github.com/microsoft/fabric-cicd/issues/904

src/fabric_cicd/_items/_lakehouse.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
"""Functions to process and deploy Lakehouse item."""
55

6+
import copy
67
import json
78
import logging
89

@@ -57,28 +58,39 @@ def check_sqlendpoint_provision_status(fabric_workspace_obj: FabricWorkspace, it
5758
iteration += 1
5859

5960

60-
def list_deployed_shortcuts(fabric_workspace_obj: FabricWorkspace, item_obj: Item) -> list:
61+
def _get_shortcut_path(shortcut: dict) -> str:
6162
"""
62-
Lists all deployed shortcut paths
63+
Build a unique shortcut path from shortcut metadata.
64+
65+
Args:
66+
shortcut: The shortcut definition dictionary
67+
"""
68+
return f"{shortcut['path']}/{shortcut['name']}"
69+
70+
71+
def list_deployed_shortcuts(fabric_workspace_obj: FabricWorkspace, item_obj: Item) -> dict[str, dict]:
72+
"""
73+
Lists all deployed shortcut by path
6374
6475
Args:
6576
fabric_workspace_obj: The FabricWorkspace object containing the items to be published
6677
item_obj: The item object to list the shortcuts for
6778
"""
6879
request_url = f"{fabric_workspace_obj.base_api_url}/items/{item_obj.guid}/shortcuts"
69-
deployed_shortcut_paths = []
80+
deployed_shortcuts_by_path = {}
7081

7182
while request_url:
7283
# https://learn.microsoft.com/en-us/rest/api/fabric/core/onelake-shortcuts/list-shortcuts
7384
response = fabric_workspace_obj.endpoint.invoke(method="GET", url=request_url)
7485

7586
# Handle cases where the response body is empty
7687
shortcuts = response["body"].get("value", [])
77-
deployed_shortcut_paths.extend(f"{shortcut['path']}/{shortcut['name']}" for shortcut in shortcuts)
88+
for shortcut in shortcuts:
89+
deployed_shortcuts_by_path[_get_shortcut_path(shortcut)] = shortcut
7890

7991
request_url = response["header"].get("continuationUri", None)
8092

81-
return deployed_shortcut_paths
93+
return deployed_shortcuts_by_path
8294

8395

8496
def replace_default_lakehouse_id(shortcut: dict, item_obj: Item) -> dict:
@@ -165,6 +177,43 @@ def _unpublish_shortcuts(self, shortcut_paths: list) -> None:
165177
url=f"{self.fabric_workspace_obj.base_api_url}/items/{self.item_obj.guid}/shortcuts/{deployed_shortcut_path}",
166178
)
167179

180+
@staticmethod
181+
def _is_shortcut_subset_match(repository_shortcut: object, deployed_shortcut: object) -> bool:
182+
"""
183+
Recursively compare shortcut fields while ignoring extra deployed fields.
184+
185+
This allows server-side schema extensions without forcing unnecessary republish.
186+
"""
187+
if isinstance(repository_shortcut, dict):
188+
if not isinstance(deployed_shortcut, dict):
189+
return False
190+
for key, repository_value in repository_shortcut.items():
191+
if key not in deployed_shortcut:
192+
return False
193+
if not ShortcutPublisher._is_shortcut_subset_match(repository_value, deployed_shortcut[key]):
194+
return False
195+
return True
196+
197+
if isinstance(repository_shortcut, list):
198+
if not isinstance(deployed_shortcut, list) or len(repository_shortcut) != len(deployed_shortcut):
199+
return False
200+
return all(
201+
ShortcutPublisher._is_shortcut_subset_match(repository_item, deployed_item)
202+
for repository_item, deployed_item in zip(repository_shortcut, deployed_shortcut)
203+
)
204+
205+
return repository_shortcut == deployed_shortcut
206+
207+
def _is_shortcut_changed(self, shortcut: dict, deployed_shortcut: dict) -> bool:
208+
"""
209+
Determine whether a shortcut needs publishing.
210+
211+
Returns:
212+
True when the shortcut differs from deployed state and should be published.
213+
"""
214+
shortcut_for_compare = replace_default_lakehouse_id(copy.deepcopy(shortcut), self.item_obj)
215+
return not self._is_shortcut_subset_match(shortcut_for_compare, deployed_shortcut)
216+
168217
def publish_one(self, _shortcut_name: str, shortcut: dict) -> None:
169218
"""
170219
Publish a single shortcut.
@@ -230,12 +279,29 @@ def publish_all(self) -> None:
230279
)
231280
logger.info(f"{constants.INDENT}Excluded shortcuts: {excluded_shortcuts}")
232281

233-
shortcuts_to_publish = {f"{shortcut['path']}/{shortcut['name']}": shortcut for shortcut in shortcuts}
282+
shortcuts_to_publish = {_get_shortcut_path(shortcut): shortcut for shortcut in shortcuts}
234283

235284
if shortcuts_to_publish:
236285
logger.info(f"Publishing Lakehouse '{self.item_obj.name}' Shortcuts")
237-
shortcut_paths_to_unpublish = [path for path in deployed_shortcuts if path not in shortcuts_to_publish]
286+
shortcut_paths_to_unpublish = [
287+
path for path in list(deployed_shortcuts.keys()) if path not in shortcuts_to_publish
288+
]
238289
self._unpublish_shortcuts(shortcut_paths_to_unpublish)
239-
# Deploy and overwrite shortcuts
290+
if FeatureFlag.ENABLE_SHORTCUT_SMART_DIFF.value in constants.FEATURE_FLAG:
291+
shortcuts_with_change = {}
292+
unchanged_shortcut_count = 0
293+
for shortcut_path, shortcut in shortcuts_to_publish.items():
294+
deployed_shortcut = deployed_shortcuts.get(shortcut_path)
295+
if deployed_shortcut and not self._is_shortcut_changed(shortcut, deployed_shortcut):
296+
unchanged_shortcut_count += 1
297+
continue
298+
shortcuts_with_change[shortcut_path] = shortcut
299+
shortcuts_to_publish = shortcuts_with_change
300+
if unchanged_shortcut_count:
301+
logger.info(
302+
f"{constants.INDENT}Skipped {unchanged_shortcut_count} unchanged shortcut(s) via smart diff"
303+
)
304+
305+
# Deploy and overwrite changed/new shortcuts
240306
for shortcut_path, shortcut in shortcuts_to_publish.items():
241307
self.publish_one(shortcut_path, shortcut)

src/fabric_cicd/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ class FeatureFlag(str, Enum):
117117
"""Set to enable the deletion of KQL Databases (attached to Eventhouses)."""
118118
ENABLE_SHORTCUT_PUBLISH = "enable_shortcut_publish"
119119
"""Set to enable deploying shortcuts with the lakehouse."""
120+
ENABLE_SHORTCUT_SMART_DIFF = "enable_shortcut_smart_diff"
121+
"""Set to enable change comparision before deploying shortcuts"""
120122
DISABLE_WORKSPACE_FOLDER_PUBLISH = "disable_workspace_folder_publish"
121123
"""Set to disable deploying workspace sub folders."""
122124
CONTINUE_ON_SHORTCUT_FAILURE = "continue_on_shortcut_failure"

tests/test_deploy_with_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def test_extract_parameter_file_path_missing(self):
267267
settings = extract_workspace_settings(config, "dev")
268268
assert "parameter_file_path" not in settings
269269

270+
270271
class TestPublishSettingsExtraction:
271272
"""Test publish settings extraction from config."""
272273

@@ -1001,6 +1002,7 @@ def test_deploy_with_config_loads_parameter_when_field_present(self, tmp_path):
10011002
call_kwargs = mock_fabric_ws.call_args[1]
10021003
assert call_kwargs.get("skip_parameterization") is False
10031004

1005+
10041006
class TestConfigIntegration:
10051007
"""Integration tests for config functionality."""
10061008

tests/test_shortcut_exclude.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pytest
1010

11+
from fabric_cicd import constants
1112
from fabric_cicd._common._item import Item
1213
from fabric_cicd._items._lakehouse import ShortcutPublisher
1314
from fabric_cicd.fabric_workspace import FabricWorkspace
@@ -56,6 +57,39 @@ def create_shortcut_file(shortcuts_data):
5657
return file_obj
5758

5859

60+
def set_deployed_shortcuts(mock_fabric_workspace, deployed_shortcuts):
61+
"""Configure endpoint behavior to return specific deployed shortcuts."""
62+
63+
def mock_invoke(method, url, **_kwargs):
64+
if method == "GET" and "shortcuts" in url:
65+
return {"body": {"value": deployed_shortcuts}, "header": {}}
66+
if method == "POST" and "shortcuts" in url:
67+
return {"body": {"id": "mock-shortcut-id"}}
68+
if method == "DELETE" and "shortcuts" in url:
69+
return {"body": {}}
70+
return {"body": {}}
71+
72+
mock_fabric_workspace.endpoint.invoke.side_effect = mock_invoke
73+
74+
75+
def get_shortcut_post_calls(mock_fabric_workspace):
76+
"""Return all shortcut create/overwrite API calls."""
77+
return [
78+
call
79+
for call in mock_fabric_workspace.endpoint.invoke.call_args_list
80+
if call[1].get("method") == "POST" and "shortcuts" in call[1].get("url", "")
81+
]
82+
83+
84+
@pytest.fixture
85+
def restore_feature_flags():
86+
"""Ensure feature flags are restored after test."""
87+
original_flags = constants.FEATURE_FLAG.copy()
88+
yield
89+
constants.FEATURE_FLAG.clear()
90+
constants.FEATURE_FLAG.update(original_flags)
91+
92+
5993
def test_process_shortcuts_with_exclude_regex_filters_shortcuts(mock_fabric_workspace, mock_item):
6094
"""Test that shortcut_exclude_regex correctly filters shortcuts from deployment."""
6195

@@ -304,3 +338,140 @@ def test_process_shortcuts_with_complex_regex_pattern(mock_fabric_workspace, moc
304338
# Verify the published shortcut is the prod one
305339
published_shortcut = post_calls[0][1]["body"]
306340
assert published_shortcut["name"] == "prod_shortcut"
341+
342+
343+
def test_shortcut_smart_diff_skips_unchanged_shortcuts(mock_fabric_workspace, mock_item):
344+
"""Smart diff should skip publish for unchanged shortcuts."""
345+
shortcuts_data = [
346+
{
347+
"name": "shortcut1",
348+
"path": "/Tables",
349+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
350+
},
351+
{
352+
"name": "shortcut2",
353+
"path": "/Files",
354+
"target": {"type": "OneLake", "oneLake": {"path": "Files/s2", "itemId": "item-2"}},
355+
},
356+
]
357+
deployed_shortcuts = [
358+
{
359+
"name": "shortcut1",
360+
"path": "/Tables",
361+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
362+
"serverManaged": {"createdBy": "system"},
363+
},
364+
{
365+
"name": "shortcut2",
366+
"path": "/Files",
367+
"target": {"type": "OneLake", "oneLake": {"path": "Files/s2", "itemId": "item-2"}},
368+
"serverManaged": {"createdBy": "system"},
369+
},
370+
]
371+
372+
mock_item.item_files = [create_shortcut_file(shortcuts_data)]
373+
set_deployed_shortcuts(mock_fabric_workspace, deployed_shortcuts)
374+
constants.FEATURE_FLAG.add("enable_shortcut_smart_diff")
375+
try:
376+
ShortcutPublisher(mock_fabric_workspace, mock_item).publish_all()
377+
post_calls = get_shortcut_post_calls(mock_fabric_workspace)
378+
assert len(post_calls) == 0
379+
finally:
380+
constants.FEATURE_FLAG.discard("enable_shortcut_smart_diff")
381+
382+
383+
def test_shortcut_smart_diff_publishes_only_changed_shortcut(mock_fabric_workspace, mock_item):
384+
"""Smart diff should publish only shortcuts with changed fields."""
385+
shortcuts_data = [
386+
{
387+
"name": "shortcut1",
388+
"path": "/Tables",
389+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
390+
},
391+
{
392+
"name": "shortcut2",
393+
"path": "/Files",
394+
"target": {"type": "OneLake", "oneLake": {"path": "Files/s2-new", "itemId": "item-2"}},
395+
},
396+
]
397+
deployed_shortcuts = [
398+
{
399+
"name": "shortcut1",
400+
"path": "/Tables",
401+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
402+
},
403+
{
404+
"name": "shortcut2",
405+
"path": "/Files",
406+
"target": {"type": "OneLake", "oneLake": {"path": "Files/s2-old", "itemId": "item-2"}},
407+
},
408+
]
409+
410+
mock_item.item_files = [create_shortcut_file(shortcuts_data)]
411+
set_deployed_shortcuts(mock_fabric_workspace, deployed_shortcuts)
412+
constants.FEATURE_FLAG.add("enable_shortcut_smart_diff")
413+
try:
414+
ShortcutPublisher(mock_fabric_workspace, mock_item).publish_all()
415+
416+
post_calls = get_shortcut_post_calls(mock_fabric_workspace)
417+
assert len(post_calls) == 1
418+
assert post_calls[0][1]["body"]["name"] == "shortcut2"
419+
finally:
420+
constants.FEATURE_FLAG.discard("enable_shortcut_smart_diff")
421+
422+
423+
def test_shortcut_smart_diff_publishes_client_side_new_field(mock_fabric_workspace, mock_item):
424+
"""Smart diff should publish when repo contains a new field missing from deployed shortcut."""
425+
shortcuts_data = [
426+
{
427+
"name": "shortcut1",
428+
"path": "/Tables",
429+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
430+
"clientField": {"owner": "team-a"},
431+
}
432+
]
433+
deployed_shortcuts = [
434+
{
435+
"name": "shortcut1",
436+
"path": "/Tables",
437+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
438+
}
439+
]
440+
441+
mock_item.item_files = [create_shortcut_file(shortcuts_data)]
442+
set_deployed_shortcuts(mock_fabric_workspace, deployed_shortcuts)
443+
constants.FEATURE_FLAG.add("enable_shortcut_smart_diff")
444+
try:
445+
ShortcutPublisher(mock_fabric_workspace, mock_item).publish_all()
446+
447+
post_calls = get_shortcut_post_calls(mock_fabric_workspace)
448+
assert len(post_calls) == 1
449+
assert post_calls[0][1]["body"]["name"] == "shortcut1"
450+
finally:
451+
constants.FEATURE_FLAG.discard("enable_shortcut_smart_diff")
452+
453+
454+
def test_shortcut_smart_diff_disabled_keeps_publish_all_behavior(mock_fabric_workspace, mock_item):
455+
"""When smart diff is disabled, unchanged shortcuts are still published."""
456+
shortcuts_data = [
457+
{
458+
"name": "shortcut1",
459+
"path": "/Tables",
460+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
461+
}
462+
]
463+
deployed_shortcuts = [
464+
{
465+
"name": "shortcut1",
466+
"path": "/Tables",
467+
"target": {"type": "OneLake", "oneLake": {"path": "Tables/s1", "itemId": "item-1"}},
468+
}
469+
]
470+
471+
mock_item.item_files = [create_shortcut_file(shortcuts_data)]
472+
set_deployed_shortcuts(mock_fabric_workspace, deployed_shortcuts)
473+
474+
ShortcutPublisher(mock_fabric_workspace, mock_item).publish_all()
475+
476+
post_calls = get_shortcut_post_calls(mock_fabric_workspace)
477+
assert len(post_calls) == 1

0 commit comments

Comments
 (0)