diff --git a/docs/source/conf.py b/docs/source/conf.py index 05dbb6eb..cf578a3e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,7 +13,7 @@ project = 'semantic-link-labs' copyright = '2024, Microsoft and community' author = 'Microsoft and community' -release = '0.10.0' +release = '0.10.1' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 5473ace0..9be8d327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name="semantic-link-labs" authors = [ { name = "Microsoft Corporation" }, ] -version="0.10.0" +version="0.10.1" description="Semantic Link Labs for Microsoft Fabric" readme="README.md" requires-python=">=3.10,<3.12" diff --git a/src/sempy_labs/_helper_functions.py b/src/sempy_labs/_helper_functions.py index 6a88dd30..0f2506a7 100644 --- a/src/sempy_labs/_helper_functions.py +++ b/src/sempy_labs/_helper_functions.py @@ -12,29 +12,29 @@ from uuid import UUID import sempy_labs._icons as icons from azure.core.credentials import TokenCredential, AccessToken -import urllib.parse import numpy as np from IPython.display import display, HTML import requests import sempy_labs._authentication as auth from jsonpath_ng.ext import parse from jsonpath_ng.jsonpath import Fields, Index +from urllib.parse import urlparse, urlunparse, urlencode, quote def _build_url(url: str, params: dict) -> str: """ Build the url with a list of parameters """ - url_parts = list(urllib.parse.urlparse(url)) - url_parts[4] = urllib.parse.urlencode(params) - url = urllib.parse.urlunparse(url_parts) + url_parts = list(urlparse(url)) + url_parts[4] = urlencode(params) + url = urlunparse(url_parts) return url def _encode_user(user: str) -> str: - return urllib.parse.quote(user, safe="@") + return quote(user, safe="@") def create_abfss_path( @@ -94,7 +94,7 @@ def _get_default_file_path() -> str: def _split_abfss_path(path: str) -> Tuple[UUID, UUID, str]: - parsed_url = urllib.parse.urlparse(path) + parsed_url = urlparse(path) workspace_id = parsed_url.netloc.split("@")[0] item_id = parsed_url.path.lstrip("/").split("/")[0] @@ -1092,7 +1092,7 @@ def resolve_workspace_capacity( from sempy_labs._capacities import list_capacities (workspace_name, workspace_id) = resolve_workspace_name_and_id(workspace) - filter_condition = urllib.parse.quote(workspace_id) + filter_condition = quote(workspace_id) dfW = fabric.list_workspaces(filter=f"id eq '{filter_condition}'") capacity_id = dfW["Capacity Id"].iloc[0] dfC = list_capacities() @@ -1126,7 +1126,7 @@ def get_capacity_id(workspace: Optional[str | UUID] = None) -> UUID: capacity_id = _get_fabric_context_setting(name="trident.capacity.id") else: (workspace_name, workspace_id) = resolve_workspace_name_and_id(workspace) - filter_condition = urllib.parse.quote(workspace_id) + filter_condition = quote(workspace_id) dfW = fabric.list_workspaces(filter=f"id eq '{filter_condition}'") if len(dfW) == 0: raise ValueError(f"{icons.red_dot} The '{workspace_name}' does not exist'.") @@ -2435,3 +2435,19 @@ def remove_json_value(path: str, payload: dict, json_path: str, verbose: bool = print(f"{icons.green_dot} Removed index [{index}] from '{path}'.") return payload + + +def fix_github_url(url: str) -> str: + parsed = urlparse(url) + + # Check for valid scheme and correct host + if parsed.scheme in ("http", "https") and parsed.netloc == "github.com": + path_parts = parsed.path.split("/") + if len(path_parts) > 4 and path_parts[3] == "blob": + # Convert to raw.githubusercontent.com + new_netloc = "raw.githubusercontent.com" + new_path = "/".join(path_parts[:3] + path_parts[4:]) # remove 'blob' + new_url = urlunparse(("https", new_netloc, new_path, "", "", "")) + return new_url + + return url # return original if not a valid GitHub blob URL diff --git a/src/sempy_labs/report/_reportwrapper.py b/src/sempy_labs/report/_reportwrapper.py index eed973ec..69344f9c 100644 --- a/src/sempy_labs/report/_reportwrapper.py +++ b/src/sempy_labs/report/_reportwrapper.py @@ -17,6 +17,7 @@ get_jsonpath_value, set_json_value, remove_json_value, + fix_github_url, ) from sempy_labs._dictionary_diffs import ( diff_parts, @@ -35,6 +36,7 @@ from urllib.parse import urlparse import os import fnmatch +from sempy_labs.tom import connect_semantic_model class ReportWrapper: @@ -153,7 +155,7 @@ def __init__( self._current_report_definition = copy.deepcopy(self._report_definition) - # self.report = self.Report(self) + self.report = self.Report(self) helper.populate_custom_visual_display_names() @@ -165,6 +167,733 @@ def _ensure_pbir(self): "See here for details: https://powerbi.microsoft.com/blog/power-bi-enhanced-report-format-pbir-in-power-bi-desktop-developer-mode-preview/" ) + class Report: + def __init__(self, wrapper): + self.ObjectType = "Report" + self.Name = wrapper._report_name + self._wrapper = wrapper + self._pages = None + self._custom_visuals = None + self._filters = None + self._wrapper._ensure_pbir() + + @property + def Pages(self): + if self._pages is None: + self._pages = ReportWrapper.PageCollection(self._wrapper) + return self._pages + + @property + def CustomVisuals(self): + if self._custom_visuals is None: + self._custom_visuals = ReportWrapper.CustomVisualCollection( + self._wrapper + ) + return self._custom_visuals + + @property + def Filters(self): + if self._filters is None: + self._filters = ReportFilterCollection(self._wrapper) + return self._filters + + class Page: + def __init__(self, file_path, page_data, wrapper): + self._wrapper = wrapper + self._visuals = None + self._filters = None + self._data = page_data + self.ObjectType = "Page" + self.FilePath = file_path + self.Name = page_data.get("name") + self.DisplayOption = page_data.get("displayOption") + self.FilterCount = len( + get_jsonpath_value( + data=page_data, path="$.filterConfig.filters", default=[] + ) + ) + self.URL = wrapper._get_url(page_name=self.Name) + + def _get_property( + self, json_path: str, default=None, remove_quotes: bool = False + ): + return get_jsonpath_value( + self._data, json_path, default=default, remove_quotes=remove_quotes + ) + + def _set_property(self, json_path, value, add_quotes: bool = False): + if add_quotes: + value = f"'{value}'" + self._data = set_json_value( + payload=self._data, + json_path=json_path, + json_value=value, + ) + self._wrapper.set_json( + file_path=self.FilePath, + json_path=json_path, + json_value=value, + ) + + def _remove_property(self, json_path): + self._wrapper.remove( + file_path=self.FilePath, json_path=json_path, verbose=False + ) + remove_json_value( + path=self.FilePath, + payload=self._data, + json_path=json_path, + verbose=False, + ) + + @property + def Metadata(self): + return self._data + + @Metadata.setter + def Metadata(self, value): + if isinstance(value, dict): + self._data = value + else: + raise ValueError("Metadata must be a dictionary") + + def set_json(self, json_path: str, json_value: str | dict | List): + ReportWrapper.set_json( + self=self._wrapper, + file_path=self.FilePath, + json_path=json_path, + json_value=json_value, + ) + + @property + def DisplayName(self): + path = "$.displayName" + return self._get_property(path) + + @DisplayName.setter + def DisplayName(self, value: str): + path = "$.displayName" + self._set_property(path, value) + + @property + def Height(self): + path = "$.height" + return self._get_property(path) + + @Height.setter + def Height(self, value: int): + if not isinstance(value, int): + raise TypeError( + f"Height must be an integer, got {type(value).__name__}" + ) + path = "$.height" + self._set_property(path, value) + + @property + def Width(self): + path = "$.width" + return self._get_property(path) + + @Width.setter + def Width(self, value: int): + if not isinstance(value, int): + raise TypeError(f"Width must be an integer, got {type(value).__name__}") + path = "$.width" + self._set_property(path, value) + + @property + def IsActive(self): + return ( + self._wrapper.get( + file_path=ReportWrapper._pages_file_path, + json_path="$.activePageName", + ) + == self.Name + ) + + @IsActive.setter + def IsActive(self, value: bool): + if not isinstance(value, bool): + raise TypeError(f"IsActive must be a bool, got {type(value).__name__}") + if not value: + raise ValueError("IsActive can only be set to True.") + self._wrapper.set_json( + file_path=ReportWrapper._pages_file_path, + json_path="$.activePageName", + json_value=self.Name, + ) + + @property + def IsHidden(self): + return self._get_property(path="$.visibility") == "HiddenInViewMode" + + @IsHidden.setter + def IsHidden(self, value: bool): + if not isinstance(value, bool): + raise TypeError(f"IsHidden must be a bool, got {type(value).__name__}") + if value: + self._set_property( + json_path="$.visibility", json_value="HiddenInViewMode" + ) + else: + self._remove_property("$.visibility") + + @property + def Visuals(self): + if self._visuals is None: + self._visuals = ReportWrapper.VisualCollection( + self._wrapper, self.FilePath + ) + return self._visuals + + @property + def Filters(self): + if self._filters is None: + self._filters = PageFilterCollection(self._wrapper, self.FilePath) + return self._filters + + class PageCollection: + def __init__(self, wrapper): + self._wrapper = wrapper + self._pages = None + + def _load_pages(self): + parts = self._wrapper.get(file_path="definition/pages/*/page.json") + self._pages = [ + ReportWrapper.Page(file_path, data, self._wrapper) + for file_path, data in parts + ] + + def __iter__(self): + if self._pages is None: + self._load_pages() + return iter(self._pages) + + def __getitem__(self, key): + if self._pages is None: + self._load_pages() + + if isinstance(key, int): + return self._pages[key] + + # Support lookup by Name or DisplayName + for page in self._pages: + if ( + getattr(page, "Name", None) == key + or getattr(page, "DisplayName", None) == key + ): + return page + + raise KeyError(f"No page found with Name or DisplayName = '{key}'") + + @property + def Count(self): + if self._pages is None: + self._load_pages() + return len(self._pages) + + class Visual: + def __init__(self, file_path, visual_data, wrapper): + self._wrapper = wrapper + self._data = visual_data + self._filters = None + self._page = None + self._fields = None + self.FilePath = file_path + self._page_file_path = self.FilePath.split("/visuals/")[0] + "/page.json" + self.ObjectType = "Visual" + self.Name = get_jsonpath_value(visual_data, "$.name") + self.Z = get_jsonpath_value(visual_data, "$.position.z") + self.TabOrder = get_jsonpath_value(visual_data, "$.position.tabOrder") + self.Type = get_jsonpath_value(visual_data, "$.visual.visualType", "Group") + self.DisplayType = helper.vis_type_mapping.get(self.Type, self.Type) + report_file = self._wrapper.get(file_path=self._wrapper._report_file_path) + custom_visuals = report_file.get("publicCustomVisuals", []) + self.IsCustomVisual = self.Type in custom_visuals + self.DataLimit = get_jsonpath_value( + data=visual_data, + path='$.filterConfig.filters[?(@.type == "VisualTopN")].filter.Where[*].Condition.VisualTopN.ItemCount', + default=0, + ) + self.FilterCount = len( + get_jsonpath_value( + data=visual_data, path="$.filterConfig.filters", default=[] + ) + ) + self.SlicerType = get_jsonpath_value( + data=visual_data, + path="$.visual.objects.data[*].properties.mode.expr.Literal.Value", + default="N/A", + remove_quotes=True, + ) + self.HowCreated = get_jsonpath_value( + data=visual_data, path="$.visual.howCreated", default="Unknown" + ) + self.HasSparkline = ( + get_jsonpath_value(data=visual_data, path="$..SparklineData") + is not None + ) + data_keys = [ + "Aggregation", + "Column", + "Measure", + "HierarchyLevel", + "NativeVisualCalculation", + ] + + self.IsDataVisual = any( + get_jsonpath_value(data=visual_data, path=f"$..{key}") + for key in data_keys + ) + + def _get_property( + self, + json_path: str, + default=None, + remove_quotes: bool = False, + fix_true: bool = False, + ): + return get_jsonpath_value( + self._data, + json_path, + default=default, + remove_quotes=remove_quotes, + fix_true=fix_true, + ) + + def _set_property(self, json_path, value, add_quotes: bool = False): + if add_quotes: + value = f"'{value}'" + self._data = set_json_value( + payload=self._data, + json_path=json_path, + json_value=value, + ) + self._wrapper.set_json( + file_path=self.FilePath, + json_path=json_path, + json_value=value, + ) + + def _remove_property(self, json_path): + self._wrapper.remove( + file_path=self.FilePath, json_path=json_path, verbose=False + ) + remove_json_value( + path=self.FilePath, + payload=self._data, + json_path=json_path, + verbose=False, + ) + + @property + def Metadata(self): + return self._data + + @Metadata.setter + def Metadata(self, value): + if isinstance(value, dict): + self._data = value + else: + raise ValueError("Metadata must be a dictionary") + + def set_json(self, json_path: str, json_value: str | dict | List): + ReportWrapper.set_json( + self=self._wrapper, + file_path=self.FilePath, + json_path=json_path, + json_value=json_value, + ) + + @property + def Page(self): + return ReportWrapper.Page( + file_path=self._page_file_path, + page_data=self._wrapper.get(file_path=self._page_file_path), + wrapper=self._wrapper, + ) + + # Title + @property + def Title(self): + base_path = "$.visual.visualContainerObjects.title[*].properties" + return TitleObject(self, base_path) + + @property + def SubTitle(self): + base_path = "$.visual.visualContainerObjects.subTitle[*].properties" + return TitleObject(self, base_path) + + @property + def X(self): + path = ReportWrapper._visual_x_path + return self._get_property(path) + + @X.setter + def X(self, value: float | int): + if not isinstance(value, float | int): + raise TypeError(f"X must be an integer, got {type(value).__name__}") + path = ReportWrapper._visual_x_path + self._set_property(path, value) + + @property + def Y(self): + path = ReportWrapper._visual_y_path + return self._get_property(path) + + @Y.setter + def Y(self, value: float | int): + if not isinstance(value, float | int): + raise TypeError(f"Y must be an integer, got {type(value).__name__}") + path = ReportWrapper._visual_y_path + self._set_property(path, value) + + @property + def Height(self): + return self._get_property("$.position.height") + + @Height.setter + def Height(self, value: float | int): + self._set_property("$.position.height", value) + + @property + def Width(self): + return self._get_property("$.position.width") + + @Width.setter + def Width(self, value: float | int): + self._set_property("$.position.width", value) + + @property + def IsHidden(self): + return self._get_property("$.isHidden", default=False) + + @IsHidden.setter + def IsHidden(self, value: bool): + if not isinstance(value, bool): + raise TypeError(f"IsHidden must be a bool, got {type(value).__name__}") + if value: + self._set_property("$.isHidden", True) + else: + self._remove_property("$.isHidden") + + @property + def ShowItemsWithNoData(self): + return self._get_property("$..showAll", default=False) + + @ShowItemsWithNoData.setter + def ShowItemsWithNoData(self, value: bool): + if not isinstance(value, bool): + raise TypeError( + f"ShowItemsWithNoData must be a bool, got {type(value).__name__}" + ) + if value: + self._set_property( + file_path=self.FilePath, json_path="$..showAll", json_value=True + ) + else: + self._remove_property(json_path="$..showAll") + + @property + def LockAspectRatio(self): + return self._get_property( + "$.visual.visualContainerObjects.lockAspect[*].properties.show.expr.Literal.Value", + default=False, + fix_true=True, + ) + + @LockAspectRatio.setter + def LockAspectRatio(self, value: bool): + if not isinstance(value, bool): + raise TypeError( + f"LockAspectRatio must be a bool, got {type(value).__name__}" + ) + if value: + self._set_property( + file_path=self.FilePath, + json_path="$.visual.visualContainerObjects.lockAspect[*].properties.show.expr.Literal.Value", + json_value="true", + ) + else: + self._remove_property( + file_path=self.FilePath, + json_path="$.visual.visualContainerObjects.lockAspect", + ) + + @property + def MaintainLayerOrder(self): + return self._get_property( + "$.visual.visualContainerObjects.general[*].properties.keepLayerOrder.expr.Literal.Value", + default=False, + fix_true=True, + ) + + @MaintainLayerOrder.setter + def MaintainLayerOrder(self, value: bool): + if not isinstance(value, bool): + raise TypeError( + f"MaintainLayerOrder must be a bool, got {type(value).__name__}" + ) + if value: + self._set_property( + "$.visual.visualContainerObjects.general[*].properties.keepLayerOrder.expr.Literal.Value", + "true", + ) + else: + self._remove_property( + "$.visual.visualContainerObjects.general[*].properties.keepLayerOrder", + ) + + @property + def AltText(self): + return self._get_property( + "$.visual.visualContainerObjects.general[*].properties.altText.expr.Literal.Value" + ) + + @AltText.setter + def AltText(self, value: str): + if value: + self._set_property( + "$.visual.visualContainerObjects.general[*].properties.altText.expr.Literal.Value", + value, + ) + else: + self._remove_property( + "$.visual.visualContainerObjects.general[*].properties.altText" + ) + + @property + def Filters(self): + if self._filters is None: + self._filters = VisualFilterCollection(self._wrapper, self.FilePath) + return self._filters + + class VisualCollection: + def __init__(self, wrapper, page_file_path=None): + self._wrapper = wrapper + self._page_file_path = page_file_path + self._visuals = None + + def _load_visuals(self): + all_visuals = [] + parts = self._wrapper.get(file_path="definition/pages/*/visual.json") + for file_path, data in parts: + if self._page_file_path: + page_dir = "/".join(self._page_file_path.split("/")[:3]) + if not file_path.startswith(page_dir): + continue + all_visuals.append(ReportWrapper.Visual(file_path, data, self._wrapper)) + self._visuals = all_visuals + + def __iter__(self): + if self._visuals is None: + self._load_visuals() + return iter(self._visuals) + + def __getitem__(self, key): + if self._visuals is None: + self._load_visuals() + + # If key is an int, return by index + if isinstance(key, int): + return self._visuals[key] + + # Otherwise, search for a visual by name + for visual in self._visuals: + if visual.Name == key: + return visual + + raise KeyError(f"Visual '{key}' not found") + + @property + def Count(self): + if self._visuals is None: + self._load_visuals() + return len(self._visuals) + + class CustomVisual: + def __init__(self, name): + self.ObjectType = "Custom Visual" + self.Name = name + self.DisplayName = helper.vis_type_mapping.get(name, name) + + class CustomVisualCollection: + def __init__(self, wrapper): + self._wrapper = wrapper + self._custom_visuals = None + + def _load_custom_visuals(self): + raw = ( + self._wrapper.get( + file_path=self._wrapper._report_file_path, + json_path="$.publicCustomVisuals", + ) + or [] + ) + self._custom_visuals = [ReportWrapper.CustomVisual(name) for name in raw] + + def __iter__(self): + if self._custom_visuals is None: + self._load_custom_visuals() + return iter(self._custom_visuals) + + def __getitem__(self, key): + if self._custom_visuals is None: + self._load_custom_visuals() + return self._custom_visuals[key] + + @property + def Count(self): + if self._custom_visuals is None: + self._load_custom_visuals() + return len(self._custom_visuals) + + # Report Level Measures + class ReportLevelMeasure: + def __init__( + self, + name, + table_name, + expr, + format_string, + data_category, + hidden, + description, + display_folder, + ): + self.ObjectType = "Report Level Measure" + self.MeasureName = name + self.TableName = table_name + self.Expression = expr + self.FormatString = format_string + self.DataCategory = data_category + self.Hidden = hidden + self.Description = description + self.DisplayFolder = display_folder + + class ReportLevelMeasureCollection: + def __init__(self, wrapper): + self._wrapper = wrapper + self._report_level_measures = None + + def _load_report_level_measures(self): + if ( + self._wrapper._report_file_path + not in self._wrapper.list_paths()["Paths"].values + ): + payload = [] + else: + payload = ( + self._wrapper.get( + file_path=self._wrapper._report_file_path, + json_path="$.entities", + ) + or [] + ) + + measures = [] + for entity in payload: + table_name = entity.get("name") + for m in entity.get("measures", []): + measure_name = m.get("name") + expr = m.get("expression") + format_string = m.get("formatString", "") + data_category = m.get("dataCategory", "") + hidden = m.get("hidden", False) + description = m.get("description", "") + display_folder = m.get("displayFolder", "") + measure = ReportWrapper.ReportLevelMeasure( + name=measure_name, + table_name=table_name, + expr=expr, + format_string=format_string, + data_category=data_category, + hidden=hidden, + description=description, + display_folder=display_folder, + ) + measures.append(measure) + + self._report_level_measures = measures + + def __iter__(self): + if self._report_level_measures is None: + self._load_report_level_measures() + return iter(self._report_level_measures) + + def __getitem__(self, key): + if self._report_level_measures is None: + self._load_report_level_measures() + return self._report_level_measures[key] + + @property + def Count(self): + if self._report_level_measures is None: + self._load_report_level_measures() + return len(self._report_level_measures) + + # Bookmarks + class Bookmark: + def __init__(self, file_path, data, wrapper): + self.ObjectType = "Bookmark" + self._wrapper = wrapper + self._data = data + self.FilePath = file_path + self.Name = get_jsonpath_value(data, "$.name") + + @property + def DisplayName(self): + path = "$.displayName" + return self._get_property(path) + + @DisplayName.setter + def DisplayName(self, value: str): + path = "$.displayName" + self._set_property(path, value) + + class BookmarkCollection: + def __init__(self, wrapper): + self._wrapper = wrapper + self._bookmarks = None + + def _load_bookmarks(self): + all_bookmarks = [] + parts = self._wrapper.get(file_path="definition/bookmarks/*/bookmark.json") + for file_path, data in parts: + all_bookmarks.append( + ReportWrapper.Visual(file_path, data, self._wrapper) + ) + self._bookmarks = all_bookmarks + + def __iter__(self): + if self._bookmarks is None: + self._load_bookmarks() + return iter(self._bookmarks) + + def __getitem__(self, key): + if self._bookmarks is None: + self._load_bookmarks() + return self._bookmarks[key] + + @property + def Count(self): + if self._bookmarks is None: + self._load_bookmarks() + return len(self._bookmarks) + + @property + def all_pages(self): + + for page in self.report.Pages: + yield page + + @property + def all_visuals(self): + + for page in self.report.Pages: + for visual in page.Visuals: + yield visual + # Basic functions def get( self, @@ -567,7 +1296,7 @@ def __add_to_registered_resources(self, name: str, path: str, type: str): print( f"{icons.info} The '{item_name}' {type.lower()} already exists in the report definition." ) - raise ValueError() + return # Add the new item to the existing RegisteredResources rp["items"].append(new_item) @@ -576,8 +1305,6 @@ def __add_to_registered_resources(self, name: str, path: str, type: str): def _add_extended(self, dataframe): - from sempy_labs.tom import connect_semantic_model - dataset_id, dataset_name, dataset_workspace_id, dataset_workspace_name = ( resolve_dataset_from_report( report=self._report_id, workspace=self._workspace_id @@ -1476,8 +2203,6 @@ def list_semantic_model_objects(self, extended: bool = False) -> pd.DataFrame: """ self._ensure_pbir() - from sempy_labs.tom import connect_semantic_model - columns = { "Table Name": "str", "Object Name": "str", @@ -1712,31 +2437,36 @@ def list_report_level_measures(self) -> pd.DataFrame: "Expression": "str", "Data Type": "str", "Format String": "str", + "Data Category": "str", } df = _create_dataframe(columns=columns) + # If no report extensions path, return empty DataFrame + if self._report_extensions_path not in self.list_paths()["Path"].values: + return df + report_file = self.get(file_path=self._report_extensions_path) dfs = [] - if report_file: - payload = report_file.get("payload") - for e in payload.get("entities", []): - table_name = e.get("name") - for m in e.get("measures", []): - measure_name = m.get("name") - expr = m.get("expression") - data_type = m.get("dataType") - format_string = m.get("formatString") + for e in report_file.get("entities", []): + table_name = e.get("name") + for m in e.get("measures", []): + measure_name = m.get("name") + expr = m.get("expression") + data_type = m.get("dataType") + format_string = m.get("formatString") + data_category = m.get("dataCategory") - new_data = { - "Measure Name": measure_name, - "Table Name": table_name, - "Expression": expr, - "Data Type": data_type, - "Format String": format_string, - } - dfs.append(pd.DataFrame(new_data, index=[0])) + new_data = { + "Measure Name": measure_name, + "Table Name": table_name, + "Expression": expr, + "Data Type": data_type, + "Format String": format_string, + "Data Category": data_category, + } + dfs.append(pd.DataFrame(new_data, index=[0])) if dfs: df = pd.concat(dfs, ignore_index=True) @@ -1813,6 +2543,7 @@ def set_theme(self, theme_file_path: str): f"{icons.red_dot} The '{theme_file_path}' theme file path must be a .json file." ) elif theme_file_path.startswith("https://"): + theme_file_path = fix_github_url(theme_file_path) response = requests.get(theme_file_path) theme_file = response.json() elif theme_file_path.startswith("/lakehouse") or theme_file_path.startswith( @@ -2069,8 +2800,6 @@ def migrate_report_level_measures(self, measures: Optional[str | List[str]] = No """ self._ensure_pbir() - from sempy_labs.tom import connect_semantic_model - rlm = self.list_report_level_measures() if rlm.empty: print( @@ -2202,7 +2931,7 @@ def _list_annotations(self) -> pd.DataFrame: return df - def _add_image(self, image_path: str, resource_name: Optional[str] = None) -> str: + def add_image(self, image_path: str, resource_name: Optional[str] = None) -> str: """ Add an image to the report definition. The image will be added to the StaticResources/RegisteredResources folder in the report definition. If the image_name already exists as a file in the report definition it will be updated. @@ -2223,6 +2952,7 @@ def _add_image(self, image_path: str, resource_name: Optional[str] = None) -> st id = generate_number_guid() if image_path.startswith("http://") or image_path.startswith("https://"): + image_path = fix_github_url(image_path) response = requests.get(image_path) response.raise_for_status() image_bytes = response.content @@ -2619,7 +3349,7 @@ def _update_to_theme_colors(self, mapping: dict[str, tuple[int, float]]): self.update(file_path=file_path, payload=payload) - def _rename_fields(self, mapping: dict): + def rename_fields(self, mapping: dict): """ Renames fields in the report definition based on the provided rename mapping. @@ -2947,3 +3677,357 @@ def connect_report( yield rw finally: rw.close() + + +# Supplementary classes +class TitleObject: + def __init__(self, visual, base_path: str): + self.visual = visual + self.base_path = base_path + + # Text + @property + def Text(self): + path = f"{self.base_path}.text.expr.Literal.Value" + return self.visual._get_property(path, remove_quotes=True) + + @Text.setter + def Text(self, value: str): + path = f"{self.base_path}.text.expr.Literal.Value" + self.visual._set_property(path, value, add_quotes=True) + + # Show + @property + def Show(self): + path = f"{self.base_path}.show.expr.Literal.Value" + return self.visual._get_property(path, fix_true=True) + + @Show.setter + def Show(self, value: bool): + if not isinstance(value, bool): + raise ValueError( + f"{icons.red_dot} The value for 'Show' must be a boolean. Provided: {type(value)}" + ) + path = f"{self.base_path}.show.expr.Literal.Value" + if value: + self.visual._set_property(path, "true") + else: + self.visual._remove_property(path) + + # Font + @property + def Font(self): + path = f"{self.base_path}.fontFamily.expr.Literal.Value" + value = self.visual._get_property(path) + if value: + value = value.strip("'") + return value + + @Font.setter + def Font(self, value: str): + path = f"{self.base_path}.fontFamily.expr.Literal.Value" + self.visual._set_property(path, f"'''{value}'''") + + # Font Size + @property + def FontSize(self): + path = f"{self.base_path}.fontSize.expr.Literal.Value" + value = self.visual._get_property(path) + if value: + value = value.rstrip("D") + return value + + @FontSize.setter + def FontSize(self, value: int): + if not isinstance(value, int): + raise ValueError( + f"{icons.red_dot} The value for 'FontSize' must be an integer. Provided: {type(value)}" + ) + path = f"{self.base_path}.fontSize.expr.Literal.Value" + self.visual._set_property(path, f"{value}D") + + # Bold + @property + def Bold(self): + path = f"{self.base_path}.bold.expr.Literal.Value" + return self.visual._get_property(path, fix_true=True) + + @Bold.setter + def Bold(self, value: bool): + if not isinstance(value, bool): + raise ValueError( + f"{icons.red_dot} The value for 'Bold' must be a bool. Provided: {type(value)}" + ) + path = f"{self.base_path}.bold.expr.Literal.Value" + if value: + self.visual._set_property(path, "true") + else: + self.visual._remove_property(path) + + # Italic + @property + def Italic(self): + path = f"{self.base_path}.italic.expr.Literal.Value" + return self.visual._get_property(path, fix_true=True) + + @Italic.setter + def Italic(self, value: bool): + if not isinstance(value, bool): + raise ValueError( + f"{icons.red_dot} The value for 'Italic' must be a bool. Provided: {type(value)}" + ) + path = f"{self.base_path}.italic.expr.Literal.Value" + if value: + self.visual._set_property(path, "true") + else: + self.visual._remove_property(path) + + # Underline + @property + def Underline(self): + path = f"{self.base_path}.underline.expr.Literal.Value" + return self.visual._get_property(path, fix_true=True) + + @Underline.setter + def Underline(self, value: bool): + if not isinstance(value, bool): + raise ValueError( + f"{icons.red_dot} The value for 'Underline' must be a bool. Provided: {type(value)}" + ) + path = f"{self.base_path}.underline.expr.Literal.Value" + if value: + self.visual._set_property(path, "true") + else: + self.visual._remove_property(path) + + # Font Color + + # Alignment + @property + def Alignment(self): + path = f"{self.base_path}.alignment.expr.Literal.Value" + value = self.visual._get_property(path, remove_quotes=True) + if value: + value = value.capitalize() + return value + + @Alignment.setter + def Alignment(self, value: Literal["Left", "Center", "Right"]): + value = value.capitalize() + if value not in ["Left", "Center", "Right"]: + raise ValueError( + f"{icons.red_dot} The value for 'Alignment' must be one of 'Left', 'Center', or 'Right'. Provided: {value}" + ) + path = f"{self.base_path}.alignment.expr.Literal.Value" + self.visual._set_property(path, f"{value.lower()}", add_quotes=True) + + # Text Wrap + @property + def TextWrap(self): + path = f"{self.base_path}.titleWrap.expr.Literal.Value" + return self.visual._get_property(path, fix_true=True) + + @TextWrap.setter + def TextWrap(self, value: bool): + if not isinstance(value, bool): + raise ValueError( + f"{icons.red_dot} The value for 'TextWrap' must be a bool. Provided: {type(value)}" + ) + path = f"{self.base_path}.underline.expr.Literal.Value" + if not value: + self.visual._set_property(path, "false") + else: + self.visual._remove_property(path) + + +class Filter: + def __init__(self, file_path, filter_data, wrapper): + self._wrapper = wrapper + self._data = filter_data + self.FilePath = file_path + self.ObjectType = "Filter" + self.Name = filter_data.get("name") + self.Ordinal = filter_data.get("ordinal") + self.HowCreated = filter_data.get("howCreated", "Unknown") + self.Locked = filter_data.get("isLockedInViewMode", False) + self.Hidden = filter_data.get("isHiddenInViewMode", False) + self.Type = filter_data.get("type", "Basic") + self.IsUsed = True if "Where" in filter_data.get("filter", {}) else False + + field_data = filter_data.get("field", {}) + self.Field = Field.from_dict(field_data) + + def to_dict(self): + return { + **self._data, + "TableName": self.TableName, + "ObjectName": self.ObjectName, + "ObjectType": self.ObjectType, + } + + @property + def Metadata(self): + return self._data + + @Metadata.setter + def Metadata(self, value): + if isinstance(value, dict): + self._data = value + else: + raise ValueError("Metadata must be a dictionary") + + +class ReportFilterCollection: + def __init__(self, wrapper): + self._wrapper = wrapper + self._filters = None + + def _load_filters(self): + data = self._wrapper.get(file_path=ReportWrapper._report_file_path) + raw_filters = get_jsonpath_value( + data=data, path="$.filterConfig.filters", default=[] + ) + self._filters = [ + Filter(ReportWrapper._report_file_path, f, self._wrapper) + for f in raw_filters + ] + + def __iter__(self): + if self._filters is None: + self._load_filters() + return iter(self._filters) + + def __getitem__(self, index): + if self._filters is None: + self._load_filters() + return self._filters[index] + + @property + def Count(self): + if self._filters is None: + self._load_filters() + return len(self._filters) + + +class PageFilterCollection: + def __init__(self, wrapper, page_file_path=None): + self._wrapper = wrapper + self._page_filters = None + self._page_file_path = page_file_path + + def _load_filters(self): + data = self._wrapper.get(file_path=self._page_file_path) + filter_list = get_jsonpath_value( + data, path="$.filterConfig.filters", default=[] + ) + self._page_filters = [ + Filter(self._page_file_path, f, self._wrapper) for f in filter_list + ] + + def __iter__(self): + if self._page_filters is None: + self._load_filters() + return iter(self._page_filters) + + def __getitem__(self, index): + if self._page_filters is None: + self._load_filters() + return self._page_filters[index] + + @property + def Count(self): + if self._page_filters is None: + self._load_filters() + return len(self._page_filters) + + +class VisualFilterCollection: + def __init__(self, wrapper, visual_file_path=None): + self._wrapper = wrapper + self._visual_filters = None + self._visual_file_path = visual_file_path + + def _load_filters(self): + data = self._wrapper.get(file_path=self._visual_file_path) + filter_list = get_jsonpath_value( + data, path="$.filterConfig.filters", default=[] + ) + self._visual_filters = [ + Filter(self._visual_file_path, f, self._wrapper) for f in filter_list + ] + + def __iter__(self): + if self._visual_filters is None: + self._load_filters() + return iter(self._visual_filters) + + def __getitem__(self, index): + if self._visual_filters is None: + self._load_filters() + return self._visual_filters[index] + + @property + def Count(self): + if self._visual_filters is None: + self._load_filters() + return len(self._visual_filters) + + +class Field: + def __init__(self, table_name=None, object_name=None, object_type=None): + self.TableName = table_name + self.ObjectName = object_name + self.ObjectType = object_type + + @classmethod + def from_dict(cls, field: dict): + # Case 1: Simple column + if "Column" in field: + col = field["Column"] + table_name = col.get("Expression", {}).get("SourceRef", {}).get("Entity") + object_name = col.get("Property") + object_type = "Column" + return cls(table_name, object_name, object_type) + + # Case 2: Measure + elif "Measure" in field: + measure = field["Measure"] + table_name = ( + measure.get("Expression", {}).get("SourceRef", {}).get("Entity") + ) + object_name = measure.get("Property") + object_type = "Measure" + return cls(table_name, object_name, object_type) + + # Case 3: Aggregation over column + elif "Aggregation" in field: + col = field.get("Aggregation", {}).get("Expression", {}).get("Column", {}) + table_name = col.get("Expression", {}).get("SourceRef", {}).get("Entity") + object_name = col.get("Property") + object_type = "Column" + return cls(table_name, object_name, object_type) + + elif "SparklineData" in field: + m = field.get("SparklineData").get("Measure") + if "Measure" in m: + measure = m["Measure"] + table_name = ( + measure.get("Expression", {}).get("SourceRef", {}).get("Entity") + ) + object_name = measure.get("Property") + object_type = "Measure" + return cls(table_name, object_name, object_type) + elif "Aggregation" in m: + col = m.get("Aggregation", {}).get("Expression", {}).get("Column", {}) + table_name = ( + col.get("Expression", {}).get("SourceRef", {}).get("Entity") + ) + object_name = col.get("Property") + object_type = "Column" + return cls(table_name, object_name, object_type) + + # If no recognized key found, return empty Field + return cls() + + def __repr__(self): + return f"Field(TableName={self.TableName}, ObjectName={self.ObjectName}, ObjectType={self.ObjectType})"