From 13fc765c84bcb7803581c0d50a2c586a4df134bc Mon Sep 17 00:00:00 2001
From: MaitriAnv <263695489+MaitriAnv@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:51:21 +0000
Subject: [PATCH 1/8] Add move objects and files sample
---
.../move-objects-and-files.ipynb | 426 ++++++++++++++++++
.../move-objects-files/workspace_utils.py | 367 +++++++++++++++
2 files changed, 793 insertions(+)
create mode 100644 code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
create mode 100644 code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
diff --git a/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
new file mode 100644
index 000000000..724483daa
--- /dev/null
+++ b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
@@ -0,0 +1,426 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "63ff431f",
+ "metadata": {},
+ "source": [
+ "# Copy or Move Objects and Files Between Workspaces\n",
+ "\n",
+ "This notebook demonstrates how to copy or move objects and files between workspaces\n",
+ "1. Sign in and select the source workspace\n",
+ "2. Select the target workspace (and whether to delete the originals after copying)\n",
+ "3. Copy/move a geoscience object, a file, or both"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b88a08fa",
+ "metadata": {},
+ "source": [
+ "#### Sign in and select Source Workspace\n",
+ "Enter the client_id and redirect_url values."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "cef21493",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from evo.notebooks import ServiceManagerWidget\n",
+ "\n",
+ "cache_location = \"data\"\n",
+ "\n",
+ "# Evo app credentials\n",
+ "client_id = \"\" # Replace with your client ID\n",
+ "redirect_url = \"\" # Replace with your redirect URL\n",
+ "\n",
+ "manager = await ServiceManagerWidget.with_auth_code(\n",
+ " discovery_url=\"https://discover.api.seequent.com\",\n",
+ " client_id=client_id,\n",
+ " redirect_url=redirect_url,\n",
+ " cache_location=cache_location,\n",
+ ").login()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3c6a6dea",
+ "metadata": {},
+ "source": [
+ "#### Create Source Clients using Evo Python SDK"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b797bbb0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from evo.files import FileAPIClient\n",
+ "from evo.objects import ObjectAPIClient\n",
+ "\n",
+ "source_environment = manager.get_environment()\n",
+ "connector = manager.get_connector()\n",
+ "\n",
+ "object_client = ObjectAPIClient(source_environment, connector)\n",
+ "data_client = object_client.get_data_client(manager.cache)\n",
+ "source_file_client = FileAPIClient(source_environment, connector)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f89b7118",
+ "metadata": {},
+ "source": [
+ "#### Select Target Workspace and Operation\n",
+ "\n",
+ "- Select the target workspace\n",
+ "- Check \"Delete original after copy\" to perform a move instead of a copy (applies to both objects and files)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0afc72ad",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "\n",
+ "from workspace_utils import ObjectSelectorWidget, TargetWorkspaceSelectorWidget, collect_data_references\n",
+ "\n",
+ "# In CI, _CIManager does not expose notebook widget internals used by this selector.\n",
+ "if os.environ.get(\"CI\"):\n",
+ " target_workspace_selector = None\n",
+ " print(\"Skipping target workspace selector widget in CI.\")\n",
+ "else:\n",
+ " target_workspace_selector = TargetWorkspaceSelectorWidget(manager)\n",
+ " await target_workspace_selector.display()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5b4dd1d2",
+ "metadata": {},
+ "source": [
+ "## Part 1: Copy / Move a Geoscience Object\n",
+ "\n",
+ "#### Select an Object using the Object Selector Widget\n",
+ "\n",
+ "The widget provides:\n",
+ "- A refresh button to load all objects from the workspace\n",
+ "- A dropdown to filter objects by type (schema classification)\n",
+ "- A dropdown to select a specific object\n",
+ "- An information panel showing details about the selected object"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6ffb7577",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "object_selector = ObjectSelectorWidget(object_client)\n",
+ "await object_selector.display()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "255301e5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "selected_object = object_selector.get_selected_object()\n",
+ "selected_id = object_selector.get_selected_id()\n",
+ "\n",
+ "if selected_id:\n",
+ " object_name = object_selector._format_object_name(selected_object)\n",
+ " print(f\"Selected Object ID: {selected_id}\")\n",
+ " print(f\"Selected Object Name: {object_name}\")\n",
+ "else:\n",
+ " object_name = None\n",
+ " print(\"No object selected. Please select an object from the dropdown above.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "92324213",
+ "metadata": {},
+ "source": [
+ "#### Download and Save Object\n",
+ "\n",
+ "Download the selected object's JSON and its data files, and save the object JSON to the data folder."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "892a7180",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import json\n",
+ "from pathlib import Path\n",
+ "\n",
+ "from evo.notebooks import FeedbackWidget\n",
+ "\n",
+ "# Download the selected object\n",
+ "if selected_id:\n",
+ " # Download the object\n",
+ " downloaded_object = await object_client.download_object_by_id(object_id=selected_id)\n",
+ "\n",
+ " # Get the object as a dictionary\n",
+ " object_data = downloaded_object.as_dict()\n",
+ "\n",
+ " # Collect all data references from the object\n",
+ " data_refs = collect_data_references(object_data)\n",
+ "\n",
+ " if data_refs:\n",
+ " print(f\"Found {len(data_refs)} data file(s) to download...\")\n",
+ "\n",
+ " # Download all data files to the cache\n",
+ " for data_download in downloaded_object.prepare_data_download(list(data_refs)):\n",
+ " await data_download.download_to_cache(\n",
+ " cache=manager.cache,\n",
+ " transport=object_client._connector.transport,\n",
+ " fb=FeedbackWidget(f\"Downloading {data_download.name}\"),\n",
+ " )\n",
+ " print(\"All data files downloaded to cache.\")\n",
+ " else:\n",
+ " print(\"No data files to download.\")\n",
+ "\n",
+ " # Remove only the top-level 'uuid' field if it exists\n",
+ " object_data.pop(\"uuid\", None)\n",
+ "\n",
+ " # Create data folder if it doesn't exist\n",
+ " data_folder = Path(\"data\")\n",
+ " data_folder.mkdir(exist_ok=True)\n",
+ "\n",
+ " # Save to file using the object name\n",
+ " object_name = object_selector._format_object_name(selected_object)\n",
+ " output_file = data_folder / f\"{object_name}.json\"\n",
+ "\n",
+ " with open(output_file, \"w\") as f:\n",
+ " json.dump(object_data, f, indent=2, default=str)\n",
+ "\n",
+ " print(f\"Object data saved to: {output_file}\")\n",
+ " print(f\"File size: {output_file.stat().st_size} bytes\")\n",
+ "else:\n",
+ " print(\"No object selected. Please select an object first.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ccc7bb07",
+ "metadata": {},
+ "source": [
+ "#### Publish Object to Target Workspace"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fcf723c9",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import json\n",
+ "from pathlib import Path\n",
+ "\n",
+ "from evo.common import Environment\n",
+ "from evo.notebooks import FeedbackWidget\n",
+ "\n",
+ "# Get the target workspace ID and operation\n",
+ "if target_workspace_selector is None:\n",
+ " target_workspace_id = None\n",
+ " operation = \"Copy\"\n",
+ "else:\n",
+ " target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n",
+ " operation = target_workspace_selector.get_operation()\n",
+ "\n",
+ "if not target_workspace_id:\n",
+ " print(\"No target workspace selected. Please select a target workspace first.\")\n",
+ "elif not selected_id:\n",
+ " print(\"No object selected. Please select an object first.\")\n",
+ "else:\n",
+ " # Read the saved JSON file\n",
+ " object_name = object_selector._format_object_name(selected_object)\n",
+ " input_file = Path(\"data\") / f\"{object_name}.json\"\n",
+ "\n",
+ " if not input_file.exists():\n",
+ " print(f\"Error: File {input_file} not found. Please run the download cell first.\")\n",
+ " else:\n",
+ " # Read the JSON data\n",
+ " with open(input_file, \"r\") as f:\n",
+ " object_data = json.load(f)\n",
+ "\n",
+ " # Publish the object to the target workspace\n",
+ " operation_verb = \"Copying\" if operation == \"Copy\" else \"Moving\"\n",
+ " print(f\"{operation_verb} object '{object_name}' to target workspace...\")\n",
+ "\n",
+ " try:\n",
+ " # Create a new ObjectAPIClient with the target workspace environment\n",
+ " target_env = Environment(\n",
+ " org_id=manager.get_environment().org_id,\n",
+ " workspace_id=target_workspace_id,\n",
+ " hub_url=manager.get_environment().hub_url,\n",
+ " )\n",
+ "\n",
+ " target_object_client = ObjectAPIClient(target_env, manager.get_connector())\n",
+ "\n",
+ " # Use the same cache as the source object client\n",
+ " # This ensures the data files downloaded earlier are accessible\n",
+ " target_data_client = target_object_client.get_data_client(manager.cache)\n",
+ "\n",
+ " # Copy the data files from the source workspace cache to the target workspace cache\n",
+ " # Get cache locations for both workspaces\n",
+ " source_cache_location = data_client.cache_location\n",
+ " target_cache_location = target_data_client.cache_location\n",
+ "\n",
+ " # Collect all data references from the object\n",
+ " data_refs = collect_data_references(object_data)\n",
+ "\n",
+ " if source_cache_location != target_cache_location and data_refs:\n",
+ " print(f\"Copying {len(data_refs)} data file(s) to target workspace cache...\")\n",
+ " import shutil\n",
+ "\n",
+ " target_cache_location.mkdir(parents=True, exist_ok=True)\n",
+ "\n",
+ " for data_ref in data_refs:\n",
+ " source_file = source_cache_location / data_ref\n",
+ " target_file = target_cache_location / data_ref\n",
+ " if source_file.exists() and not target_file.exists():\n",
+ " shutil.copy2(source_file, target_file)\n",
+ " print(\"Data files copied to target workspace cache.\")\n",
+ "\n",
+ " # Upload the referenced data to the target workspace\n",
+ " await target_data_client.upload_referenced_data(object_data, FeedbackWidget(\"Uploading data\"))\n",
+ "\n",
+ " # Create the geoscience object in the target workspace\n",
+ " # Use the same path as the original object\n",
+ " target_path = selected_object.path\n",
+ " published_object = await target_object_client.create_geoscience_object(target_path, object_data)\n",
+ "\n",
+ " print(\"Successfully published object to target workspace!\")\n",
+ " print(f\"New Object ID: {published_object.id}\")\n",
+ " print(f\"New Object Path: {published_object.path}\")\n",
+ "\n",
+ " # If this is a move operation, delete the original object\n",
+ " if operation == \"Move\":\n",
+ " print(\"Deleting original object from source workspace...\")\n",
+ " await object_client.delete_object_by_id(selected_id)\n",
+ " print(\"Original object deleted successfully.\")\n",
+ "\n",
+ " except Exception as e:\n",
+ " print(f\"Error publishing object: {e!s}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "15590bf9",
+ "metadata": {},
+ "source": [
+ "## Part 2: Copy / Move a File\n",
+ "\n",
+ "#### Select a Source File"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0b965bac",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import ipywidgets as widgets\n",
+ "from IPython.display import display\n",
+ "\n",
+ "source_files = await source_file_client.list_all_files()\n",
+ "\n",
+ "if not source_files:\n",
+ " raise ValueError(\"No files found in the source workspace.\")\n",
+ "\n",
+ "source_file_selector = widgets.Dropdown(\n",
+ " options=[(file.path, file.path) for file in source_files],\n",
+ " description=\"Source file:\",\n",
+ " layout=widgets.Layout(width=\"80%\"),\n",
+ ")\n",
+ "\n",
+ "display(source_file_selector)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "157d90c6",
+ "metadata": {},
+ "source": [
+ "#### Copy / Move the File to the Target Workspace\n",
+ "\n",
+ "Uses the target workspace and the \"Delete original after copy\" option selected above."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2d04eb94",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from dataclasses import replace\n",
+ "\n",
+ "if target_workspace_selector is None:\n",
+ " raise ValueError(\"Target workspace selection is not available (CI).\")\n",
+ "\n",
+ "target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n",
+ "operation = target_workspace_selector.get_operation()\n",
+ "if not target_workspace_id:\n",
+ " raise ValueError(\"Select a target workspace first.\")\n",
+ "\n",
+ "destination_environment = replace(source_environment, workspace_id=target_workspace_id)\n",
+ "destination_file_client = FileAPIClient(destination_environment, connector)\n",
+ "\n",
+ "source_path = source_file_selector.value\n",
+ "destination_path = source_path # Keep the same path/name in the destination\n",
+ "\n",
+ "download = await source_file_client.prepare_download_by_path(source_path)\n",
+ "local_file = await download.download_to_cache(manager.cache, connector.transport)\n",
+ "\n",
+ "upload = await destination_file_client.prepare_upload_by_path(destination_path)\n",
+ "await upload.upload_from_path(str(local_file), connector.transport)\n",
+ "\n",
+ "print(f\"Copied {source_path} to {destination_path}\")\n",
+ "\n",
+ "if operation == \"Move\":\n",
+ " await source_file_client.delete_file_by_path(source_path)\n",
+ " print(f\"Deleted original file {source_path} from source workspace.\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "evo-code-samples (3.14.0)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.14.0"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
new file mode 100644
index 000000000..75de9ed2b
--- /dev/null
+++ b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
@@ -0,0 +1,367 @@
+from uuid import UUID
+
+import ipywidgets as widgets
+from IPython.display import display
+
+
+def collect_data_references(obj, data_refs=None):
+ """Recursively collect all 'data' field values from the object dictionary."""
+ if data_refs is None:
+ data_refs = set()
+
+ if isinstance(obj, dict):
+ # If this dict has a 'data' key with a string value, it's a data reference
+ if "data" in obj and isinstance(obj["data"], str):
+ data_refs.add(obj["data"])
+ # Recursively search through all values
+ for value in obj.values():
+ collect_data_references(value, data_refs)
+ elif isinstance(obj, list):
+ # Recursively search through all items
+ for item in obj:
+ collect_data_references(item, data_refs)
+
+ return data_refs
+
+
+"""Custom widgets for workspace maintenance operations."""
+
+
+class ObjectSelectorWidget:
+ """Widget for selecting geoscience objects with filtering by object type."""
+
+ def __init__(self, object_client):
+ self.object_client = object_client
+ self.all_objects = []
+ self.filtered_objects = []
+
+ # Create widgets
+ self.refresh_btn = widgets.Button(
+ description="Refresh Objects",
+ button_style="info",
+ icon="refresh",
+ layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center"),
+ )
+ self.refresh_btn.style.button_color = "#265C7F"
+ self.refresh_btn.on_click(self._on_refresh_click)
+
+ self.loading_label = widgets.Label(value="")
+
+ # Object type filter
+ self.object_type_filter = widgets.Dropdown(
+ options=[("All Types", "")],
+ value="",
+ description="Object Type",
+ style={"description_width": "100px"},
+ )
+ self.object_type_filter.observe(self._on_filter_changed, names="value")
+
+ # Object selector
+ self.object_selector = widgets.Dropdown(
+ options=[("Select an object...", None)],
+ value=None,
+ description="Object",
+ disabled=True,
+ style={"description_width": "100px"},
+ )
+
+ # Info display
+ self.info_display = widgets.HTML(value="")
+
+ # Layout
+ self.widget = widgets.VBox(
+ [
+ widgets.HBox([self.refresh_btn, self.loading_label]),
+ self.object_type_filter,
+ self.object_selector,
+ self.info_display,
+ ]
+ )
+
+ def _on_refresh_click(self, btn):
+ """Handle refresh button click."""
+ import asyncio
+
+ asyncio.create_task(self.refresh())
+
+ async def refresh(self):
+ """Load all objects from the workspace."""
+ self.loading_label.value = "Loading objects..."
+ self.refresh_btn.disabled = True
+ self.object_type_filter.disabled = True
+ self.object_selector.disabled = True
+
+ try:
+ # Fetch all objects
+ self.all_objects = await self.object_client.list_all_objects()
+
+ # Extract unique object types
+ object_types = set()
+ for obj in self.all_objects:
+ # Get the schema classification (e.g., "objects/pointset")
+ schema_type = obj.schema_id.classification
+ object_types.add(schema_type)
+
+ # Update object type filter options
+ type_options = [("All Types", "")]
+ type_options.extend(sorted([(t, t) for t in object_types]))
+ self.object_type_filter.options = type_options
+
+ # Update filtered objects
+ self._update_filtered_objects()
+
+ self.loading_label.value = f"Loaded {len(self.all_objects)} object(s)"
+
+ except Exception as e:
+ self.loading_label.value = f"Error: {e!s}"
+
+ finally:
+ self.refresh_btn.disabled = False
+ self.object_type_filter.disabled = False
+ self.object_selector.disabled = False
+
+ def _on_filter_changed(self, change):
+ """Handle filter change."""
+ self._update_filtered_objects()
+
+ def _format_object_name(self, obj):
+ """Format the object name by removing .json extension."""
+ name = obj.name
+ name = name.removesuffix(".json")
+ return name
+
+ def _update_filtered_objects(self):
+ """Update the object selector based on the current filter."""
+ selected_type = self.object_type_filter.value
+
+ # Filter objects
+ if selected_type == "":
+ self.filtered_objects = self.all_objects
+ else:
+ self.filtered_objects = [obj for obj in self.all_objects if obj.schema_id.classification == selected_type]
+
+ # Update object selector options
+ if self.filtered_objects:
+ object_options = [("Select an object...", None)]
+ object_options.extend(
+ [
+ (f"{self._format_object_name(obj)} ({obj.schema_id.sub_classification})", obj.id)
+ for obj in self.filtered_objects
+ ]
+ )
+ self.object_selector.options = object_options
+ self.object_selector.value = None
+ else:
+ self.object_selector.options = [("No objects found", None)]
+ self.object_selector.value = None
+
+ # Update info display when selection changes
+ self.object_selector.observe(self._on_object_selected, names="value")
+ self._update_info_display()
+
+ def _on_object_selected(self, change):
+ """Handle object selection change."""
+ self._update_info_display()
+
+ def _update_info_display(self):
+ """Update the info display with details about the selected object."""
+ selected_id = self.object_selector.value
+
+ if selected_id is None:
+ self.info_display.value = ""
+ return
+
+ # Find the selected object
+ selected_obj = next((obj for obj in self.filtered_objects if obj.id == selected_id), None)
+
+ if selected_obj is None:
+ self.info_display.value = ""
+ return
+
+ # Build info HTML
+ info_html = f"""
+
+ Object Details:
+ Name: {self._format_object_name(selected_obj)}
+ Path: {selected_obj.path}
+ ID: {selected_obj.id}
+ Schema: {selected_obj.schema_id}
+ Version: {selected_obj.version_id}
+ Created: {selected_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")}
+ Modified: {selected_obj.modified_at.strftime("%Y-%m-%d %H:%M:%S")}
+
+ """
+ self.info_display.value = info_html
+
+ async def display(self):
+ """Display the widget and auto-load objects."""
+ display(self.widget)
+ # Auto-load objects on display
+ await self.refresh()
+
+ def get_selected_object(self):
+ """Get the currently selected object metadata."""
+ selected_id = self.object_selector.value
+ if selected_id is None:
+ return None
+ return next((obj for obj in self.filtered_objects if obj.id == selected_id), None)
+
+ def get_selected_id(self) -> UUID | None:
+ """Get the UUID of the currently selected object."""
+ return self.object_selector.value
+
+
+class TargetWorkspaceSelectorWidget:
+ """Widget for selecting a target workspace and operation type (copy/move)."""
+
+ def __init__(self, manager_widget):
+ self.manager_widget = manager_widget
+ # Get the selected workspace ID
+ selected_id = manager_widget._workspace_selector.selected
+
+ if selected_id != manager_widget._workspace_selector.UNSELECTED[1]:
+ self.current_workspace_id = selected_id
+ else:
+ raise ValueError("No source workspace is currently selected.")
+
+ # Create widgets
+ self.refresh_btn = widgets.Button(
+ description="Refresh Workspaces",
+ button_style="info",
+ icon="refresh",
+ layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center", width="auto"),
+ )
+ self.refresh_btn.style.button_color = "#265C7F"
+ self.refresh_btn.on_click(self._on_refresh_click)
+
+ self.loading_label = widgets.Label(value="")
+
+ # Operation section - label and checkbox
+ self.operation_label = widgets.HTML(value="Copy object/file")
+ self.delete_original_checkbox = widgets.Checkbox(
+ value=False,
+ description="Delete original after copy",
+ indent=False,
+ style={"description_width": "auto"},
+ )
+
+ # Workspace selector
+ self.workspace_selector = widgets.Dropdown(
+ options=[("Select target workspace...", None)],
+ value=None,
+ description="To Workspace",
+ disabled=True,
+ style={"description_width": "100px"},
+ )
+
+ # Info display
+ self.info_display = widgets.HTML(value="")
+
+ # Layout
+ self.widget = widgets.VBox(
+ [
+ widgets.HBox([self.refresh_btn, self.loading_label]),
+ self.operation_label,
+ self.delete_original_checkbox,
+ self.workspace_selector,
+ self.info_display,
+ ]
+ )
+
+ # Observe selection changes
+ self.workspace_selector.observe(self._on_workspace_selected, names="value")
+ self.delete_original_checkbox.observe(self._on_checkbox_changed, names="value")
+
+ def _on_refresh_click(self, btn):
+ """Handle refresh button click."""
+ import asyncio
+
+ asyncio.create_task(self.refresh())
+
+ async def refresh(self):
+ """Load all workspaces."""
+ self.loading_label.value = "Loading workspaces..."
+ self.refresh_btn.disabled = True
+ self.workspace_selector.disabled = True
+
+ try:
+ # Get all workspaces from the service manager
+ workspaces = self.manager_widget.workspaces
+
+ # Filter out the current workspace
+ other_workspaces = [ws for ws in workspaces if ws.id != self.current_workspace_id]
+
+ # Update workspace selector options
+ if other_workspaces:
+ workspace_options = [("Select target workspace...", None)]
+ workspace_options.extend(
+ [(ws.display_name, ws.id) for ws in sorted(other_workspaces, key=lambda w: w.display_name)]
+ )
+ self.workspace_selector.options = workspace_options
+ self.workspace_selector.value = None
+ self.loading_label.value = f"Loaded {len(other_workspaces)} workspace(s)"
+ else:
+ self.workspace_selector.options = [("No other workspaces available", None)]
+ self.workspace_selector.value = None
+ self.loading_label.value = "No other workspaces found"
+
+ except Exception as e:
+ self.loading_label.value = f"Error: {e!s}"
+
+ finally:
+ self.refresh_btn.disabled = False
+ self.workspace_selector.disabled = False
+
+ def _on_workspace_selected(self, change):
+ """Handle workspace selection change."""
+ self._update_info_display()
+
+ def _on_checkbox_changed(self, change):
+ """Handle delete original checkbox change."""
+ self._update_info_display()
+
+ def _update_info_display(self):
+ """Update the info display with details about the selected workspace."""
+ selected_id = self.workspace_selector.value
+
+ if selected_id is None:
+ self.info_display.value = ""
+ return
+
+ # Find the selected workspace
+ workspaces = self.manager_widget.workspaces
+ selected_ws = next((ws for ws in workspaces if ws.id == selected_id), None)
+
+ if selected_ws is None:
+ self.info_display.value = ""
+ return
+
+ # Get current workspace info
+ current_ws = next((ws for ws in workspaces if ws.id == self.current_workspace_id), None)
+ operation = "Move" if self.delete_original_checkbox.value else "Copy"
+
+ # Build info HTML
+ info_html = f"""
+
+ Operation Summary:
+ Action: {operation} object
+ From: {current_ws.display_name}
+ To: {selected_ws.display_name}
+ Target Workspace ID: {selected_ws.id}
+
+ """
+ self.info_display.value = info_html
+
+ async def display(self):
+ """Display the widget and auto-load workspaces."""
+ display(self.widget)
+ # Auto-load workspaces on display
+ await self.refresh()
+
+ def get_selected_workspace_id(self) -> UUID | None:
+ """Get the UUID of the currently selected target workspace."""
+ return self.workspace_selector.value
+
+ def get_operation(self) -> str:
+ """Get the selected operation type ('Copy' or 'Move')."""
+ return "Move" if self.delete_original_checkbox.value else "Copy"
From a08c42aef5f9a2c7ebd9746551b4c4ba5c1f899b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:57:07 +0000
Subject: [PATCH 2/8] Update to MkDocs documentation
---
.../report/ReportCategorySpec.html | 2 +-
.../report/ReportColumnSpec.html | 2 +-
.../typed-objects/report/ReportResult.html | 43 +++++++++++-
uv.lock | 68 +++++++++----------
4 files changed, 78 insertions(+), 37 deletions(-)
diff --git a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html
index 995eb4fde..47a50484d 100644
--- a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html
+++ b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportCategorySpec.html
@@ -39,7 +39,7 @@
-
+
Next
diff --git a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html
index b9e96b211..4d97ece51 100644
--- a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html
+++ b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportColumnSpec.html
@@ -34,7 +34,7 @@
-
-
+
Previous
diff --git a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportResult.html b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportResult.html
index 4b07d0486..034f60dda 100644
--- a/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportResult.html
+++ b/mkdocs/site/packages/evo-blockmodels/typed-objects/report/ReportResult.html
@@ -121,7 +121,7 @@ to_dataframe() -> pd.DataFrame
+to_dataframe(include_status: bool = False) -> pd.DataFrame
@@ -129,6 +129,47 @@
).
+By default only the numeric value is placed in the DataFrame (with
+None where a value could not be aggregated), keeping the output the
+same shape as previous SDK versions. Set include_status=True to add a
+companion "{column}:status" column exposing each cell's status. The
+: separator is used because it can never appear in a column's label:
+Block Model Service column/group titles cannot contain :, and
+qualified (grouped) titles are joined with ▸ instead, so the
+companion column name can never collide with a real column.
+
+
+
Parameters:
+
+
+
+ | Name |
+ Type |
+ Description |
+ Default |
+
+
+
+
+
+ include_status
+ |
+
+ bool
+ |
+
+
+ When True, add a status column alongside each value
+column.
+
+ |
+
+ False
+ |
+
+
+
Returns:
diff --git a/uv.lock b/uv.lock
index 0ce34b074..f66cfbe76 100644
--- a/uv.lock
+++ b/uv.lock
@@ -900,7 +900,7 @@ wheels = [
[[package]]
name = "evo-blockmodels"
-version = "0.7.0"
+version = "0.8.0"
source = { editable = "packages/evo-blockmodels" }
dependencies = [
{ name = "evo-sdk-common" },
@@ -1458,7 +1458,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -1752,17 +1752,17 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "exceptiongroup" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
- { name = "typing-extensions" },
+ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version < '3.11'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "jedi", marker = "python_full_version < '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
+ { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
+ { name = "pygments", marker = "python_full_version < '3.11'" },
+ { name = "stack-data", marker = "python_full_version < '3.11'" },
+ { name = "traitlets", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
wheels = [
@@ -1782,18 +1782,18 @@ resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "ipython-pygments-lexers" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "psutil" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
- { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+ { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
+ { name = "decorator", marker = "python_full_version >= '3.11'" },
+ { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
+ { name = "jedi", marker = "python_full_version >= '3.11'" },
+ { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
+ { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
+ { name = "psutil", marker = "python_full_version >= '3.11'" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
+ { name = "stack-data", marker = "python_full_version >= '3.11'" },
+ { name = "traitlets", marker = "python_full_version >= '3.11'" },
+ { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" }
wheels = [
@@ -1805,7 +1805,7 @@ name = "ipython-pygments-lexers"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pygments" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [
@@ -2859,10 +2859,10 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
- { name = "python-dateutil" },
- { name = "pytz" },
- { name = "tzdata" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "python-dateutil", marker = "python_full_version < '3.11'" },
+ { name = "pytz", marker = "python_full_version < '3.11'" },
+ { name = "tzdata", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
wheels = [
@@ -2928,9 +2928,9 @@ resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
- { name = "python-dateutil" },
- { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "python-dateutil", marker = "python_full_version >= '3.11'" },
+ { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
wheels = [
@@ -3024,7 +3024,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ptyprocess" },
+ { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
From c28f3ce10c1076f68321f2a7b00cdd5ce41b37bb Mon Sep 17 00:00:00 2001
From: MaitriAnv <263695489+MaitriAnv@users.noreply.github.com>
Date: Thu, 29 Oct 2026 09:39:08 +0000
Subject: [PATCH 3/8] Normalize workspace transfer notebook cell IDs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../move-objects-and-files.ipynb | 36 +++++++++----------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
index 724483daa..c6b23fae9 100644
--- a/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
+++ b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
- "id": "63ff431f",
+ "id": "0",
"metadata": {},
"source": [
"# Copy or Move Objects and Files Between Workspaces\n",
@@ -15,7 +15,7 @@
},
{
"cell_type": "markdown",
- "id": "b88a08fa",
+ "id": "1",
"metadata": {},
"source": [
"#### Sign in and select Source Workspace\n",
@@ -25,7 +25,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "cef21493",
+ "id": "2",
"metadata": {},
"outputs": [],
"source": [
@@ -47,7 +47,7 @@
},
{
"cell_type": "markdown",
- "id": "3c6a6dea",
+ "id": "3",
"metadata": {},
"source": [
"#### Create Source Clients using Evo Python SDK"
@@ -56,7 +56,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b797bbb0",
+ "id": "4",
"metadata": {},
"outputs": [],
"source": [
@@ -73,7 +73,7 @@
},
{
"cell_type": "markdown",
- "id": "f89b7118",
+ "id": "5",
"metadata": {},
"source": [
"#### Select Target Workspace and Operation\n",
@@ -85,7 +85,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0afc72ad",
+ "id": "6",
"metadata": {},
"outputs": [],
"source": [
@@ -104,7 +104,7 @@
},
{
"cell_type": "markdown",
- "id": "5b4dd1d2",
+ "id": "7",
"metadata": {},
"source": [
"## Part 1: Copy / Move a Geoscience Object\n",
@@ -121,7 +121,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "6ffb7577",
+ "id": "8",
"metadata": {},
"outputs": [],
"source": [
@@ -132,7 +132,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "255301e5",
+ "id": "9",
"metadata": {},
"outputs": [],
"source": [
@@ -150,7 +150,7 @@
},
{
"cell_type": "markdown",
- "id": "92324213",
+ "id": "10",
"metadata": {},
"source": [
"#### Download and Save Object\n",
@@ -161,7 +161,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "892a7180",
+ "id": "11",
"metadata": {},
"outputs": [],
"source": [
@@ -217,7 +217,7 @@
},
{
"cell_type": "markdown",
- "id": "ccc7bb07",
+ "id": "12",
"metadata": {},
"source": [
"#### Publish Object to Target Workspace"
@@ -226,7 +226,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "fcf723c9",
+ "id": "13",
"metadata": {},
"outputs": [],
"source": [
@@ -323,7 +323,7 @@
},
{
"cell_type": "markdown",
- "id": "15590bf9",
+ "id": "14",
"metadata": {},
"source": [
"## Part 2: Copy / Move a File\n",
@@ -334,7 +334,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "0b965bac",
+ "id": "15",
"metadata": {},
"outputs": [],
"source": [
@@ -357,7 +357,7 @@
},
{
"cell_type": "markdown",
- "id": "157d90c6",
+ "id": "16",
"metadata": {},
"source": [
"#### Copy / Move the File to the Target Workspace\n",
@@ -368,7 +368,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "2d04eb94",
+ "id": "17",
"metadata": {},
"outputs": [],
"source": [
From 416b9fda529564be1f58e1d49826c1dced5cb4f3 Mon Sep 17 00:00:00 2001
From: MaitriAnv <263695489+MaitriAnv@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:51:03 +0000
Subject: [PATCH 4/8] Fix Windows long-path transfers and improve workspace
copy/move sample
---
.../move-objects-and-files.ipynb | 88 ++++++++++---------
.../move-objects-files/workspace_utils.py | 13 +--
.../evo-sdk-common/src/evo/common/_types.py | 13 ++-
.../tests/common/io/test_http.py | 12 +++
.../tests/common/io/test_storage.py | 12 +++
.../evo-sdk-common/tests/common/test_types.py | 42 +++++++++
6 files changed, 129 insertions(+), 51 deletions(-)
create mode 100644 packages/evo-sdk-common/tests/common/test_types.py
diff --git a/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
index c6b23fae9..45ebcbc1a 100644
--- a/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
+++ b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb
@@ -102,6 +102,44 @@
" await target_workspace_selector.display()"
]
},
+ {
+ "cell_type": "markdown",
+ "id": "3e41fd27",
+ "metadata": {},
+ "source": [
+ "#### Create Target Clients\n",
+ "\n",
+ "Run this cell after selecting the target workspace and operation. Rerun it whenever either selection changes; both object and file transfers use these clients and the captured operation."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3f23d718",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from dataclasses import replace\n",
+ "\n",
+ "target_workspace_id = None\n",
+ "operation = \"Copy\"\n",
+ "target_object_client = None\n",
+ "target_data_client = None\n",
+ "target_file_client = None\n",
+ "\n",
+ "if target_workspace_selector is not None:\n",
+ " target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n",
+ " operation = target_workspace_selector.get_operation()\n",
+ "\n",
+ "if target_workspace_id is None:\n",
+ " print(\"No target workspace selected. Please select a target workspace first.\")\n",
+ "else:\n",
+ " target_environment = replace(source_environment, workspace_id=target_workspace_id)\n",
+ " target_object_client = ObjectAPIClient(target_environment, connector)\n",
+ " target_data_client = target_object_client.get_data_client(manager.cache)\n",
+ " target_file_client = FileAPIClient(target_environment, connector)"
+ ]
+ },
{
"cell_type": "markdown",
"id": "7",
@@ -140,7 +178,7 @@
"selected_id = object_selector.get_selected_id()\n",
"\n",
"if selected_id:\n",
- " object_name = object_selector._format_object_name(selected_object)\n",
+ " object_name = object_selector.format_object_name(selected_object)\n",
" print(f\"Selected Object ID: {selected_id}\")\n",
" print(f\"Selected Object Name: {object_name}\")\n",
"else:\n",
@@ -188,7 +226,7 @@
" for data_download in downloaded_object.prepare_data_download(list(data_refs)):\n",
" await data_download.download_to_cache(\n",
" cache=manager.cache,\n",
- " transport=object_client._connector.transport,\n",
+ " transport=connector.transport,\n",
" fb=FeedbackWidget(f\"Downloading {data_download.name}\"),\n",
" )\n",
" print(\"All data files downloaded to cache.\")\n",
@@ -203,7 +241,7 @@
" data_folder.mkdir(exist_ok=True)\n",
"\n",
" # Save to file using the object name\n",
- " object_name = object_selector._format_object_name(selected_object)\n",
+ " object_name = object_selector.format_object_name(selected_object)\n",
" output_file = data_folder / f\"{object_name}.json\"\n",
"\n",
" with open(output_file, \"w\") as f:\n",
@@ -233,24 +271,15 @@
"import json\n",
"from pathlib import Path\n",
"\n",
- "from evo.common import Environment\n",
"from evo.notebooks import FeedbackWidget\n",
"\n",
- "# Get the target workspace ID and operation\n",
- "if target_workspace_selector is None:\n",
- " target_workspace_id = None\n",
- " operation = \"Copy\"\n",
- "else:\n",
- " target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n",
- " operation = target_workspace_selector.get_operation()\n",
- "\n",
- "if not target_workspace_id:\n",
- " print(\"No target workspace selected. Please select a target workspace first.\")\n",
+ "if target_object_client is None:\n",
+ " print(\"Select a target workspace and run the target client setup cell first.\")\n",
"elif not selected_id:\n",
" print(\"No object selected. Please select an object first.\")\n",
"else:\n",
" # Read the saved JSON file\n",
- " object_name = object_selector._format_object_name(selected_object)\n",
+ " object_name = object_selector.format_object_name(selected_object)\n",
" input_file = Path(\"data\") / f\"{object_name}.json\"\n",
"\n",
" if not input_file.exists():\n",
@@ -265,19 +294,6 @@
" print(f\"{operation_verb} object '{object_name}' to target workspace...\")\n",
"\n",
" try:\n",
- " # Create a new ObjectAPIClient with the target workspace environment\n",
- " target_env = Environment(\n",
- " org_id=manager.get_environment().org_id,\n",
- " workspace_id=target_workspace_id,\n",
- " hub_url=manager.get_environment().hub_url,\n",
- " )\n",
- "\n",
- " target_object_client = ObjectAPIClient(target_env, manager.get_connector())\n",
- "\n",
- " # Use the same cache as the source object client\n",
- " # This ensures the data files downloaded earlier are accessible\n",
- " target_data_client = target_object_client.get_data_client(manager.cache)\n",
- "\n",
" # Copy the data files from the source workspace cache to the target workspace cache\n",
" # Get cache locations for both workspaces\n",
" source_cache_location = data_client.cache_location\n",
@@ -372,18 +388,8 @@
"metadata": {},
"outputs": [],
"source": [
- "from dataclasses import replace\n",
- "\n",
- "if target_workspace_selector is None:\n",
- " raise ValueError(\"Target workspace selection is not available (CI).\")\n",
- "\n",
- "target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n",
- "operation = target_workspace_selector.get_operation()\n",
- "if not target_workspace_id:\n",
- " raise ValueError(\"Select a target workspace first.\")\n",
- "\n",
- "destination_environment = replace(source_environment, workspace_id=target_workspace_id)\n",
- "destination_file_client = FileAPIClient(destination_environment, connector)\n",
+ "if target_file_client is None:\n",
+ " raise ValueError(\"Select a target workspace and run the target client setup cell first.\")\n",
"\n",
"source_path = source_file_selector.value\n",
"destination_path = source_path # Keep the same path/name in the destination\n",
@@ -391,7 +397,7 @@
"download = await source_file_client.prepare_download_by_path(source_path)\n",
"local_file = await download.download_to_cache(manager.cache, connector.transport)\n",
"\n",
- "upload = await destination_file_client.prepare_upload_by_path(destination_path)\n",
+ "upload = await target_file_client.prepare_upload_by_path(destination_path)\n",
"await upload.upload_from_path(str(local_file), connector.transport)\n",
"\n",
"print(f\"Copied {source_path} to {destination_path}\")\n",
diff --git a/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
index 75de9ed2b..3c7a3da17 100644
--- a/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
+++ b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
@@ -124,7 +124,7 @@ def _on_filter_changed(self, change):
"""Handle filter change."""
self._update_filtered_objects()
- def _format_object_name(self, obj):
+ def format_object_name(self, obj):
"""Format the object name by removing .json extension."""
name = obj.name
name = name.removesuffix(".json")
@@ -145,7 +145,7 @@ def _update_filtered_objects(self):
object_options = [("Select an object...", None)]
object_options.extend(
[
- (f"{self._format_object_name(obj)} ({obj.schema_id.sub_classification})", obj.id)
+ (f"{self.format_object_name(obj)} ({obj.schema_id.sub_classification})", obj.id)
for obj in self.filtered_objects
]
)
@@ -182,7 +182,7 @@ def _update_info_display(self):
info_html = f"""
Object Details:
-
Name: {self._format_object_name(selected_obj)}
+
Name: {self.format_object_name(selected_obj)}
Path: {selected_obj.path}
ID: {selected_obj.id}
Schema: {selected_obj.schema_id}
@@ -217,12 +217,7 @@ class TargetWorkspaceSelectorWidget:
def __init__(self, manager_widget):
self.manager_widget = manager_widget
# Get the selected workspace ID
- selected_id = manager_widget._workspace_selector.selected
-
- if selected_id != manager_widget._workspace_selector.UNSELECTED[1]:
- self.current_workspace_id = selected_id
- else:
- raise ValueError("No source workspace is currently selected.")
+ self.current_workspace_id = manager_widget.get_environment().workspace_id
# Create widgets
self.refresh_btn = widgets.Button(
diff --git a/packages/evo-sdk-common/src/evo/common/_types.py b/packages/evo-sdk-common/src/evo/common/_types.py
index 578d86830..fe7ad6000 100644
--- a/packages/evo-sdk-common/src/evo/common/_types.py
+++ b/packages/evo-sdk-common/src/evo/common/_types.py
@@ -27,7 +27,7 @@
PathLike: TypeAlias = str | os.PathLike | Path
-MAX_WINDOWS_PATH_LEN = 259
+MAX_WINDOWS_PATH_LEN = 32766
FILE_NAME_TOO_LONG_ERR_NO = 63
@@ -63,6 +63,8 @@ def _check_path_length(path: Path) -> None:
def resolve_path(path: PathLike, *, check_path_length: bool = False) -> Path:
"""Resolve the given path and check for maximum path length.
+ Windows paths use the extended-length form so filesystem operations do not depend on the system long-path setting.
+
:param path: The path to resolve.
:param check_path_length: If True, check the path length.
@@ -77,6 +79,15 @@ def resolve_path(path: PathLike, *, check_path_length: bool = False) -> Path:
except TypeError:
raise TypeError(f"Expected a PathLike object, got {path!r}")
+ if os.name == "nt":
+ absolute_path = os.path.abspath(path)
+ if not absolute_path.startswith(("\\\\?\\", "\\\\.\\")):
+ if absolute_path.startswith("\\\\"):
+ absolute_path = "\\\\?\\UNC\\" + absolute_path[2:]
+ else:
+ absolute_path = "\\\\?\\" + absolute_path
+ path = Path(absolute_path)
+
resolved = path.resolve()
if check_path_length:
_check_path_length(resolved)
diff --git a/packages/evo-sdk-common/tests/common/io/test_http.py b/packages/evo-sdk-common/tests/common/io/test_http.py
index 0c23aa8ae..925c6431f 100644
--- a/packages/evo-sdk-common/tests/common/io/test_http.py
+++ b/packages/evo-sdk-common/tests/common/io/test_http.py
@@ -12,6 +12,7 @@
from pathlib import Path
from unittest import mock
+from evo.common._types import resolve_path
from evo.common.connector import APIConnector
from evo.common.io import HTTPSource
from evo.common.test_tools import TestWithDownloadHandler
@@ -78,6 +79,17 @@ async def test_download_file(self) -> None:
self.assertTrue(test_data_file.exists())
self.assertEqual(TEST_DATA, test_data_file.read_bytes())
+ async def test_download_file_long_path(self) -> None:
+ test_data_file = self.CACHE_DIR / ("a" * 100) / ("b" * 100) / "download.csv"
+ resolved = resolve_path(test_data_file)
+ resolved.parent.mkdir(parents=True)
+ self.assertGreater(len(str(test_data_file)), 259)
+
+ await HTTPSource.download_file(str(test_data_file), self.url_generator.get_new_url, self.transport)
+
+ self.assertEqual(TEST_DATA, resolved.read_bytes())
+ self.assertEqual([resolved], list(resolved.parent.iterdir()))
+
async def test_download_file_uses_concurrently_published_file(self) -> None:
"""Test that a concurrent process publishing the destination is accepted."""
test_data_file = self.CACHE_DIR / "test_data_download_concurrent.csv"
diff --git a/packages/evo-sdk-common/tests/common/io/test_storage.py b/packages/evo-sdk-common/tests/common/io/test_storage.py
index 79692807e..33045e3bd 100644
--- a/packages/evo-sdk-common/tests/common/io/test_storage.py
+++ b/packages/evo-sdk-common/tests/common/io/test_storage.py
@@ -23,6 +23,7 @@
from parameterized import parameterized_class
from evo.common import RequestMethod
+from evo.common._types import resolve_path
from evo.common.connector import APIConnector
from evo.common.io.storage import BlockList, StorageBlock, StorageDestination
from evo.common.test_tools import TestWithUploadHandler, utc_datetime
@@ -198,6 +199,17 @@ async def test_upload_file(self) -> None:
uploaded_data = await self.handler.get_committed()
self.assertEqual(test_data_file.read_bytes(), uploaded_data)
+ async def test_upload_file_long_path(self) -> None:
+ test_data_file = self.CACHE_DIR / ("a" * 100) / ("b" * 100) / "upload.csv"
+ resolved = resolve_path(test_data_file)
+ resolved.parent.mkdir(parents=True)
+ resolved.write_bytes(TEST_DATA)
+ self.assertGreater(len(str(test_data_file)), 259)
+
+ await StorageDestination.upload_file(str(test_data_file), self.url_generator.get_new_url, self.transport)
+
+ self.assertEqual(TEST_DATA, await self.handler.get_committed())
+
@mock.patch("evo.common.io.http.APIConnector", wraps=APIConnector)
async def test_additional_headers_passed_to_connector(self, mock_api_connector: mock.Mock) -> None:
additional_headers = {"X-Custom-Header": "custom-value", "X-Another-Header": "another-value"}
diff --git a/packages/evo-sdk-common/tests/common/test_types.py b/packages/evo-sdk-common/tests/common/test_types.py
new file mode 100644
index 000000000..2bb35ef17
--- /dev/null
+++ b/packages/evo-sdk-common/tests/common/test_types.py
@@ -0,0 +1,42 @@
+import os
+import unittest
+from pathlib import Path
+from tempfile import NamedTemporaryFile, TemporaryDirectory
+
+from evo.common._types import resolve_path
+
+
+@unittest.skipUnless(os.name == "nt", "Windows extended-length paths")
+class TestWindowsPaths(unittest.TestCase):
+ def test_long_path_can_be_opened(self) -> None:
+ with TemporaryDirectory() as directory:
+ path = Path(directory) / ("a" * 100) / ("b" * 100) / ("c" * 100) / "data.bin"
+ resolved = resolve_path(path, check_path_length=True)
+ self.assertTrue(str(resolved).startswith("\\\\?\\"))
+ resolved.parent.mkdir(parents=True)
+ resolved.write_bytes(b"transfer data")
+ self.assertEqual(b"transfer data", resolved.read_bytes())
+ self.assertEqual(resolved, resolve_path(resolved, check_path_length=True))
+
+ def test_short_path_supports_long_sibling_temporary_file(self) -> None:
+ with TemporaryDirectory() as directory:
+ path = Path(directory) / ("a" * 100)
+ path /= "b" * (250 - len(str(path)))
+ path /= "x"
+ self.assertLess(len(str(path)), 260)
+ resolved = resolve_path(path, check_path_length=True)
+ self.assertTrue(str(resolved).startswith("\\\\?\\"))
+ resolved.parent.mkdir(parents=True)
+ with NamedTemporaryFile(dir=resolved.parent) as temporary_file:
+ self.assertGreater(len(temporary_file.name), 259)
+ temporary_file.write(b"transfer data")
+
+ def test_unc_path(self) -> None:
+ path = resolve_path(r"\\server\share\folder\data.bin")
+ self.assertEqual(r"\\?\UNC\server\share\folder\data.bin", str(path))
+ self.assertEqual(path, resolve_path(path))
+
+
+class TestResolvePath(unittest.TestCase):
+ def test_relative_path(self) -> None:
+ self.assertEqual(resolve_path("data.bin"), resolve_path(Path("folder") / ".." / "data.bin"))
From 3800a2a19168027fc74c487b2a88b492fa9ea64d Mon Sep 17 00:00:00 2001
From: MaitriAnv <263695489+MaitriAnv@users.noreply.github.com>
Date: Thu, 5 Nov 2026 11:35:50 +0000
Subject: [PATCH 5/8] Handle long paths when creating file cache directories
---
packages/evo-files/src/evo/files/io.py | 3 ++-
packages/evo-files/tests/test_file_io.py | 29 ++++++++++++++++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/packages/evo-files/src/evo/files/io.py b/packages/evo-files/src/evo/files/io.py
index ec71ee795..4d79ab674 100644
--- a/packages/evo-files/src/evo/files/io.py
+++ b/packages/evo-files/src/evo/files/io.py
@@ -16,6 +16,7 @@
from uuid import UUID
from evo.common import APIConnector, Environment, ICache
+from evo.common._types import resolve_path
from evo.common.io import Download, Upload
from .data import FileMetadata
@@ -176,7 +177,7 @@ def _get_cache_location(self, cache: ICache) -> Path:
/ str(self.metadata.id)
/ self.metadata.version_id
)
- download_dir.mkdir(parents=True, exist_ok=True)
+ resolve_path(download_dir).mkdir(parents=True, exist_ok=True)
return download_dir / _make_name_safe(self.metadata.name)
async def get_download_url(self) -> str:
diff --git a/packages/evo-files/tests/test_file_io.py b/packages/evo-files/tests/test_file_io.py
index 70d1fe314..fbf54b96a 100644
--- a/packages/evo-files/tests/test_file_io.py
+++ b/packages/evo-files/tests/test_file_io.py
@@ -10,10 +10,14 @@
# limitations under the License.
import json
+import os
+import unittest
+from unittest import mock
from uuid import UUID
from data import load_test_data
from evo.common import ServiceUser
+from evo.common._types import resolve_path
from evo.common.test_tools import TestWithConnector, TestWithDownloadHandler, TestWithUploadHandler, utc_datetime
from evo.files import FileAPIDownload, FileAPIUpload, FileMetadata
@@ -109,6 +113,31 @@ async def test_download_to_cache(self) -> None:
self.assertEqual(TEST_DATA, dest.read_bytes())
self.assert_download_requests(INITIAL_URL)
+ @unittest.skipUnless(os.name == "nt", "Windows long-path regression")
+ async def test_download_to_cache_long_path(self) -> None:
+ cache_location = self.cache.root / ("a" * 70) / ("b" * 70) / ("c" * 70)
+ resolved_cache_location = resolve_path(cache_location)
+ resolved_cache_location.mkdir(parents=True, exist_ok=True)
+ cache = mock.Mock()
+ cache.get_location.return_value = cache_location
+
+ expected = resolve_path(cache_location / str(FILE_ID) / VERSION_ID / self.metadata.name)
+ self.assertGreater(len(str(expected)), 259)
+
+ original_mkdir = type(resolved_cache_location).mkdir
+
+ def mkdir_with_legacy_limit(path, *args, **kwargs):
+ if len(str(path)) > 259 and not str(path).startswith("\\\\?\\"):
+ raise OSError("Path exceeds the legacy Windows limit")
+ return original_mkdir(path, *args, **kwargs)
+
+ with mock.patch.object(type(resolved_cache_location), "mkdir", mkdir_with_legacy_limit):
+ dest = await self.download.download_to_cache(cache, self.transport)
+
+ self.assertEqual(expected, resolve_path(dest))
+ self.assertEqual(TEST_DATA, dest.read_bytes())
+ self.assert_download_requests(INITIAL_URL)
+
class TestFileAPIUpload(TestWithConnector, TestWithUploadHandler):
def setUp(self) -> None:
From 0976d091fe4e72eed373314dccccf52a1fbbe0bd Mon Sep 17 00:00:00 2001
From: MaitriAnv <263695489+MaitriAnv@users.noreply.github.com>
Date: Thu, 5 Nov 2026 11:53:23 +0000
Subject: [PATCH 6/8] Handle long paths in file cache and escape object
metadata
---
.../bonus/move-objects-files/workspace_utils.py | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
index 3c7a3da17..95ff6e32d 100644
--- a/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
+++ b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py
@@ -1,3 +1,4 @@
+from html import escape
from uuid import UUID
import ipywidgets as widgets
@@ -178,15 +179,21 @@ def _update_info_display(self):
self.info_display.value = ""
return
+ object_name = escape(str(self.format_object_name(selected_obj)))
+ object_path = escape(str(selected_obj.path))
+ object_id = escape(str(selected_obj.id))
+ object_schema = escape(str(selected_obj.schema_id))
+ object_version = escape(str(selected_obj.version_id))
+
# Build info HTML
info_html = f"""
Object Details:
- Name: {self.format_object_name(selected_obj)}
- Path: {selected_obj.path}
- ID: {selected_obj.id}
- Schema: {selected_obj.schema_id}
- Version: {selected_obj.version_id}
+ Name: {object_name}
+ Path: {object_path}
+ ID: {object_id}
+ Schema: {object_schema}
+ Version: {object_version}
Created: {selected_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")}
Modified: {selected_obj.modified_at.strftime("%Y-%m-%d %H:%M:%S")}
From 5f5eb462345398b2f3978a9b705405e550801d4f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 5 Nov 2026 12:05:29 +0000
Subject: [PATCH 7/8] Update to MkDocs documentation
---
.../evo-blockmodels/BlockModelAPIClient.html | 117 ++++++++++++++++--
1 file changed, 108 insertions(+), 9 deletions(-)
diff --git a/mkdocs/site/packages/evo-blockmodels/BlockModelAPIClient.html b/mkdocs/site/packages/evo-blockmodels/BlockModelAPIClient.html
index e8e813652..5197568a1 100644
--- a/mkdocs/site/packages/evo-blockmodels/BlockModelAPIClient.html
+++ b/mkdocs/site/packages/evo-blockmodels/BlockModelAPIClient.html
@@ -1729,6 +1729,8 @@
column_groups: dict[str, str] | None = None,
group_missing_column_override: dict[str, MissingColumnPolicy] | None = None,
separator: str = _QUALIFIED_TITLE_SEPARATOR,
+ new_groups: list[GroupDefinition] | None = None,
+ column_metadata_updates: dict[str, ColumnMetadataUpdate] | None = None,
) -> Version
@@ -1879,8 +1881,8 @@
A dictionary assigning new columns to groups: map a new column's qualified
title (its key in data, e.g. "Assays▸Cu") to the qualified title of the group it belongs to.
-To move or ungroup an existing column, use :meth:update_column_metadata instead — a group change is
-metadata-only and does not require re-uploading data.
+To move or ungroup an
existing column, use
column_metadata_updates (in the same update) or
+:meth:
update_column_metadata (on its own).
@@ -1926,6 +1928,43 @@ QUALIFIED_TITLE_SEPARATOR
|
+
+
+ new_groups
+ |
+
+ list[GroupDefinition] | None
+ |
+
+
+ Definitions of new groups to create in the same update, so a group can be created and
+populated (by new columns or by moving existing ones) atomically in a single new version.
+
+ |
+
+ None
+ |
+
+
+
+ column_metadata_updates
+ |
+
+ dict[str, ColumnMetadataUpdate] | None
+ |
+
+
+ Metadata updates (unit, tags and/or group) for existing columns, applied in
+the same update and keyed by each column's current title. Set ColumnMetadataUpdate(group=...) to
+move a column into a group (or "" to ungroup it). When a moved column's data is re-uploaded (it is in
+update_columns, named by its current title), key that data by its destination title, e.g.
+Assays▸Cu after moving Cu into Assays. A column cannot also be in delete_columns.
+
+ |
+
+ None
+ |
+
@@ -2006,6 +2045,8 @@ column_groups: dict[str, str] | None = None,
group_missing_column_override: dict[str, MissingColumnPolicy] | None = None,
separator: str = _QUALIFIED_TITLE_SEPARATOR,
+ new_groups: list[GroupDefinition] | None = None,
+ column_metadata_updates: dict[str, ColumnMetadataUpdate] | None = None,
) -> Version
@@ -2193,8 +2234,8 @@
A dictionary assigning new columns to groups: map a new column's qualified
title (its key in data, e.g. "Assays▸Cu") to the qualified title of the group it belongs to.
-To move or ungroup an existing column, use :meth:update_column_metadata instead — a group change is
-metadata-only and does not require re-uploading data.
+To move or ungroup an existing column, use column_metadata_updates (in the same update) or
+:meth:update_column_metadata (on its own).
@@ -2240,6 +2281,43 @@ QUALIFIED_TITLE_SEPARATOR
|
+
+
+ new_groups
+ |
+
+ list[GroupDefinition] | None
+ |
+
+
+ Definitions of new groups to create in the same update, so a group can be created and
+populated (by new columns or by moving existing ones) atomically in a single new version.
+
+ |
+
+ None
+ |
+
+
+
+ column_metadata_updates
+ |
+
+ dict[str, ColumnMetadataUpdate] | None
+ |
+
+
+ Metadata updates (unit, tags and/or group) for existing columns, applied in
+the same update and keyed by each column's current title. Set ColumnMetadataUpdate(group=...) to
+move a column into a group (or "" to ungroup it). When a moved column's data is re-uploaded (it is in
+update_columns, named by its current title), key that data by its destination title, e.g.
+Assays▸Cu after moving Cu into Assays. A column cannot also be in delete_columns.
+
+ |
+
+ None
+ |
+
@@ -2395,19 +2473,20 @@ new: list[GroupDefinition] | None = None,
update: dict[str, GroupMetadataUpdate] | None = None,
delete: list[str] | None = None,
+ move_columns: dict[str, str] | None = None,
comment: str | None = None,
) -> Version
-
Create, update, and/or delete column groups on a block model.
-
This method manages group definitions without requiring data upload or cache configuration. Any
-combination of new, update and delete can be supplied in a single call.
+
Create, update, and/or delete column groups, and move existing columns between groups.
+
This method manages group definitions and group membership without requiring data upload or cache
+configuration. Any combination of new, update, delete and move_columns can be supplied
+in a single call, producing a single new version.
Groups are addressed by their qualified title (a bare title for a top-level group, or segments
joined by ▸ for a nested group). To assign a new column to a group, use the column_groups
-parameter on the column methods; to move or ungroup an existing column, use
-:meth:update_column_metadata. To resolve a written group back to its server-assigned UUID and
+parameter on the column methods; to move or ungroup an existing column, use move_columns. To resolve a written group back to its server-assigned UUID and
resolved policy, use the helpers on the returned :class:~evo.blockmodels.data.Version, e.g.
:meth:~evo.blockmodels.data.Version.group_by_qualified_title.
@@ -2489,6 +2568,26 @@
+
+ move_columns
+ |
+
+ dict[str, str] | None
+ |
+
+
+ A dictionary mapping an existing column's current title (qualified
+group▸title if it is currently grouped, plain otherwise) to the qualified title of the group to
+move it into, or "" to ungroup it. Only the column's group changes; the target group may be one
+created in new in the same call. To change a column's unit or tags, use
+:meth:update_column_metadata.
+
+ |
+
+ None
+ |
+
comment
From d5ffd68e2a8dc5066d64c7c909efdcd053421456 Mon Sep 17 00:00:00 2001
From: MaitriAnv <263695489+MaitriAnv@users.noreply.github.com>
Date: Mon, 9 Nov 2026 11:44:03 +0000
Subject: [PATCH 8/8] Remove move objects notebook
---
.../bonus/move-objects/move-objects.ipynb | 328 ----------------
.../bonus/move-objects/workspace_utils.py | 367 ------------------
2 files changed, 695 deletions(-)
delete mode 100644 code-samples/workspaces/bonus/move-objects/move-objects.ipynb
delete mode 100644 code-samples/workspaces/bonus/move-objects/workspace_utils.py
diff --git a/code-samples/workspaces/bonus/move-objects/move-objects.ipynb b/code-samples/workspaces/bonus/move-objects/move-objects.ipynb
deleted file mode 100644
index e24057a94..000000000
--- a/code-samples/workspaces/bonus/move-objects/move-objects.ipynb
+++ /dev/null
@@ -1,328 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "0",
- "metadata": {},
- "outputs": [],
- "source": [
- "from evo.notebooks import ServiceManagerWidget\n",
- "from evo.objects import ObjectAPIClient\n",
- "\n",
- "cache_location = \"data\"\n",
- "\n",
- "# Evo app credentials\n",
- "client_id = \"\" # Replace with your client ID\n",
- "redirect_url = \"\" # Replace with your redirect URL\n",
- "\n",
- "manager = await ServiceManagerWidget.with_auth_code(\n",
- " discovery_url=\"https://discover.api.seequent.com\",\n",
- " client_id=client_id,\n",
- " redirect_url=redirect_url,\n",
- " cache_location=cache_location,\n",
- ").login()"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "1",
- "metadata": {},
- "source": [
- "### Use the Evo Python SDK to create an object client and a data client"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "2",
- "metadata": {},
- "outputs": [],
- "source": [
- "# The object client will manage your auth token and Geoscience Object API requests.\n",
- "object_client = ObjectAPIClient(manager.get_environment(), manager.get_connector())\n",
- "\n",
- "# The data client will manage saving your data as Parquet and publishing your data to Evo storage.\n",
- "data_client = object_client.get_data_client(manager.cache)"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "3",
- "metadata": {},
- "source": [
- "#### Use the Object Selector Widget\n",
- "\n",
- "The widget provides:\n",
- "- A refresh button to load all objects from the workspace\n",
- "- A dropdown to filter objects by type (schema classification)\n",
- "- A dropdown to select a specific object\n",
- "- An information panel showing details about the selected object"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "4",
- "metadata": {},
- "outputs": [],
- "source": [
- "from workspace_utils import ObjectSelectorWidget\n",
- "\n",
- "# Create and display the object selector widget (auto-loads objects)\n",
- "object_selector = ObjectSelectorWidget(object_client)\n",
- "await object_selector.display()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "5",
- "metadata": {},
- "outputs": [],
- "source": [
- "# Get the selected object ID and print it\n",
- "selected_object = object_selector.get_selected_object()\n",
- "selected_id = object_selector.get_selected_id()\n",
- "\n",
- "if selected_id:\n",
- " print(f\"Selected Object ID: {selected_id}\")\n",
- " print(f\"Selected Object Name: {object_selector._format_object_name(selected_object)}\")\n",
- "else:\n",
- " print(\"No object selected. Please select an object from the dropdown above.\")"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "6",
- "metadata": {},
- "source": [
- "#### Select Target Workspace and Operation\n",
- "\n",
- "This widget allows you to:\n",
- "- Select a target workspace to copy or move the object to\n",
- "- Check \"Delete original after copy\" to perform a move operation instead of copy\n",
- "- View details about the selected target workspace"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "7",
- "metadata": {},
- "outputs": [],
- "source": [
- "import os\n",
- "\n",
- "from workspace_utils import TargetWorkspaceSelectorWidget\n",
- "\n",
- "# Create and display the target workspace selector widget (auto-loads workspaces).\n",
- "# In CI, _CIManager does not expose notebook widget internals used by this selector.\n",
- "if os.environ.get(\"CI\"):\n",
- " target_workspace_selector = None\n",
- " print(\"Skipping target workspace selector widget in CI.\")\n",
- "else:\n",
- " target_workspace_selector = TargetWorkspaceSelectorWidget(manager)\n",
- " await target_workspace_selector.display()"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "8",
- "metadata": {},
- "source": [
- "#### Download and Save Object\n",
- "\n",
- "Download the selected object's JSON and save the \"object\" portion to the data folder."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "9",
- "metadata": {},
- "outputs": [],
- "source": [
- "import json\n",
- "from pathlib import Path\n",
- "\n",
- "from workspace_utils import collect_data_references\n",
- "\n",
- "from evo.notebooks import FeedbackWidget\n",
- "\n",
- "# Download the selected object\n",
- "if selected_id:\n",
- " # Download the object\n",
- " downloaded_object = await object_client.download_object_by_id(object_id=selected_id)\n",
- "\n",
- " # Get the object as a dictionary\n",
- " object_data = downloaded_object.as_dict()\n",
- "\n",
- " # Collect all data references from the object\n",
- " data_refs = collect_data_references(object_data)\n",
- "\n",
- " if data_refs:\n",
- " print(f\"Found {len(data_refs)} data file(s) to download...\")\n",
- "\n",
- " # Download all data files to the cache\n",
- " for data_download in downloaded_object.prepare_data_download(list(data_refs)):\n",
- " await data_download.download_to_cache(\n",
- " cache=manager.cache,\n",
- " transport=object_client._connector.transport,\n",
- " fb=FeedbackWidget(f\"Downloading {data_download.name}\"),\n",
- " )\n",
- " print(\"All data files downloaded to cache.\")\n",
- " else:\n",
- " print(\"No data files to download.\")\n",
- "\n",
- " # Remove only the top-level 'uuid' field if it exists\n",
- " object_data.pop(\"uuid\", None)\n",
- "\n",
- " # Create data folder if it doesn't exist\n",
- " data_folder = Path(\"data\")\n",
- " data_folder.mkdir(exist_ok=True)\n",
- "\n",
- " # Save to file using the object name\n",
- " object_name = object_selector._format_object_name(selected_object)\n",
- " output_file = data_folder / f\"{object_name}.json\"\n",
- "\n",
- " with open(output_file, \"w\") as f:\n",
- " json.dump(object_data, f, indent=2, default=str)\n",
- "\n",
- " print(f\"Object data saved to: {output_file}\")\n",
- " print(f\"File size: {output_file.stat().st_size} bytes\")\n",
- "else:\n",
- " print(\"No object selected. Please select an object first.\")"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "10",
- "metadata": {},
- "source": [
- "#### Publish Object to Target Workspace\n",
- "\n",
- "Read the saved JSON file and publish it as a geoscience object to the selected target workspace."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "11",
- "metadata": {},
- "outputs": [],
- "source": [
- "import json\n",
- "from pathlib import Path\n",
- "\n",
- "from evo.common import Environment\n",
- "from evo.notebooks import FeedbackWidget\n",
- "\n",
- "# Get the target workspace ID and operation\n",
- "if target_workspace_selector is None:\n",
- " target_workspace_id = None\n",
- " operation = \"Copy\"\n",
- "else:\n",
- " target_workspace_id = target_workspace_selector.get_selected_workspace_id()\n",
- " operation = target_workspace_selector.get_operation()\n",
- "\n",
- "if not target_workspace_id:\n",
- " print(\"No target workspace selected. Please select a target workspace first.\")\n",
- "elif not selected_id:\n",
- " print(\"No object selected. Please select an object first.\")\n",
- "else:\n",
- " # Read the saved JSON file\n",
- " object_name = object_selector._format_object_name(selected_object)\n",
- " input_file = Path(\"data\") / f\"{object_name}.json\"\n",
- "\n",
- " if not input_file.exists():\n",
- " print(f\"Error: File {input_file} not found. Please run the download cell first.\")\n",
- " else:\n",
- " # Read the JSON data\n",
- " with open(input_file, \"r\") as f:\n",
- " object_data = json.load(f)\n",
- "\n",
- " # Publish the object to the target workspace\n",
- " operation_verb = \"Copying\" if operation == \"Copy\" else \"Moving\"\n",
- " print(f\"{operation_verb} object '{object_name}' to target workspace...\")\n",
- "\n",
- " try:\n",
- " # Create a new ObjectAPIClient with the target workspace environment\n",
- " target_env = Environment(\n",
- " org_id=manager.get_environment().org_id,\n",
- " workspace_id=target_workspace_id,\n",
- " hub_url=manager.get_environment().hub_url,\n",
- " )\n",
- "\n",
- " target_object_client = ObjectAPIClient(target_env, manager.get_connector())\n",
- "\n",
- " # Use the same cache as the source object client\n",
- " # This ensures the data files downloaded earlier are accessible\n",
- " target_data_client = target_object_client.get_data_client(manager.cache)\n",
- "\n",
- " # Copy the data files from the source workspace cache to the target workspace cache\n",
- " # Get cache locations for both workspaces\n",
- " source_cache_location = data_client.cache_location\n",
- " target_cache_location = target_data_client.cache_location\n",
- "\n",
- " # Collect all data references from the object\n",
- " data_refs = collect_data_references(object_data)\n",
- "\n",
- " if source_cache_location != target_cache_location and data_refs:\n",
- " print(f\"Copying {len(data_refs)} data file(s) to target workspace cache...\")\n",
- " import shutil\n",
- "\n",
- " target_cache_location.mkdir(parents=True, exist_ok=True)\n",
- "\n",
- " for data_ref in data_refs:\n",
- " source_file = source_cache_location / data_ref\n",
- " target_file = target_cache_location / data_ref\n",
- " if source_file.exists() and not target_file.exists():\n",
- " shutil.copy2(source_file, target_file)\n",
- " print(\"Data files copied to target workspace cache.\")\n",
- "\n",
- " # Upload the referenced data to the target workspace\n",
- " await target_data_client.upload_referenced_data(object_data, FeedbackWidget(\"Uploading data\"))\n",
- "\n",
- " # Create the geoscience object in the target workspace\n",
- " # Use the same path as the original object\n",
- " target_path = selected_object.path\n",
- " published_object = await target_object_client.create_geoscience_object(target_path, object_data)\n",
- "\n",
- " print(\"Successfully published object to target workspace!\")\n",
- " print(f\"New Object ID: {published_object.id}\")\n",
- " print(f\"New Object Path: {published_object.path}\")\n",
- "\n",
- " # If this is a move operation, delete the original object\n",
- " if operation == \"Move\":\n",
- " print(\"Deleting original object from source workspace...\")\n",
- " await object_client.delete_object_by_id(selected_id)\n",
- " print(\"Original object deleted successfully.\")\n",
- "\n",
- " except Exception as e:\n",
- " print(f\"Error publishing object: {e!s}\")"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "samples",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.10.16"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/code-samples/workspaces/bonus/move-objects/workspace_utils.py b/code-samples/workspaces/bonus/move-objects/workspace_utils.py
deleted file mode 100644
index 772d2730d..000000000
--- a/code-samples/workspaces/bonus/move-objects/workspace_utils.py
+++ /dev/null
@@ -1,367 +0,0 @@
-from uuid import UUID
-
-import ipywidgets as widgets
-from IPython.display import display
-
-
-def collect_data_references(obj, data_refs=None):
- """Recursively collect all 'data' field values from the object dictionary."""
- if data_refs is None:
- data_refs = set()
-
- if isinstance(obj, dict):
- # If this dict has a 'data' key with a string value, it's a data reference
- if "data" in obj and isinstance(obj["data"], str):
- data_refs.add(obj["data"])
- # Recursively search through all values
- for value in obj.values():
- collect_data_references(value, data_refs)
- elif isinstance(obj, list):
- # Recursively search through all items
- for item in obj:
- collect_data_references(item, data_refs)
-
- return data_refs
-
-
-"""Custom widgets for workspace maintenance operations."""
-
-
-class ObjectSelectorWidget:
- """Widget for selecting geoscience objects with filtering by object type."""
-
- def __init__(self, object_client):
- self.object_client = object_client
- self.all_objects = []
- self.filtered_objects = []
-
- # Create widgets
- self.refresh_btn = widgets.Button(
- description="Refresh Objects",
- button_style="info",
- icon="refresh",
- layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center"),
- )
- self.refresh_btn.style.button_color = "#265C7F"
- self.refresh_btn.on_click(self._on_refresh_click)
-
- self.loading_label = widgets.Label(value="")
-
- # Object type filter
- self.object_type_filter = widgets.Dropdown(
- options=[("All Types", "")],
- value="",
- description="Object Type",
- style={"description_width": "100px"},
- )
- self.object_type_filter.observe(self._on_filter_changed, names="value")
-
- # Object selector
- self.object_selector = widgets.Dropdown(
- options=[("Select an object...", None)],
- value=None,
- description="Object",
- disabled=True,
- style={"description_width": "100px"},
- )
-
- # Info display
- self.info_display = widgets.HTML(value="")
-
- # Layout
- self.widget = widgets.VBox(
- [
- widgets.HBox([self.refresh_btn, self.loading_label]),
- self.object_type_filter,
- self.object_selector,
- self.info_display,
- ]
- )
-
- def _on_refresh_click(self, btn):
- """Handle refresh button click."""
- import asyncio
-
- asyncio.create_task(self.refresh())
-
- async def refresh(self):
- """Load all objects from the workspace."""
- self.loading_label.value = "Loading objects..."
- self.refresh_btn.disabled = True
- self.object_type_filter.disabled = True
- self.object_selector.disabled = True
-
- try:
- # Fetch all objects
- self.all_objects = await self.object_client.list_all_objects()
-
- # Extract unique object types
- object_types = set()
- for obj in self.all_objects:
- # Get the schema classification (e.g., "objects/pointset")
- schema_type = obj.schema_id.classification
- object_types.add(schema_type)
-
- # Update object type filter options
- type_options = [("All Types", "")]
- type_options.extend(sorted([(t, t) for t in object_types]))
- self.object_type_filter.options = type_options
-
- # Update filtered objects
- self._update_filtered_objects()
-
- self.loading_label.value = f"Loaded {len(self.all_objects)} object(s)"
-
- except Exception as e:
- self.loading_label.value = f"Error: {e!s}"
-
- finally:
- self.refresh_btn.disabled = False
- self.object_type_filter.disabled = False
- self.object_selector.disabled = False
-
- def _on_filter_changed(self, change):
- """Handle filter change."""
- self._update_filtered_objects()
-
- def _format_object_name(self, obj):
- """Format the object name by removing .json extension."""
- name = obj.name
- name = name.removesuffix(".json")
- return name
-
- def _update_filtered_objects(self):
- """Update the object selector based on the current filter."""
- selected_type = self.object_type_filter.value
-
- # Filter objects
- if selected_type == "":
- self.filtered_objects = self.all_objects
- else:
- self.filtered_objects = [obj for obj in self.all_objects if obj.schema_id.classification == selected_type]
-
- # Update object selector options
- if self.filtered_objects:
- object_options = [("Select an object...", None)]
- object_options.extend(
- [
- (f"{self._format_object_name(obj)} ({obj.schema_id.sub_classification})", obj.id)
- for obj in self.filtered_objects
- ]
- )
- self.object_selector.options = object_options
- self.object_selector.value = None
- else:
- self.object_selector.options = [("No objects found", None)]
- self.object_selector.value = None
-
- # Update info display when selection changes
- self.object_selector.observe(self._on_object_selected, names="value")
- self._update_info_display()
-
- def _on_object_selected(self, change):
- """Handle object selection change."""
- self._update_info_display()
-
- def _update_info_display(self):
- """Update the info display with details about the selected object."""
- selected_id = self.object_selector.value
-
- if selected_id is None:
- self.info_display.value = ""
- return
-
- # Find the selected object
- selected_obj = next((obj for obj in self.filtered_objects if obj.id == selected_id), None)
-
- if selected_obj is None:
- self.info_display.value = ""
- return
-
- # Build info HTML
- info_html = f"""
-
- Object Details:
- Name: {self._format_object_name(selected_obj)}
- Path: {selected_obj.path}
- ID: {selected_obj.id}
- Schema: {selected_obj.schema_id}
- Version: {selected_obj.version_id}
- Created: {selected_obj.created_at.strftime("%Y-%m-%d %H:%M:%S")}
- Modified: {selected_obj.modified_at.strftime("%Y-%m-%d %H:%M:%S")}
-
- """
- self.info_display.value = info_html
-
- async def display(self):
- """Display the widget and auto-load objects."""
- display(self.widget)
- # Auto-load objects on display
- await self.refresh()
-
- def get_selected_object(self):
- """Get the currently selected object metadata."""
- selected_id = self.object_selector.value
- if selected_id is None:
- return None
- return next((obj for obj in self.filtered_objects if obj.id == selected_id), None)
-
- def get_selected_id(self) -> UUID | None:
- """Get the UUID of the currently selected object."""
- return self.object_selector.value
-
-
-class TargetWorkspaceSelectorWidget:
- """Widget for selecting a target workspace and operation type (copy/move)."""
-
- def __init__(self, manager_widget):
- self.manager_widget = manager_widget
- # Get the selected workspace ID
- selected_id = manager_widget._workspace_selector.selected
-
- if selected_id != manager_widget._workspace_selector.UNSELECTED[1]:
- self.current_workspace_id = selected_id
- else:
- raise ValueError("No source workspace is currently selected.")
-
- # Create widgets
- self.refresh_btn = widgets.Button(
- description="Refresh Workspaces",
- button_style="info",
- icon="refresh",
- layout=widgets.Layout(margin="5px 5px 5px 5px", align_self="center", width="auto"),
- )
- self.refresh_btn.style.button_color = "#265C7F"
- self.refresh_btn.on_click(self._on_refresh_click)
-
- self.loading_label = widgets.Label(value="")
-
- # Operation section - label and checkbox
- self.operation_label = widgets.HTML(value="Copy object")
- self.delete_original_checkbox = widgets.Checkbox(
- value=False,
- description="Delete original after copy",
- indent=False,
- style={"description_width": "auto"},
- )
-
- # Workspace selector
- self.workspace_selector = widgets.Dropdown(
- options=[("Select target workspace...", None)],
- value=None,
- description="To Workspace",
- disabled=True,
- style={"description_width": "100px"},
- )
-
- # Info display
- self.info_display = widgets.HTML(value="")
-
- # Layout
- self.widget = widgets.VBox(
- [
- widgets.HBox([self.refresh_btn, self.loading_label]),
- self.operation_label,
- self.delete_original_checkbox,
- self.workspace_selector,
- self.info_display,
- ]
- )
-
- # Observe selection changes
- self.workspace_selector.observe(self._on_workspace_selected, names="value")
- self.delete_original_checkbox.observe(self._on_checkbox_changed, names="value")
-
- def _on_refresh_click(self, btn):
- """Handle refresh button click."""
- import asyncio
-
- asyncio.create_task(self.refresh())
-
- async def refresh(self):
- """Load all workspaces."""
- self.loading_label.value = "Loading workspaces..."
- self.refresh_btn.disabled = True
- self.workspace_selector.disabled = True
-
- try:
- # Get all workspaces from the service manager
- workspaces = self.manager_widget.workspaces
-
- # Filter out the current workspace
- other_workspaces = [ws for ws in workspaces if ws.id != self.current_workspace_id]
-
- # Update workspace selector options
- if other_workspaces:
- workspace_options = [("Select target workspace...", None)]
- workspace_options.extend(
- [(ws.display_name, ws.id) for ws in sorted(other_workspaces, key=lambda w: w.display_name)]
- )
- self.workspace_selector.options = workspace_options
- self.workspace_selector.value = None
- self.loading_label.value = f"Loaded {len(other_workspaces)} workspace(s)"
- else:
- self.workspace_selector.options = [("No other workspaces available", None)]
- self.workspace_selector.value = None
- self.loading_label.value = "No other workspaces found"
-
- except Exception as e:
- self.loading_label.value = f"Error: {e!s}"
-
- finally:
- self.refresh_btn.disabled = False
- self.workspace_selector.disabled = False
-
- def _on_workspace_selected(self, change):
- """Handle workspace selection change."""
- self._update_info_display()
-
- def _on_checkbox_changed(self, change):
- """Handle delete original checkbox change."""
- self._update_info_display()
-
- def _update_info_display(self):
- """Update the info display with details about the selected workspace."""
- selected_id = self.workspace_selector.value
-
- if selected_id is None:
- self.info_display.value = ""
- return
-
- # Find the selected workspace
- workspaces = self.manager_widget.workspaces
- selected_ws = next((ws for ws in workspaces if ws.id == selected_id), None)
-
- if selected_ws is None:
- self.info_display.value = ""
- return
-
- # Get current workspace info
- current_ws = next((ws for ws in workspaces if ws.id == self.current_workspace_id), None)
- operation = "Move" if self.delete_original_checkbox.value else "Copy"
-
- # Build info HTML
- info_html = f"""
-
- Operation Summary:
- Action: {operation} object
- From: {current_ws.display_name}
- To: {selected_ws.display_name}
- Target Workspace ID: {selected_ws.id}
-
- """
- self.info_display.value = info_html
-
- async def display(self):
- """Display the widget and auto-load workspaces."""
- display(self.widget)
- # Auto-load workspaces on display
- await self.refresh()
-
- def get_selected_workspace_id(self) -> UUID | None:
- """Get the UUID of the currently selected target workspace."""
- return self.workspace_selector.value
-
- def get_operation(self) -> str:
- """Get the selected operation type ('Copy' or 'Move')."""
- return "Move" if self.delete_original_checkbox.value else "Copy"
|