diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c3b089..9c24e88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.95.0 (2026-06-30) + +### Breaking: + +- Removes the `cloudos datasets link` command. Linking files and folders to an interactive analysis is now done through `cloudos interactive-session link` or the `--link` option of `cloudos interactive-session create` + ## v2.94.1 (2026-06-29) ### Patch: diff --git a/README.md b/README.md index c4fdb3f6..c95f8872 100644 --- a/README.md +++ b/README.md @@ -2613,7 +2613,7 @@ All configuration parameters are optional. If not specified, the session resumes ### Link -The `cloudos interactive-session link` command provides a unified interface for linking files and folders to interactive analysis sessions. It consolidates functionality previously available through separate commands (`cloudos job results --link`, `cloudos job workdir --link`, `cloudos job logs --link`, and `cloudos datasets link`) into a single, intuitive interface. +The `cloudos interactive-session link` command provides a unified interface for linking files and folders to interactive analysis sessions. It consolidates functionality previously available through separate commands (`cloudos job results --link`, `cloudos job workdir --link`, and `cloudos job logs --link`) into a single, intuitive interface. #### Link Files and Folders to Interactive Analysis @@ -2845,41 +2845,8 @@ cloudos datasets cp AnalysesResults/my_analysis/results/my_plot.png Data/plots ``` -#### Link Files and Folders to Interactive Analysis - -Connect external S3 buckets, S3 files, or File Explorer files/folders to your interactive analysis sessions. This provides direct access to data without needing to copy files. - -This subcommand uses the `--session-id` option to access the correct interactive session. This option can be added to the CLI or defined in a profile, for convenience. - -```bash -cloudos datasets link --profile --session-id -``` - -Link an S3 folder: -```console -cloudos datasets link s3://bucket/path/folder --profile test --session-id 1234 -``` - -Link an S3 file: -```console -cloudos datasets link s3://bucket/path/data.csv --profile test --session-id 1234 -``` - -Link a File Explorer folder (requires `--project-name`): -```console -cloudos datasets link "Data/HLA" --project-name my-project --session-id 1234 -``` - -Link a File Explorer file (requires `--project-name`): -```console -cloudos datasets link "Data/observations.csv" --project-name my-project --session-id 1234 -``` - -> [!NOTE] -> If running the CLI inside a jupyter session, the pre-configured CLI installation will have the session ID already installed and only the `--apikey` needs to be added. - > [!NOTE] -> Virtual folders in File Explorer (folders created in File Explorer that are not actual storage locations) cannot be linked. +> To link files or folders (S3 or File Explorer) to an interactive analysis session, use `cloudos interactive-session link` or the `--link` option of `cloudos interactive-session create`. Virtual folders in File Explorer cannot be linked. > [!NOTE] > A maximum of 100 items can be linked per session. If the new items combined with already-linked items exceed this limit, the entire request is rejected. diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index b6d05c11..d72db282 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.94.1' +__version__ = '2.95.0' diff --git a/cloudos_cli/datasets/cli.py b/cloudos_cli/datasets/cli.py index b9407cb8..aaa83a75 100644 --- a/cloudos_cli/datasets/cli.py +++ b/cloudos_cli/datasets/cli.py @@ -4,7 +4,6 @@ import csv import sys from cloudos_cli.datasets import Datasets -from cloudos_cli.interactive_session.link import Link from cloudos_cli.utils.resources import ssl_selector, format_bytes from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.logging.logger import update_command_context_from_click @@ -727,57 +726,18 @@ def rm_item(ctx, raise ValueError(f"Remove operation failed. {str(e)}") -@datasets.command(name="link") -@click.argument("path", required=True) -@click.option('-k', '--apikey', help='Your Lifebit Platform API key', required=True) -@click.option('-c', '--cloudos-url', - help=(f'The Lifebit Platform url you are trying to access to. Default={CLOUDOS_URL}.'), - default=CLOUDOS_URL) -@click.option('--project-name', - help='The name of a Lifebit Platform project.', - required=False) -@click.option('--workspace-id', help='The specific Lifebit Platform workspace id.', required=True) -@click.option('--session-id', help='The specific Lifebit Platform interactive session id.', required=True) -@click.option('--disable-ssl-verification', is_flag=True, help='Disable SSL certificate verification.') -@click.option('--ssl-cert', help='Path to your SSL certificate file.') -@click.option('--profile', help='Profile to use from the config file', default='default') +@datasets.command( + name="link", + hidden=True, + add_help_option=False, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) @click.pass_context -@with_profile_config(required_params=['apikey', 'workspace_id', 'session_id']) -def link(ctx, - path, - apikey, - cloudos_url, - project_name, - workspace_id, - session_id, - disable_ssl_verification, - ssl_cert, - profile): - """ - Link a file or folder (S3 or File Explorer) to an active interactive analysis. - - PATH [path]: the full path to the S3 file/folder or relative path in File Explorer - (relative to the project specified by --project-name). - E.g.: 's3://bucket-name/folder/subfolder', 's3://bucket/data/file.csv', - 'Data/Downloads', 'Data', or 'Data/file.csv'. - """ - if not path.startswith("s3://") and project_name is None: - raise click.UsageError("When using File Explorer paths '--project-name' needs to be defined") - - verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) - - link_p = Link( - cloudos_url=cloudos_url, - apikey=apikey, - workspace_id=workspace_id, - cromwell_token=None, - project_name=project_name, - verify=verify_ssl +def link_deprecated(ctx): + raise click.ClickException( + "'cloudos datasets link' has been removed.\n\n" + "Use one of the supported alternatives:\n" + " cloudos interactive-session link --session-id \n" + " cloudos interactive-session create --link [...]\n\n" + "Add --profile to either command to use a saved profile." ) - - try: - success = link_p.link_folder(path, session_id) - except Exception as e: - raise ValueError(f"Could not link item. {e}") - if not success: - raise click.ClickException("Linking failed: mount verification did not reach 'mounted' status.") diff --git a/cloudos_cli/interactive_session/link.py b/cloudos_cli/interactive_session/link.py index aae27f0d..6a09ca06 100644 --- a/cloudos_cli/interactive_session/link.py +++ b/cloudos_cli/interactive_session/link.py @@ -78,8 +78,7 @@ def link_folders_batch(self, committed_count: int = 0) -> bool: """Link multiple folders/files (S3 or File Explorer) to an interactive session in one request. - Attempts to use API v2 (which supports multiple items per request) first, - with automatic fallback to v1 (individual requests) if v2 is not available. + Uses API v2 which supports multiple items per request. Parameters ---------- @@ -112,17 +111,13 @@ def link_folders_batch(self, # Parse and validate all items data_items, folder_info = self._parse_items_to_data_items(folders, existing_mount_names) - # Try v2 API first (supports batch) + # Mount via v2 API (supports batch) status_code = self._try_mount_v2(data_items, session_id) - if status_code is None: - # v2 failed or not available, fall back to v1 - status_code = self._fallback_mount_v1(folder_info, session_id) - # Verify mount completion for all items (any 2xx response means success) - if status_code is not None and 200 <= status_code < 300: + if 200 <= status_code < 300: return self._verify_all_mounts(folder_info, session_id) - return True + return False def _parse_items_to_data_items(self, folders: list, existing_mount_names: set = None) -> tuple: """Parse and validate folders/files, extracting data items for API payload. @@ -206,7 +201,7 @@ def _raise_if_duplicate_mount(mount_name: str, path: str, mount_names_seen: dict ) def _try_mount_v2(self, data_items: list, session_id: str) -> int: - """Attempt to mount folders using API v2. + """Mount items using API v2. Parameters ---------- @@ -217,151 +212,35 @@ def _try_mount_v2(self, data_items: list, session_id: str) -> int: Returns ------- - int or None - Status code if successful, None if v2 unavailable (triggering fallback). + int + HTTP status code on success. Raises ------ ValueError - If v2 fails for reasons other than unavailability. + If the mount request fails. """ v2_payload = {"dataItems": data_items} try: - status_code = self.mount_fuse_filesystem_v2( + return self.mount_fuse_filesystem_v2( session_id=session_id, team_id=self.workspace_id, payload=v2_payload, verify=self.verify ) - return status_code except Exception as v2_error: - # Check if error indicates v2 endpoint not available (404 only, but not session-not-found) - error_str = str(v2_error) - # Only fall back to v1 if it's a genuine endpoint-not-available 404 - # Session-not-found errors should propagate immediately - if "Session not found" in error_str: - raise # Re-raise session-not-found errors immediately - - should_fallback = ( - "404" in error_str or "Not Found" in error_str or "not found" in error_str.lower() - ) - - if should_fallback: - return None # Trigger v1 fallback - else: - # v2 failed for reasons other than not available - self._handle_mount_error(v2_error, "folder") - - def _fallback_mount_v1(self, folder_info: list, session_id: str) -> int: - """Fall back to v1 API, mounting folders one at a time. - - Parameters - ---------- - folder_info : list - List of folder metadata dictionaries. - session_id : str - The interactive session ID. - - Returns - ------- - int - Status code from the last successful mount (typically 204). - - Raises - ------ - ValueError - If any item is a file (v1 only supports folders), or if any folder - fails to mount. Note: Earlier folders may have successfully mounted - before the failure. - """ - for f in folder_info: - item_type = f['data'].get('type', '') - item_kind = f['data'].get('kind', '') - if item_type == 'S3File' or item_kind == 'File': + if isinstance(v2_error, ValueError): + raise + if isinstance(v2_error, BadRequestException) and v2_error.rv.status_code == 404: raise ValueError( - f"File linking requires API v2, which is not available for this session. " - f"Only folder linking is supported via the v1 API fallback." - ) - - status_code = None - mounted_folders = [] - - for folder_data in folder_info: - try: - status_code = self._mount_single_folder_v1(folder_data, session_id) - mounted_folders.append(folder_data['path']) - except ValueError as e: - # If we've already mounted some folders, inform the user - if mounted_folders: - error_msg = f"{str(e)}\n\nNote: The following folders were successfully mounted before this error: {', '.join(mounted_folders)}" - raise ValueError(error_msg) - else: - raise - return status_code - - def _mount_single_folder_v1(self, folder_data: dict, session_id: str) -> int: - """Mount a single folder using API v1. - - Parameters - ---------- - folder_data : dict - Folder metadata including type, path, and data. - session_id : str - The interactive session ID. - - Returns - ------- - int - Status code (typically 204 on success). - - Raises - ------ - ValueError - If the mount request fails. - """ - v1_payload = {"dataItem": folder_data["data"]} - - url = ( - f"{self.cloudos_url}/api/v1/" - f"interactive-sessions/{session_id}/fuse-filesystem/mount" - f"?teamId={self.workspace_id}" - ) - headers = { - "Content-type": "application/json", - "apikey": self.apikey - } - - try: - r = retry_requests_post(url, headers=headers, json=v1_payload, verify=self.verify) - - if r.status_code >= 400: - # Handle v1 errors using consolidated error handling - if r.status_code == 403: - raise ValueError(f"Provided {folder_data['type']} item already exists with 'mounted' status") - elif r.status_code == 401: - raise ValueError("Unauthorized. Invalid API key or insufficient permissions.") - elif r.status_code == 400: - try: - r_content = json.loads(r.content) - if r_content.get("message") == "Invalid Supported DataItem folderType. Supported values are S3Folder": - raise ValueError(f"Invalid Supported DataItem '{folder_data['type']}' folderType. Virtual folders cannot be linked.") - elif r_content.get("message") == "Request failed with status code 403": - raise ValueError(f"Interactive Analysis session is not active") - else: - raise ValueError(f"Cannot link item") - except json.JSONDecodeError: - raise ValueError(f"Bad request (400): Unable to parse error response") - else: - raise ValueError(f"Failed to mount item: HTTP {r.status_code}") - - return r.status_code - - except ValueError: - # Re-raise ValueError as-is - raise - except Exception as v1_error: - raise ValueError(f"Failed to mount {folder_data['type']} item: {str(v1_error)}") + "The linking API (v2) is not available on this platform. " + "Contact your platform administrator." + ) from v2_error + all_s3 = all(item.get("type", "").startswith("S3") for item in data_items) + all_fe = all(not item.get("type", "").startswith("S3") for item in data_items) + label = "S3" if all_s3 else "File Explorer" if all_fe else "item" + self._handle_mount_error(v2_error, label) def _verify_all_mounts(self, folder_info: list, session_id: str): """Verify mount completion status for all items (files and folders). diff --git a/tests/test_datasets/test_link.py b/tests/test_datasets/test_link.py index a90ec350..056e4c95 100644 --- a/tests/test_datasets/test_link.py +++ b/tests/test_datasets/test_link.py @@ -1,7 +1,5 @@ import pytest -from unittest import mock from cloudos_cli.interactive_session.link import Link -from cloudos_cli.utils.requests import retry_requests_post import responses @@ -56,177 +54,6 @@ def test_parse_s3_path_valid(link_instance): assert result == expected_result -@mock.patch('cloudos_cli.clos', mock.MagicMock()) -@responses.activate -def test_link_s3_folder_success(): - """Test link_S3_folder with a successful response.""" - s3_folder = "s3://mybucket/myfolder" - session_id = "session123" - headers = { - "Content-type": "application/json", - "apikey": APIKEY - } - url=f"{CLOUDOS_URL}/api/v1/interactive-sessions/{session_id}/fuse-filesystem/mount?teamId={WORKSPACE_ID}" - - expected_payload = { - "dataItem": { - "type": "S3Folder", - "data": { - "name": "mysubfolder", - "s3BucketName": "mybucket", - "s3Prefix": "myfolder/mysubfolder", - }, - } - } - - # Register mock response (simulate 204 No Content with empty body) - responses.add( - method=responses.POST, - url=url, - headers=headers, - body='', - match=[responses.json_params_matcher(expected_payload)], - status=204 - ) - - r = retry_requests_post(url, headers=headers, json=expected_payload, verify=True) - assert r.status_code == 204 # Expecting a 204 No Content response - - -@mock.patch('cloudos_cli.clos', mock.MagicMock()) -@responses.activate -def test_link_file_explorer_folder_success(): - """Test linking File Explorer folder with a successful response.""" - fe_folder = "Data/Downloads" - session_id = "session123" - headers = { - "Content-type": "application/json", - "apikey": APIKEY - } - url=f"{CLOUDOS_URL}/api/v1/interactive-sessions/{session_id}/fuse-filesystem/mount?teamId={WORKSPACE_ID}" - - expected_payload = { - "dataItem": { - "kind": "Folder", - "item": "123r758asfkasf", - "name": "Downloads" - } - } - - # Register mock response (simulate 204 No Content with empty body) - responses.add( - method=responses.POST, - url=url, - headers=headers, - body='', - match=[responses.json_params_matcher(expected_payload)], - status=204 - ) - - r = retry_requests_post(url, headers=headers, json=expected_payload, verify=True) - assert r.status_code == 204 # Expecting a 204 No Content response - - -@responses.activate -def test_link_folder_204_s3(capsys, link_instance_test_response, monkeypatch): - """Test successful S3 folder linking and mounting.""" - status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) - responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - - # Mock v2 endpoint to return 404 (testing fallback to v1) - url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v2, status=404, json={"message": "Not Found"}) - - # Mock v1 endpoint - url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url, status=204) - - # Second GET: post-mount status verification - mock_response = { - "fuseFileSystems": [ - { - "_id": "123", - "resource": "sessionABC", - "storageProvider": "s3", - "kind": "source", - "item": "456", - "mountPoint": "/opt/lifebit/volumes/file-systems/folder", - "mountName": "folder", - "readOnly": True, - "status": "mounted", - "errorMessage": None - } - ], - "paginationMetadata": {"Pagination-Count": 1, "Pagination-Page": 1, "Pagination-Limit": 30} - } - responses.add(responses.GET, status_url, json=mock_response, status=200) - - monkeypatch.setattr(link_instance_test_response, "parse_s3_path", lambda x: { - "dataItem": { - "type": "S3Folder", - "data": { - "name": "folder", - "s3BucketName": "bucket", - "s3Prefix": "path/to/folder/" - } - } - }) - - link_instance_test_response.link_folder("s3://bucket/path/to/folder", "sessionABC") - captured = capsys.readouterr() - assert "Successfully mounted S3 folder: s3://bucket/path/to/folder/" in captured.out - - -@responses.activate -def test_link_folder_204_file_explorer(capsys, link_instance_test_response, monkeypatch): - """Test successful File Explorer folder linking and mounting.""" - status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) - responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - - # Mock v2 endpoint to return 404 (testing fallback to v1) - url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v2, status=404, json={"message": "Not Found"}) - - # Mock v1 endpoint - url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url, status=204) - - # Second GET: post-mount status verification - mock_response = { - "fuseFileSystems": [ - { - "_id": "123", - "resource": "sessionABC", - "storageProvider": "file", - "kind": "source", - "item": "456", - "mountPoint": "/opt/lifebit/volumes/file-systems/data", - "mountName": "data", - "readOnly": False, - "status": "mounted", - "errorMessage": None - } - ], - "paginationMetadata": {"Pagination-Count": 1, "Pagination-Page": 1, "Pagination-Limit": 30} - } - responses.add(responses.GET, status_url, json=mock_response, status=200) - - # Patch parse_file_explorer_item - monkeypatch.setattr(link_instance_test_response, "parse_file_explorer_item", lambda x: { - "dataItem": { - "kind": "Folder", - "item": "456", - "name": "data" - } - }) - - link_instance_test_response.link_folder("/home/user/data", "sessionABC") - captured = capsys.readouterr() - assert "Successfully mounted File Explorer folder: test_project/home/user/data" in captured.out - - @responses.activate def test_get_fuse_filesystems_status_success(link_instance_test_response): """Test successful retrieval of fuse filesystem status.""" @@ -254,14 +81,11 @@ def test_get_fuse_filesystems_status_success(link_instance_test_response): def test_link_folder_v2_success_s3(capsys, link_instance_test_response, monkeypatch): """Test successful S3 folder linking using API v2.""" status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - # Mock v2 endpoint url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" responses.add(responses.POST, url_v2, status=204) - # Second GET: post-mount status verification mock_response = { "fuseFileSystems": [ { @@ -295,74 +119,17 @@ def test_link_folder_v2_success_s3(capsys, link_instance_test_response, monkeypa link_instance_test_response.link_folder("s3://bucket/path/to/folder", "sessionABC") captured = capsys.readouterr() assert "Successfully mounted S3 folder: s3://bucket/path/to/folder/" in captured.out - # Should not show fallback message - assert "Using API v1" not in captured.out - - -@responses.activate -def test_link_folder_v2_fallback_to_v1(capsys, link_instance_test_response, monkeypatch): - """Test fallback from API v2 to v1 when v2 is not available.""" - status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) - responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - - # Mock v2 endpoint to return 404 (not found) - url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v2, status=404, json={"message": "Not Found"}) - - # Mock v1 endpoint to succeed - url_v1 = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v1, status=204) - - # Second GET: post-mount status verification - mock_response = { - "fuseFileSystems": [ - { - "_id": "123", - "resource": "sessionABC", - "storageProvider": "s3", - "kind": "source", - "item": "456", - "mountPoint": "/opt/lifebit/volumes/file-systems/folder", - "mountName": "folder", - "readOnly": True, - "status": "mounted", - "errorMessage": None - } - ], - "paginationMetadata": {"Pagination-Count": 1, "Pagination-Page": 1, "Pagination-Limit": 30} - } - responses.add(responses.GET, status_url, json=mock_response, status=200) - - monkeypatch.setattr(link_instance_test_response, "parse_s3_path", lambda x: { - "dataItem": { - "type": "S3Folder", - "data": { - "name": "folder", - "s3BucketName": "bucket", - "s3Prefix": "path/to/folder/" - } - } - }) - - link_instance_test_response.link_folder("s3://bucket/path/to/folder", "sessionABC") - captured = capsys.readouterr() - assert "Successfully mounted S3 folder: s3://bucket/path/to/folder/" in captured.out - # Fallback to v1 happens silently (no message shown to user) @responses.activate def test_link_folder_v2_file_explorer(capsys, link_instance_test_response, monkeypatch): """Test successful File Explorer folder linking using API v2.""" status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - # Mock v2 endpoint url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" responses.add(responses.POST, url_v2, status=204) - # Second GET: post-mount status verification mock_response = { "fuseFileSystems": [ { @@ -382,7 +149,6 @@ def test_link_folder_v2_file_explorer(capsys, link_instance_test_response, monke } responses.add(responses.GET, status_url, json=mock_response, status=200) - # Patch parse_file_explorer_item monkeypatch.setattr(link_instance_test_response, "parse_file_explorer_item", lambda x: { "dataItem": { "kind": "Folder", @@ -400,49 +166,15 @@ def test_link_folder_v2_file_explorer(capsys, link_instance_test_response, monke def test_link_folders_batch_multiple_s3(capsys, link_instance_test_response, monkeypatch): """Test linking multiple S3 folders in one batch request using v2 API.""" status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - # Mock v2 endpoint for batch request url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" responses.add(responses.POST, url_v2, status=204) - # Mock the GET request for checking fuse filesystem status for each folder - - # Second call - returns folder1 - mock_response_1 = { - "fuseFileSystems": [{ - "_id": "123", - "mountName": "folder1", - "status": "mounted" - }], - "paginationMetadata": {"Pagination-Count": 1} - } - responses.add(responses.GET, status_url, json=mock_response_1, status=200) - - # Second call - returns folder2 - mock_response_2 = { - "fuseFileSystems": [{ - "_id": "124", - "mountName": "folder2", - "status": "mounted" - }], - "paginationMetadata": {"Pagination-Count": 1} - } - responses.add(responses.GET, status_url, json=mock_response_2, status=200) - - # Third call - returns folder3 - mock_response_3 = { - "fuseFileSystems": [{ - "_id": "125", - "mountName": "folder3", - "status": "mounted" - }], - "paginationMetadata": {"Pagination-Count": 1} - } - responses.add(responses.GET, status_url, json=mock_response_3, status=200) + responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "123", "mountName": "folder1", "status": "mounted"}], "paginationMetadata": {"Pagination-Count": 1}}, status=200) + responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "124", "mountName": "folder2", "status": "mounted"}], "paginationMetadata": {"Pagination-Count": 1}}, status=200) + responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "125", "mountName": "folder3", "status": "mounted"}], "paginationMetadata": {"Pagination-Count": 1}}, status=200) - # Patch parse_s3_path def mock_parse_s3_path(url): if "folder1" in url: return {"dataItem": {"type": "S3Folder", "data": {"name": "folder1", "s3BucketName": "bucket1", "s3Prefix": "path1/folder1/"}}} @@ -450,101 +182,36 @@ def mock_parse_s3_path(url): return {"dataItem": {"type": "S3Folder", "data": {"name": "folder2", "s3BucketName": "bucket2", "s3Prefix": "path2/folder2/"}}} else: return {"dataItem": {"type": "S3Folder", "data": {"name": "folder3", "s3BucketName": "bucket3", "s3Prefix": "path3/folder3/"}}} - - monkeypatch.setattr(link_instance_test_response, "parse_s3_path", mock_parse_s3_path) - - # Test batch linking - folders = [ - "s3://bucket1/path1/folder1/", - "s3://bucket2/path2/folder2/", - "s3://bucket3/path3/folder3/" - ] - link_instance_test_response.link_folders_batch(folders, "sessionABC") - - captured = capsys.readouterr() - assert "Successfully mounted S3 folder: s3://bucket1/path1/folder1/" in captured.out - assert "Successfully mounted S3 folder: s3://bucket2/path2/folder2/" in captured.out - assert "Successfully mounted S3 folder: s3://bucket3/path3/folder3/" in captured.out - - -@responses.activate -def test_link_folders_batch_v2_fallback_to_v1_multiple(capsys, link_instance_test_response, monkeypatch): - """Test fallback to v1 API when linking multiple folders.""" - status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) - responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - - # Mock v2 endpoint to return 404 - url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v2, status=404, json={"message": "Not Found"}) - - # Mock v1 endpoint for each folder (3 separate requests in fallback) - url_v1 = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v1, status=204) # folder1 - responses.add(responses.POST, url_v1, status=204) # folder2 - responses.add(responses.POST, url_v1, status=204) # folder3 - # Mock status checks - responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "1", "mountName": "folder1", "status": "mounted"}]}, status=200) - responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "2", "mountName": "folder2", "status": "mounted"}]}, status=200) - responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "3", "mountName": "folder3", "status": "mounted"}]}, status=200) - - def mock_parse_s3_path(url): - if "folder1" in url: - return {"dataItem": {"type": "S3Folder", "data": {"name": "folder1", "s3BucketName": "bucket1", "s3Prefix": "path1/folder1/"}}} - elif "folder2" in url: - return {"dataItem": {"type": "S3Folder", "data": {"name": "folder2", "s3BucketName": "bucket2", "s3Prefix": "path2/folder2/"}}} - else: - return {"dataItem": {"type": "S3Folder", "data": {"name": "folder3", "s3BucketName": "bucket3", "s3Prefix": "path3/folder3/"}}} - monkeypatch.setattr(link_instance_test_response, "parse_s3_path", mock_parse_s3_path) - folders = [ + link_instance_test_response.link_folders_batch([ "s3://bucket1/path1/folder1/", "s3://bucket2/path2/folder2/", "s3://bucket3/path3/folder3/" - ] - link_instance_test_response.link_folders_batch(folders, "sessionABC") - + ], "sessionABC") + captured = capsys.readouterr() - # All three should succeed via v1 fallback assert "Successfully mounted S3 folder: s3://bucket1/path1/folder1/" in captured.out assert "Successfully mounted S3 folder: s3://bucket2/path2/folder2/" in captured.out assert "Successfully mounted S3 folder: s3://bucket3/path3/folder3/" in captured.out @responses.activate -def test_link_folders_batch_partial_failure_v1_fallback(capsys, link_instance_test_response, monkeypatch): - """Test error handling when one folder fails during v1 fallback.""" - status_url = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" - # First GET: pre-mount limit/duplicate check (empty session) +def test_link_folder_v2_unavailable_raises(link_instance_test_response, monkeypatch): + """Test that a 404 from the v2 endpoint raises a clear error (no fallback).""" + status_url = "https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId=team123&limit=100&page=1" responses.add(responses.GET, status_url, json={"fuseFileSystems": [], "paginationMetadata": {}}, status=200) - # Mock v2 endpoint to return 404 (forcing v1 fallback) - url_v2 = f"https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" + url_v2 = "https://lifebit.ai/api/v2/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" responses.add(responses.POST, url_v2, status=404, json={"message": "Not Found"}) - # Mock v1 endpoint - first succeeds, second fails with 403 - url_v1 = f"https://lifebit.ai/api/v1/interactive-sessions/sessionABC/fuse-filesystem/mount?teamId=team123" - responses.add(responses.POST, url_v1, status=204) # folder1 succeeds - responses.add(responses.POST, url_v1, status=403, json={"message": "Folder already mounted"}) # folder2 fails - - # Mock status check for successful folder1 - responses.add(responses.GET, status_url, json={"fuseFileSystems": [{"_id": "1", "mountName": "folder1", "status": "mounted"}]}, status=200) - - def mock_parse_s3_path(url): - if "folder1" in url: - return {"dataItem": {"type": "S3Folder", "data": {"name": "folder1", "s3BucketName": "bucket1", "s3Prefix": "path1/folder1/"}}} - else: - return {"dataItem": {"type": "S3Folder", "data": {"name": "folder2", "s3BucketName": "bucket2", "s3Prefix": "path2/folder2/"}}} - - monkeypatch.setattr(link_instance_test_response, "parse_s3_path", mock_parse_s3_path) + monkeypatch.setattr(link_instance_test_response, "parse_s3_path", lambda x: { + "dataItem": { + "type": "S3Folder", + "data": {"name": "folder", "s3BucketName": "bucket", "s3Prefix": "path/folder/"} + } + }) - folders = [ - "s3://bucket1/path1/folder1/", - "s3://bucket2/path2/folder2/" - ] - - # Should raise ValueError for the second folder - with pytest.raises(ValueError, match="already exists with 'mounted' status"): - link_instance_test_response.link_folders_batch(folders, "sessionABC") + with pytest.raises(ValueError, match="linking API.*not available"): + link_instance_test_response.link_folder("s3://bucket/path/folder", "sessionABC") diff --git a/tests/test_datasets/test_link_files.py b/tests/test_datasets/test_link_files.py index 2a26f646..1b67f556 100644 --- a/tests/test_datasets/test_link_files.py +++ b/tests/test_datasets/test_link_files.py @@ -383,13 +383,13 @@ def test_unknown_error_passes_through_unchanged(self, link_instance): # --------------------------------------------------------------------------- -# v1 fallback rejects file items +# v2 unavailability raises an error for all item types # --------------------------------------------------------------------------- -class TestV1FallbackRejectsFiles: +class TestV2UnavailableRaisesError: @responses.activate - def test_v1_fallback_rejects_s3_file(self, link_instance, monkeypatch): + def test_v2_unavailable_s3_file_raises(self, link_instance, monkeypatch): status_url = f"{CLOUDOS_URL}/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId={WORKSPACE_ID}&limit=100&page=1" responses.add(responses.GET, status_url, json={"fuseFileSystems": []}, status=200) @@ -404,11 +404,11 @@ def test_v1_fallback_rejects_s3_file(self, link_instance, monkeypatch): } }) - with pytest.raises(ValueError, match="File linking requires API v2"): + with pytest.raises(ValueError, match="linking API.*not available"): link_instance.link_folders_batch(["s3://b/p/file.csv"], "sessionABC") @responses.activate - def test_v1_fallback_rejects_fe_file(self, link_instance, monkeypatch): + def test_v2_unavailable_fe_file_raises(self, link_instance, monkeypatch): status_url = f"{CLOUDOS_URL}/api/v1/interactive-sessions/sessionABC/fuse-filesystems?teamId={WORKSPACE_ID}&limit=100&page=1" responses.add(responses.GET, status_url, json={"fuseFileSystems": []}, status=200) @@ -419,7 +419,7 @@ def test_v1_fallback_rejects_fe_file(self, link_instance, monkeypatch): "dataItem": {"kind": "File", "item": "id1", "name": "data.csv"} }) - with pytest.raises(ValueError, match="File linking requires API v2"): + with pytest.raises(ValueError, match="linking API.*not available"): link_instance.link_folders_batch(["Data/data.csv"], "sessionABC")