Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Comment thread
l-mansouri marked this conversation as resolved.
Comment thread
l-mansouri marked this conversation as resolved.

## v2.94.1 (2026-06-29)

### Patch:
Expand Down
37 changes: 2 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <PATH> --profile <profile> --session-id <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.
Expand Down
2 changes: 1 addition & 1 deletion cloudos_cli/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '2.94.1'
__version__ = '2.95.0'
66 changes: 13 additions & 53 deletions cloudos_cli/datasets/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <PATH> --session-id <SESSION_ID>\n"
" cloudos interactive-session create --link <PATH> [...]\n\n"
"Add --profile <my_profile> to either command to use a saved profile."
)
Comment thread
l-mansouri marked this conversation as resolved.

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.")
159 changes: 19 additions & 140 deletions cloudos_cli/interactive_session/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
----------
Expand All @@ -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).
Expand Down
Loading