Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c8e6870
Adds support for environmetn definitions
v-alexmoraru Apr 15, 2026
063c6fc
Merge branch 'main' into dev/v-alexmoraru/support-environment-definit…
v-alexmoraru Apr 15, 2026
5711f26
Re-records failing tests
v-alexmoraru Apr 16, 2026
0d45c51
Merge branch 'main' into dev/v-alexmoraru/support-environment-definit…
v-alexmoraru Apr 16, 2026
a463c2a
Re-records import tests
v-alexmoraru Apr 16, 2026
d328b9e
Re-records all import tests
v-alexmoraru Apr 16, 2026
47bdecb
Adds changelog
v-alexmoraru Apr 17, 2026
933f1f1
Reverts environment processing
v-alexmoraru Apr 22, 2026
668ea4b
Reverts import file
v-alexmoraru Apr 22, 2026
d1cfdf3
Updates import item success test
v-alexmoraru Apr 23, 2026
785a126
Merge branch 'main' into dev/v-alexmoraru/support-environment-definit…
v-alexmoraru Apr 23, 2026
dd0f57d
Re-records import tests
v-alexmoraru Apr 23, 2026
8a15dcc
Reverts to main
v-alexmoraru May 29, 2026
14e955a
Merge branch 'main' into dev/v-alexmoraru/support-environment-definit…
v-alexmoraru May 29, 2026
2c467eb
Support environment definitions
v-alexmoraru May 29, 2026
9002906
Removes env from import
v-alexmoraru May 29, 2026
a71fc48
Copilot review suggestion
v-alexmoraru May 29, 2026
69c40a6
Merge branch 'main' into dev/v-alexmoraru/support-environment-definit…
v-alexmoraru Jun 2, 2026
ac1053f
Re-records environment tests
v-alexmoraru Jun 2, 2026
9a6e4ba
Records new environment tests
v-alexmoraru Jun 2, 2026
c72c127
Review suggestions
v-alexmoraru Jun 2, 2026
0504caf
Merge branch 'main' into dev/v-alexmoraru/support-environment-definit…
v-alexmoraru Jun 2, 2026
1e45a5c
Supports import environment
v-alexmoraru Jun 2, 2026
e1d2d52
Re-records import env tests
v-alexmoraru Jun 2, 2026
6d7667d
Re-records all import tests
v-alexmoraru Jun 2, 2026
fc072fc
Review suggestions
v-alexmoraru Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/added-20260417-105920.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: added
body: Adds support for environment definitions
time: 2026-04-17T10:59:20.288002+03:00
custom:
Author: v-alexmoraru
AuthorLink: https://github.com/v-alexmoraru
37 changes: 2 additions & 35 deletions src/fabric_cli/commands/fs/impor/fab_fs_import_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from fabric_cli.client.fab_api_types import ApiResponse
from fabric_cli.core import fab_constant, fab_logger
from fabric_cli.core.fab_exceptions import FabricCLIError
from fabric_cli.core.fab_types import ItemType
from fabric_cli.core.hiearchy.fab_hiearchy import Item
from fabric_cli.utils import fab_cmd_import_utils as utils_import
from fabric_cli.utils import fab_item_util
Expand Down Expand Up @@ -53,11 +52,7 @@ def import_single_item(item: Item, args: Namespace) -> None:
f"Importing (update) '{_input_path}' → '{item.path}'..."
)

# Environment item type, not supporting definition yet
if item.item_type == ItemType.ENVIRONMENT:
_import_update_environment_item(args, payload)
else:
_import_update_item(args, payload)
_import_update_item(args, payload)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just want to make sure that current version available today vs your changes produce the same results and state of item.


utils_ui.print_output_format(
args, message=f"'{item.name}' imported")
Expand All @@ -66,11 +61,7 @@ def import_single_item(item: Item, args: Namespace) -> None:
utils_ui.print_grey(
f"Importing '{_input_path}' → '{item.path}'...")

# Environment item type, not supporting definition yet
if item.item_type == ItemType.ENVIRONMENT:
response = _import_create_environment_item(item, args, payload)
else:
response = _import_create_item(args, payload)
response = _import_create_item(args, payload)

