diff --git a/src/google/adk/artifacts/__init__.py b/src/google/adk/artifacts/__init__.py index 4a6c7c6c51..9a506ab06b 100644 --- a/src/google/adk/artifacts/__init__.py +++ b/src/google/adk/artifacts/__init__.py @@ -15,9 +15,11 @@ from .base_artifact_service import BaseArtifactService from .gcs_artifact_service import GcsArtifactService from .in_memory_artifact_service import InMemoryArtifactService +from .local_file_artifact_service import LocalFileArtifactService __all__ = [ 'BaseArtifactService', 'GcsArtifactService', 'InMemoryArtifactService', + 'LocalFileArtifactService', ] diff --git a/src/google/adk/artifacts/local_file_artifact_service.py b/src/google/adk/artifacts/local_file_artifact_service.py new file mode 100644 index 0000000000..5fc24c82bc --- /dev/null +++ b/src/google/adk/artifacts/local_file_artifact_service.py @@ -0,0 +1,356 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An artifact service implementation using the local file system. + +The file path format used depends on whether the filename has a user namespace: + - For files with user namespace (starting with "user:"): + {base_path}/{app_name}/{user_id}/user/{filename}/{version} + - For regular session-scoped files: + {base_path}/{app_name}/{user_id}/{session_id}/{filename}/{version} +""" +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +import shutil +from typing import Optional + +from google.genai import types +from typing_extensions import override + +from .base_artifact_service import BaseArtifactService + +logger = logging.getLogger("google_adk." + __name__) + + +class LocalFileArtifactService(BaseArtifactService): + """An artifact service implementation using the local file system.""" + + def __init__(self, base_path: str = "./adk_artifacts"): + """Initializes the LocalFileArtifactService. + + Args: + base_path: The base directory path where artifacts will be stored. + Defaults to "./adk_artifacts". + """ + self.base_path = Path(base_path).resolve() + self.base_path.mkdir(parents=True, exist_ok=True) + + @override + async def save_artifact( + self, + *, + app_name: str, + user_id: str, + session_id: str, + filename: str, + artifact: types.Part, + ) -> int: + return await asyncio.to_thread( + self._save_artifact, + app_name, + user_id, + session_id, + filename, + artifact, + ) + + @override + async def load_artifact( + self, + *, + app_name: str, + user_id: str, + session_id: str, + filename: str, + version: Optional[int] = None, + ) -> Optional[types.Part]: + return await asyncio.to_thread( + self._load_artifact, + app_name, + user_id, + session_id, + filename, + version, + ) + + @override + async def list_artifact_keys( + self, *, app_name: str, user_id: str, session_id: str + ) -> list[str]: + return await asyncio.to_thread( + self._list_artifact_keys, + app_name, + user_id, + session_id, + ) + + @override + async def delete_artifact( + self, *, app_name: str, user_id: str, session_id: str, filename: str + ) -> None: + return await asyncio.to_thread( + self._delete_artifact, + app_name, + user_id, + session_id, + filename, + ) + + @override + async def list_versions( + self, *, app_name: str, user_id: str, session_id: str, filename: str + ) -> list[int]: + return await asyncio.to_thread( + self._list_versions, + app_name, + user_id, + session_id, + filename, + ) + + def _file_has_user_namespace(self, filename: str) -> bool: + """Checks if the filename has a user namespace. + + Args: + filename: The filename to check. + + Returns: + True if the filename has a user namespace (starts with "user:"), + False otherwise. + """ + return filename.startswith("user:") + + def _get_artifact_dir( + self, + app_name: str, + user_id: str, + session_id: str, + filename: str, + ) -> Path: + """Constructs the directory path for an artifact. + + Args: + app_name: The name of the application. + user_id: The ID of the user. + session_id: The ID of the session. + filename: The name of the artifact file. + + Returns: + The constructed directory path. + """ + if self._file_has_user_namespace(filename): + return self.base_path / app_name / user_id / "user" / filename + return self.base_path / app_name / user_id / session_id / filename + + def _get_artifact_file_path( + self, + app_name: str, + user_id: str, + session_id: str, + filename: str, + version: int, + ) -> Path: + """Constructs the full file path for an artifact version. + + Args: + app_name: The name of the application. + user_id: The ID of the user. + session_id: The ID of the session. + filename: The name of the artifact file. + version: The version of the artifact. + + Returns: + The constructed file path. + """ + artifact_dir = self._get_artifact_dir( + app_name, user_id, session_id, filename + ) + return artifact_dir / str(version) + + def _get_metadata_file_path( + self, + app_name: str, + user_id: str, + session_id: str, + filename: str, + version: int, + ) -> Path: + """Constructs the metadata file path for an artifact version. + + Args: + app_name: The name of the application. + user_id: The ID of the user. + session_id: The ID of the session. + filename: The name of the artifact file. + version: The version of the artifact. + + Returns: + The constructed metadata file path. + """ + artifact_dir = self._get_artifact_dir( + app_name, user_id, session_id, filename + ) + return artifact_dir / f"{version}.metadata.json" + + def _save_artifact( + self, + app_name: str, + user_id: str, + session_id: str, + filename: str, + artifact: types.Part, + ) -> int: + versions = self._list_versions( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + version = 0 if not versions else max(versions) + 1 + + artifact_dir = self._get_artifact_dir( + app_name, user_id, session_id, filename + ) + artifact_dir.mkdir(parents=True, exist_ok=True) + + artifact_file_path = self._get_artifact_file_path( + app_name, user_id, session_id, filename, version + ) + metadata_file_path = self._get_metadata_file_path( + app_name, user_id, session_id, filename, version + ) + + # Save the artifact data + artifact_file_path.write_bytes(artifact.inline_data.data) + + # Save metadata (mime_type) + metadata = {"mime_type": artifact.inline_data.mime_type} + metadata_file_path.write_text(json.dumps(metadata)) + + return version + + def _load_artifact( + self, + app_name: str, + user_id: str, + session_id: str, + filename: str, + version: Optional[int] = None, + ) -> Optional[types.Part]: + if version is None: + versions = self._list_versions( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + if not versions: + return None + version = max(versions) + + artifact_file_path = self._get_artifact_file_path( + app_name, user_id, session_id, filename, version + ) + metadata_file_path = self._get_metadata_file_path( + app_name, user_id, session_id, filename, version + ) + + if not artifact_file_path.exists() or not metadata_file_path.exists(): + return None + + try: + artifact_data = artifact_file_path.read_bytes() + metadata_text = metadata_file_path.read_text() + metadata = json.loads(metadata_text) + + artifact = types.Part.from_bytes( + data=artifact_data, mime_type=metadata["mime_type"] + ) + return artifact + except (OSError, json.JSONDecodeError, KeyError): + logger.warning( + "Failed to load artifact %s for app %s, user %s, session %s," + " version %d", + filename, + app_name, + user_id, + session_id, + version, + ) + return None + + def _list_artifact_keys( + self, app_name: str, user_id: str, session_id: str + ) -> list[str]: + filenames = set() + + # List session-scoped artifacts + session_dir = self.base_path / app_name / user_id / session_id + if session_dir.exists(): + for item in session_dir.iterdir(): + if item.is_dir(): + filenames.add(item.name) + + # List user-namespaced artifacts + user_namespace_dir = self.base_path / app_name / user_id / "user" + if user_namespace_dir.exists(): + for item in user_namespace_dir.iterdir(): + if item.is_dir(): + filenames.add(item.name) + + return sorted(list(filenames)) + + def _delete_artifact( + self, app_name: str, user_id: str, session_id: str, filename: str + ) -> None: + artifact_dir = self._get_artifact_dir( + app_name, user_id, session_id, filename + ) + if artifact_dir.exists(): + shutil.rmtree(artifact_dir) + + def _list_versions( + self, app_name: str, user_id: str, session_id: str, filename: str + ) -> list[int]: + """Lists all available versions of an artifact. + + This method retrieves all versions of a specific artifact by listing + the version directories within the artifact's directory. + + Args: + app_name: The name of the application. + user_id: The ID of the user who owns the artifact. + session_id: The ID of the session (ignored for user-namespaced files). + filename: The name of the artifact file. + + Returns: + A list of version numbers (integers) available for the specified + artifact. Returns an empty list if no versions are found. + """ + artifact_dir = self._get_artifact_dir( + app_name, user_id, session_id, filename + ) + if not artifact_dir.exists(): + return [] + + versions = [] + for item in artifact_dir.iterdir(): + if item.is_file() and item.name.isdigit(): + versions.append(int(item.name)) + + return sorted(versions) diff --git a/tests/unittests/artifacts/test_artifact_service.py b/tests/unittests/artifacts/test_artifact_service.py index 920aad6835..6bf1bdd57f 100644 --- a/tests/unittests/artifacts/test_artifact_service.py +++ b/tests/unittests/artifacts/test_artifact_service.py @@ -16,6 +16,7 @@ from datetime import datetime import enum +import tempfile from typing import Optional from typing import Union from unittest import mock @@ -24,6 +25,7 @@ from google.adk.artifacts.base_artifact_service import ArtifactVersion from google.adk.artifacts.gcs_artifact_service import GcsArtifactService from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService +from google.adk.artifacts.local_file_artifact_service import LocalFileArtifactService from google.genai import types import pytest @@ -36,6 +38,7 @@ class ArtifactServiceType(Enum): IN_MEMORY = "IN_MEMORY" GCS = "GCS" + LOCAL_FILE = "LOCAL_FILE" class MockBlob: @@ -153,12 +156,20 @@ def get_artifact_service( """Creates an artifact service for testing.""" if service_type == ArtifactServiceType.GCS: return mock_gcs_artifact_service() + elif service_type == ArtifactServiceType.LOCAL_FILE: + temp_dir = tempfile.mkdtemp() + return LocalFileArtifactService(base_path=temp_dir) return InMemoryArtifactService() @pytest.mark.asyncio @pytest.mark.parametrize( - "service_type", [ArtifactServiceType.IN_MEMORY, ArtifactServiceType.GCS] + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.LOCAL_FILE, + ], ) async def test_load_empty(service_type): """Tests loading an artifact when none exists.""" @@ -173,7 +184,12 @@ async def test_load_empty(service_type): @pytest.mark.asyncio @pytest.mark.parametrize( - "service_type", [ArtifactServiceType.IN_MEMORY, ArtifactServiceType.GCS] + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.LOCAL_FILE, + ], ) async def test_save_load_delete(service_type): """Tests saving, loading, and deleting an artifact.""" @@ -226,7 +242,12 @@ async def test_save_load_delete(service_type): @pytest.mark.asyncio @pytest.mark.parametrize( - "service_type", [ArtifactServiceType.IN_MEMORY, ArtifactServiceType.GCS] + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.LOCAL_FILE, + ], ) async def test_list_keys(service_type): """Tests listing keys in the artifact service.""" @@ -257,7 +278,12 @@ async def test_list_keys(service_type): @pytest.mark.asyncio @pytest.mark.parametrize( - "service_type", [ArtifactServiceType.IN_MEMORY, ArtifactServiceType.GCS] + "service_type", + [ + ArtifactServiceType.IN_MEMORY, + ArtifactServiceType.GCS, + ArtifactServiceType.LOCAL_FILE, + ], ) async def test_list_versions(service_type): """Tests listing versions of an artifact.""" diff --git a/tests/unittests/artifacts/test_local_file_artifact_service.py b/tests/unittests/artifacts/test_local_file_artifact_service.py new file mode 100644 index 0000000000..51e07d1897 --- /dev/null +++ b/tests/unittests/artifacts/test_local_file_artifact_service.py @@ -0,0 +1,518 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the LocalFileArtifactService.""" + +import json +from pathlib import Path +import tempfile + +from google.adk.artifacts.local_file_artifact_service import LocalFileArtifactService +from google.genai import types +import pytest + + +@pytest.fixture +def temp_dir(): + """Creates a temporary directory for testing.""" + with tempfile.TemporaryDirectory() as temp_dir: + yield temp_dir + + +@pytest.fixture +def artifact_service(temp_dir): + """Creates a LocalFileArtifactService for testing.""" + return LocalFileArtifactService(base_path=temp_dir) + + +@pytest.mark.asyncio +async def test_load_empty(artifact_service): + """Tests loading an artifact when none exists.""" + assert not await artifact_service.load_artifact( + app_name="test_app", + user_id="test_user", + session_id="session_id", + filename="filename", + ) + + +@pytest.mark.asyncio +async def test_save_load_delete(artifact_service): + """Tests saving, loading, and deleting an artifact.""" + artifact = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "file456" + + # Save artifact + version = await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=artifact, + ) + assert version == 0 + + # Load artifact + loaded_artifact = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + assert loaded_artifact == artifact + + # Delete artifact + await artifact_service.delete_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + assert not await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + + +@pytest.mark.asyncio +async def test_list_keys(artifact_service): + """Tests listing keys in the artifact service.""" + artifact = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "filename" + filenames = [filename + str(i) for i in range(5)] + + for f in filenames: + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=f, + artifact=artifact, + ) + + assert ( + await artifact_service.list_artifact_keys( + app_name=app_name, user_id=user_id, session_id=session_id + ) + == filenames + ) + + +@pytest.mark.asyncio +async def test_list_versions(artifact_service): + """Tests listing versions of an artifact.""" + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "with/slash/filename" + versions = [ + types.Part.from_bytes( + data=i.to_bytes(2, byteorder="big"), mime_type="text/plain" + ) + for i in range(3) + ] + + # Save multiple versions + for i in range(3): + version = await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=versions[i], + ) + assert version == i + + # List versions + response_versions = await artifact_service.list_versions( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + + assert response_versions == list(range(3)) + + +@pytest.mark.asyncio +async def test_load_specific_version(artifact_service): + """Tests loading a specific version of an artifact.""" + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "versioned_file" + + # Save multiple versions + version1 = types.Part.from_bytes(data=b"version_1", mime_type="text/plain") + version2 = types.Part.from_bytes(data=b"version_2", mime_type="text/plain") + version3 = types.Part.from_bytes(data=b"version_3", mime_type="text/plain") + + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=version1, + ) + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=version2, + ) + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=version3, + ) + + # Load specific versions + loaded_v0 = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + version=0, + ) + loaded_v1 = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + version=1, + ) + loaded_v2 = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + version=2, + ) + + # Load latest version (without specifying version) + loaded_latest = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + + assert loaded_v0 == version1 + assert loaded_v1 == version2 + assert loaded_v2 == version3 + assert loaded_latest == version3 + + +@pytest.mark.asyncio +async def test_user_namespaced_files(artifact_service): + """Tests handling of user-namespaced files (starting with 'user:').""" + artifact = types.Part.from_bytes(data=b"user_data", mime_type="text/plain") + app_name = "app0" + user_id = "user0" + session_id = "123" + user_filename = "user:shared_file" + regular_filename = "regular_file" + + # Save user-namespaced artifact + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=user_filename, + artifact=artifact, + ) + + # Save regular session-scoped artifact + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=regular_filename, + artifact=artifact, + ) + + # Load both artifacts + loaded_user_artifact = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=user_filename, + ) + loaded_regular_artifact = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=regular_filename, + ) + + assert loaded_user_artifact == artifact + assert loaded_regular_artifact == artifact + + # List artifacts should include both + artifact_keys = await artifact_service.list_artifact_keys( + app_name=app_name, user_id=user_id, session_id=session_id + ) + assert regular_filename in artifact_keys + assert user_filename in artifact_keys + + +@pytest.mark.asyncio +async def test_different_mime_types(artifact_service): + """Tests handling different MIME types.""" + app_name = "app0" + user_id = "user0" + session_id = "123" + + # Test different MIME types + text_artifact = types.Part.from_bytes( + data=b"text content", mime_type="text/plain" + ) + json_artifact = types.Part.from_bytes( + data=b'{"key": "value"}', mime_type="application/json" + ) + image_artifact = types.Part.from_bytes( + data=b"fake_image_data", mime_type="image/png" + ) + + # Save artifacts with different MIME types + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename="text_file", + artifact=text_artifact, + ) + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename="json_file", + artifact=json_artifact, + ) + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename="image_file", + artifact=image_artifact, + ) + + # Load and verify MIME types are preserved + loaded_text = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename="text_file", + ) + loaded_json = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename="json_file", + ) + loaded_image = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename="image_file", + ) + + assert loaded_text == text_artifact + assert loaded_json == json_artifact + assert loaded_image == image_artifact + + +@pytest.mark.asyncio +async def test_nonexistent_version(artifact_service): + """Tests loading a non-existent version.""" + artifact = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + app_name = "app0" + user_id = "user0" + session_id = "123" + filename = "test_file" + + # Save one version + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=artifact, + ) + + # Try to load non-existent version + loaded_artifact = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + version=999, + ) + + assert loaded_artifact is None + + +@pytest.mark.asyncio +async def test_empty_list_versions(artifact_service): + """Tests listing versions when no artifact exists.""" + response_versions = await artifact_service.list_versions( + app_name="app0", + user_id="user0", + session_id="123", + filename="nonexistent_file", + ) + + assert response_versions == [] + + +@pytest.mark.asyncio +async def test_file_structure(artifact_service, temp_dir): + """Tests that files are stored in the correct directory structure.""" + artifact = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + app_name = "test_app" + user_id = "test_user" + session_id = "test_session" + filename = "test_file" + user_filename = "user:shared_file" + + # Save regular file + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=artifact, + ) + + # Save user-namespaced file + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=user_filename, + artifact=artifact, + ) + + base_path = Path(temp_dir) + + # Check regular file structure + regular_file_path = ( + base_path / app_name / user_id / session_id / filename / "0" + ) + regular_metadata_path = ( + base_path / app_name / user_id / session_id / filename / "0.metadata.json" + ) + assert regular_file_path.exists() + assert regular_metadata_path.exists() + + # Check user-namespaced file structure + user_file_path = base_path / app_name / user_id / "user" / user_filename / "0" + user_metadata_path = ( + base_path + / app_name + / user_id + / "user" + / user_filename + / "0.metadata.json" + ) + assert user_file_path.exists() + assert user_metadata_path.exists() + + # Check metadata content + with open(regular_metadata_path) as f: + metadata = json.load(f) + assert metadata["mime_type"] == "text/plain" + + +@pytest.mark.asyncio +async def test_corrupted_metadata_handling(artifact_service, temp_dir): + """Tests handling of corrupted metadata files.""" + artifact = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + app_name = "test_app" + user_id = "test_user" + session_id = "test_session" + filename = "test_file" + + # Save artifact normally + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=artifact, + ) + + # Corrupt the metadata file + base_path = Path(temp_dir) + metadata_path = ( + base_path / app_name / user_id / session_id / filename / "0.metadata.json" + ) + metadata_path.write_text("invalid json{") + + # Try to load the artifact - should return None due to corrupted metadata + loaded_artifact = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + + assert loaded_artifact is None + + +@pytest.mark.asyncio +async def test_missing_metadata_file(artifact_service, temp_dir): + """Tests handling when metadata file is missing.""" + artifact = types.Part.from_bytes(data=b"test_data", mime_type="text/plain") + app_name = "test_app" + user_id = "test_user" + session_id = "test_session" + filename = "test_file" + + # Save artifact normally + await artifact_service.save_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + artifact=artifact, + ) + + # Delete the metadata file + base_path = Path(temp_dir) + metadata_path = ( + base_path / app_name / user_id / session_id / filename / "0.metadata.json" + ) + metadata_path.unlink() + + # Try to load the artifact - should return None due to missing metadata + loaded_artifact = await artifact_service.load_artifact( + app_name=app_name, + user_id=user_id, + session_id=session_id, + filename=filename, + ) + + assert loaded_artifact is None