diff --git a/src/sempy_labs/__init__.py b/src/sempy_labs/__init__.py index 8fa1e8659..b33ee179a 100644 --- a/src/sempy_labs/__init__.py +++ b/src/sempy_labs/__init__.py @@ -291,6 +291,7 @@ get_capacity_name, resolve_capacity_name, get_tenant_id, + update_item_logical_id, ) from ._model_bpa_bulk import ( run_model_bpa_bulk, @@ -606,6 +607,7 @@ "search_notebooks", "list_notebooks", "get_item_definition", + "update_item_logical_id", "get_workspace_network_communication_policy", "set_workspace_network_communication_policy", "get_connection_string", diff --git a/src/sempy_labs/_app.py b/src/sempy_labs/_app.py index 62d370333..ac39d9476 100644 --- a/src/sempy_labs/_app.py +++ b/src/sempy_labs/_app.py @@ -29,6 +29,15 @@ "module": "sempy_labs.semantic_model._dax_perf", "function": "dax_perf_optimizer", }, + { + "key": "bpa", + "name": "Semantic Model BPA", + "description": "Run Best Practice Analyzer rules against a semantic model.", + "tags": ("Semantic Model",), + "icon": "shield_check", + "module": "sempy_labs.semantic_model._bpa", + "function": "bpa", + }, { "key": "vertipaq_analyzer", "name": "Vertipaq Analyzer", @@ -38,6 +47,15 @@ "module": "sempy_labs.semantic_model._vertipaq_analyzer", "function": "vertipaq_analyzer", }, + { + "key": "refresh_manager", + "name": "Refresh Manager", + "description": "Refresh a semantic model, its tables or individual partitions.", + "tags": ("Semantic Model",), + "icon": "sync", + "module": "sempy_labs.semantic_model._refresh_manager", + "function": "refresh_manager", + }, { "key": "lineage_view", "name": "Lineage View", @@ -47,15 +65,6 @@ "module": "sempy_labs.semantic_model._lineage_view", "function": "lineage_view", }, - { - "key": "mini_model_manager", - "name": "Mini Model Manager", - "description": "Create a smaller semantic model from a subset of a master model.", - "tags": ("Semantic Model", "Direct Lake"), - "icon": "mini_model", - "module": "sempy_labs.semantic_model._mini_model_manager", - "function": "mini_model_manager", - }, { "key": "find_unused_objects", "name": "Find Unused Objects", @@ -66,31 +75,13 @@ "function": "find_unused_objects", }, { - "key": "perspective_editor", - "name": "Perspective Editor", - "description": "Create and manage perspectives in a semantic model.", - "tags": ("Semantic Model",), - "icon": "perspective", - "module": "sempy_labs.semantic_model._perspective_editor", - "function": "perspective_editor", - }, - { - "key": "bpa", - "name": "Semantic Model BPA", - "description": "Run Best Practice Analyzer rules against a semantic model.", - "tags": ("Semantic Model",), - "icon": "shield_check", - "module": "sempy_labs.semantic_model._bpa", - "function": "bpa", - }, - { - "key": "refresh_manager", - "name": "Refresh Manager", - "description": "Refresh a semantic model, its tables or individual partitions.", - "tags": ("Semantic Model",), - "icon": "sync", - "module": "sempy_labs.semantic_model._refresh_manager", - "function": "refresh_manager", + "key": "delta_analyzer", + "name": "Delta Analyzer", + "description": "Analyze the parquet files and row groups of a delta table.", + "tags": ("Direct Lake", "Lakehouse"), + "icon": "delta_stats", + "module": "sempy_labs._delta_analyzer", + "function": "delta_analyzer", }, { "key": "migrate_to_direct_lake", @@ -102,13 +93,22 @@ "function": "migrate_to_direct_lake", }, { - "key": "delta_analyzer", - "name": "Delta Analyzer", - "description": "Analyze the parquet files and row groups of a delta table.", - "tags": ("Direct Lake", "Lakehouse"), - "icon": "delta_stats", - "module": "sempy_labs._delta_analyzer", - "function": "delta_analyzer", + "key": "mini_model_manager", + "name": "Mini Model Manager", + "description": "Create a smaller semantic model from a subset of a master model.", + "tags": ("Semantic Model", "Direct Lake"), + "icon": "mini_model", + "module": "sempy_labs.semantic_model._mini_model_manager", + "function": "mini_model_manager", + }, + { + "key": "perspective_editor", + "name": "Perspective Editor", + "description": "Create and manage perspectives in a semantic model.", + "tags": ("Semantic Model",), + "icon": "perspective", + "module": "sempy_labs.semantic_model._perspective_editor", + "function": "perspective_editor", }, { "key": "model_comparison", diff --git a/src/sempy_labs/_capacities.py b/src/sempy_labs/_capacities.py index ebb99db9a..847fde105 100644 --- a/src/sempy_labs/_capacities.py +++ b/src/sempy_labs/_capacities.py @@ -1,33 +1,16 @@ from typing import Optional, List, Tuple from sempy._utils._log import log -import sempy_labs._icons as icons -from sempy.fabric.exceptions import FabricHTTPException -import requests import pandas as pd -from sempy_labs._authentication import ( - _get_headers, - ServicePrincipalTokenProvider, -) from uuid import UUID -from sempy_labs._helper_functions import ( - _is_valid_uuid, - _update_dataframe_datatypes, - _base_api, - _create_dataframe, -) -import sempy_labs._authentication as auth +from sempy_labs._helper_functions import _create_dataframe, _update_dataframe_datatypes, _base_api +import sempy_labs._icons as icons def _add_sll_tag(payload, tags): - if tags is None: - payload["tags"] = {"SLL": 1} - else: - if "tags" not in payload: - payload["tags"] = tags - payload["tags"]["SLL"] = 1 + from sempy_labs.capacity._items import _add_sll_tag as _impl - return payload + return _impl(payload=payload, tags=tags) @log @@ -66,155 +49,17 @@ def create_fabric_capacity( Tag(s) to add to the capacity. Example: {'tagName': 'tagValue'}. """ - if isinstance(admin_members, str): - admin_members = [admin_members] - - # list source: https://learn.microsoft.com/fabric/admin/region-availability - region_list = [ - "Brazil South", - "North Europe", - "UAE North", - "South Africa North", - "Australia East", - "Canada Central", - "West Europe", - "Australia Southeast", - "Canada East", - "France Central", - "Central India", - "East US", - "Germany West Central", - "East Asia", - "East US 2", - "Norway East", - "Japan East", - "North Central US", - "Sweden Central", - "Korea Central", - "South Central US", - "Switzerland North", - "Southeast Asia", - "West US", - "Switzerland West", - "South India", - "West US 2", - "UK South", - "West US 3", - "UK West", - "brazilsouth", - "northeurope", - "uaenorth", - "southafricanorth", - "australiaeast", - "canadacentral", - "westeurope", - "australiasoutheast", - "canadaeast", - "francecentral", - "centralindia", - "eastus", - "germanywestcentral", - "eastasia", - "eastus2", - "norwayeast", - "japaneast", - "northcentralus", - "swedencentral", - "koreacentral", - "southcentralus", - "switzerlandnorth", - "southeastasia", - "westus", - "switzerlandwest", - "southindia", - "westus2", - "uksouth", - "westus3", - "ukwest", - ] - - valid_regions = [ - region for region in region_list if any(char.isupper() for char in region) - ] - - if region not in region_list: - raise ValueError( - f"{icons.red_dot} Invalid region. Valid options: {valid_regions}." - ) - - token_provider = auth.token_provider.get() - if token_provider is None: - token_provider = ServicePrincipalTokenProvider.from_azure_key_vault( - key_vault_uri=kwargs["key_vault_uri"], - key_vault_tenant_id=kwargs["key_vault_tenant_id"], - key_vault_client_id=kwargs["key_vault_client_id"], - key_vault_client_secret=kwargs["key_vault_client_secret"], - ) - print( - f"{icons.info} Please use the 'token_provider' parameter instead of the key vault parameters within this function as the key vault parameters have been deprecated." - ) - headers = _get_headers(token_provider, audience="azure") - - if resource_group is None: - dfRG = list_resource_groups( - azure_subscription_id=azure_subscription_id, - filter="resourceType eq 'Microsoft.PowerBIDedicated/capacities'", - ) - dfRG_filt = dfRG[ - dfRG["Resource Group Name"] - == capacity_name.removesuffix(icons.migrate_capacity_suffix) - ] - if not dfRG_filt.empty: - resource_group = dfRG_filt["Resource Group Name"].iloc[0] - print( - f"{icons.yellow_dot} Override resource group flag detected for A SKUs - using the existing resource group '{resource_group}' for the '{capacity_name}' capacity." - ) - else: - # Attempt to get the resource group - try: - dfRG = get_resource_group( - azure_subscription_id=azure_subscription_id, - resource_group=resource_group, - ) - if dfRG["Location"].iloc[0] != region: - print( - f"{icons.yellow_dot} The '{resource_group}' resource group exists, but in a different region." - ) - except Exception: - # If the resource group does not exist, create it - print( - f"{icons.yellow_dot} The '{resource_group}' resource group does not exist." - ) - print( - f"{icons.in_progress} Creating the '{resource_group}' resource group in the '{region}' region" - ) - create_or_update_resource_group( - azure_subscription_id=azure_subscription_id, - resource_group=resource_group, - region=region, - ) - - payload = { - "properties": {"administration": {"members": admin_members}}, - "sku": {"name": sku, "tier": "Fabric"}, - "location": region, - } - - payload = _add_sll_tag(payload, tags) - - print( - f"{icons.in_progress} Creating the '{capacity_name}' capacity as an '{sku}' SKU within the '{region}' region..." - ) - - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}?api-version={icons.azure_api_version}" - - response = requests.put(url, headers=headers, json=payload) - - if response.status_code not in [200, 201]: - raise FabricHTTPException(response) + from sempy_labs.capacity._items import create_fabric_capacity as _impl - print( - f"{icons.green_dot} Successfully created the '{capacity_name}' capacity within the '{region}' region." + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + region=region, + sku=sku, + admin_members=admin_members, + tags=tags, + **kwargs, ) @@ -239,17 +84,12 @@ def list_vcores() -> pd.DataFrame: return df - @log def get_capacity_resource_governance(capacity_name: str): - dfC = list_capacities() - dfC_filt = dfC[dfC["Display Name"] == capacity_name] - capacity_id = dfC_filt["Id"].iloc[0].upper() - - response = _base_api(request=f"capacities/{capacity_id}/resourceGovernance") + from sempy_labs.capacity._items import get_capacity_resource_governance as _impl - return response.json().get("workloadSettings", {}) + return _impl(capacity_name=capacity_name) @log @@ -275,11 +115,13 @@ def suspend_fabric_capacity( The name of the Azure resource group. """ - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}/suspend?api-version={icons.azure_api_version}" + from sempy_labs.capacity._items import suspend_fabric_capacity as _impl - _base_api(request=url, client="azure", method="post", status_codes=202) - - print(f"{icons.green_dot} The '{capacity_name}' capacity has been suspended.") + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + ) @log @@ -305,11 +147,13 @@ def resume_fabric_capacity( The name of the Azure resource group. """ - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}/resume?api-version={icons.azure_api_version}" - - _base_api(request=url, client="azure", method="post", status_codes=202) + from sempy_labs.capacity._items import resume_fabric_capacity as _impl - print(f"{icons.green_dot} The '{capacity_name}' capacity has been resumed.") + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + ) @log @@ -333,11 +177,13 @@ def delete_embedded_capacity( The name of the Azure resource group. """ - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.PowerBIDedicated/capacities/{capacity_name}?api-version={icons.azure_api_version}" + from sempy_labs.capacity._items import delete_embedded_capacity as _impl - _base_api(request=url, client="azure", method="delete", status_codes=[200, 202]) - - print(f"{icons.green_dot} The '{capacity_name}' capacity has been deleted.") + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + ) @log @@ -350,19 +196,10 @@ def delete_premium_capacity(capacity: str | UUID, **kwargs): capacity : str | uuid.UUID Name or ID of the Fabric capacity. """ - from sempy_labs._helper_functions import resolve_capacity_id - - if "capacity_name" in kwargs: - capacity = kwargs["capacity_name"] - print( - f"{icons.warning} The 'capacity_name' parameter is deprecated. Please use 'capacity' instead." - ) - capacity_id = resolve_capacity_id(capacity=capacity).upper() + from sempy_labs.capacity._items import delete_premium_capacity as _impl - _base_api(request=f"capacities/{capacity_id}", method="delete", status_codes=204) - - print(f"{icons.green_dot} The '{capacity}' capacity has been deleted.") + return _impl(capacity=capacity, **kwargs) @log @@ -388,11 +225,13 @@ def delete_fabric_capacity( The name of the Azure resource group. """ - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}?api-version={icons.azure_api_version}" - - _base_api(request=url, client="azure", method="delete", status_codes=202) + from sempy_labs.capacity._items import delete_fabric_capacity as _impl - print(f"{icons.green_dot} The '{capacity_name}' capacity has been deleted.") + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + ) @log @@ -427,46 +266,15 @@ def update_fabric_capacity( Tag(s) to add to the capacity. Example: {'tagName': 'tagValue'}. """ - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}?api-version={icons.azure_api_version}" - - get_response = _base_api(request=url, client="azure") + from sempy_labs.capacity._items import update_fabric_capacity as _impl - get_json = get_response.json() - current_sku = get_json.get("sku", {}).get("name") - current_admins = ( - get_json.get("properties", {}).get("administration", {}).get("members") - ) - current_tags = get_json.get("tags") - - payload = {} - payload["sku"] = { - "name": current_sku, - "tier": "Fabric", - } - payload["tags"] = current_tags - payload["properties"] = get_json["properties"] - - if sku is not None: - payload["sku"]["name"] = sku - if admin_members is not None: - payload["properties"] = {"administration": {"members": admin_members}} - if tags is not None: - payload["tags"] = tags - - # Do not proceed if no properties are being changed - if current_sku == sku and current_admins == admin_members and current_tags == tags: - print( - f"{icons.yellow_dot} The properties of the '{capacity_name}' are the same as those specified in the parameters of this function. No changes have been made." - ) - return - - payload = _add_sll_tag(payload, tags) - _base_api( - request=url, client="azure", method="patch", payload=payload, status_codes=202 - ) - - print( - f"{icons.green_dot} The '{capacity_name}' capacity has been updated accordingly." + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + sku=sku, + admin_members=admin_members, + tags=tags, ) @@ -499,15 +307,16 @@ def check_fabric_capacity_name_availablility( An indication as to whether the Fabric capacity name is available or not. """ - payload = {"name": capacity_name, "type": "Microsoft.Fabric/capacities"} - - url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/providers/Microsoft.Fabric/locations/{region}/checkNameAvailability?api-version={icons.azure_api_version}" - - response = _base_api( - request=url, client="azure", method="post", payload=payload, status_codes=202 + from sempy_labs.capacity._items import ( + check_fabric_capacity_name_availablility as _impl, ) - return bool(response.json().get("nameAvailable")) + return _impl( + capacity_name=capacity_name, + azure_subscription_id=azure_subscription_id, + region=region, + **kwargs, + ) @log @@ -515,7 +324,6 @@ def create_resource_group( azure_subscription_id: str, resource_group: str, region: str, - **kwargs, ): """ This function creates a resource group in a region within an Azure subscription. @@ -1157,6 +965,7 @@ def get_resource_group(azure_subscription_id: str, resource_group: str) -> pd.Da return pd.DataFrame(new_data, index=[0]) +@log def list_capacities() -> pd.DataFrame: """ Shows the capacities and their properties. @@ -1167,32 +976,6 @@ def list_capacities() -> pd.DataFrame: A pandas dataframe showing the capacities and their properties """ - columns = { - "Id": "string", - "Display Name": "string", - "Sku": "string", - "Region": "string", - "State": "string", - "Admins": "string", - } - df = _create_dataframe(columns=columns) - - response = _base_api(request="/v1.0/myorg/capacities", client="fabric_sp") + from sempy_labs.capacity._items import list_capacities as _impl - rows = [] - for i in response.json().get("value", []): - rows.append( - { - "Id": i.get("id").lower(), - "Display Name": i.get("displayName"), - "Sku": i.get("sku"), - "Region": i.get("region"), - "State": i.get("state"), - "Admins": [i.get("admins", [])], - } - ) - - if rows: - df = pd.DataFrame(rows, columns=columns.keys()) - - return df + return _impl() diff --git a/src/sempy_labs/_helper_functions.py b/src/sempy_labs/_helper_functions.py index e335d4e51..6ffc02ae7 100644 --- a/src/sempy_labs/_helper_functions.py +++ b/src/sempy_labs/_helper_functions.py @@ -3182,3 +3182,56 @@ def _normalize_comment(x): ) return result + + +@log +def update_item_logical_id( + item: str | UUID, + logical_id: UUID, + type: Optional[str] = None, + workspace: Optional[str | UUID] = None, +): + """ + Updates the logical ID of an item. + + Corresponding item instances share the same logical ID. Use this function to resolve logical ID conflicts. + Before changing the logical ID, review any automations that rely on its current value and update them as needed. + + This is a wrapper function for the following API: `Items - Update Logical Id `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + item : str | uuid.UUID + The item name or ID. If specifying the item name, the item type is required. + logical_id : uuid.UUID + The new logical ID for the item. Must be a non-empty GUID which is unique within the workspace. + type : str, default=None + The item `type `_. If specifying the item name as the item, the item type is required. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + """ + + if not _is_valid_uuid(str(logical_id)) or UUID(str(logical_id)).int == 0: + raise ValueError( + f"{icons.red_dot} The '{logical_id}' logical ID must be a valid, non-empty GUID." + ) + + workspace_name, workspace_id = resolve_workspace_name_and_id(workspace) + item_name, item_id = resolve_item_name_and_id( + item=item, type=type, workspace=workspace_id + ) + + _base_api( + request=f"/v1/workspaces/{workspace_id}/items/{item_id}/logicalId", + method="patch", + payload={"logicalId": str(logical_id)}, + client="fabric_sp", + ) + + print( + f"{icons.green_dot} The logical ID of the '{item_name}' item within the '{workspace_name}' workspace has been updated to '{logical_id}'." + ) diff --git a/src/sempy_labs/admin/__init__.py b/src/sempy_labs/admin/__init__.py index a5f8fae0e..3b9a70476 100644 --- a/src/sempy_labs/admin/__init__.py +++ b/src/sempy_labs/admin/__init__.py @@ -105,6 +105,11 @@ bulk_set_labels, bulk_remove_labels, ) +from ._policy_set import ( + activate_policy_set, + deactivate_policy_set, + get_active_policy_set, +) __all__ = [ "list_items", @@ -173,4 +178,7 @@ "bulk_remove_labels", "sync_role_assignments_to_subdomains", "enable_item_recovery", + "activate_policy_set", + "deactivate_policy_set", + "get_active_policy_set", ] diff --git a/src/sempy_labs/admin/_policy_set.py b/src/sempy_labs/admin/_policy_set.py new file mode 100644 index 000000000..fd2c6cbfa --- /dev/null +++ b/src/sempy_labs/admin/_policy_set.py @@ -0,0 +1,117 @@ +from typing import Optional, Tuple +from uuid import UUID +import sempy_labs._icons as icons +from sempy_labs._helper_functions import _base_api, _is_valid_uuid +from sempy_labs.admin._basic_functions import _resolve_workspace_name_and_id +from sempy_labs.admin._items import _resolve_item_name_and_id +from sempy._utils._log import log + + +def _resolve_policy_set(policy_set: str | UUID, workspace_id: UUID) -> Tuple[str, UUID]: + # An ID is used as-is so it does not depend on the admin item listing. + if _is_valid_uuid(policy_set): + return str(policy_set), policy_set + return _resolve_item_name_and_id( + item=policy_set, type="PolicySet", workspace=workspace_id + ) + + +@log +def activate_policy_set( + policy_set: str | UUID, + workspace: Optional[str | UUID] = None, + allow_replace: bool = False, +): + """ + Activates a policy set on the tenant scope. + + Only a single policy set can be active on the tenant scope. The policy set must have a 'Tenant' scope. + + This is a wrapper function for the following API: `Policy Sets - Activate `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + policy_set : str | uuid.UUID + The name or ID of the policy set. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID in which the policy set resides. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + allow_replace : bool, default=False + If True, replaces the policy set which is currently active on the tenant scope (if any). + If False, the activation fails if another policy set is already active on the tenant scope. + """ + + workspace_name, workspace_id = _resolve_workspace_name_and_id(workspace) + policy_set_name, policy_set_id = _resolve_policy_set(policy_set, workspace_id) + + _base_api( + request=f"/v1/admin/workspaces/{workspace_id}/policySets/{policy_set_id}/activate?allowReplace={str(allow_replace).lower()}", + method="post", + client="fabric_sp", + ) + + print( + f"{icons.green_dot} The '{policy_set_name}' policy set within the '{workspace_name}' workspace has been activated on the tenant scope." + ) + + +@log +def deactivate_policy_set( + policy_set: str | UUID, + workspace: Optional[str | UUID] = None, +): + """ + Deactivates a policy set from the tenant scope. + + This is a wrapper function for the following API: `Policy Sets - Deactivate `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + policy_set : str | uuid.UUID + The name or ID of the policy set. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID in which the policy set resides. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + """ + + workspace_name, workspace_id = _resolve_workspace_name_and_id(workspace) + policy_set_name, policy_set_id = _resolve_policy_set(policy_set, workspace_id) + + _base_api( + request=f"/v1/admin/workspaces/{workspace_id}/policySets/{policy_set_id}/deactivate", + method="post", + client="fabric_sp", + ) + + print( + f"{icons.green_dot} The '{policy_set_name}' policy set within the '{workspace_name}' workspace has been deactivated from the tenant scope." + ) + + +@log +def get_active_policy_set() -> dict: + """ + Returns the active policy set on the tenant scope, including its policy rules. + + This is a wrapper function for the following API: `Policy Sets - Get Active (beta) `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Returns + ------- + dict + The active policy set, containing the 'id', 'displayName', 'description', 'workspaceId' and 'policyRules' keys. + """ + + response = _base_api( + request="/v1/admin/policySets/active?beta=true", + client="fabric_sp", + ) + + return response.json() diff --git a/src/sempy_labs/capacity/__init__.py b/src/sempy_labs/capacity/__init__.py new file mode 100644 index 000000000..f67c801c6 --- /dev/null +++ b/src/sempy_labs/capacity/__init__.py @@ -0,0 +1,27 @@ +from ._items import ( + create_fabric_capacity, + resume_fabric_capacity, + suspend_fabric_capacity, + update_fabric_capacity, + delete_fabric_capacity, + check_fabric_capacity_name_availablility, + delete_embedded_capacity, + delete_premium_capacity, + list_capacities, + get_overage_configuration, + update_overage_configuration, +) + +__all__ = [ + "create_fabric_capacity", + "resume_fabric_capacity", + "suspend_fabric_capacity", + "update_fabric_capacity", + "delete_fabric_capacity", + "check_fabric_capacity_name_availablility", + "delete_embedded_capacity", + "delete_premium_capacity", + "list_capacities", + "get_overage_configuration", + "update_overage_configuration", +] diff --git a/src/sempy_labs/capacity/_items.py b/src/sempy_labs/capacity/_items.py new file mode 100644 index 000000000..113dc8546 --- /dev/null +++ b/src/sempy_labs/capacity/_items.py @@ -0,0 +1,643 @@ +from typing import Optional, List, Literal +from sempy._utils._log import log +import sempy_labs._icons as icons +from sempy.fabric.exceptions import FabricHTTPException +import requests +import pandas as pd +from sempy_labs._authentication import ( + _get_headers, + ServicePrincipalTokenProvider, +) +from uuid import UUID +from sempy_labs._helper_functions import ( + _base_api, + _create_dataframe, + resolve_capacity_id, +) +import sempy_labs._authentication as auth +from sempy_labs._capacities import ( + create_or_update_resource_group, + get_resource_group, + list_resource_groups, +) + + +def _add_sll_tag(payload, tags): + + if tags is None: + payload["tags"] = {"SLL": 1} + else: + if "tags" not in payload: + payload["tags"] = tags + payload["tags"]["SLL"] = 1 + + return payload + + +@log +def create_fabric_capacity( + capacity_name: str, + azure_subscription_id: str, + resource_group: str, + region: str, + sku: str, + admin_members: str | List[str], + tags: Optional[dict] = None, + **kwargs, +): + """ + This function creates a new Fabric capacity within an Azure subscription. + + This is a wrapper function for the following API: `Fabric Capacities - Create Or Update `_. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + resource_group : str + The name of the Azure resource group. + region : str + The name of the region in which the capacity will be created. + sku : str + The `sku size `_ of the Fabric capacity. + admin_members : str | typing.List[str] + The email address(es) of the admin(s) of the Fabric capacity. + tags: dict, default=None + Tag(s) to add to the capacity. Example: {'tagName': 'tagValue'}. + """ + + if isinstance(admin_members, str): + admin_members = [admin_members] + + # list source: https://learn.microsoft.com/fabric/admin/region-availability + region_list = [ + "Brazil South", + "North Europe", + "UAE North", + "South Africa North", + "Australia East", + "Canada Central", + "West Europe", + "Australia Southeast", + "Canada East", + "France Central", + "Central India", + "East US", + "Germany West Central", + "East Asia", + "East US 2", + "Norway East", + "Japan East", + "North Central US", + "Sweden Central", + "Korea Central", + "South Central US", + "Switzerland North", + "Southeast Asia", + "West US", + "Switzerland West", + "South India", + "West US 2", + "UK South", + "West US 3", + "UK West", + "brazilsouth", + "northeurope", + "uaenorth", + "southafricanorth", + "australiaeast", + "canadacentral", + "westeurope", + "australiasoutheast", + "canadaeast", + "francecentral", + "centralindia", + "eastus", + "germanywestcentral", + "eastasia", + "eastus2", + "norwayeast", + "japaneast", + "northcentralus", + "swedencentral", + "koreacentral", + "southcentralus", + "switzerlandnorth", + "southeastasia", + "westus", + "switzerlandwest", + "southindia", + "westus2", + "uksouth", + "westus3", + "ukwest", + ] + + valid_regions = [ + region for region in region_list if any(char.isupper() for char in region) + ] + + if region not in region_list: + raise ValueError( + f"{icons.red_dot} Invalid region. Valid options: {valid_regions}." + ) + + token_provider = auth.token_provider.get() + if token_provider is None: + token_provider = ServicePrincipalTokenProvider.from_azure_key_vault( + key_vault_uri=kwargs["key_vault_uri"], + key_vault_tenant_id=kwargs["key_vault_tenant_id"], + key_vault_client_id=kwargs["key_vault_client_id"], + key_vault_client_secret=kwargs["key_vault_client_secret"], + ) + print( + f"{icons.info} Please use the 'token_provider' parameter instead of the key vault parameters within this function as the key vault parameters have been deprecated." + ) + headers = _get_headers(token_provider, audience="azure") + + if resource_group is None: + dfRG = list_resource_groups( + azure_subscription_id=azure_subscription_id, + filter="resourceType eq 'Microsoft.PowerBIDedicated/capacities'", + ) + dfRG_filt = dfRG[ + dfRG["Resource Group Name"] + == capacity_name.removesuffix(icons.migrate_capacity_suffix) + ] + if not dfRG_filt.empty: + resource_group = dfRG_filt["Resource Group Name"].iloc[0] + print( + f"{icons.yellow_dot} Override resource group flag detected for A SKUs - using the existing resource group '{resource_group}' for the '{capacity_name}' capacity." + ) + else: + # Attempt to get the resource group + try: + dfRG = get_resource_group( + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + ) + if dfRG["Location"].iloc[0] != region: + print( + f"{icons.yellow_dot} The '{resource_group}' resource group exists, but in a different region." + ) + except Exception: + # If the resource group does not exist, create it + print( + f"{icons.yellow_dot} The '{resource_group}' resource group does not exist." + ) + print( + f"{icons.in_progress} Creating the '{resource_group}' resource group in the '{region}' region" + ) + create_or_update_resource_group( + azure_subscription_id=azure_subscription_id, + resource_group=resource_group, + region=region, + ) + + payload = { + "properties": {"administration": {"members": admin_members}}, + "sku": {"name": sku, "tier": "Fabric"}, + "location": region, + } + + payload = _add_sll_tag(payload, tags) + + print( + f"{icons.in_progress} Creating the '{capacity_name}' capacity as an '{sku}' SKU within the '{region}' region..." + ) + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}?api-version={icons.azure_api_version}" + + response = requests.put(url, headers=headers, json=payload) + + if response.status_code not in [200, 201]: + raise FabricHTTPException(response) + + print( + f"{icons.green_dot} Successfully created the '{capacity_name}' capacity within the '{region}' region." + ) + + +@log +def get_capacity_resource_governance(capacity_name: str): + + dfC = list_capacities() + dfC_filt = dfC[dfC["Display Name"] == capacity_name] + capacity_id = dfC_filt["Id"].iloc[0].upper() + + response = _base_api(request=f"capacities/{capacity_id}/resourceGovernance") + + return response.json().get("workloadSettings", {}) + + +@log +def suspend_fabric_capacity( + capacity_name: str, + azure_subscription_id: str, + resource_group: str, +): + """ + This function suspends a Fabric capacity. + + This is a wrapper function for the following API: `Fabric Capacities - Suspend `_. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + resource_group : str + The name of the Azure resource group. + """ + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}/suspend?api-version={icons.azure_api_version}" + + _base_api(request=url, client="azure", method="post", status_codes=202) + + print(f"{icons.green_dot} The '{capacity_name}' capacity has been suspended.") + + +@log +def resume_fabric_capacity( + capacity_name: str, + azure_subscription_id: str, + resource_group: str, +): + """ + This function resumes a Fabric capacity. + + This is a wrapper function for the following API: `Fabric Capacities - Resume `_. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + resource_group : str + The name of the Azure resource group. + """ + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}/resume?api-version={icons.azure_api_version}" + + _base_api(request=url, client="azure", method="post", status_codes=202) + + print(f"{icons.green_dot} The '{capacity_name}' capacity has been resumed.") + + +@log +def delete_embedded_capacity( + capacity_name: str, + azure_subscription_id: str, + resource_group: str, +): + """ + This function deletes a Power BI Embedded capacity. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + resource_group : str + The name of the Azure resource group. + """ + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.PowerBIDedicated/capacities/{capacity_name}?api-version={icons.azure_api_version}" + + _base_api(request=url, client="azure", method="delete", status_codes=[200, 202]) + + print(f"{icons.green_dot} The '{capacity_name}' capacity has been deleted.") + + +@log +def delete_premium_capacity(capacity: str | UUID, **kwargs): + """ + This function deletes a Power BI Premium capacity. + + Parameters + ---------- + capacity : str | uuid.UUID + Name or ID of the Fabric capacity. + """ + from sempy_labs._helper_functions import resolve_capacity_id + + if "capacity_name" in kwargs: + capacity = kwargs["capacity_name"] + print( + f"{icons.warning} The 'capacity_name' parameter is deprecated. Please use 'capacity' instead." + ) + + capacity_id = resolve_capacity_id(capacity=capacity).upper() + + _base_api(request=f"capacities/{capacity_id}", method="delete", status_codes=204) + + print(f"{icons.green_dot} The '{capacity}' capacity has been deleted.") + + +@log +def delete_fabric_capacity( + capacity_name: str, + azure_subscription_id: str, + resource_group: str, +): + """ + This function deletes a Fabric capacity. + + This is a wrapper function for the following API: `Fabric Capacities - Delete `_. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + resource_group : str + The name of the Azure resource group. + """ + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}?api-version={icons.azure_api_version}" + + _base_api(request=url, client="azure", method="delete", status_codes=202) + + print(f"{icons.green_dot} The '{capacity_name}' capacity has been deleted.") + + +@log +def update_fabric_capacity( + capacity_name: str, + azure_subscription_id: str, + resource_group: str, + sku: Optional[str] = None, + admin_members: Optional[str | List[str]] = None, + tags: Optional[dict] = None, +): + """ + This function updates a Fabric capacity's properties. + + This is a wrapper function for the following API: `Fabric Capacities - Update `_. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + resource_group : str + The name of the Azure resource group. + sku : str, default=None + The `sku size `_ of the Fabric capacity. + admin_members : str | typing.List[str], default=None + The email address(es) of the admin(s) of the Fabric capacity. + tags : dict, default=None + Tag(s) to add to the capacity. Example: {'tagName': 'tagValue'}. + """ + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Fabric/capacities/{capacity_name}?api-version={icons.azure_api_version}" + + get_response = _base_api(request=url, client="azure") + + get_json = get_response.json() + current_sku = get_json.get("sku", {}).get("name") + current_admins = ( + get_json.get("properties", {}).get("administration", {}).get("members") + ) + current_tags = get_json.get("tags") + + payload = {} + payload["sku"] = { + "name": current_sku, + "tier": "Fabric", + } + payload["tags"] = current_tags + payload["properties"] = get_json["properties"] + + if sku is not None: + payload["sku"]["name"] = sku + if admin_members is not None: + payload["properties"] = {"administration": {"members": admin_members}} + if tags is not None: + payload["tags"] = tags + + # Do not proceed if no properties are being changed + if current_sku == sku and current_admins == admin_members and current_tags == tags: + print( + f"{icons.yellow_dot} The properties of the '{capacity_name}' are the same as those specified in the parameters of this function. No changes have been made." + ) + return + + payload = _add_sll_tag(payload, tags) + _base_api( + request=url, client="azure", method="patch", payload=payload, status_codes=202 + ) + + print( + f"{icons.green_dot} The '{capacity_name}' capacity has been updated accordingly." + ) + + +@log +def check_fabric_capacity_name_availablility( + capacity_name: str, + azure_subscription_id: str, + region: str, + **kwargs, +) -> bool: + """ + This function updates a Fabric capacity's properties. + + This is a wrapper function for the following API: `Fabric Capacities - Check Name Availability `_. + + Service Principal Authentication is required (see `here `_ for examples). + + Parameters + ---------- + capacity_name : str + Name of the Fabric capacity. + azure_subscription_id : str + The Azure subscription ID. + region : str + The region name. + + Returns + ------- + bool + An indication as to whether the Fabric capacity name is available or not. + """ + + payload = {"name": capacity_name, "type": "Microsoft.Fabric/capacities"} + + url = f"https://management.azure.com/subscriptions/{azure_subscription_id}/providers/Microsoft.Fabric/locations/{region}/checkNameAvailability?api-version={icons.azure_api_version}" + + response = _base_api( + request=url, client="azure", method="post", payload=payload, status_codes=202 + ) + + return bool(response.json().get("nameAvailable")) + + +@log +def list_capacities() -> pd.DataFrame: + """ + Shows the capacities and their properties. + + Returns + ------- + pandas.DataFrame + A pandas dataframe showing the capacities and their properties + """ + + columns = { + "Id": "string", + "Display Name": "string", + "Sku": "string", + "Region": "string", + "State": "string", + "Admins": "string", + } + df = _create_dataframe(columns=columns) + + response = _base_api(request="/v1.0/myorg/capacities", client="fabric_sp") + + rows = [] + for i in response.json().get("value", []): + rows.append( + { + "Id": i.get("id").lower(), + "Display Name": i.get("displayName"), + "Sku": i.get("sku"), + "Region": i.get("region"), + "State": i.get("state"), + "Admins": [i.get("admins", [])], + } + ) + + if rows: + df = pd.DataFrame(rows, columns=columns.keys()) + + return df + + +@log +def get_overage_configuration( + capacity: Optional[str | UUID] = None, +) -> dict: + """ + Gets the overage configuration for the specified capacity. + + Capacity overage configuration is currently in preview. The caller must have admin permissions on the capacity. + + This is a wrapper function for the following API: `Capacities - Get Overage Configuration `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + capacity : str | uuid.UUID, default=None + The capacity name or ID. + Defaults to None which resolves to the capacity of the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the capacity of the workspace of the notebook. + + Returns + ------- + dict + The overage configuration of the capacity, containing the 'state' and 'thresholdCapacityUnitHours' keys. + """ + + capacity_id = resolve_capacity_id(capacity) + + response = _base_api( + request=f"/v1/capacities/{capacity_id}/overageConfiguration", + client="fabric_sp", + ) + + return response.json() + + +@log +def update_overage_configuration( + capacity: Optional[str | UUID] = None, + state: Optional[Literal["Enabled", "Disabled"]] = None, + threshold_capacity_unit_hours: Optional[int] = None, +) -> dict: + """ + Updates the overage configuration for the specified capacity. + + Capacity overage configuration is currently in preview. The caller must have admin permissions on the capacity. + + This is a wrapper function for the following API: `Capacities - Update Overage Configuration `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + capacity : str | uuid.UUID, default=None + The capacity name or ID. + Defaults to None which resolves to the capacity of the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the capacity of the workspace of the notebook. + state : typing.Literal["Enabled", "Disabled"], default=None + The overage state. When set to 'Disabled', the threshold is automatically reset to 0. + When set to 'Enabled', a threshold greater than 0 must be supplied (either in the same request or already configured). + Defaults to None which leaves the state unchanged. + threshold_capacity_unit_hours : int, default=None + The capacity unit hours threshold for overage. Valid range is 0 to 240000. + Setting this to 0 automatically disables overage. + Defaults to None which leaves the threshold unchanged. + + Returns + ------- + dict + The updated overage configuration of the capacity, containing the 'state' and 'thresholdCapacityUnitHours' keys. + """ + + if state is None and threshold_capacity_unit_hours is None: + raise ValueError( + f"{icons.red_dot} At least one of the 'state' or 'threshold_capacity_unit_hours' parameters must be provided." + ) + + payload = {} + if state is not None: + valid_states = ["Enabled", "Disabled"] + state = state.capitalize() + if state not in valid_states: + raise ValueError( + f"{icons.red_dot} Invalid 'state' value. Valid values: {valid_states}." + ) + payload["state"] = state + if threshold_capacity_unit_hours is not None: + if not 0 <= threshold_capacity_unit_hours <= 240000: + raise ValueError( + f"{icons.red_dot} The 'threshold_capacity_unit_hours' parameter must be between 0 and 240000." + ) + payload["thresholdCapacityUnitHours"] = threshold_capacity_unit_hours + + capacity_id = resolve_capacity_id(capacity) + + response = _base_api( + request=f"/v1/capacities/{capacity_id}/overageConfiguration", + method="patch", + payload=payload, + client="fabric_sp", + ) + + print( + f"{icons.green_dot} The overage configuration of the '{capacity if capacity is not None else capacity_id}' capacity has been updated." + ) + + return response.json() diff --git a/src/sempy_labs/mirrored_catalog/__init__.py b/src/sempy_labs/mirrored_catalog/__init__.py index 4f91deb6d..295e8b154 100644 --- a/src/sempy_labs/mirrored_catalog/__init__.py +++ b/src/sempy_labs/mirrored_catalog/__init__.py @@ -9,6 +9,10 @@ get_mirroring_status, list_tables_mirroring_status, ) +from ._discovery import ( + list_scopes, + list_tables, +) __all__ = [ "list_mirrored_catalogs", @@ -16,4 +20,6 @@ "refresh_catalog_metadata", "get_mirroring_status", "list_tables_mirroring_status", + "list_scopes", + "list_tables", ] diff --git a/src/sempy_labs/mirrored_catalog/_discovery.py b/src/sempy_labs/mirrored_catalog/_discovery.py index e69de29bb..1cb9ea135 100644 --- a/src/sempy_labs/mirrored_catalog/_discovery.py +++ b/src/sempy_labs/mirrored_catalog/_discovery.py @@ -0,0 +1,176 @@ +from typing import Optional, List +from urllib.parse import urlencode +import pandas as pd +from sempy_labs._helper_functions import ( + _base_api, + _create_dataframe, + resolve_workspace_id, + _update_dataframe_datatypes, +) +from sempy_labs.connection._items import resolve_connection_id +from uuid import UUID +from sempy._utils._log import log + + +@log +def list_scopes( + connection: str | UUID, + parent: Optional[str | List[str]] = None, + recursive: bool = False, + workspace: Optional[str | UUID] = None, +) -> pd.DataFrame: + """ + Returns a list of namespaces (scopes) from the source catalog. This is used to browse and select the scope when creating or updating a mirrored catalog. + + When listing from the root level, the virtual '(root)' entry returned by the API is omitted and its children are returned instead. + When recursive is True, nested child namespaces are flattened into the dataframe. + + This is a wrapper function for the following API: `Discovery - List Scopes `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + connection : str | uuid.UUID + The name or ID of the connection to the catalog. + parent : str | List[str], default=None + The parent namespace segments in hierarchical order (root-level segment first, leaf segment last). + Defaults to None which returns the root namespaces. + recursive : bool, default=False + If True, returns nested child namespaces. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + + Returns + ------- + pandas.DataFrame + A pandas dataframe showing the namespaces (scopes) from the source catalog. + """ + + workspace_id = resolve_workspace_id(workspace) + connection_id = resolve_connection_id(connection) + + if isinstance(parent, str): + parent = [parent] + + params = [("beta", "True"), ("connectionId", str(connection_id))] + params.extend(("parent", p) for p in (parent or [])) + if recursive: + params.append(("recursive", "True")) + + columns = { + "Scope Name": "str", + "Fully Qualified Scope": "list", + "Is Selectable": "bool", + "Parent Scope": "list", + "Level": "int", + } + df = _create_dataframe(columns=columns) + + responses = _base_api( + request=f"/v1/workspaces/{workspace_id}/catalogmirroring/scopes?{urlencode(params)}", + uses_pagination=True, + client="fabric_sp", + ) + + rows = [] + + def _add_scope(scope: dict): + fq_scope = scope.get("fullyQualifiedScope") or [] + # Skip the virtual '(root)' wrapper returned when no parent is specified + if fq_scope: + rows.append( + { + "Scope Name": scope.get("name"), + "Fully Qualified Scope": fq_scope, + "Is Selectable": scope.get("isSelectable") == "Selectable", + "Parent Scope": fq_scope[:-1], + "Level": len(fq_scope), + } + ) + for child in scope.get("children") or []: + _add_scope(child) + + for r in responses: + for scope in r.get("value", []): + _add_scope(scope) + + if rows: + df = pd.DataFrame(rows, columns=list(columns.keys())) + _update_dataframe_datatypes(dataframe=df, column_map=columns) + + return df + + +@log +def list_tables( + connection: str | UUID, + scope: Optional[str | List[str]] = None, + workspace: Optional[str | UUID] = None, +) -> pd.DataFrame: + """ + Returns a list of tables within a specified namespace scope from the source catalog. This is used to browse tables when configuring partial mirroring with table-level selection. + + This is a wrapper function for the following API: `Discovery - List Tables `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + connection : str | uuid.UUID + The name or ID of the connection to the catalog. + scope : str | List[str], default=None + The selectable namespace scope segments in hierarchical order (root-level segment first). + Defaults to None which resolves to the root namespace. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + + Returns + ------- + pandas.DataFrame + A pandas dataframe showing the tables within the specified namespace scope. + """ + + workspace_id = resolve_workspace_id(workspace) + connection_id = resolve_connection_id(connection) + + if isinstance(scope, str): + scope = [scope] + + params = [("beta", "True"), ("connectionId", str(connection_id))] + # The API requires an empty 'scope=' parameter for the root namespace + params.extend(("scope", s) for s in (scope or [""])) + + columns = { + "Table Name": "str", + "Schema Name": "str", + "Fully Qualified Scope": "list", + } + df = _create_dataframe(columns=columns) + + responses = _base_api( + request=f"/v1/workspaces/{workspace_id}/catalogmirroring/tables?{urlencode(params)}", + uses_pagination=True, + client="fabric_sp", + ) + + rows = [] + for r in responses: + for table in r.get("value", []): + fq_scope = table.get("fullyQualifiedScope") or [] + rows.append( + { + "Table Name": table.get("name"), + "Schema Name": fq_scope[-1] if fq_scope else None, + "Fully Qualified Scope": fq_scope, + } + ) + + if rows: + df = pd.DataFrame(rows, columns=list(columns.keys())) + + return df diff --git a/src/sempy_labs/onelake/__init__.py b/src/sempy_labs/onelake/__init__.py index 3809afc6e..353a08061 100644 --- a/src/sempy_labs/onelake/__init__.py +++ b/src/sempy_labs/onelake/__init__.py @@ -8,6 +8,7 @@ modify_immutability_policy, get_onelake_settings, modify_default_tier, + modify_access_time_tracking, ) __all__ = [ @@ -18,4 +19,5 @@ "modify_immutability_policy", "get_onelake_settings", "modify_default_tier", + "modify_access_time_tracking", ] diff --git a/src/sempy_labs/onelake/_items.py b/src/sempy_labs/onelake/_items.py index 3dd99125c..3ebb7dd4f 100644 --- a/src/sempy_labs/onelake/_items.py +++ b/src/sempy_labs/onelake/_items.py @@ -186,4 +186,44 @@ def modify_default_tier(default_tier: Literal["Hot", "Cool", "Cold"], workspace: _base_api(request=f"/v1/workspaces/{workspace_id}/onelake/settings/modifyDefaultTier?defaultTier={default_tier}", method="post") - print(f"{icons.green_dot} The default tier for OneLake has been set to '{default_tier}' in the '{workspace_name}' workspace.") \ No newline at end of file + print(f"{icons.green_dot} The default tier for OneLake has been set to '{default_tier}' in the '{workspace_name}' workspace.") + + +@log +def modify_access_time_tracking( + enabled: bool = True, + workspace: Optional[str | UUID] = None, +): + """ + Enables or disables OneLake access time tracking for a workspace. + + Enables or disables Azure Storage LastAccessTime tracking for OneLake files in the workspace. + When enabled, file reads and writes update the file's last access timestamp; metadata-only operations do not. Changes take effect immediately. + + This is a wrapper function for the following API: `OneLake Settings - Modify Access Time Tracking `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + enabled : bool, default=True + Whether to enable or disable OneLake access time tracking. + workspace : str | uuid.UUID, default=None + The name or ID of the workspace. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + """ + + (workspace_name, workspace_id) = resolve_workspace_name_and_id(workspace) + status = "Enabled" if enabled else "Disabled" + + _base_api( + request=f"/v1/workspaces/{workspace_id}/onelake/settings/modifyAccessTimeTracking", + client="fabric_sp", + method="post", + payload={"status": status}, + ) + + print( + f"{icons.green_dot} OneLake access time tracking has been {status.lower()} in the '{workspace_name}' workspace." + ) diff --git a/src/sempy_labs/policy_set/__init__.py b/src/sempy_labs/policy_set/__init__.py new file mode 100644 index 000000000..66ee910b1 --- /dev/null +++ b/src/sempy_labs/policy_set/__init__.py @@ -0,0 +1,17 @@ +from ._items import ( + list_policy_sets, + delete_policy_set, +) +from ._activate import ( + activate_policy_set, + deactivate_policy_set, + get_active_policy_set_for_capacity, +) + +__all__ = [ + "list_policy_sets", + "delete_policy_set", + "activate_policy_set", + "deactivate_policy_set", + "get_active_policy_set_for_capacity", +] diff --git a/src/sempy_labs/policy_set/_activate.py b/src/sempy_labs/policy_set/_activate.py new file mode 100644 index 000000000..5cbaadf90 --- /dev/null +++ b/src/sempy_labs/policy_set/_activate.py @@ -0,0 +1,139 @@ +from sempy_labs._helper_functions import ( + resolve_workspace_id, + resolve_item_name_and_id, + resolve_capacity_id, + _base_api, +) +import sempy_labs._icons as icons +from uuid import UUID +from sempy._utils._log import log +from typing import Optional + + +@log +def activate_policy_set( + policy_set: str | UUID, + capacity: str | UUID, + allow_replace: bool = False, + workspace: Optional[str | UUID] = None, +): + """ + Activates a policy set on the specified capacity. + + Only a single policy set can be active on a given capacity. The caller must be an admin of the capacity. + + This is a wrapper function for the following API: `Activation - Activate `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + policy_set : str | uuid.UUID + The name or ID of the policy set. + capacity : str | uuid.UUID + The name or ID of the capacity on which to activate the policy set. + allow_replace : bool, default=False + If True, replaces the policy set which is currently active on the capacity (if any). + If False, the activation fails if another policy set is already active on the capacity. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID in which the policy set resides. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + """ + + workspace_id = resolve_workspace_id(workspace) + policy_set_name, policy_set_id = resolve_item_name_and_id( + item=policy_set, type="PolicySet", workspace=workspace_id + ) + capacity_id = resolve_capacity_id(capacity) + + payload = { + "scopeType": "Capacity", + "capacityId": str(capacity_id), + } + + _base_api( + request=f"/v1/workspaces/{workspace_id}/policySets/{policy_set_id}/activate?allowReplace={str(allow_replace).lower()}", + method="post", + payload=payload, + client="fabric_sp", + ) + + print( + f"{icons.green_dot} The '{policy_set_name}' policy set has been activated on the '{capacity}' capacity." + ) + + +@log +def deactivate_policy_set( + policy_set: str | UUID, + workspace: Optional[str | UUID] = None, +): + """ + Deactivates a policy set from its capacity scope. + + The caller must be an admin of the capacity on which the policy set is active. + + This is a wrapper function for the following API: `Activation - Deactivate `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + policy_set : str | uuid.UUID + The name or ID of the policy set. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID in which the policy set resides. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + """ + + workspace_id = resolve_workspace_id(workspace) + policy_set_name, policy_set_id = resolve_item_name_and_id( + item=policy_set, type="PolicySet", workspace=workspace_id + ) + + _base_api( + request=f"/v1/workspaces/{workspace_id}/policySets/{policy_set_id}/deactivate", + method="post", + payload={"scopeType": "Capacity"}, + client="fabric_sp", + ) + + print(f"{icons.green_dot} The '{policy_set_name}' policy set has been deactivated.") + + +@log +def get_active_policy_set_for_capacity( + capacity: Optional[str | UUID] = None, +) -> dict: + """ + Returns the active policy set on the specified capacity. + + The caller must be an admin of the capacity. + + This is a wrapper function for the following API: `Activation - Get Active For Capacity `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + capacity : str | uuid.UUID, default=None + The name or ID of the capacity. + Defaults to None which resolves to the capacity of the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the capacity of the workspace of the notebook. + + Returns + ------- + dict + The active policy set, containing the 'id', 'displayName', 'description' and 'workspaceId' keys. + """ + + capacity_id = resolve_capacity_id(capacity) + + response = _base_api( + request=f"/v1/capacities/{capacity_id}/policySets/active", + client="fabric_sp", + ) + + return response.json() diff --git a/src/sempy_labs/policy_set/_items.py b/src/sempy_labs/policy_set/_items.py new file mode 100644 index 000000000..1e9eaf71a --- /dev/null +++ b/src/sempy_labs/policy_set/_items.py @@ -0,0 +1,96 @@ +from sempy_labs._helper_functions import ( + resolve_workspace_id, + _base_api, + _create_dataframe, + delete_item, +) +from uuid import UUID +from sempy._utils._log import log +import pandas as pd +from typing import Optional + + +@log +def list_policy_sets(workspace: Optional[str | UUID] = None) -> pd.DataFrame: + """ + Shows the policy sets within a workspace. + + This is a wrapper function for the following API: `Items - List Policy Sets `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + + Returns + ------- + pandas.DataFrame + A pandas dataframe showing the policy sets within a workspace. + """ + + columns = { + "Policy Set Name": "string", + "Policy Set Id": "string", + "Description": "string", + "Folder Id": "string", + "Scope Type": "string", + "Scope Id": "string", + "Status": "string", + } + + df = _create_dataframe(columns=columns) + + workspace_id = resolve_workspace_id(workspace) + + responses = _base_api( + request=f"/v1/workspaces/{workspace_id}/policySets", + client="fabric_sp", + uses_pagination=True, + ) + + rows = [] + for r in responses: + for v in r.get("value", []): + props = v.get("properties", {}) or {} + scope = props.get("scope", {}) or {} + rows.append( + { + "Policy Set Name": v.get("displayName"), + "Policy Set Id": v.get("id"), + "Description": v.get("description"), + "Folder Id": v.get("folderId"), + "Scope Type": scope.get("type"), + "Scope Id": scope.get("id"), + "Status": props.get("status"), + } + ) + + if rows: + df = pd.DataFrame(rows, columns=list(columns.keys())) + + return df + + +@log +def delete_policy_set(policy_set: str | UUID, workspace: Optional[str | UUID] = None): + """ + Deletes a policy set within a workspace. + + This is a wrapper function for the following API: `Items - Delete Policy Set `_. + + Service Principal Authentication is supported (see `here `_ for examples). + + Parameters + ---------- + policy_set : str | uuid.UUID + The ID of the policy set to delete. + workspace : str | uuid.UUID, default=None + The Fabric workspace name or ID. + Defaults to None which resolves to the workspace of the attached lakehouse + or if no lakehouse attached, resolves to the workspace of the notebook. + """ + delete_item(item=policy_set, type="PolicySet", workspace=workspace) diff --git a/src/sempy_labs/semantic_model/__init__.py b/src/sempy_labs/semantic_model/__init__.py index 835c9aeb1..7a483a2d9 100644 --- a/src/sempy_labs/semantic_model/__init__.py +++ b/src/sempy_labs/semantic_model/__init__.py @@ -21,6 +21,9 @@ from ._find_unused_objects import ( find_unused_objects, ) +from ._refresh_manager import ( + refresh_manager, +) __all__ = [ "approved_for_copilot", @@ -32,4 +35,5 @@ "lineage_view", "migrate_to_direct_lake", "find_unused_objects", + "refresh_manager", ]