if response.status_code in (200, 201):
utils_ui.print_output_format(
Expand All @@ -83,10 +74,6 @@ def import_single_item(item: Item, args: Namespace) -> None:


# Utils
def _import_update_environment_item(args: Namespace, payload: dict) -> None:
utils_import.publish_environment_item(args, payload)


def _import_update_item(args: Namespace, payload: dict) -> None:
definition_payload = json.dumps(
{
Expand All @@ -96,26 +83,6 @@ def _import_update_item(args: Namespace, payload: dict) -> None:
item_api.update_item_definition(args, payload=definition_payload)


def _import_create_environment_item(
item: Item, args: Namespace, payload: dict
) -> ApiResponse:

item_payload: dict = {
"type": str(item.item_type),
"displayName": item.short_name,
"folderId": item.folder_id,
}
item_payload_str = json.dumps(item_payload)

# Create the item
response = item_api.create_item(args, payload=item_payload_str)
data = json.loads(response.text)
args.id = data["id"]

utils_import.publish_environment_item(args, payload)
return response


def _import_create_item(args: Namespace, payload: dict) -> ApiResponse:
_payload = json.dumps(payload)
return item_api.create_item(args, payload=_payload)
4 changes: 4 additions & 0 deletions src/fabric_cli/core/fab_config/command_support.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ commands:
- sql_database
- user_data_function
- map
- environment
cp:
supported_elements:
- workspace
Expand Down Expand Up @@ -205,6 +206,7 @@ commands:
- sql_database
- user_data_function
- map
- environment
ln:
supported_elements:
- onelake
Expand Down Expand Up @@ -263,6 +265,7 @@ commands:
- graph_query_set
- map
- lakehouse
- environment
import:
supported_items:
- report
Expand Down Expand Up @@ -290,6 +293,7 @@ commands:
- user_data_function
- map
- lakehouse
- environment
unsupported_items:
- graph_query_set
get:
Expand Down
1 change: 1 addition & 0 deletions src/fabric_cli/core/fab_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,4 +605,5 @@ class MirroredDatabaseFolders(Enum):
ItemType.GRAPH_QUERY_SET: {"default": ""},
ItemType.VARIABLE_LIBRARY: {"default": ""},
ItemType.MAP: {"default": ""},
ItemType.ENVIRONMENT: {"default": ""},
}
244 changes: 10 additions & 234 deletions src/fabric_cli/utils/fab_cmd_import_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,34 @@
import base64
import json
import os
import time
from argparse import Namespace
from typing import Any, Optional

import yaml

from fabric_cli.client import fab_api_item as item_api
from fabric_cli.core import fab_constant
from fabric_cli.core.fab_exceptions import FabricCLIError
from fabric_cli.core.fab_types import ItemType
from fabric_cli.core.hiearchy.fab_hiearchy import Item
from fabric_cli.utils import fab_ui as utils_ui


def get_payload_for_item_type(
path: str, item: Item, input_format: Optional[str] = None
) -> dict:
# Environment does not support updateDefinition yet, custom payload / dev
if item.item_type == ItemType.ENVIRONMENT:
return _build_environment_payload(path)
else:
definition = _build_definition(path, input_format)
return {
"type": str(item.item_type),
"folderId": item.folder_id,
"displayName": item.short_name,
"definition": definition,
}
definition = _build_definition(path, input_format)
return {
"type": str(item.item_type),
"folderId": item.folder_id,
"displayName": item.short_name,
"definition": definition,
}
Comment thread
v-alexmoraru marked this conversation as resolved.


def _build_definition(input_path: Any, input_format: Optional[str] = None) -> dict:
directory = input_path
parts = []

# Recursively traverses the directory and builds the payload structure
# Sort dirs and files to ensure deterministic ordering across platforms
for root, dirs, files in os.walk(directory):
for file in files:
dirs.sort()
for file in sorted(files):
# Get full path and relative path
full_path = os.path.join(root, file)
relative_path = os.path.relpath(full_path, directory)
Expand Down Expand Up @@ -81,218 +72,3 @@ def _build_definition(input_path: Any, input_format: Optional[str] = None) -> di
def _encode_file_to_base64(file_path: str) -> str:
with open(file_path, "rb") as file:
return base64.b64encode(file.read()).decode("utf-8")


# Environments


def publish_environment_item(args: Namespace, payload: dict) -> None:
# Check for ongoing publish
_check_environment_publish_state(args, True)

# Update compute settings
_update_compute_settings(args, payload)

# Add libraries to environment, overwriting anything with the same name and return the list of libraries
_add_libraries(args, payload)

# Remove libraries from live environment
_remove_libraries(args, payload)

# Publish
item_api.environment_publish(args)

# Wait for ongoing publish to complete
_check_environment_publish_state(args)

utils_ui.print_info(f"Published")


def _check_environment_publish_state(
args: Namespace, initial_check: bool = False
) -> None:
publishing = True
iteration = 1

while publishing:
args.item_uri = "environments"
response = item_api.get_item(args, item_uri=True)
data = response.json()

current_state = (
data.get("properties", {})
.get("publishDetails", {})
.get("state", "Unknown")
.lower()
)

if initial_check:

prepend_message = "Existing Environment publish is in progess"
pass_values = ["success", "failed", "cancelled"]
fail_values = []

else:
prepend_message = "Operation in progress"
pass_values = ["success"]
fail_values = ["failed", "cancelled"]

if current_state in pass_values:
publishing = False
elif current_state in fail_values:
msg = f"Publish {current_state} for Libraries"
raise Exception(msg)
else:
_handle_retry(
attempt=iteration,
base_delay=5,
max_retries=20,
response_retry_after=120,
prepend_message=prepend_message,
)
iteration += 1


def _build_environment_payload(input_path: Any) -> dict:
directory = input_path

parts: dict[Any, Any] = {}
for root, dirs, files in os.walk(directory):
for file in files:
# Get full path and relative path
full_path = os.path.join(root, file)

# Spark compute settings
if "Setting" in full_path:
with open(full_path, "r") as file:
yaml_body = yaml.safe_load(file)
parts["sparkCompute"] = _convert_environment_compute_to_camel(yaml_body)

# Spark libraries
elif "Libraries" in full_path:
parts["libraries"] = parts.get("libraries", [])
# Append instead of overwrite
parts["libraries"].append(full_path)

return {"parts": parts}


def _convert_environment_compute_to_camel(input_dict: dict) -> dict:
new_input_dict = {}

for key, value in input_dict.items():
if key == "spark_conf":
new_key = "sparkProperties"
else:
# Convert the key to camelCase
key_components = key.split("_")
# Capitalize the first letter of each component except the first one
new_key = key_components[0] + "".join(x.title() for x in key_components[1:])

# Recursively update dictionary values if they are dictionaries
if isinstance(value, dict):
value = _convert_environment_compute_to_camel(value)

new_input_dict[new_key] = value

return new_input_dict


def _update_compute_settings(args: Namespace, payload: dict) -> None:
if "sparkCompute" in payload["parts"]:
spark_compute = payload["parts"]["sparkCompute"]
_spark_compute_payload = json.dumps(spark_compute)

args.ext_uri = "/staging/sparkcompute"
args.item_uri = "environments"

response = item_api.update_item(
args, payload=_spark_compute_payload, item_uri=True, ext_uri=True
)

if response.status_code == 200:
utils_ui.print_info("Updated Spark Settings")


def _add_libraries(args: Namespace, payload: dict) -> None:
if "libraries" in payload["parts"]:
# Extract the list of libraries
library_paths = payload["parts"]["libraries"]

for file_path in library_paths:
file_name = os.path.basename(file_path)

# Open the file in binary mode for reading
with open(file_path, "rb") as file:
library_file = {"file": (file_name, file)}

# Upload libraries to the environment
response = item_api.environment_upload_staging_library(
args, library_file
)

if response.status_code == 200:
utils_ui.print_info(f"Updated Library '{file_name}'")


def _remove_libraries(args: Namespace, payload: dict) -> None:
args.ext_uri = "/libraries"
args.item_uri = "environments"

try:
response = item_api.get_item(args, item_uri=True, ext_uri=True)
if response.status_code == 200:
response_json = response.json() # Convert to dictionary

repo_library_files = tuple(
os.path.basename(file) for file in payload["parts"]["libraries"]
)

if (
"environmentYml" in response_json
and response_json["environmentYml"] # Not None or ''
and "environment.yml" not in repo_library_files
):
_remove_library(args, "environment.yml")

custom_libraries = response_json.get("customLibraries", {})
if isinstance(custom_libraries, dict):
for files in custom_libraries.values():
if isinstance(files, list):
for file in files:
if file not in repo_library_files:
_remove_library(args, file)

except Exception as e:
pass


def _remove_library(args: Namespace, file_name: str) -> None:
item_api.environment_delete_library_staging(args, file_name)
utils_ui.print_info(f"Removed {file_name}")


def _handle_retry(
attempt: int,
base_delay: float,
max_retries: int,
response_retry_after: float = 60,
prepend_message: str = "",
) -> None:
if attempt < max_retries:
retry_after = float(response_retry_after)
base_delay = float(base_delay)
delay = min(retry_after, base_delay * (2**attempt))

# Modify output for proper plurality and formatting
delay_str = f"{delay:.0f}" if delay.is_integer() else f"{delay:.2f}"
second_str = "second" if delay == 1 else "seconds"
prepend_message += " " if prepend_message else ""

utils_ui.print_progress(
f"{prepend_message}Checking again in {delay_str} {second_str} (Attempt {attempt}/{max_retries})..."
)
time.sleep(delay)
else:
msg = f"Maximum retry attempts ({max_retries}) exceeded"
raise Exception(msg)
Loading
Loading