diff --git a/code-samples/workspaces/bonus/move-objects/move-objects.ipynb b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb similarity index 63% rename from code-samples/workspaces/bonus/move-objects/move-objects.ipynb rename to code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb index e24057a94..45ebcbc1a 100644 --- a/code-samples/workspaces/bonus/move-objects/move-objects.ipynb +++ b/code-samples/workspaces/bonus/move-objects-files/move-objects-and-files.ipynb @@ -1,14 +1,35 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "0", + "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": "1", + "metadata": {}, + "source": [ + "#### Sign in and select Source Workspace\n", + "Enter the client_id and redirect_url values." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "0", + "id": "2", "metadata": {}, "outputs": [], "source": [ "from evo.notebooks import ServiceManagerWidget\n", - "from evo.objects import ObjectAPIClient\n", "\n", "cache_location = \"data\"\n", "\n", @@ -26,128 +47,165 @@ }, { "cell_type": "markdown", - "id": "1", + "id": "3", "metadata": {}, "source": [ - "### Use the Evo Python SDK to create an object client and a data client" + "#### Create Source Clients using Evo Python SDK" ] }, { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "4", "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", + "from evo.files import FileAPIClient\n", + "from evo.objects import ObjectAPIClient\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)" + "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": "3", + "id": "5", "metadata": {}, "source": [ - "#### Use the Object Selector Widget\n", + "#### Select Target Workspace and Operation\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" + "- 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": "4", + "id": "6", "metadata": {}, "outputs": [], "source": [ - "from workspace_utils import ObjectSelectorWidget\n", + "import os\n", "\n", - "# Create and display the object selector widget (auto-loads objects)\n", - "object_selector = ObjectSelectorWidget(object_client)\n", - "await object_selector.display()" + "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": "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": "5", + "id": "3f23d718", "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", + "from dataclasses import replace\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", + "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", - " print(\"No object selected. Please select an object from the dropdown above.\")" + " 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": "6", + "id": "7", "metadata": {}, "source": [ - "#### Select Target Workspace and Operation\n", + "## Part 1: Copy / Move a Geoscience Object\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" + "#### 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": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ - "import os\n", - "\n", - "from workspace_utils import TargetWorkspaceSelectorWidget\n", + "object_selector = ObjectSelectorWidget(object_client)\n", + "await object_selector.display()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "selected_object = object_selector.get_selected_object()\n", + "selected_id = object_selector.get_selected_id()\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", + "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", - " target_workspace_selector = TargetWorkspaceSelectorWidget(manager)\n", - " await target_workspace_selector.display()" + " object_name = None\n", + " print(\"No object selected. Please select an object from the dropdown above.\")" ] }, { "cell_type": "markdown", - "id": "8", + "id": "10", "metadata": {}, "source": [ "#### Download and Save Object\n", "\n", - "Download the selected object's JSON and save the \"object\" portion to the data folder." + "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": "9", + "id": "11", "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", @@ -168,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", @@ -183,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", @@ -197,42 +255,31 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "12", "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." + "#### Publish Object to Target Workspace" ] }, { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "13", "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", + "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", @@ -247,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", @@ -302,11 +336,81 @@ " except Exception as e:\n", " print(f\"Error publishing object: {e!s}\")" ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## Part 2: Copy / Move a File\n", + "\n", + "#### Select a Source File" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "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": "16", + "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": "17", + "metadata": {}, + "outputs": [], + "source": [ + "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", + "\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 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", + "\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": "samples", + "display_name": "evo-code-samples (3.14.0)", "language": "python", "name": "python3" }, @@ -320,7 +424,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.16" + "version": "3.14.0" } }, "nbformat": 4, diff --git a/code-samples/workspaces/bonus/move-objects/workspace_utils.py b/code-samples/workspaces/bonus/move-objects-files/workspace_utils.py similarity index 94% rename from code-samples/workspaces/bonus/move-objects/workspace_utils.py rename to code-samples/workspaces/bonus/move-objects-files/workspace_utils.py index 772d2730d..95ff6e32d 100644 --- a/code-samples/workspaces/bonus/move-objects/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 @@ -124,7 +125,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 +146,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 ] ) @@ -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")}
@@ -217,12 +224,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( @@ -237,7 +239,7 @@ def __init__(self, manager_widget): self.loading_label = widgets.Label(value="") # Operation section - label and checkbox - self.operation_label = widgets.HTML(value="Copy object") + self.operation_label = widgets.HTML(value="Copy object/file") self.delete_original_checkbox = widgets.Checkbox( value=False, description="Delete original after copy", 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 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: 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"))