From a9c16217f56e42a9a10ec34d695efe9d77a56fda Mon Sep 17 00:00:00 2001 From: Alon Yeshurun <98805507+ayeshurun@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:14:50 +0200 Subject: [PATCH 1/3] Add benchmark script for CLI startup performance This script benchmarks the startup performance of the CLI by measuring module import times, CLI invocation times, and heavy dependency loading. It allows comparisons against a baseline branch or tag. --- scripts/benchmark_startup.py | 270 +++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 scripts/benchmark_startup.py diff --git a/scripts/benchmark_startup.py b/scripts/benchmark_startup.py new file mode 100644 index 000000000..0d502613c --- /dev/null +++ b/scripts/benchmark_startup.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +Benchmark CLI startup performance. + +Compares the current branch against 'main' (or any other branch) by measuring: + 1. Module import time (fabric_cli.main) + 2. CLI invocation time (fab --version) + 3. Heavy dependency loading (msal, jwt, cryptography, requests, prompt_toolkit) + +Usage: + # Compare current branch against main + python scripts/benchmark_startup.py + + # Compare current branch against a specific branch/tag/commit + python scripts/benchmark_startup.py --baseline v1.3.0 + + # Run only on the current branch (no git checkout) + python scripts/benchmark_startup.py --current-only + + # Change number of iterations (default: 10) + python scripts/benchmark_startup.py --iterations 20 +""" + +import argparse +import importlib +import json +import os +import shutil +import statistics +import subprocess +import sys +import time + + +HEAVY_MODULES = ["msal", "jwt", "cryptography", "requests", "prompt_toolkit", "psutil"] + + +def measure_import_time(iterations: int) -> dict: + """Measure fabric_cli.main import time across multiple iterations.""" + times = [] + for _ in range(iterations): + # Clear all fabric_cli modules from cache + mods = [k for k in sys.modules if k.startswith("fabric_cli")] + for m in mods: + del sys.modules[m] + + start = time.perf_counter() + importlib.import_module("fabric_cli.main") + elapsed_ms = (time.perf_counter() - start) * 1000 + times.append(elapsed_ms) + + return { + "median_ms": round(statistics.median(times), 1), + "min_ms": round(min(times), 1), + "max_ms": round(max(times), 1), + "mean_ms": round(statistics.mean(times), 1), + "stdev_ms": round(statistics.stdev(times), 1) if len(times) > 1 else 0, + "samples": times, + } + + +def check_heavy_modules() -> dict: + """Check which heavy modules are loaded after importing fabric_cli.main.""" + # Clear all fabric_cli modules + mods = [k for k in sys.modules if k.startswith("fabric_cli")] + for m in mods: + del sys.modules[m] + + # Also clear heavy modules + for mod in HEAVY_MODULES: + keys = [k for k in sys.modules if k.startswith(mod)] + for k in keys: + del sys.modules[k] + + importlib.import_module("fabric_cli.main") + + return {mod: mod in sys.modules for mod in HEAVY_MODULES} + + +def measure_cli_time(iterations: int) -> dict: + """Measure 'fab --version' wall-clock time.""" + fab_path = shutil.which("fab") + if not fab_path: + return {"error": "'fab' not found in PATH. Run 'pip install -e .' first."} + + times = [] + for _ in range(iterations): + start = time.perf_counter() + subprocess.run( + [fab_path, "--version"], + capture_output=True, + text=True, + ) + elapsed_ms = (time.perf_counter() - start) * 1000 + times.append(elapsed_ms) + + return { + "median_ms": round(statistics.median(times), 1), + "min_ms": round(min(times), 1), + "max_ms": round(max(times), 1), + "mean_ms": round(statistics.mean(times), 1), + "stdev_ms": round(statistics.stdev(times), 1) if len(times) > 1 else 0, + } + + +def run_benchmark(label: str, iterations: int) -> dict: + """Run all benchmarks and return results.""" + print(f"\n{'=' * 60}") + print(f" Benchmarking: {label}") + print(f"{'=' * 60}") + + # 1. Import time + print(f" Measuring import time ({iterations} iterations)...", end="", flush=True) + import_results = measure_import_time(iterations) + print(f" {import_results['median_ms']:.0f}ms median") + + # 2. Heavy modules + print(" Checking heavy module loading...", end="", flush=True) + heavy_results = check_heavy_modules() + loaded = [m for m, v in heavy_results.items() if v] + print(f" {len(loaded)} loaded: {', '.join(loaded) if loaded else 'none'}") + + # 3. CLI time + print(f" Measuring 'fab --version' ({iterations} iterations)...", end="", flush=True) + cli_results = measure_cli_time(iterations) + if "error" in cli_results: + print(f" {cli_results['error']}") + else: + print(f" {cli_results['median_ms']:.0f}ms median") + + return { + "label": label, + "import_time": import_results, + "heavy_modules": heavy_results, + "cli_time": cli_results, + } + + +def print_comparison(baseline: dict, current: dict): + """Print a formatted comparison table.""" + print(f"\n{'=' * 60}") + print(" COMPARISON") + print(f"{'=' * 60}\n") + + bl = baseline["import_time"]["median_ms"] + cu = current["import_time"]["median_ms"] + diff = bl - cu + pct = (diff / bl * 100) if bl > 0 else 0 + + print(f" {'Metric':<30} {'Baseline':>10} {'Current':>10} {'Change':>10}") + print(f" {'-' * 62}") + print(f" {'Import time (median):':<30} {bl:>9.0f}ms {cu:>9.0f}ms {diff:>+8.0f}ms") + print(f" {'Import improvement:':<30} {'':>10} {'':>10} {pct:>+8.0f}%") + + if "error" not in baseline["cli_time"] and "error" not in current["cli_time"]: + bl_cli = baseline["cli_time"]["median_ms"] + cu_cli = current["cli_time"]["median_ms"] + cli_diff = bl_cli - cu_cli + cli_pct = (cli_diff / bl_cli * 100) if bl_cli > 0 else 0 + print(f" {'CLI time (median):':<30} {bl_cli:>9.0f}ms {cu_cli:>9.0f}ms {cli_diff:>+8.0f}ms") + print(f" {'CLI improvement:':<30} {'':>10} {'':>10} {cli_pct:>+8.0f}%") + + print(f"\n {'Heavy modules at startup:':<30}") + for mod in HEAVY_MODULES: + bl_loaded = "LOADED" if baseline["heavy_modules"].get(mod) else "deferred" + cu_loaded = "LOADED" if current["heavy_modules"].get(mod) else "deferred" + marker = " ✓" if cu_loaded == "deferred" and bl_loaded == "LOADED" else "" + print(f" {mod:<25} {bl_loaded:>10} {cu_loaded:>10}{marker}") + + print() + + +def git_checkout_and_install(ref: str): + """Checkout a git ref and reinstall the package.""" + print(f"\n Switching to '{ref}'...") + subprocess.run(["git", "checkout", ref], capture_output=True, check=True) + subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", ".", "-q"], + capture_output=True, + check=True, + ) + # Clear all cached fabric_cli modules after reinstall + mods = [k for k in sys.modules if k.startswith("fabric_cli")] + for m in mods: + del sys.modules[m] + + +def main(): + parser = argparse.ArgumentParser( + description="Benchmark CLI startup performance between branches." + ) + parser.add_argument( + "--baseline", + default="main", + help="Git ref to compare against (default: main)", + ) + parser.add_argument( + "--iterations", "-n", + type=int, + default=10, + help="Number of iterations per measurement (default: 10)", + ) + parser.add_argument( + "--current-only", + action="store_true", + help="Only benchmark the current branch (skip baseline)", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON", + ) + args = parser.parse_args() + + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + os.chdir(repo_root) + + # Get current branch name + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, + ) + current_branch = result.stdout.strip() + + print(f" Repo: {repo_root}") + print(f" Current branch: {current_branch}") + print(f" Baseline: {args.baseline}") + print(f" Iterations: {args.iterations}") + + results = {} + + if not args.current_only: + # Benchmark baseline + try: + git_checkout_and_install(args.baseline) + results["baseline"] = run_benchmark(f"Baseline ({args.baseline})", args.iterations) + except subprocess.CalledProcessError: + print(f"\n ERROR: Could not checkout '{args.baseline}'. Does it exist?") + print(f" Try: python scripts/benchmark_startup.py --current-only") + sys.exit(1) + finally: + # Always return to original branch + subprocess.run(["git", "checkout", current_branch], capture_output=True) + subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", ".", "-q"], + capture_output=True, + ) + # Clear modules again + mods = [k for k in sys.modules if k.startswith("fabric_cli")] + for m in mods: + del sys.modules[m] + + # Benchmark current + results["current"] = run_benchmark(f"Current ({current_branch})", args.iterations) + + # Print comparison + if "baseline" in results: + print_comparison(results["baseline"], results["current"]) + + # JSON output + if args.json: + # Remove raw samples for cleaner JSON + for key in results: + if "samples" in results[key].get("import_time", {}): + del results[key]["import_time"]["samples"] + print(json.dumps(results, indent=2)) + + +if __name__ == "__main__": + main() From 87fe82d4018e23b67d5ef7377309ca1ea827bfc2 Mon Sep 17 00:00:00 2001 From: Alon Yeshurun Date: Tue, 17 Mar 2026 12:35:04 +0200 Subject: [PATCH 2/3] refactor(core): move get_payload and _build_import_definition to fab_cmd_import_utils - Create shared resolve_definition_format utility in fab_item_util.py - Update fab_fs_export_item.py and fab_fs_import_item.py to use shared function - Add invalid_definition_format to ExportErrors with backward-compatible alias - Consolidate common format validation pattern across export/import commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fabric_cli/client/fab_api_item.py | 4 +- .../commands/fs/export/fab_fs_export_item.py | 24 +-- .../commands/fs/impor/fab_fs_import_item.py | 26 +-- .../commands/fs/set/fab_fs_set_item.py | 13 +- src/fabric_cli/core/fab_types.py | 16 +- src/fabric_cli/core/hiearchy/fab_item.py | 83 --------- src/fabric_cli/errors/__init__.py | 2 - src/fabric_cli/errors/common.py | 9 + src/fabric_cli/errors/export.py | 9 - src/fabric_cli/utils/fab_cmd_import_utils.py | 19 ++- src/fabric_cli/utils/fab_item_util.py | 32 +++- tests/test_commands/conftest.py | 2 + ...t_create_new_notebook_py_item_success.yaml | 4 +- tests/test_commands/test_import.py | 2 + tests/test_core/test_fab_hiearchy.py | 158 +++++++++++++++--- 15 files changed, 219 insertions(+), 184 deletions(-) delete mode 100644 src/fabric_cli/errors/export.py diff --git a/src/fabric_cli/client/fab_api_item.py b/src/fabric_cli/client/fab_api_item.py index 3d3837dd4..117661bd6 100644 --- a/src/fabric_cli/client/fab_api_item.py +++ b/src/fabric_cli/client/fab_api_item.py @@ -100,7 +100,9 @@ def get_item( def get_item_definition(args: Namespace) -> ApiResponse: """https://learn.microsoft.com/en-us/rest/api/fabric/core/items/get-item-definition""" - args.uri = f"workspaces/{args.ws_id}/items/{args.id}/getDefinition{args.format}" + args.uri = f"workspaces/{args.ws_id}/items/{args.id}/getDefinition" + if args.format: + args.uri += f"?format={args.format}" args.method = "post" args.wait = True diff --git a/src/fabric_cli/commands/fs/export/fab_fs_export_item.py b/src/fabric_cli/commands/fs/export/fab_fs_export_item.py index db11cf5d7..56e1d5d14 100644 --- a/src/fabric_cli/commands/fs/export/fab_fs_export_item.py +++ b/src/fabric_cli/commands/fs/export/fab_fs_export_item.py @@ -10,10 +10,9 @@ from fabric_cli.core import fab_constant from fabric_cli.core.fab_commands import Command from fabric_cli.core.fab_exceptions import FabricCLIError -from fabric_cli.core.fab_types import ItemType, definition_format_mapping +from fabric_cli.core.fab_types import ItemType from fabric_cli.core.hiearchy.fab_folder import Folder from fabric_cli.core.hiearchy.fab_hiearchy import Item, Workspace -from fabric_cli.errors import ErrorMessages from fabric_cli.utils import fab_cmd_export_utils as utils_export from fabric_cli.utils import fab_item_util, fab_mem_store, fab_storage, fab_ui @@ -103,27 +102,8 @@ def export_single_item( args.from_path = item.path.strip("/") args.ws_id, args.id, args.item_type = workspace_id, item_id, str(item_type) - # Get definition_format_mapping for item without default fallback - valid_export_formats = definition_format_mapping.get(item_type, {}) - # Get export_format_param from args without default export_format_param = getattr(args, "format", None) - - if export_format_param not in valid_export_formats: - # Export format not in definition_format_mapping - if not export_format_param: - # Empty format param - use default formats if exists - args.format = valid_export_formats.get("default", "") - else: - # Non-empty format param but not supported - available_formats = [k for k in valid_export_formats.keys() if k != "default"] - raise FabricCLIError( - ErrorMessages.Export.invalid_export_format(available_formats), - fab_constant.ERROR_INVALID_INPUT, - ) - else: - # Export format is explicitly supported - args.format = valid_export_formats[export_format_param] - + args.format = fab_item_util.resolve_definition_format(item_type, export_format_param) item_def = item_api.get_item_withdefinition(args, item_uri) diff --git a/src/fabric_cli/commands/fs/impor/fab_fs_import_item.py b/src/fabric_cli/commands/fs/impor/fab_fs_import_item.py index a7765b6bb..b3883699b 100644 --- a/src/fabric_cli/commands/fs/impor/fab_fs_import_item.py +++ b/src/fabric_cli/commands/fs/impor/fab_fs_import_item.py @@ -8,33 +8,19 @@ 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, definition_format_mapping +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 from fabric_cli.utils import fab_mem_store as utils_mem_store from fabric_cli.utils import fab_storage as utils_storage from fabric_cli.utils import fab_ui as utils_ui def import_single_item(item: Item, args: Namespace) -> None: - _input_format = None - if args.format: - _input_format = args.format - if item.item_type in definition_format_mapping: - valid_formats = list( - definition_format_mapping[item.item_type].keys()) - if _input_format not in valid_formats: - available_formats = [ - k for k in valid_formats if k != "default"] - raise FabricCLIError( - f"Invalid format. Only the following formats are supported: {', '.join(available_formats)}", - fab_constant.ERROR_INVALID_INPUT, - ) - else: - raise FabricCLIError( - f"Invalid format. No formats are supported", - fab_constant.ERROR_INVALID_INPUT, - ) + _input_format = fab_item_util.resolve_definition_format( + item_type=item.item_type, format_param=getattr(args, "format", None) + ) args.ws_id = item.workspace.id input_path = utils_storage.get_import_path(args.input) @@ -54,7 +40,7 @@ def import_single_item(item: Item, args: Namespace) -> None: # Get the payload payload = utils_import.get_payload_for_item_type( - _input_path, item, _input_format + _input_path, item, input_format=_input_format ) if item_exists: diff --git a/src/fabric_cli/commands/fs/set/fab_fs_set_item.py b/src/fabric_cli/commands/fs/set/fab_fs_set_item.py index 7fea4ef8c..9d1c61008 100644 --- a/src/fabric_cli/commands/fs/set/fab_fs_set_item.py +++ b/src/fabric_cli/commands/fs/set/fab_fs_set_item.py @@ -33,19 +33,24 @@ def exec(item: Item, args: Namespace) -> None: args.item_uri = format_mapping.get(item.item_type, "items") if query_value.startswith(fab_constant.ITEM_QUERY_DEFINITION): - formats = definition_format_mapping.get(item.item_type, {"default": ""}) + formats = definition_format_mapping.get( + item.item_type, {"default": ""}) + # plain value; query param built in get_item_definition() args.format = formats["default"] def_response = item_api.get_item_definition(args) definition = json.loads(def_response.text) - updated_def = _update_item_definition(definition, query_value, args.input) + updated_def = _update_item_definition( + definition, query_value, args.input) update_item_definition_payload = json.dumps(updated_def) utils_ui.print_grey(f"Setting new property for '{item.name}'...") - item_api.update_item_definition(args, update_item_definition_payload) + item_api.update_item_definition( + args, update_item_definition_payload) else: - item_metadata = json.loads(item_api.get_item(args, item_uri=True).text) + item_metadata = json.loads( + item_api.get_item(args, item_uri=True).text) update_payload_dict = _update_item_metadata( item_metadata, query_value, args.input diff --git a/src/fabric_cli/core/fab_types.py b/src/fabric_cli/core/fab_types.py index ef29554eb..0bd4b0a82 100644 --- a/src/fabric_cli/core/fab_types.py +++ b/src/fabric_cli/core/fab_types.py @@ -578,19 +578,19 @@ class MirroredDatabaseFolders(Enum): definition_format_mapping = { ItemType.SPARK_JOB_DEFINITION: { - "default": "?format=SparkJobDefinitionV1", - "SparkJobDefinitionV1": "?format=SparkJobDefinitionV1", - "SparkJobDefinitionV2": "?format=SparkJobDefinitionV2", + "default": "SparkJobDefinitionV1", + "SparkJobDefinitionV1": "SparkJobDefinitionV1", + "SparkJobDefinitionV2": "SparkJobDefinitionV2", }, ItemType.NOTEBOOK: { - "default": "?format=ipynb", - ".py": "?format=fabricGitSource", - ".ipynb": "?format=ipynb", + "default": "ipynb", + ".py": "fabricGitSource", + ".ipynb": "ipynb", }, ItemType.SEMANTIC_MODEL: { "default": "", - "TMDL": "?format=TMDL", - "TMSL": "?format=TMSL", + "TMDL": "TMDL", + "TMSL": "TMSL", }, ItemType.COSMOS_DB_DATABASE: {"default": ""}, ItemType.USER_DATA_FUNCTION: {"default": ""}, diff --git a/src/fabric_cli/core/hiearchy/fab_item.py b/src/fabric_cli/core/hiearchy/fab_item.py index 37f3c72cd..bf86e56e4 100644 --- a/src/fabric_cli/core/hiearchy/fab_item.py +++ b/src/fabric_cli/core/hiearchy/fab_item.py @@ -74,88 +74,5 @@ def workspace(self) -> Workspace: assert isinstance(self.parent, Folder) return self.parent.workspace - def get_payload(self, definition, input_format=None) -> dict: - match self.item_type: - - case ItemType.SPARK_JOB_DEFINITION: - return { - "type": str(self.item_type), - "description": "Imported from fab", - "folderId": self.folder_id, - "displayName": self.short_name, - "definition": { - "format": ( - "SparkJobDefinitionV1" - if input_format is None - else input_format - ), - "parts": definition["parts"], - }, - } - case ItemType.NOTEBOOK: - return { - "type": str(self.item_type), - "description": "Imported from fab", - "folderId": self.folder_id, - "displayName": self.short_name, - "definition": { - **( - {"parts": definition["parts"]} - if input_format == ".py" - else {"format": "ipynb", "parts": definition["parts"]} - ) - }, - } - case ItemType.SEMANTIC_MODEL: - return { - "type": str(self.item_type), - "description": "Imported from fab", - "folderId": self.folder_id, - "displayName": self.short_name, - "definition": ( - definition - if input_format is None - else { - "format": input_format, - "parts": definition["parts"], - } - ), - } - case ( - ItemType.REPORT - | ItemType.KQL_DASHBOARD - | ItemType.DATA_PIPELINE - | ItemType.KQL_QUERYSET - | ItemType.EVENTHOUSE - | ItemType.KQL_DATABASE - | ItemType.MIRRORED_DATABASE - | ItemType.DIGITAL_TWIN_BUILDER - | ItemType.REFLEX - | ItemType.EVENTSTREAM - | ItemType.MOUNTED_DATA_FACTORY - | ItemType.COPYJOB - | ItemType.VARIABLE_LIBRARY - | ItemType.GRAPHQLAPI - | ItemType.DATAFLOW - | ItemType.SQL_DATABASE - | ItemType.COSMOS_DB_DATABASE - | ItemType.GRAPH_QUERY_SET - | ItemType.USER_DATA_FUNCTION - ): - return { - "type": str(self.item_type), - "description": "Imported from fab", - "folderId": self.folder_id, - "displayName": self.short_name, - "definition": definition, - } - case _: - raise FabricCLIError( - ErrorMessages.Hierarchy.item_type_doesnt_support_definition_payload( - str(self.item_type) - ), - fab_constant.ERROR_UNSUPPORTED_COMMAND, - ) - def get_folders(self) -> List[str]: return ItemFoldersMap.get(self.item_type, []) diff --git a/src/fabric_cli/errors/__init__.py b/src/fabric_cli/errors/__init__.py index d4c61be5c..161022c8b 100644 --- a/src/fabric_cli/errors/__init__.py +++ b/src/fabric_cli/errors/__init__.py @@ -7,7 +7,6 @@ from .config import ConfigErrors from .context import ContextErrors from .cp import CpErrors -from .export import ExportErrors from .hierarchy import HierarchyErrors from .labels import LabelsErrors from .mkdir import MkdirErrors @@ -23,7 +22,6 @@ class ErrorMessages: Config = ConfigErrors Context = ContextErrors Cp = CpErrors - Export = ExportErrors Hierarchy = HierarchyErrors Labels = LabelsErrors Mkdir = MkdirErrors diff --git a/src/fabric_cli/errors/common.py b/src/fabric_cli/errors/common.py index 801fafc03..0896f169c 100644 --- a/src/fabric_cli/errors/common.py +++ b/src/fabric_cli/errors/common.py @@ -248,3 +248,12 @@ def gateway_property_not_supported_for_type( @staticmethod def query_not_supported_for_set(query: str) -> str: return f"Query '{query}' is not supported for set command" + + @staticmethod + def invalid_definition_format(valid_formats: list[str]) -> str: + if valid_formats: + message = f"Only the following formats are supported: {', '.join(valid_formats)}" + else: + message = "No formats are supported" + return f"Invalid format. {message}" + diff --git a/src/fabric_cli/errors/export.py b/src/fabric_cli/errors/export.py deleted file mode 100644 index 47470e0d4..000000000 --- a/src/fabric_cli/errors/export.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -class ExportErrors: - @staticmethod - def invalid_export_format(valid_formats: list[str]) -> str: - message = "Only the following formats are supported: " + ", ".join(valid_formats) if len(valid_formats) else "No formats are supported" - return f"Invalid format. {message}" diff --git a/src/fabric_cli/utils/fab_cmd_import_utils.py b/src/fabric_cli/utils/fab_cmd_import_utils.py index 1b128e781..ef73525f3 100644 --- a/src/fabric_cli/utils/fab_cmd_import_utils.py +++ b/src/fabric_cli/utils/fab_cmd_import_utils.py @@ -25,11 +25,17 @@ def get_payload_for_item_type( if item.item_type == ItemType.ENVIRONMENT: return _build_environment_payload(path) else: - base64_definition = _build_payload(path) - return item.get_payload(base64_definition, input_format) + definition = _build_definition(path, input_format) + return { + "type": str(item.item_type), + "description": "Imported from fab", + "folderId": item.folder_id, + "displayName": item.short_name, + "definition": definition, + } -def _build_payload(input_path: Any) -> dict: +def _build_definition(input_path: Any, input_format: Optional[str] = None) -> dict: directory = input_path parts = [] @@ -67,9 +73,10 @@ def _build_payload(input_path: Any) -> dict: } ) - # Create the final JSON structure - payload_structure = {"parts": parts} - return payload_structure + definition: dict = {"parts": parts} + if input_format: + definition["format"] = input_format + return definition def _encode_file_to_base64(file_path: str) -> str: diff --git a/src/fabric_cli/utils/fab_item_util.py b/src/fabric_cli/utils/fab_item_util.py index d7ce545d8..ac8975edd 100644 --- a/src/fabric_cli/utils/fab_item_util.py +++ b/src/fabric_cli/utils/fab_item_util.py @@ -26,9 +26,14 @@ from fabric_cli.core import fab_constant, fab_state_config from fabric_cli.core.fab_commands import Command from fabric_cli.core.fab_exceptions import FabricCLIError -from fabric_cli.core.fab_types import ItemType, format_mapping +from fabric_cli.core.fab_types import ( + ItemType, + definition_format_mapping, + format_mapping, +) from fabric_cli.core.hiearchy.fab_folder import Folder from fabric_cli.core.hiearchy.fab_hiearchy import FabricElement, Item, OneLakeItem +from fabric_cli.errors import ErrorMessages from fabric_cli.utils import fab_ui @@ -125,4 +130,27 @@ def get_confirm_copy_move_message(is_move_command: bool) -> str: confirm_message = ( f"Item definition is {action} without its sensitivity label. Are you sure?" ) - return confirm_message \ No newline at end of file + return confirm_message + + +def resolve_definition_format(item_type: ItemType, format_param: Optional[str]) -> str: + """Validate and resolve a user-supplied format against definition_format_mapping. + + Returns the resolved API format value (may be empty string for no-format items). + Raises FabricCLIError if the format is invalid. + """ + valid_formats = definition_format_mapping.get(item_type, {}) + + if format_param and format_param in valid_formats: + return valid_formats[format_param] + + if format_param: + # Non-empty format param but not in the mapping + available = [k for k in valid_formats if k != "default"] + raise FabricCLIError( + ErrorMessages.Common.invalid_definition_format(available), + fab_constant.ERROR_INVALID_INPUT, + ) + + # No format supplied — use the default + return valid_formats.get("default", "") \ No newline at end of file diff --git a/tests/test_commands/conftest.py b/tests/test_commands/conftest.py index 114bc54ca..8ea178124 100644 --- a/tests/test_commands/conftest.py +++ b/tests/test_commands/conftest.py @@ -799,6 +799,7 @@ def mkdir(element_full_path, params=None): command_path="mkdir", path=element_full_path, params=params if params else ["run=true"], + output_format="text", ) context = handle_context.get_command_context(args.path, False) @@ -812,6 +813,7 @@ def rm(element_full_path): command_path="rm", path=element_full_path, force=True, + output_format="text", ) context = handle_context.get_command_context(args.path) diff --git a/tests/test_commands/recordings/test_commands/test_import/test_import_create_new_notebook_py_item_success.yaml b/tests/test_commands/recordings/test_commands/test_import/test_import_create_new_notebook_py_item_success.yaml index f5194a4a5..a4a063a25 100644 --- a/tests/test_commands/recordings/test_commands/test_import/test_import_create_new_notebook_py_item_success.yaml +++ b/tests/test_commands/recordings/test_commands/test_import/test_import_create_new_notebook_py_item_success.yaml @@ -827,8 +827,8 @@ interactions: message: OK - request: body: '{"type": "Notebook", "description": "Imported from fab", "folderId": null, - "displayName": "fabcli000001_new_5", "definition": {"parts": [{"path": "notebook-content.py", - "payload": "IyBGYWJyaWMgbm90ZWJvb2sgc291cmNlCgojIE1FVEFEQVRBICoqKioqKioqKioqKioqKioqKioqCgojIE1FVEEgewojIE1FVEEgICAia2VybmVsX2luZm8iOiB7CiMgTUVUQSAgICAgIm5hbWUiOiAic3luYXBzZV9weXNwYXJrIgojIE1FVEEgICB9LAojIE1FVEEgICAiZGVwZW5kZW5jaWVzIjoge30KIyBNRVRBIH0KCiMgQ0VMTCAqKioqKioqKioqKioqKioqKioqKgoKIyBXZWxjb21lIHRvIHlvdXIgbmV3IG5vdGVib29rCiMgVHlwZSBoZXJlIGluIHRoZSBjZWxsIGVkaXRvciB0byBhZGQgY29kZSEKCgojIE1FVEFEQVRBICoqKioqKioqKioqKioqKioqKioqCgojIE1FVEEgewojIE1FVEEgICAibGFuZ3VhZ2UiOiAicHl0aG9uIiwKIyBNRVRBICAgImxhbmd1YWdlX2dyb3VwIjogInN5bmFwc2VfcHlzcGFyayIKIyBNRVRBIH0K", + "displayName": "fabcli000001_new_5", "definition": {"format": "fabricGitSource", + "parts": [{"path": "notebook-content.py", "payload": "IyBGYWJyaWMgbm90ZWJvb2sgc291cmNlCgojIE1FVEFEQVRBICoqKioqKioqKioqKioqKioqKioqCgojIE1FVEEgewojIE1FVEEgICAia2VybmVsX2luZm8iOiB7CiMgTUVUQSAgICAgIm5hbWUiOiAic3luYXBzZV9weXNwYXJrIgojIE1FVEEgICB9LAojIE1FVEEgICAiZGVwZW5kZW5jaWVzIjoge30KIyBNRVRBIH0KCiMgQ0VMTCAqKioqKioqKioqKioqKioqKioqKgoKIyBXZWxjb21lIHRvIHlvdXIgbmV3IG5vdGVib29rCiMgVHlwZSBoZXJlIGluIHRoZSBjZWxsIGVkaXRvciB0byBhZGQgY29kZSEKCgojIE1FVEFEQVRBICoqKioqKioqKioqKioqKioqKioqCgojIE1FVEEgewojIE1FVEEgICAibGFuZ3VhZ2UiOiAicHl0aG9uIiwKIyBNRVRBICAgImxhbmd1YWdlX2dyb3VwIjogInN5bmFwc2VfcHlzcGFyayIKIyBNRVRBIH0K", "payloadType": "InlineBase64"}, {"path": ".platform", "payload": "ewogICAgIiRzY2hlbWEiOiAiaHR0cHM6Ly9kZXZlbG9wZXIubWljcm9zb2Z0LmNvbS9qc29uLXNjaGVtYXMvZmFicmljL2dpdEludGVncmF0aW9uL3BsYXRmb3JtUHJvcGVydGllcy8yLjAuMC9zY2hlbWEuanNvbiIsCiAgICAibWV0YWRhdGEiOiB7CiAgICAgICAgInR5cGUiOiAiTm90ZWJvb2siLAogICAgICAgICJkaXNwbGF5TmFtZSI6ICJmYWJjbGkwMDAwMDEiLAogICAgICAgICJkZXNjcmlwdGlvbiI6ICJDcmVhdGVkIGJ5IGZhYiIKICAgIH0sCiAgICAiY29uZmlnIjogewogICAgICAgICJ2ZXJzaW9uIjogIjIuMCIsCiAgICAgICAgImxvZ2ljYWxJZCI6ICIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAiCiAgICB9Cn0=", "payloadType": "InlineBase64"}]}}' headers: diff --git a/tests/test_commands/test_import.py b/tests/test_commands/test_import.py index 7b2f70879..0507d2a1a 100644 --- a/tests/test_commands/test_import.py +++ b/tests/test_commands/test_import.py @@ -1165,6 +1165,7 @@ def _build_export_args(path, output, force=True): path=path, output=output, force=force, + output_format="text", ) @@ -1183,6 +1184,7 @@ def _build_export_format_args(path, output, force=True, format=None): output=output, force=force, format=format, + output_format="text", ) diff --git a/tests/test_core/test_fab_hiearchy.py b/tests/test_core/test_fab_hiearchy.py index e130917e5..a16e6ac33 100644 --- a/tests/test_core/test_fab_hiearchy.py +++ b/tests/test_core/test_fab_hiearchy.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from unittest.mock import patch + import pytest import fabric_cli.core.fab_constant as fab_constant @@ -8,6 +10,10 @@ from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.core.fab_types import * from fabric_cli.core.hiearchy.fab_hiearchy import * +from fabric_cli.utils.fab_cmd_import_utils import ( + _build_definition, + get_payload_for_item_type, +) def test_create_tenant(): @@ -386,6 +392,12 @@ def test_get_item_payloads(): } } + def _mock_build(path, resolved_format=""): + result = {"parts": _base_payload["parts"]} + if resolved_format: + result["format"] = resolved_format + return result + # Test Notebook notebook = Item( name="item_name", @@ -403,7 +415,9 @@ def test_get_item_payloads(): } # Check that the payload is correct - assert notebook.get_payload(_base_payload) == _expected_payload + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert get_payload_for_item_type( + "dummy", notebook, "ipynb") == _expected_payload # Test Spark Job Definition spark_job_def = Item( @@ -425,8 +439,9 @@ def test_get_item_payloads(): } # Check that the payload is correct - assert spark_job_def.get_payload( - _base_payload, "SparkJobDefinitionV2") == _expected_payload + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert get_payload_for_item_type("dummy", + spark_job_def, "SparkJobDefinitionV2") == _expected_payload _expected_payload = { "type": "SparkJobDefinition", @@ -440,7 +455,9 @@ def test_get_item_payloads(): } # Check that the payload is correct - assert spark_job_def.get_payload(_base_payload) == _expected_payload + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert get_payload_for_item_type( + "dummy", spark_job_def, "SparkJobDefinitionV1") == _expected_payload # Test EventHouse event_house = Item( @@ -455,11 +472,13 @@ def test_get_item_payloads(): "description": "Imported from fab", "displayName": "item_name", "folderId": None, - "definition": _base_payload, + "definition": {"parts": _base_payload["parts"]}, } # Check that the payload is correct - assert event_house.get_payload(_base_payload) == _expected_payload + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert get_payload_for_item_type( + "dummy", event_house) == _expected_payload # Test Report report = Item( @@ -474,7 +493,7 @@ def test_get_item_payloads(): "description": "Imported from fab", "displayName": "item_name", "folderId": None, - "definition": _base_payload, + "definition": {"parts": _base_payload["parts"]}, } # Check that the payload is correct for SemanticModel which can have different formatting @@ -490,11 +509,12 @@ def test_get_item_payloads(): "description": "Imported from fab", "displayName": "item_name", "folderId": None, - "definition": _base_payload, + "definition": {"parts": _base_payload["parts"]}, } - assert smenticModel.get_payload( - _base_payload) == _expected_payload_without_format + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert get_payload_for_item_type("dummy", + smenticModel) == _expected_payload_without_format _expected_payload_with_format = { "type": "SemanticModel", @@ -507,24 +527,112 @@ def test_get_item_payloads(): }, } - assert ( - smenticModel.get_payload(_base_payload, input_format="TMDL") - == _expected_payload_with_format - ) + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert ( + get_payload_for_item_type("dummy", smenticModel, "TMDL") + == _expected_payload_with_format + ) # Check that the payload is correct - assert report.get_payload(_base_payload) == _expected_payload + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + assert get_payload_for_item_type("dummy", report) == _expected_payload - # Unsuported item - with pytest.raises(FabricCLIError) as e: - unsupported_item = Item( - name="item_name", - id="item_id", - parent=workspace, - item_type="Lakehouse", - ) - unsupported_item.get_payload(_base_payload) - assert e.value.status_code == fab_constant.ERROR_UNSUPPORTED_COMMAND + +# ------------------------------------------------------------------- +# Tests for _build_definition format handling +# ------------------------------------------------------------------- + + +def _make_item(item_type: str, parent=None) -> Item: + """Helper to create an Item with minimal boilerplate.""" + if parent is None: + tenant = Tenant(name="t", id="tid") + parent = Workspace(name="ws", id="wsid", + parent=tenant, type="Workspace") + return Item(name="item", id="iid", parent=parent, item_type=item_type) + + +class TestBuildPayload: + """Validate _build_definition includes format when provided.""" + + def test_with_format__includes_format_key(self, tmp_path): + (tmp_path / "notebook.ipynb").write_text("{}") + result = _build_definition(str(tmp_path), "ipynb") + assert result["format"] == "ipynb" + assert len(result["parts"]) == 1 + assert result["parts"][0]["path"] == "notebook.ipynb" + + def test_without_format__no_format_key(self, tmp_path): + (tmp_path / "notebook.ipynb").write_text("{}") + result = _build_definition(str(tmp_path)) + assert "format" not in result + assert len(result["parts"]) == 1 + + def test_empty_format__no_format_key(self, tmp_path): + (tmp_path / "file.json").write_text("{}") + result = _build_definition(str(tmp_path), "") + assert "format" not in result + + # -- Payload construction tests ------------------------------------------- + + def test_payload__lakehouse(self): + """Any item type can have a payload constructed.""" + item = _make_item("Lakehouse") + + def _mock_build(path, resolved_format=""): + return {"parts": {"key": "value"}} + + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + payload = get_payload_for_item_type("dummy", item) + assert payload["type"] == "Lakehouse" + assert payload["displayName"] == "item" + assert payload["definition"] == {"parts": {"key": "value"}} + + def test_payload__kql_dashboard(self): + """KQLDashboard (was in ImportDefinitionTypes) still works.""" + item = _make_item("KQLDashboard") + + def _mock_build(path, resolved_format=""): + return {"parts": {"key": "value"}} + + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + payload = get_payload_for_item_type("dummy", item) + assert payload["definition"] == {"parts": {"key": "value"}} + + # -- Folder-based items include folderId ---------------------------------- + + def test_payload__item_in_folder__includes_folder_id(self): + """Items inside a folder should have folderId set.""" + tenant = Tenant(name="t", id="tid") + ws = Workspace(name="ws", id="wsid", parent=tenant, type="Workspace") + folder = Folder(name="myfolder", id="folder123", parent=ws) + item = Item(name="nb", id="nbid", parent=folder, item_type="Notebook") + + def _mock_build(path, resolved_format=""): + result = {"parts": {"key": "value"}} + if resolved_format: + result["format"] = resolved_format + return result + + with patch("fabric_cli.utils.fab_cmd_import_utils._build_definition", side_effect=_mock_build): + payload = get_payload_for_item_type("dummy", item, "ipynb") + assert payload["folderId"] == "folder123" + assert payload["definition"]["format"] == "ipynb" + + def test_payload__item_in_workspace__folder_id_none(self): + """Items directly under workspace should have folderId=None.""" + item = _make_item("Notebook") + assert item.folder_id is None + + # -- Unknown format is now validated upstream by resolve_definition_format -- + + def test_unknown_format__raises_error(self): + """Unknown format raises FabricCLIError during resolution.""" + from fabric_cli.utils.fab_item_util import resolve_definition_format + + item = _make_item("SemanticModel") + with pytest.raises(FabricCLIError): + resolve_definition_format(item.item_type, "UnknownFormat") def test_create_folder(): From a57f735dac855d935f6433b3f0e1bd81aa5d83b3 Mon Sep 17 00:00:00 2001 From: Alon Yeshurun <98805507+ayeshurun@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:34:02 +0200 Subject: [PATCH 3/3] Delete scripts/benchmark_startup.py --- scripts/benchmark_startup.py | 270 ----------------------------------- 1 file changed, 270 deletions(-) delete mode 100644 scripts/benchmark_startup.py diff --git a/scripts/benchmark_startup.py b/scripts/benchmark_startup.py deleted file mode 100644 index 0d502613c..000000000 --- a/scripts/benchmark_startup.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -""" -Benchmark CLI startup performance. - -Compares the current branch against 'main' (or any other branch) by measuring: - 1. Module import time (fabric_cli.main) - 2. CLI invocation time (fab --version) - 3. Heavy dependency loading (msal, jwt, cryptography, requests, prompt_toolkit) - -Usage: - # Compare current branch against main - python scripts/benchmark_startup.py - - # Compare current branch against a specific branch/tag/commit - python scripts/benchmark_startup.py --baseline v1.3.0 - - # Run only on the current branch (no git checkout) - python scripts/benchmark_startup.py --current-only - - # Change number of iterations (default: 10) - python scripts/benchmark_startup.py --iterations 20 -""" - -import argparse -import importlib -import json -import os -import shutil -import statistics -import subprocess -import sys -import time - - -HEAVY_MODULES = ["msal", "jwt", "cryptography", "requests", "prompt_toolkit", "psutil"] - - -def measure_import_time(iterations: int) -> dict: - """Measure fabric_cli.main import time across multiple iterations.""" - times = [] - for _ in range(iterations): - # Clear all fabric_cli modules from cache - mods = [k for k in sys.modules if k.startswith("fabric_cli")] - for m in mods: - del sys.modules[m] - - start = time.perf_counter() - importlib.import_module("fabric_cli.main") - elapsed_ms = (time.perf_counter() - start) * 1000 - times.append(elapsed_ms) - - return { - "median_ms": round(statistics.median(times), 1), - "min_ms": round(min(times), 1), - "max_ms": round(max(times), 1), - "mean_ms": round(statistics.mean(times), 1), - "stdev_ms": round(statistics.stdev(times), 1) if len(times) > 1 else 0, - "samples": times, - } - - -def check_heavy_modules() -> dict: - """Check which heavy modules are loaded after importing fabric_cli.main.""" - # Clear all fabric_cli modules - mods = [k for k in sys.modules if k.startswith("fabric_cli")] - for m in mods: - del sys.modules[m] - - # Also clear heavy modules - for mod in HEAVY_MODULES: - keys = [k for k in sys.modules if k.startswith(mod)] - for k in keys: - del sys.modules[k] - - importlib.import_module("fabric_cli.main") - - return {mod: mod in sys.modules for mod in HEAVY_MODULES} - - -def measure_cli_time(iterations: int) -> dict: - """Measure 'fab --version' wall-clock time.""" - fab_path = shutil.which("fab") - if not fab_path: - return {"error": "'fab' not found in PATH. Run 'pip install -e .' first."} - - times = [] - for _ in range(iterations): - start = time.perf_counter() - subprocess.run( - [fab_path, "--version"], - capture_output=True, - text=True, - ) - elapsed_ms = (time.perf_counter() - start) * 1000 - times.append(elapsed_ms) - - return { - "median_ms": round(statistics.median(times), 1), - "min_ms": round(min(times), 1), - "max_ms": round(max(times), 1), - "mean_ms": round(statistics.mean(times), 1), - "stdev_ms": round(statistics.stdev(times), 1) if len(times) > 1 else 0, - } - - -def run_benchmark(label: str, iterations: int) -> dict: - """Run all benchmarks and return results.""" - print(f"\n{'=' * 60}") - print(f" Benchmarking: {label}") - print(f"{'=' * 60}") - - # 1. Import time - print(f" Measuring import time ({iterations} iterations)...", end="", flush=True) - import_results = measure_import_time(iterations) - print(f" {import_results['median_ms']:.0f}ms median") - - # 2. Heavy modules - print(" Checking heavy module loading...", end="", flush=True) - heavy_results = check_heavy_modules() - loaded = [m for m, v in heavy_results.items() if v] - print(f" {len(loaded)} loaded: {', '.join(loaded) if loaded else 'none'}") - - # 3. CLI time - print(f" Measuring 'fab --version' ({iterations} iterations)...", end="", flush=True) - cli_results = measure_cli_time(iterations) - if "error" in cli_results: - print(f" {cli_results['error']}") - else: - print(f" {cli_results['median_ms']:.0f}ms median") - - return { - "label": label, - "import_time": import_results, - "heavy_modules": heavy_results, - "cli_time": cli_results, - } - - -def print_comparison(baseline: dict, current: dict): - """Print a formatted comparison table.""" - print(f"\n{'=' * 60}") - print(" COMPARISON") - print(f"{'=' * 60}\n") - - bl = baseline["import_time"]["median_ms"] - cu = current["import_time"]["median_ms"] - diff = bl - cu - pct = (diff / bl * 100) if bl > 0 else 0 - - print(f" {'Metric':<30} {'Baseline':>10} {'Current':>10} {'Change':>10}") - print(f" {'-' * 62}") - print(f" {'Import time (median):':<30} {bl:>9.0f}ms {cu:>9.0f}ms {diff:>+8.0f}ms") - print(f" {'Import improvement:':<30} {'':>10} {'':>10} {pct:>+8.0f}%") - - if "error" not in baseline["cli_time"] and "error" not in current["cli_time"]: - bl_cli = baseline["cli_time"]["median_ms"] - cu_cli = current["cli_time"]["median_ms"] - cli_diff = bl_cli - cu_cli - cli_pct = (cli_diff / bl_cli * 100) if bl_cli > 0 else 0 - print(f" {'CLI time (median):':<30} {bl_cli:>9.0f}ms {cu_cli:>9.0f}ms {cli_diff:>+8.0f}ms") - print(f" {'CLI improvement:':<30} {'':>10} {'':>10} {cli_pct:>+8.0f}%") - - print(f"\n {'Heavy modules at startup:':<30}") - for mod in HEAVY_MODULES: - bl_loaded = "LOADED" if baseline["heavy_modules"].get(mod) else "deferred" - cu_loaded = "LOADED" if current["heavy_modules"].get(mod) else "deferred" - marker = " ✓" if cu_loaded == "deferred" and bl_loaded == "LOADED" else "" - print(f" {mod:<25} {bl_loaded:>10} {cu_loaded:>10}{marker}") - - print() - - -def git_checkout_and_install(ref: str): - """Checkout a git ref and reinstall the package.""" - print(f"\n Switching to '{ref}'...") - subprocess.run(["git", "checkout", ref], capture_output=True, check=True) - subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", ".", "-q"], - capture_output=True, - check=True, - ) - # Clear all cached fabric_cli modules after reinstall - mods = [k for k in sys.modules if k.startswith("fabric_cli")] - for m in mods: - del sys.modules[m] - - -def main(): - parser = argparse.ArgumentParser( - description="Benchmark CLI startup performance between branches." - ) - parser.add_argument( - "--baseline", - default="main", - help="Git ref to compare against (default: main)", - ) - parser.add_argument( - "--iterations", "-n", - type=int, - default=10, - help="Number of iterations per measurement (default: 10)", - ) - parser.add_argument( - "--current-only", - action="store_true", - help="Only benchmark the current branch (skip baseline)", - ) - parser.add_argument( - "--json", - action="store_true", - help="Output results as JSON", - ) - args = parser.parse_args() - - repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - os.chdir(repo_root) - - # Get current branch name - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, - ) - current_branch = result.stdout.strip() - - print(f" Repo: {repo_root}") - print(f" Current branch: {current_branch}") - print(f" Baseline: {args.baseline}") - print(f" Iterations: {args.iterations}") - - results = {} - - if not args.current_only: - # Benchmark baseline - try: - git_checkout_and_install(args.baseline) - results["baseline"] = run_benchmark(f"Baseline ({args.baseline})", args.iterations) - except subprocess.CalledProcessError: - print(f"\n ERROR: Could not checkout '{args.baseline}'. Does it exist?") - print(f" Try: python scripts/benchmark_startup.py --current-only") - sys.exit(1) - finally: - # Always return to original branch - subprocess.run(["git", "checkout", current_branch], capture_output=True) - subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", ".", "-q"], - capture_output=True, - ) - # Clear modules again - mods = [k for k in sys.modules if k.startswith("fabric_cli")] - for m in mods: - del sys.modules[m] - - # Benchmark current - results["current"] = run_benchmark(f"Current ({current_branch})", args.iterations) - - # Print comparison - if "baseline" in results: - print_comparison(results["baseline"], results["current"]) - - # JSON output - if args.json: - # Remove raw samples for cleaner JSON - for key in results: - if "samples" in results[key].get("import_time", {}): - del results[key]["import_time"]["samples"] - print(json.dumps(results, indent=2)) - - -if __name__ == "__main__": - main()