diff --git a/README.md b/README.md index 0c70490..2be0da4 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,36 @@ await async_client.files.delete( ) ``` +#### Moving and Copying Files + +Use `move_to()` to relocate a file within DIAL storage and `copy_to()` to duplicate it. Both accept the same `source` / `destination` URLs (relative, absolute, or `PurePosixPath`) and an optional `overwrite` flag (default `False`): + +```python +# Sync client +sync_client.files.move_to( + source=sync_client.my_files_home() / "draft/my-file.txt", + destination=sync_client.my_files_home() / "final/my-file.txt", +) +sync_client.files.copy_to( + source=sync_client.my_files_home() / "final/my-file.txt", + destination=sync_client.my_files_home() / "backup/my-file.txt", + overwrite=True, +) + +# Async client +await async_client.files.move_to( + source=await async_client.my_files_home() / "draft/my-file.txt", + destination=await async_client.my_files_home() / "final/my-file.txt", +) +await async_client.files.copy_to( + source=await async_client.my_files_home() / "final/my-file.txt", + destination=await async_client.my_files_home() / "backup/my-file.txt", + overwrite=True, +) +``` + +Both methods return `None` on success. `source` and `destination` must point to files in the same DIAL storage (passing a `prompts/...` URL raises `InvalidDialURLError`). + #### Accessing Metadata Use `metadata()` to access metadata of a file: diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py index 73fa1f7..47421aa 100644 --- a/aidial_client/resources/files.py +++ b/aidial_client/resources/files.py @@ -109,6 +109,46 @@ def delete( on_http_error=_files_error_processor, ) + def move_to( + self, + source: Union[str, PurePosixPath], + destination: Union[str, PurePosixPath], + overwrite: bool = False, + ) -> None: + return self.http_client.request( + cast_to=NoneType, + options=FinalRequestOptions( + method="POST", + url=urljoin(API_PREFIX, "ops/resource/move"), + json_data={ + "sourceUrl": self.get_api_path(str(source)), + "destinationUrl": self.get_api_path(str(destination)), + "overwrite": overwrite, + }, + ), + on_http_error=_files_error_processor, + ) + + def copy_to( + self, + source: Union[str, PurePosixPath], + destination: Union[str, PurePosixPath], + overwrite: bool = False, + ) -> None: + return self.http_client.request( + cast_to=NoneType, + options=FinalRequestOptions( + method="POST", + url=urljoin(API_PREFIX, "ops/resource/copy"), + json_data={ + "sourceUrl": self.get_api_path(str(source)), + "destinationUrl": self.get_api_path(str(destination)), + "overwrite": overwrite, + }, + ), + on_http_error=_files_error_processor, + ) + def get_metadata(self, url: Union[str, PurePosixPath]) -> FileMetadata: return self.metadata.get( resource="files", @@ -188,6 +228,46 @@ async def delete( on_http_error=_files_error_processor, ) + async def move_to( + self, + source: Union[str, PurePosixPath], + destination: Union[str, PurePosixPath], + overwrite: bool = False, + ) -> None: + return await self.http_client.request( + cast_to=NoneType, + options=FinalRequestOptions( + method="POST", + url=urljoin(API_PREFIX, "ops/resource/move"), + json_data={ + "sourceUrl": self.get_api_path(str(source)), + "destinationUrl": self.get_api_path(str(destination)), + "overwrite": overwrite, + }, + ), + on_http_error=_files_error_processor, + ) + + async def copy_to( + self, + source: Union[str, PurePosixPath], + destination: Union[str, PurePosixPath], + overwrite: bool = False, + ) -> None: + return await self.http_client.request( + cast_to=NoneType, + options=FinalRequestOptions( + method="POST", + url=urljoin(API_PREFIX, "ops/resource/copy"), + json_data={ + "sourceUrl": self.get_api_path(str(source)), + "destinationUrl": self.get_api_path(str(destination)), + "overwrite": overwrite, + }, + ), + on_http_error=_files_error_processor, + ) + async def get_metadata( self, url: Union[str, PurePosixPath] ) -> FileMetadata: diff --git a/tests/resources/files/test_move_copy.py b/tests/resources/files/test_move_copy.py new file mode 100644 index 0000000..b50650b --- /dev/null +++ b/tests/resources/files/test_move_copy.py @@ -0,0 +1,165 @@ +import json +from typing import Any, Dict, List +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from aidial_client import Dial +from aidial_client._client import AsyncDial +from aidial_client._exception import InvalidDialURLError + + +def _make_capturing_client(captured: List[httpx.Request]) -> Dial: + client = Dial(api_key="dummy", base_url="http://dial.core") + + def send_mock(request: httpx.Request, **_: Any) -> httpx.Response: + captured.append(request) + response = httpx.Response(status_code=200, request=request, json={}) + response.request = request + return response + + client._http_client._internal_http_client.send = send_mock + client._get_my_bucket = Mock(return_value="test-bucket") + return client + + +def _make_async_capturing_client( + captured: List[httpx.Request], +) -> AsyncDial: + client = AsyncDial(api_key="dummy", base_url="http://dial.core") + + async def send_mock(request: httpx.Request, **_: Any) -> httpx.Response: + captured.append(request) + response = httpx.Response(status_code=200, request=request, json={}) + response.request = request + return response + + client._http_client._internal_http_client.send = send_mock + client._get_my_bucket = AsyncMock(return_value="test-bucket") + return client + + +def _body(request: httpx.Request) -> Dict[str, Any]: + return json.loads(request.content.decode()) + + +parametrize_method_and_endpoint = pytest.mark.parametrize( + "method, endpoint", [("move_to", "move"), ("copy_to", "copy")] +) +parametrize_method = pytest.mark.parametrize("method", ["move_to", "copy_to"]) + + +@parametrize_method_and_endpoint +def test_move_copy_returns_none_and_sends_expected_body( + method: str, endpoint: str +): + captured: List[httpx.Request] = [] + client = _make_capturing_client(captured) + + result = getattr(client.files, method)( + source="files/test-bucket/draft/file.txt", + destination="files/test-bucket/final/file.txt", + ) + + assert result is None + assert len(captured) == 1 + request = captured[0] + assert request.method == "POST" + assert request.url.path == f"/v1/ops/resource/{endpoint}" + assert _body(request) == { + "sourceUrl": "files/test-bucket/draft/file.txt", + "destinationUrl": "files/test-bucket/final/file.txt", + "overwrite": False, + } + + +@parametrize_method +def test_overwrite_round_trips(method: str): + captured: List[httpx.Request] = [] + client = _make_capturing_client(captured) + + getattr(client.files, method)( + source="files/test-bucket/a.txt", + destination="files/test-bucket/b.txt", + overwrite=True, + ) + + assert _body(captured[0])["overwrite"] is True + + +@parametrize_method +def test_accepts_pureposixpath_and_absolute_urls(method: str): + captured: List[httpx.Request] = [] + client = _make_capturing_client(captured) + + getattr(client.files, method)( + source=client.my_files_home() / "draft/file.txt", + destination="http://dial.core/v1/files/test-bucket/final/file.txt", + ) + + body = _body(captured[0]) + assert body["sourceUrl"] == "files/test-bucket/draft/file.txt" + assert body["destinationUrl"] == "files/test-bucket/final/file.txt" + + +@parametrize_method +@pytest.mark.parametrize( + "bad_arg", + ["source", "destination"], +) +def test_rejects_non_files_urls(method: str, bad_arg: str): + captured: List[httpx.Request] = [] + client = _make_capturing_client(captured) + + kwargs = { + "source": "files/test-bucket/a.txt", + "destination": "files/test-bucket/b.txt", + } + kwargs[bad_arg] = "prompts/test-bucket/a.txt" + + with pytest.raises(InvalidDialURLError, match="Invalid resource type"): + getattr(client.files, method)(**kwargs) + + assert captured == [] + + +@parametrize_method_and_endpoint +@pytest.mark.asyncio +async def test_move_copy_async_returns_none_and_sends_expected_body( + method: str, endpoint: str +): + captured: List[httpx.Request] = [] + client = _make_async_capturing_client(captured) + + result = await getattr(client.files, method)( + source="files/test-bucket/draft/file.txt", + destination=await client.my_files_home() / "final/file.txt", + overwrite=True, + ) + + assert result is None + assert len(captured) == 1 + request = captured[0] + assert request.method == "POST" + assert request.url.path == f"/v1/ops/resource/{endpoint}" + assert _body(request) == { + "sourceUrl": "files/test-bucket/draft/file.txt", + "destinationUrl": "files/test-bucket/final/file.txt", + "overwrite": True, + } + + +@parametrize_method +@pytest.mark.asyncio +async def test_async_rejects_non_files_urls(method: str): + captured: List[httpx.Request] = [] + client = _make_async_capturing_client(captured) + + with pytest.raises(InvalidDialURLError, match="Invalid resource type"): + await getattr(client.files, method)( + source="conversations/test-bucket/c1", + destination="files/test-bucket/b.txt", + ) + + assert captured == []