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"""
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.
column_metadata_updates (in the same update) or
+:meth:update_column_metadata (on its own).
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
+ 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.
column_metadata_updates (in the same update) or
+:meth:update_column_metadata (on its own).
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
+ 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.
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"))