diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 582968fa..7b230a81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1002,6 +1002,31 @@ jobs: SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} run: | cloudos interactive-session pause --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --yes + interactive_session_resume: + needs: [interactive_session_pause, interactive_session_create] + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.py + - name: Install dependencies + run: | + pip install -e . + - name: Resume interactive session + env: + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_ADAPT }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_ADAPT }} + CLOUDOS_URL: "https://cloudos.lifebit.ai" + SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} + run: | + cloudos interactive-session resume --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --shutdown-in 1m diff --git a/.github/workflows/ci_az.yml b/.github/workflows/ci_az.yml index 453c7e68..2ccc8063 100644 --- a/.github/workflows/ci_az.yml +++ b/.github/workflows/ci_az.yml @@ -790,5 +790,30 @@ jobs: SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} run: | cloudos interactive-session pause --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --yes + interactive_session_resume: + needs: [interactive_session_pause, interactive_session_create] + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.py + - name: Install dependencies + run: | + pip install -e . + - name: Resume interactive session + env: + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_AZURE }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_AZURE }} + CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" + SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} + run: | + cloudos interactive-session resume --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --shutdown-in 1m diff --git a/.github/workflows/ci_dev.yml b/.github/workflows/ci_dev.yml index b7b8757d..fb576ae4 100644 --- a/.github/workflows/ci_dev.yml +++ b/.github/workflows/ci_dev.yml @@ -1008,3 +1008,28 @@ jobs: SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} run: | cloudos interactive-session pause --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --yes + interactive_session_resume: + needs: [interactive_session_pause, interactive_session_create] + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.py + - name: Install dependencies + run: | + pip install -e . + - name: Resume interactive session + env: + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_DEV }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_DEV }} + CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" + SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} + run: | + cloudos interactive-session resume --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --shutdown-in 1m diff --git a/CHANGELOG.md b/CHANGELOG.md index 134dcfa1..3da37d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.86.0 (2026-03-23) + +### Feat + +- Adds resuming of an interactive session + ## v2.85.0 (2026-03-20) ### Feat diff --git a/README.md b/README.md index 99bb4482..ec56aac1 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,10 @@ Python package for interacting with CloudOS - [Use multiple projects for files in `--parameter` option](#use-multiple-projects-for-files-in---parameter-option) - [Interactive Sessions](#interactive-sessions) - [List Interactive Sessions](#list-interactive-sessions) + - [Create Interactive Session](#create-interactive-session) - [Get Interactive Session Status](#get-interactive-session-status) - [Pause Interactive Session](#pause-interactive-session) - - [Create Interactive Session](#create-interactive-session) + - [Resume Interactive Session](#resume-interactive-session) - [Datasets](#datasets) - [List Files](#list-files) - [Move Files](#move-files) @@ -1970,13 +1971,13 @@ The table displays sessions with pagination controls (press `n` for next page, ` ```console Interactive Sessions -┏━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┓ -┃ Status ┃ Name ┃ Type ┃ ID ┃ Owner ┃ -┡━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━┩ -│ paused │ cloudosR │ RStudio │ 69aee0dba197… │ Leila │ -│ running │ analysis-dev │ Jupyter │ 69ae972a18f0… │ John │ -│ paused │ test_session │ VS Code │ 69a996c098ab… │ James │ -└─────────┴──────────────┴────────────────────┴───────────────┴────────┘ +┏━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┓ +┃ Status ┃ Name ┃ Type ┃ ID ┃ Owner ┃ +┡━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━┩ +│ paused │ cloudosR │ RStudio │ 69aee0dba197… │ Leila │ +│ running │ analysis-dev │ Jupyter │ 69ae972a18f0… │ John │ +│ paused │ test_session │ VS Code │ 69a996c098ab… │ James │ +└─────────┴──────────────┴────────────────┴───────────────┴────────┘ Total sessions: 15 Page: 1 of 3 @@ -2037,6 +2038,172 @@ cloudos interactive-session list --profile my_profile --table-columns "status,na Available columns: `backend`, `cost`, `cost_limit`, `created_at`, `id`, `instance`, `name`, `owner`, `project`, `resources`, `runtime`, `saved_at`, `spot`, `status`, `time_left`, `type`, `version` +#### Create Interactive Session + +You can create and start a new interactive session using the `cloudos interactive-session create` command. This command provisions a new virtual environment with your specified configuration. + +The command automatically loads API credentials and workspace information from your profile configuration, so you only need to specify the session-specific details. + +**Basic Usage** + +Create a simple Jupyter notebook session: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "My Analysis" \ + --session-type jupyter +``` + +Create an RStudio session with specific R version: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "R Analysis" \ + --session-type rstudio \ + --r-version 4.4.2 +``` + +Create a VS Code session (AWS only): + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "Development" \ + --session-type vscode +``` + +Create a Spark cluster session with custom instance types (AWS only): + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "Spark Analysis" \ + --session-type spark \ + --spark-master c5.2xlarge \ + --spark-core c5.xlarge \ + --spark-workers 3 +``` + +**Configuration Options** + +You can customize your session with various options: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "Advanced Session" \ + --session-type jupyter \ + --instance c5.2xlarge \ + --storage 1000 \ + --spot \ + --shared \ + --cost-limit 50.0 \ + --shutdown-in 8h +``` + +**Required for Each Session:** +- `--name`: Session name (1-100 characters) +- `--session-type`: Type of backend - `jupyter`, `vscode`, `rstudio`, or `spark` (platform dependent) + +**Optional Configuration:** +- `--instance`: Instance type (default depends on execution platform: `c5.xlarge` for AWS, `Standard_F1s` for Azure) +- `--storage`: Storage in GB (default: 500, range: 100-5000) +- `--spot`: Use spot instances (AWS only, cost-saving) +- `--shared`: Make session accessible to workspace members +- `--cost-limit`: Compute cost limit in USD (default: -1 for unlimited) +- `--shutdown-in`: Auto-shutdown duration (e.g., `8h`, `2d`, `30m`) + +**Data & Storage Management:** +- `--mount`: Mount a data file into the session. Supports both CloudOS datasets and S3 files (AWS only). Format: `project_name/dataset_path` (e.g., `leila-test/Data/file.csv`) or `s3://bucket/path/to/file` (e.g., `s3://my-bucket/data/file.csv`). Can be used multiple times. +- `--link`: Link a folder into the session for read/write access (AWS only). Supports S3 folders and CloudOS folders. Format: `s3://bucket/prefix` (e.g., `s3://my-bucket/data/`) or `project_name/folder_path` (e.g., `leila-test/AnalysesResults/analysis_id/results`). Can be used multiple times. **Note:** Linking is not supported on Azure. Use CloudOS file explorer for data access. + +**Backend-Specific:** +- `--r-version`: R version for RStudio (options: `4.4.2`, `4.5.2`) - **optional for rstudio** (default: `4.4.2`) +- `--spark-master`: Master instance type for Spark (default: `c5.2xlarge`) +- `--spark-core`: Core instance type for Spark (default: `c5.xlarge`) +- `--spark-workers`: Initial worker count for Spark (default: 1) +- `--verbose`: Show detailed progress messages + + +**Data Management** + +CloudOS CLI supports multiple ways to access data in interactive sessions, depending on your execution platform: +- you can load data directly into the session (i.e. files are copied into the session's mounted-data volume) +- you can link folders to your session (i.e the folders are sym-linked to the session). This works only for folders (S3-based) and only in AWS enviornments. + + +**Data Mounting Examples** + +Mount a file from File Explorer: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "Data Analysis" \ + --session-type jupyter \ + --mount "my_project/training_data.csv" +``` + +Link an S3 bucket: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "S3 Access" \ + --session-type jupyter \ + --link "s3://my-results-bucket/output/" +``` + +Link multiple S3 buckets: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "Multi-S3 Session" \ + --session-type jupyter \ + --link "s3://input-bucket/data/" \ + --link "s3://output-bucket/results/" +``` + + + +This will show progress updates like: + +```console +✓ Interactive Session Created Successfully + +┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓ +┃ Property ┃ Value ┃ +┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩ +│ Session ID │ 69aee0dba197abc123 │ +│ Name │ Ready Session │ +│ Backend │ awsJupyterNotebook │ +│ Status │ initialising │ +└─────────────┴─────────────────────┘ +``` +``` + +**Output Display** + +The session creation output displays a success message with session details: + +```bash +cloudos interactive-session create \ + --profile my_profile \ + --name "My Session" \ + --session-type jupyter +``` + +The output shows the session details including: +- Session ID +- Session name +- Backend type (jupyter, vscode, rstudio, spark) +- Current status (scheduled, initialising, setup, running, paused) + + + #### Get Interactive Session Status You can retrieve detailed status information for a specific interactive session using the `cloudos interactive-session status` command. This command provides comprehensive information about the session including its current state, resource allocation, costs, and more. @@ -2058,7 +2225,7 @@ The command displays session information in a formatted table: ║ Session ID ║ 69bc00cb1488084e5a6cae70 ║ ║ Name ║ analysis-dev (linked) ║ ║ Status ║ running ║ -║ Backend ║ awsJupyterNotebook ║ +║ Backend ║ Jupyter ║ ║ Owner ║ John Doe ║ ║ Project ║ research ║ ║ Instance Type ║ c5.xlarge ║ @@ -2239,7 +2406,7 @@ Pausing session... Status: shutting_down Status: uploading_data Status: cleaning_up -Status: stopped +Status: paused ✓ Session paused successfully ``` @@ -2315,142 +2482,61 @@ The command automatically loads from profile (via `@with_profile_config` decorat - `--ssl-cert`: Path to SSL certificate file - `--profile`: Profile to use from config file (default: default profile) -#### Create Interactive Session +#### Resume Interactive Session -You can create and start a new interactive session using the `cloudos interactive-session create` command. This command provisions a new virtual environment with your specified configuration. +Resume a paused interactive session with optional configuration updates. You can change instance type, storage, cost limit, auto-shutdown time, and mount additional data files or folders when resuming. -The command automatically loads API credentials and workspace information from your profile configuration, so you only need to specify the session-specific details. **Basic Usage** -Create a simple Jupyter notebook session: +Resume a paused session: ```bash -cloudos interactive-session create \ - --profile my_profile \ - --name "My Analysis" \ - --session-type jupyter +cloudos interactive-session resume --session-id --profile my_profile ``` -Create an RStudio session with specific R version: +Resume with updated instance type: ```bash -cloudos interactive-session create \ - --profile my_profile \ - --name "R Analysis" \ - --session-type rstudio \ - --r-version 4.4.2 -``` - -Create a VS Code session (AWS only): - -```bash -cloudos interactive-session create \ - --profile my_profile \ - --name "Development" \ - --session-type vscode -``` - -Create a Spark cluster session with custom instance types (AWS only): - -```bash -cloudos interactive-session create \ +cloudos interactive-session resume \ + --session-id \ --profile my_profile \ - --name "Spark Analysis" \ - --session-type spark \ - --spark-master c5.2xlarge \ - --spark-core c5.xlarge \ - --spark-workers 3 + --instance c5.2xlarge ``` -**Configuration Options** - -You can customize your session with various options: +Resume with multiple updates: ```bash -cloudos interactive-session create \ +cloudos interactive-session resume \ + --session-id \ --profile my_profile \ - --name "Advanced Session" \ - --session-type jupyter \ - --instance c5.2xlarge \ + --instance m5.xlarge \ --storage 1000 \ - --spot \ - --shared \ --cost-limit 50.0 \ - --shutdown-in 8h + --shutdown-in 12h ``` -**Required for Each Session:** -- `--name`: Session name (1-100 characters) -- `--session-type`: Type of backend - `jupyter`, `vscode`, `rstudio`, or `spark` (platform dependent) +**Mount Additional Data** -**Optional Configuration:** -- `--instance`: Instance type (default depends on execution platform: `c5.xlarge` for AWS, `Standard_F1s` for Azure) -- `--storage`: Storage in GB (default: 500, range: 100-5000) -- `--spot`: Use spot instances (AWS only, cost-saving) -- `--shared`: Make session accessible to workspace members -- `--cost-limit`: Compute cost limit in USD (default: -1 for unlimited) -- `--shutdown-in`: Auto-shutdown duration (e.g., `8h`, `2d`, `30m`) - -**Data & Storage Management:** -- `--mount`: Mount a data file into the session. Supports both CloudOS datasets and S3 files (AWS only). Format: `project_name/dataset_path` (e.g., `leila-test/Data/file.csv`) or `s3://bucket/path/to/file` (e.g., `s3://my-bucket/data/file.csv`). Can be used multiple times. -- `--link`: Link a folder into the session for read/write access (AWS only). Supports S3 folders and CloudOS folders. Format: `s3://bucket/prefix` (e.g., `s3://my-bucket/data/`) or `project_name/folder_path` (e.g., `leila-test/AnalysesResults/analysis_id/results`). Can be used multiple times. **Note:** Linking is not supported on Azure. Use CloudOS file explorer for data access. - -**Backend-Specific:** -- `--r-version`: R version for RStudio (options: `4.4.2`, `4.5.2`) - **optional for rstudio** (default: `4.4.2`) -- `--spark-master`: Master instance type for Spark (default: `c5.2xlarge`) -- `--spark-core`: Core instance type for Spark (default: `c5.xlarge`) -- `--spark-workers`: Initial worker count for Spark (default: 1) -- `--verbose`: Show detailed progress messages - - -**Data Management** - -CloudOS CLI supports multiple ways to access data in interactive sessions, depending on your execution platform: -- you can load data directly into the session (i.e. files are copied into the session's mounted-data volume) -- you can link folders to your session (i.e the folders are sym-linked to the session). This works only for folders (S3-based) and only in AWS enviornments. - - -**Data Mounting Examples** - -Mount a file from File Explorer: - -```bash -cloudos interactive-session create \ - --profile my_profile \ - --name "Data Analysis" \ - --session-type jupyter \ - --mount "my_project/training_data.csv" -``` - -Link an S3 bucket: +Resume and mount additional files: ```bash -cloudos interactive-session create \ +cloudos interactive-session resume \ + --session-id \ --profile my_profile \ - --name "S3 Access" \ - --session-type jupyter \ - --link "s3://my-results-bucket/output/" + --mount my-project/Data/new-dataset.csv \ + --mount s3://my-bucket/data/file.txt ``` -**Output Display** - -The session creation output displays a success message with session details: - -```bash -cloudos interactive-session create \ - --profile my_profile \ - --name "My Session" \ - --session-type jupyter -``` +**Configuration Updates** -The output shows the session details including: -- Session ID -- Session name -- Backend type (jupyter, vscode, rstudio, spark) -- Current status (scheduled, initialising, setup, running, paused) +All configuration parameters are optional. If not specified, the session resumes with its previous configuration. +- `--instance ` - Change instance type (validated by platform) +- `--storage ` - Update storage size (100-5000 GB) +- `--cost-limit ` - Update compute cost limit (-1 for unlimited) +- `--shutdown-in ` - Update auto-shutdown time (e.g., 8h, 2d) ### Datasets diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index a9962c60..c27892cb 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.85.0' +__version__ = '2.86.0' diff --git a/cloudos_cli/clos.py b/cloudos_cli/clos.py index 5812f297..acee04d0 100644 --- a/cloudos_cli/clos.py +++ b/cloudos_cli/clos.py @@ -2419,6 +2419,60 @@ def create_interactive_session(self, team_id, payload, verify=True): content = r.json() return content + def resume_interactive_session(self, session_id, team_id, payload, verify=True): + """Resume a stopped interactive session with optional configuration updates. + + Parameters + ---------- + session_id : string + The session ID (MongoDB ObjectId) to resume. + team_id : string + The CloudOS team id (workspace id) where the session is running. + payload : dict + Resume payload with optional dataItems and newInteractiveSessionConfiguration. + verify: [bool|string], default=True + Whether to use SSL verification or not. Alternatively, if + a string is passed, it will be interpreted as the path to + the SSL certificate file. + + Returns + ------- + dict + Updated session object from API response. + """ + # Validate inputs + if not session_id or not isinstance(session_id, str): + raise ValueError("Invalid session_id: must be a non-empty string") + if not team_id or not isinstance(team_id, str): + raise ValueError("Invalid team_id: must be a non-empty string") + + headers = { + "Content-type": "application/json", + "apikey": self.apikey + } + + # Build URL with sessionId and teamId + url = f"{self.cloudos_url}/api/v1/interactive-sessions/{session_id}/resume?teamId={team_id}" + + # Make the API request with PUT method + try: + r = retry_requests_put( + url, + headers=headers, + data=json.dumps(payload), + verify=verify, + timeout=30 + ) + except Exception as e: + raise Exception(f"Failed to resume interactive session: {str(e)}") + + if r.status_code >= 400: + raise BadRequestException(r) + + # Return the full session object from response + content = r.json() + return content + def abort_interactive_session(self, session_id, team_id, upload_on_close=True, force_abort=False, verify=True): """Abort and stop a running interactive session. @@ -2466,7 +2520,7 @@ def abort_interactive_session(self, session_id, team_id, upload_on_close=True, f # Make the API request with PUT method try: - r = requests.put( + r = retry_requests_put( url, headers=headers, data=json.dumps(payload), diff --git a/cloudos_cli/interactive_session/cli.py b/cloudos_cli/interactive_session/cli.py index 04d06a96..c1c57dd5 100644 --- a/cloudos_cli/interactive_session/cli.py +++ b/cloudos_cli/interactive_session/cli.py @@ -29,6 +29,8 @@ PRE_RUNNING_STATUSES, format_stop_success_output, poll_session_termination, + build_resume_payload, + fetch_interactive_session_page ) from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands @@ -121,54 +123,45 @@ def list_sessions(ctx, ssl_cert, profile): """List interactive sessions for a CloudOS team.""" - # apikey, cloudos_url, and team_id are now automatically resolved by the decorator - + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) - # Validate limit parameter if not isinstance(limit, int) or limit < 1: raise ValueError('Please use a positive integer (>= 1) for the --limit parameter') - if limit > 100: click.secho('Error: Limit cannot exceed 100. Please use --limit with a value <= 100', fg='red', err=True) raise SystemExit(1) - # Validate page parameter if not isinstance(page, int) or page < 1: raise ValueError('Please use a positive integer (>= 1) for the --page parameter') - # Validate table columns if specified + valid_columns = {'id', 'name', 'status', 'type', 'instance', 'cost', 'owner', 'project', 'created_at', 'runtime', 'saved_at', 'resources', 'backend', 'version', 'spot', 'cost_limit', 'time_left'} selected_columns = table_columns - + if selected_columns: # Parse columns (split by comma and strip whitespace) col_list = [col.strip() for col in selected_columns.split(',')] invalid_cols = [col for col in col_list if col not in valid_columns] - if invalid_cols: click.secho(f'Error: Invalid column(s): {", ".join(invalid_cols)}', fg='red', err=True) click.secho(f'Valid columns: {", ".join(sorted(valid_columns))}', fg='yellow', err=True) click.secho(f'\nTip: Use --help without other options to see command help', fg='cyan', err=True) raise SystemExit(1) - - # Prepare output file if needed + if output_format != 'stdout': outfile = output_basename + '.' + output_format - if verbose: print('Executing list...') print('\t...Preparing objects') - cl = Cloudos(cloudos_url, apikey, None) - if verbose: print('\tThe following Cloudos object was created:') print('\t' + str(cl) + '\n') print('\tSearching for interactive sessions in the following workspace: ' + f'{workspace_id}') - + try: # Call the API method to get interactive sessions result = cl.get_interactive_session_list( @@ -180,23 +173,14 @@ def list_sessions(ctx, include_archived=archived, verify=verify_ssl ) - sessions = result.get('sessions', []) pagination_metadata = result.get('pagination_metadata', None) - - # Define callback function for fetching additional pages - def fetch_page(page_num): - """Fetch a specific page of interactive sessions.""" - return cl.get_interactive_session_list( - workspace_id, - page=page_num, - limit=limit, - status=list(filter_status) if filter_status else None, - owner_only=filter_only_mine, - include_archived=archived, - verify=verify_ssl - ) - + + # Create callback function for fetching additional pages + fetch_page = lambda page_num: fetch_interactive_session_page( + cl, workspace_id, page_num, limit, filter_status, filter_only_mine, archived, verify_ssl + ) + # Handle empty results if len(sessions) == 0: if filter_status: @@ -208,24 +192,20 @@ def fetch_page(page_num): create_interactive_session_list_table([], pagination_metadata, selected_columns, page_size=limit, fetch_page_callback=fetch_page) else: print('A total of 0 interactive sessions collected.') - # Display results based on output format elif output_format == 'stdout': create_interactive_session_list_table(sessions, pagination_metadata, selected_columns, page_size=limit, fetch_page_callback=fetch_page) - elif output_format == 'csv': sessions_df = process_interactive_session_list(sessions, all_fields) save_interactive_session_list_to_csv(sessions_df, outfile, count=len(sessions)) - elif output_format == 'json': with open(outfile, 'w') as o: o.write(json.dumps(sessions, indent=2)) print(f'\tInteractive session list collected with a total of {len(sessions)} sessions on this page.') - print(f'\tInteractive session list saved to {outfile}') - + print(f'\tInteractive session list saved to {outfile}') else: raise ValueError('Unrecognised output format. Please use one of [stdout|csv|json]') - + except BadRequestException as e: error_str = str(e) # Check if the error is related to authentication @@ -241,6 +221,7 @@ def fetch_page(page_num): else: click.secho(f'Error: Failed to retrieve interactive sessions: {e}', fg='red', err=True) raise SystemExit(1) + except Exception as e: error_str = str(e) # Check for DNS/connection errors @@ -357,27 +338,25 @@ def create_session(ctx, profile, verbose): """Create a new interactive session.""" - - verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) - + + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) # Default execution_platform to 'aws' if not specified by user or profile if execution_platform is None: execution_platform = 'aws' else: # Normalize to lowercase execution_platform = execution_platform.lower() - # Set instance default based on execution_platform if not specified if instance is None: instance = 'c5.xlarge' if execution_platform == 'aws' else 'Standard_F1s' - + # Validate instance type format is_valid, error_msg = validate_instance_type(instance, execution_platform) if not is_valid: click.secho(f'Error: {error_msg}', fg='red', err=True) click.secho(f'Hint: Check your instance type spelling and format for {execution_platform.upper()}.', fg='yellow', err=True) raise SystemExit(1) - + # Validate Spark instance types if session type is spark if session_type.lower() == 'spark': # Spark is AWS only, so use 'aws' for validation @@ -385,32 +364,29 @@ def create_session(ctx, if not is_valid_master: click.secho(f'Error: Invalid Spark master instance type: {error_msg_master}', fg='red', err=True) raise SystemExit(1) - is_valid_core, error_msg_core = validate_instance_type(spark_core, 'aws') if not is_valid_core: click.secho(f'Error: Invalid Spark core instance type: {error_msg_core}', fg='red', err=True) raise SystemExit(1) - if verbose: print('Executing create interactive session...') print('\t...Preparing objects') - + cl = Cloudos(cloudos_url, apikey, None) - if verbose: print('\tThe following Cloudos object was created:') print('\t' + str(cl) + '\n') print(f'\tCreating interactive session in workspace: {workspace_id}') - + try: # Resolve project name to project ID project_id = cl.get_project_id_from_name(workspace_id, project_name, verify=verify_ssl) if verbose: print(f'\tResolved project name "{project_name}" to ID: {project_id}') - + # Parse session type to lowercase session_type_lower = session_type.lower() - + # Map session type to backend name backend_type_mapping = { 'jupyter': 'regular', @@ -419,11 +395,10 @@ def create_session(ctx, 'rstudio': 'rstudio' } backend_type = backend_type_mapping.get(session_type_lower) - if not backend_type: click.secho(f'Error: Invalid session type: {session_type}', fg='red', err=True) raise SystemExit(1) - + # Parse shutdown duration shutdown_at_parsed = None if shutdown_in: @@ -432,7 +407,7 @@ def create_session(ctx, except ValueError as e: click.secho(f'Error: Invalid shutdown duration: {str(e)}', fg='red', err=True) raise SystemExit(1) - + # Parse and resolve mounted data files (both CloudOS and S3) parsed_data_files = [] parsed_s3_mounts = [] # S3 folders go into FUSE mounts @@ -440,17 +415,14 @@ def create_session(ctx, try: for df in mount: parsed = parse_data_file(df) - if parsed['type'] == 's3': # S3 files are only supported on AWS if execution_platform != 'aws': click.secho(f'Error: S3 mounts are only supported on AWS. Use CloudOS file explorer paths for Azure.', fg='red', err=True) raise SystemExit(1) - # S3 file: add to dataItems as S3File type if verbose: print(f'\tMounting S3 file: s3://{parsed["s3_bucket"]}/{parsed["s3_prefix"]}') - # Use the full path as the name s3_file_item = { "type": "S3File", @@ -461,18 +433,14 @@ def create_session(ctx, } } parsed_data_files.append(s3_file_item) - if verbose: print(f'\t ✓ Added S3 file to mount') - else: # type == 'cloudos' # CloudOS dataset file: resolve via Datasets API data_project = parsed['project_name'] dataset_path = parsed['dataset_path'] - if verbose: print(f'\tResolving dataset: {data_project}/{dataset_path}') - # Create a Datasets API instance for this specific project datasets_api = Datasets( cloudos_url=cloudos_url, @@ -482,16 +450,14 @@ def create_session(ctx, verify=verify_ssl, cromwell_token=None ) - resolved = resolve_data_file_id(datasets_api, dataset_path) parsed_data_files.append(resolved) - if verbose: print(f'\t ✓ Resolved to file ID: {resolved["item"]}') except Exception as e: click.secho(f'Error: Failed to resolve dataset files: {str(e)}', fg='red', err=True) raise SystemExit(1) - + # Parse and add linked folders from --link (S3 or CloudOS) for link_path in link: try: @@ -499,19 +465,15 @@ def create_session(ctx, if execution_platform == 'azure': click.secho(f'Error: Linking folders is not supported on Azure. Please use `cloudos interactive-session create --mount` to load your data in the session.', fg='red', err=True) raise SystemExit(1) - parsed = parse_link_path(link_path) - if parsed['type'] == 's3': # S3 folders are only supported on AWS (additional safeguard) if execution_platform != 'aws': click.secho(f'Error: S3 links are only supported on AWS execution platform.', fg='red', err=True) raise SystemExit(1) - # S3 folder: create S3Folder FUSE mount if verbose: print(f'\tLinking S3: s3://{parsed["s3_bucket"]}/{parsed["s3_prefix"]}') - # Use bucket name or mount_name if provided (legacy format) mount_name = parsed.get('mount_name', f"{parsed['s3_bucket']}-mount") s3_mount_item = { @@ -523,18 +485,15 @@ def create_session(ctx, } } parsed_s3_mounts.append(s3_mount_item) - if verbose: print(f'\t ✓ Linked S3: {mount_name}') - + else: # type == 'cloudos' # CloudOS folder: resolve via Datasets API folder_project = parsed['project_name'] folder_path = parsed['folder_path'] - if verbose: print(f'\tLinking CloudOS folder: {folder_project}/{folder_path}') - # Create Datasets API instance for this project datasets_api = Datasets( cloudos_url=cloudos_url, @@ -544,10 +503,6 @@ def create_session(ctx, verify=verify_ssl, cromwell_token=None ) - - # Get folder contents to verify it exists - folder_content = datasets_api.list_folder_content(folder_path) - # For CloudOS folders, we create a mount item mount_name = folder_path.split('/')[-1] if folder_path else folder_project cloudos_mount_item = { @@ -559,14 +514,14 @@ def create_session(ctx, } } parsed_s3_mounts.append(cloudos_mount_item) - + if verbose: print(f'\t ✓ Linked CloudOS folder: {mount_name}') - + except Exception as e: click.secho(f'Error: Failed to link folder: {str(e)}', fg='red', err=True) raise SystemExit(1) - + # Build the session payload payload = build_session_payload( name=name, @@ -586,19 +541,15 @@ def create_session(ctx, spark_core_type=spark_core, spark_workers=spark_workers ) - if verbose: print('\tPayload constructed:') print(json.dumps(payload, indent=2)) - + # Create the session via API response = cl.create_interactive_session(workspace_id, payload, verify=verify_ssl) - session_id = response.get('_id') - if verbose: print(f'\tSession created with ID: {session_id}') - # Display session creation details in table format format_session_creation_table( response, @@ -612,13 +563,11 @@ def create_session(ctx, data_files=parsed_data_files, s3_mounts=parsed_s3_mounts ) - # Output session link in greppable format for CI/automation click.echo(f"Session link: {cloudos_url}/app/data-science/interactive-analysis/view/{session_id}") - if verbose: print('\tSession creation completed successfully!') - + except BadRequestException as e: error_str = str(e) if '401' in error_str or 'Unauthorized' in error_str: @@ -639,7 +588,6 @@ def create_session(ctx, raise SystemExit(1) - @interactive_session.command('status') @click.option('-k', '--apikey', @@ -703,40 +651,34 @@ def get_session_status(ctx, ssl_cert, profile): """Get status of an interactive session.""" - + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) - # Validate session ID format if not validate_session_id(session_id): click.secho(f'Error: Invalid session ID format. Expected 24-character hex string, got: {session_id}', fg='red', err=True) raise SystemExit(1) - # Validate watch-interval if watch_interval <= 0: click.secho(f'Error: --watch-interval must be a positive number, got: {watch_interval}', fg='red', err=True) raise SystemExit(1) - # Parse and validate max-wait-time try: max_wait_time_seconds = parse_watch_timeout_duration(max_wait_time) except ValueError as e: click.secho(f'Error: Invalid --max-wait-time format: {str(e)}', fg='red', err=True) raise SystemExit(1) - # Validate output format if output_format.lower() not in ['stdout', 'csv', 'json']: click.secho(f'Error: Invalid output format. Must be one of: stdout, csv, json', fg='red', err=True) raise SystemExit(1) - if verbose: print('Executable: get interactive session status...') print('\t...Preparing objects') - + try: # Get initial status if verbose: print(f'\tRetrieving session status from: {cloudos_url}') - session_response = get_interactive_session_status( cloudos_url=cloudos_url, apikey=apikey, @@ -745,14 +687,11 @@ def get_session_status(ctx, verify_ssl=verify_ssl, verbose=verbose ) - if verbose: print(f'\t✓ Session retrieved successfully') - # Get mapped status for display api_status = session_response.get('status', '') display_status = map_status(api_status) - # Apply watch mode if requested if watch: # Check if watch mode is appropriate for this session status @@ -766,25 +705,19 @@ def get_session_status(ctx, else: # Print initial status message before starting watch click.echo(f'Session {session_id} currently is in {display_status}...') - start_time = time.time() previous_status = display_status # Track previous status to detect changes - while True: # Get current status api_status = session_response.get('status', '') display_status = map_status(api_status) - elapsed = time.time() - start_time - if verbose: print(f'\tPolling... Status: {display_status} | Elapsed: {int(elapsed)}s') - # Print status change message if display_status != previous_status: click.echo(f'Status changed: {previous_status} → {display_status}') previous_status = display_status - # Exit watch mode if session is ready or terminated if display_status == 'running': click.secho('✓ Session is now running and ready to use!', fg='green') @@ -792,7 +725,6 @@ def get_session_status(ctx, elif display_status in ['paused', 'terminated']: click.secho(f'⚠ Session reached terminal state: {display_status}', fg='yellow') break - # Check timeout AFTER evaluating current status if elapsed > max_wait_time_seconds: click.secho( @@ -802,10 +734,8 @@ def get_session_status(ctx, err=True ) break - # Wait before next poll time.sleep(watch_interval) - # Fetch updated status for next iteration session_response = get_interactive_session_status( cloudos_url=cloudos_url, @@ -815,7 +745,7 @@ def get_session_status(ctx, verify_ssl=verify_ssl, verbose=False ) - + # Transform and display response based on format if output_format.lower() == 'json': json_output = export_session_status_json(session_response) @@ -823,7 +753,6 @@ def get_session_status(ctx, with open(outfile, 'w') as f: f.write(json_output) click.echo(f'Session status saved to {outfile}') - elif output_format.lower() == 'csv': transformed_data = transform_session_response(session_response) csv_output = export_session_status_csv(transformed_data) @@ -831,27 +760,26 @@ def get_session_status(ctx, with open(outfile, 'w') as f: f.write(csv_output) click.echo(f'Session status saved to {outfile}') - else: # stdout (default) transformed_data = transform_session_response(session_response) format_session_status_table(transformed_data, cloudos_url=cloudos_url) - + except ValueError as e: # Handle validation errors (e.g., session not found) click.secho(f'Error: {str(e)}', fg='red', err=True) raise SystemExit(1) - + except PermissionError as e: # Handle authentication/permission errors click.secho(f'Error: {str(e)}', fg='red', err=True) if '401' in str(e) or 'Unauthorized' in str(e): click.secho('Please check your API credentials (apikey and cloudos-url).', fg='yellow', err=True) raise SystemExit(1) - + except KeyboardInterrupt: click.secho('\n⚠ Watch mode interrupted by user.', fg='yellow', err=True) raise SystemExit(0) - + except Exception as e: error_str = str(e) # Check for network errors @@ -919,23 +847,21 @@ def pause_session(ctx, ssl_cert, profile): """Pause a running interactive session.""" - + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) - # Validate session ID format if not validate_session_id(session_id): click.secho(f'Error: Invalid session ID format. Expected 24-character hex string, got: {session_id}', fg='red', err=True) raise SystemExit(1) - if verbose: print('Executing pause interactive session...') print('\t...Preparing objects') - + try: # Check session status BEFORE prompting for confirmation if verbose: print('\t...Checking session status') - + try: session_response = get_interactive_session_status( cloudos_url=cloudos_url, @@ -953,11 +879,9 @@ def pause_session(ctx, else: click.secho(f'Error: Unable to retrieve session status: {str(e)}', fg='red', err=True) raise SystemExit(1) - + # Check if session is already paused or terminated api_status = session_response.get('status', '') - - if api_status == 'aborted': click.secho(f'Error: Cannot pause session - the session is already paused.', fg='red', err=True) click.secho(f'Tip: Check the session status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) @@ -966,17 +890,14 @@ def pause_session(ctx, click.secho(f'Error: Cannot pause session - the session is already being paused.', fg='red', err=True) click.secho(f'Tip: Wait a moment and check status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) raise SystemExit(1) - if api_status == 'terminated': click.secho(f'Error: Session is terminated and cannot be paused.', fg='red', err=True) raise SystemExit(1) - # Show confirmation prompt unless --yes or --force flag is used if not skip_confirmation and not force: click.echo(f'About to pause session: {session_id}') click.echo(f'Upload data before pausing: {not no_upload}') click.echo(f'Force immediate termination: {force}') - # Get user confirmation try: response = click.prompt('Continue? [y/N]', type=str, default='N') @@ -986,17 +907,14 @@ def pause_session(ctx, except KeyboardInterrupt: click.secho('\n⚠ Operation cancelled by user.', fg='yellow', err=True) raise SystemExit(0) - # Prepare abort parameters upload_on_close = not no_upload # Invert no_upload to get upload_on_close force_abort = force - # Create Cloudos client and abort session cl = Cloudos(cloudos_url, apikey, None) - if verbose: print('\t...Sending abort request to CloudOS') - + # Call the abort endpoint status_code = cl.abort_interactive_session( session_id=session_id, @@ -1005,19 +923,15 @@ def pause_session(ctx, force_abort=force_abort, verify=verify_ssl ) - if verbose: print(f'\t✓ Abort request sent successfully (HTTP {status_code})') - # Show force abort warning if applicable if force: click.secho('\n⚠ Warning: Session was force-aborted by the user. Some data may have not been saved.', fg='yellow', err=True) - # If --wait flag is set, poll until session is paused if wait: if verbose: print('\t...Waiting for session to fully pause') - try: final_response = poll_session_termination( cloudos_url=cloudos_url, @@ -1028,10 +942,8 @@ def pause_session(ctx, poll_interval=5, # Poll every 5 seconds verify_ssl=verify_ssl ) - # Display final status (pass raw API response, not transformed data) format_stop_success_output(final_response, wait=True) - except TimeoutError as e: click.secho(f'⚠ Timeout: {str(e)}', fg='yellow', err=True) click.echo('The session pause command has been sent, but the session did not fully terminate within the timeout period.') @@ -1041,30 +953,30 @@ def pause_session(ctx, # Show success message without waiting click.secho('✓ Session pause request sent successfully.', fg='green') click.echo(f'You can monitor the session status using: cloudos interactive-session status --session-id {session_id} --profile {profile or "default"}') - + except ValueError as e: # Handle validation errors click.secho(f'Error: {str(e)}', fg='red', err=True) raise SystemExit(1) - + except PermissionError as e: # Handle authentication/permission errors click.secho(f'Error: {str(e)}', fg='red', err=True) if '401' in str(e) or 'Unauthorized' in str(e): click.secho('Please check your API credentials (apikey and cloudos-url).', fg='yellow', err=True) raise SystemExit(1) - + except BadRequestException as e: # Handle API errors with better messages error_str = str(e) # Show the original error for other bad request errors click.secho(f'Error: {str(e)}', fg='red', err=True) raise SystemExit(1) - + except KeyboardInterrupt: click.secho('\n⚠ Operation interrupted by user.', fg='yellow', err=True) raise SystemExit(0) - + except Exception as e: error_str = str(e) # Check for network errors @@ -1085,3 +997,309 @@ def pause_session(ctx, else: click.secho(f'Error: Failed to pause session: {str(e)}', fg='red', err=True) raise SystemExit(1) + + +@interactive_session.command('resume') +@click.option('--session-id', + help='Session ID to resume.', + required=True) +@click.option('-k', + '--apikey', + help='Your CloudOS API key', + required=False) +@click.option('-c', + '--cloudos-url', + help=(f'The CloudOS url you are trying to access to. Default={CLOUDOS_URL}.'), + default=CLOUDOS_URL, + required=False) +@click.option('--workspace-id', + help='The specific CloudOS workspace id.', + required=False) +@click.option('--instance', + help='Change instance type when resuming.', + default=None) +@click.option('--storage', + type=int, + help='Update storage size in GB (100-5000).', + default=None) +@click.option('--cost-limit', + type=float, + help='Update compute cost limit in USD. Default=-1 (unlimited).', + default=None) +@click.option('--shutdown-in', + help='Update auto-shutdown duration (e.g., 8h, 2d).', + default=None) +@click.option('--mount', + multiple=True, + help='Mount additional data file. Format: project_name/dataset_path or s3://bucket/path/to/file. Can be used multiple times.') +@click.option('--link', + multiple=True, + help='Link additional folder. Format: s3://bucket/prefix or project_name/folder_path. Can be used multiple times.') +@click.option('--verbose', + help='Whether to print information messages or not.', + is_flag=True) +@click.option('--disable-ssl-verification', + help=('Disable SSL certificate verification. Please, remember that this option is ' + + 'not generally recommended for security reasons.'), + is_flag=True) +@click.option('--ssl-cert', + help='Path to your SSL certificate file.') +@click.option('--profile', help='Profile to use from the config file', default=None) +@click.pass_context +@with_profile_config(required_params=['apikey', 'workspace_id']) +def resume_session(ctx, + session_id, + apikey, + cloudos_url, + workspace_id, + instance, + storage, + cost_limit, + shutdown_in, + mount, + link, + verbose, + disable_ssl_verification, + ssl_cert, + profile): + """Resume a paused interactive session with optional configuration updates.""" + + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + # Validate session ID format + if not validate_session_id(session_id): + click.secho(f'Error: Invalid session ID format. Expected 24-character hex string, got: {session_id}', fg='red', err=True) + raise SystemExit(1) + # Validate storage if provided + if storage is not None and not (100 <= storage <= 5000): + click.secho('Error: Storage size must be between 100-5000 GB', fg='red', err=True) + raise SystemExit(1) + if verbose: + print('Executing resume interactive session...') + print('\t...Preparing objects') + + cl = Cloudos(cloudos_url, apikey, None) + if verbose: + print('\tThe following Cloudos object was created:') + print('\t' + str(cl) + '\n') + print(f'\tResuming session: {session_id}') + + try: + # Get current session details to determine execution platform + try: + session_data = get_interactive_session_status( + cloudos_url=cloudos_url, + apikey=apikey, + session_id=session_id, + team_id=workspace_id, + verify_ssl=verify_ssl, + verbose=False + ) + current_config = session_data.get('interactiveSessionConfiguration', {}) + execution_platform = current_config.get('executionPlatform', 'aws') + if verbose: + print(f'\tCurrent session platform: {execution_platform}') + print(f'\tCurrent status: {session_data.get("status", "unknown")}') + except Exception as e: + # If we can't get session details, default to aws + execution_platform = 'aws' + if verbose: + print(f'\tCould not retrieve session details (using default platform: aws)') + + # Parse shutdown duration if provided + shutdown_at_parsed = None + if shutdown_in: + try: + shutdown_at_parsed = parse_shutdown_duration(shutdown_in) + if verbose: + print(f'\tParsed shutdown duration: {shutdown_in} -> {shutdown_at_parsed}') + except ValueError as e: + click.secho(f'Error: Invalid shutdown duration: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + # Parse and resolve mounted data files + parsed_data_files = [] + if mount: + try: + for df in mount: + parsed = parse_data_file(df) + if parsed['type'] == 's3': + # S3 files are only supported on AWS + if execution_platform != 'aws': + click.secho(f'Error: S3 mounts are only supported on AWS.', fg='red', err=True) + raise SystemExit(1) + if verbose: + print(f'\tMounting S3 file: s3://{parsed["s3_bucket"]}/{parsed["s3_prefix"]}') + s3_file_item = { + "type": "S3File", + "data": { + "name": parsed["s3_prefix"], + "s3BucketName": parsed["s3_bucket"], + "s3ObjectKey": parsed["s3_prefix"] + } + } + parsed_data_files.append(s3_file_item) + else: # CloudOS dataset + data_project = parsed['project_name'] + dataset_path = parsed['dataset_path'] + if verbose: + print(f'\tResolving dataset: {data_project}/{dataset_path}') + datasets_api = Datasets( + cloudos_url=cloudos_url, + apikey=apikey, + workspace_id=workspace_id, + project_name=data_project, + verify=verify_ssl, + cromwell_token=None + ) + resolved = resolve_data_file_id(datasets_api, dataset_path) + parsed_data_files.append(resolved) + if verbose: + print(f'\t ✓ Resolved to file ID: {resolved["item"]}') + except Exception as e: + click.secho(f'Error: Failed to resolve dataset files: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + # Parse and add linked folders + parsed_s3_mounts = [] + if link: + try: + for link_path in link: + # Block all linking on Azure + if execution_platform == 'azure': + click.secho(f'Error: Linking folders is not supported on Azure. Please use --mount instead.', fg='red', err=True) + raise SystemExit(1) + parsed = parse_link_path(link_path) + if parsed['type'] == 's3': + if verbose: + print(f'\tLinking S3: s3://{parsed["s3_bucket"]}/{parsed["s3_prefix"]}') + mount_name = parsed.get('mount_name', f"{parsed['s3_bucket']}-mount") + s3_mount_item = { + "type": "S3Folder", + "data": { + "name": mount_name, + "s3BucketName": parsed["s3_bucket"], + "s3Prefix": parsed["s3_prefix"] + } + } + parsed_s3_mounts.append(s3_mount_item) + else: # CloudOS folder + folder_project = parsed['project_name'] + folder_path = parsed['folder_path'] + if verbose: + print(f'\tLinking CloudOS folder: {folder_project}/{folder_path}') + # Create Datasets API instance for this project + datasets_api = Datasets( + cloudos_url=cloudos_url, + apikey=apikey, + workspace_id=workspace_id, + project_name=folder_project, + verify=verify_ssl, + cromwell_token=None + ) + # AWS-only: Create S3Folder mount for CloudOS folders + mount_name = folder_path.split('/')[-1] if folder_path else folder_project + cloudos_mount_item = { + "type": "S3Folder", + "data": { + "name": mount_name, + "s3BucketName": folder_project, + "s3Prefix": folder_path + ("/" if folder_path and not folder_path.endswith('/') else "") + } + } + parsed_s3_mounts.append(cloudos_mount_item) + if verbose: + print(f'\t ✓ Linked CloudOS folder: {mount_name}') + except Exception as e: + click.secho(f'Error: Failed to parse link path: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + # Build the resume payload + payload = build_resume_payload( + instance_type=instance, + storage_size=storage, + cost_limit=cost_limit, + shutdown_at=shutdown_at_parsed, + data_files=parsed_data_files, + s3_mounts=parsed_s3_mounts if execution_platform == 'aws' else None + ) + if verbose: + print('\tResume payload constructed:') + print(json.dumps(payload, indent=2)) + # Resume the session via API + response = cl.resume_interactive_session(session_id, workspace_id, payload, verify=verify_ssl) + if verbose: + print(f'\tSession resumed successfully') + # Display success message + click.secho(f'✓ Session {session_id} has been resumed successfully!', fg='green') + # Show updated configuration + updated_config = response.get('interactiveSessionConfiguration', {}) + if instance or storage or cost_limit is not None or shutdown_at_parsed: + click.echo('\nUpdated configuration:') + if instance: + click.echo(f' Instance type: {updated_config.get("instanceType", instance)}') + if storage: + click.echo(f' Storage: {updated_config.get("storageSizeInGb", storage)} GB') + if cost_limit is not None: + exec_config = updated_config.get('execution', {}) + click.echo(f' Cost limit: ${exec_config.get("computeCostLimit", cost_limit)}') + if shutdown_at_parsed: + exec_config = updated_config.get('execution', {}) + click.echo(f' Auto-shutdown: {exec_config.get("autoShutdownAtDate", shutdown_at_parsed)}') + if parsed_data_files: + click.echo(f'\n {len(parsed_data_files)} additional file(s) mounted') + if parsed_s3_mounts: + click.echo(f' {len(parsed_s3_mounts)} additional folder(s) linked') + click.echo(f'\nSession status: {response.get("status", "unknown")}') + click.secho(f'\nTip: Check session status with: cloudos interactive-session status --session-id {session_id}', fg='yellow') + + except BadRequestException as e: + error_str = str(e) + # Check for specific error patterns + if '401' in error_str or 'Unauthorized' in error_str: + click.secho(f'Error: Failed to resume session. Please check your credentials.', fg='red', err=True) + elif '404' in error_str or 'not found' in error_str.lower(): + click.secho(f'Error: Session not found. Please check the session ID.', fg='red', err=True) + elif 'not in a resumable status' in error_str.lower(): + # Try to fetch the current session status to show the user + try: + from cloudos_cli.interactive_session.interactive_session import get_interactive_session_status, map_status + status_response = get_interactive_session_status( + cloudos_url=cloudos_url, + apikey=apikey, + session_id=session_id, + team_id=workspace_id, + verify_ssl=verify_ssl, + verbose=False + ) + current_status = map_status(status_response.get('status', 'unknown')) + click.secho(f'Error: Cannot resume session - current status is "{current_status}".', fg='red', err=True) + click.secho(f'Only sessions with status "paused" can be resumed.', fg='yellow', err=True) + if current_status == 'running': + click.secho(f'Tip: This session is already running. Use the CloudOS web interface to access it.', fg='yellow', err=True) + elif current_status == 'terminated': + click.secho(f'Tip: Terminated sessions cannot be resumed. Please create a new session instead.', fg='yellow', err=True) + else: + click.secho(f'Tip: Wait for the session to reach "paused" status, or check: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + except: + # Fallback if we can't fetch status + click.secho(f'Error: Cannot resume session - it is not in a resumable status.', fg='red', err=True) + click.secho(f'Only sessions with status "paused" can be resumed.', fg='yellow', err=True) + click.secho(f'Tip: Check current status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + elif 'already running' in error_str.lower() or 'ready' in error_str.lower(): + click.secho(f'Error: Cannot resume session - the session is already running.', fg='red', err=True) + click.secho(f'Tip: Check status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + else: + click.secho(f'Error: Failed to resume session: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + except Exception as e: + error_str = str(e) + # Check for network errors + if 'Failed to resolve' in error_str or 'Name or service not known' in error_str: + click.secho(f'Error: Unable to connect to CloudOS. Please verify the CloudOS URL is correct.', fg='red', err=True) + elif '401' in error_str or 'Unauthorized' in error_str: + click.secho(f'Error: Failed to resume session. Please check your credentials.', fg='red', err=True) + else: + click.secho(f'Error: Failed to resume session: {str(e)}', fg='red', err=True) + raise SystemExit(1) + diff --git a/cloudos_cli/interactive_session/interactive_session.py b/cloudos_cli/interactive_session/interactive_session.py index 53096ccb..99f8f9ed 100644 --- a/cloudos_cli/interactive_session/interactive_session.py +++ b/cloudos_cli/interactive_session/interactive_session.py @@ -5,11 +5,10 @@ import re import json import time -import json -import time from datetime import datetime, timedelta, timezone from rich.table import Table from rich.console import Console +from rich.panel import Panel import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -18,14 +17,14 @@ def validate_instance_type(instance_type, execution_platform='aws'): """Validate instance type format for the given execution platform. - + Parameters ---------- instance_type : str Instance type to validate execution_platform : str 'aws' or 'azure' - + Returns ------- tuple @@ -33,7 +32,7 @@ def validate_instance_type(instance_type, execution_platform='aws'): """ if not instance_type or not isinstance(instance_type, str): return False, "Instance type must be a non-empty string" - + if execution_platform == 'aws': # AWS EC2 instance format: . # Examples: c5.xlarge, m5.2xlarge, r6i.large, t3.medium, g4dn.xlarge @@ -41,35 +40,31 @@ def validate_instance_type(instance_type, execution_platform='aws'): # Generation: digit(s) optionally followed by letter(s) for variants # Size: nano, micro, small, medium, large, xlarge, 2xlarge, 4xlarge, etc. aws_pattern = r'^[a-z]{1,4}\d+[a-z]*\.(\d+)?(nano|micro|small|medium|large|xlarge|metal)$' - if not re.match(aws_pattern, instance_type, re.IGNORECASE): return False, (f"Invalid AWS instance type format: '{instance_type}'. " f"Expected format: . (e.g., c5.xlarge, m5.2xlarge)") - elif execution_platform == 'azure': # Azure VM format: Standard__ or Basic_ # Examples: Standard_F1s, Standard_D4as_v4, Standard_B2ms, Basic_A1 azure_pattern = r'^(Standard|Basic)_[A-Z]\d+[a-z]*(_v\d+)?$' - if not re.match(azure_pattern, instance_type): return False, (f"Invalid Azure instance type format: '{instance_type}'. " f"Expected format: Standard_ (e.g., Standard_F1s, Standard_D4as_v4)") - else: # Unknown platform - skip validation return True, None - + return True, None def _map_session_type_to_friendly_name(session_type): """Map internal session type names to user-friendly display names. - + Parameters ---------- session_type : str Internal session type (e.g., 'awsJupyterNotebook') - + Returns ------- str @@ -87,14 +82,15 @@ def _map_session_type_to_friendly_name(session_type): 'azureJupyterSparkNotebook': 'Spark', 'azureSpark': 'Spark', 'awsRStudio': 'RStudio', # Handle both capitalizations - 'azureRStudio': 'RStudio' + 'azureRStudio': 'RStudio', + 'awsWindowsSession': 'Windows' } return type_mapping.get(session_type, session_type) def create_interactive_session_list_table(sessions, pagination_metadata=None, selected_columns=None, page_size=10, fetch_page_callback=None): """Create a rich table displaying interactive sessions with interactive pagination. - + Parameters ---------- sessions : list @@ -112,7 +108,6 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se Should accept page number (1-indexed) and return dict with 'sessions' and 'pagination_metadata' keys. """ console = Console() - # Define all available columns with their configuration all_columns = { 'id': { @@ -235,7 +230,7 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se 'accessor': 'execution.autoShutdownAtDate' } } - + # Determine columns to display if selected_columns: if isinstance(selected_columns, str): @@ -252,12 +247,10 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se columns_to_show = ['status', 'name', 'type', 'instance', 'cost', 'id', 'owner'] else: columns_to_show = ['id', 'name', 'status', 'type', 'instance', 'cost', 'owner'] - # Handle empty results if len(sessions) == 0: console.print('[yellow]No interactive sessions found.[/yellow]') return - # Prepare rows data rows = [] for session in sessions: @@ -267,16 +260,13 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se continue col_config = all_columns[col_name] accessor = col_config['accessor'] - # Extract value from session object value = _get_nested_value(session, accessor) - # Format the value formatted_value = _format_session_field(col_name, value) row_data.append(formatted_value) - rows.append(row_data) - + # Interactive pagination - use API pagination metadata if available if pagination_metadata: # Server-side pagination @@ -288,9 +278,7 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se current_api_page = 0 total_sessions = len(sessions) total_pages = (len(sessions) + page_size - 1) // page_size if len(sessions) > 0 else 1 - show_error = None # Track error messages to display - while True: # For client-side pagination, start/end are indices into the local rows array # For server-side pagination, we use the API page directly @@ -302,13 +290,10 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se start = current_api_page * page_size end = start + page_size page_rows = rows[start:end] - # Clear console first console.clear() - # Create table table = Table(title='Interactive Sessions') - # Add columns to table for col_name in columns_to_show: if col_name not in all_columns: @@ -319,14 +304,11 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se style=col_config.get('style', 'white'), no_wrap=col_config.get('no_wrap', False) ) - # Add rows to table for row in page_rows: table.add_row(*row) - # Print table console.print(table) - # Display pagination info console.print(f"\n[cyan]Total sessions:[/cyan] {total_sessions}") if total_pages > 1: @@ -337,7 +319,6 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se if show_error: console.print(show_error) show_error = None # Reset error after displaying - # Show pagination controls if total_pages > 1: # Check if we're in an interactive environment @@ -345,9 +326,7 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se console.print("\n[yellow]Note: Pagination not available in non-interactive mode. Showing page 1 of {0}.[/yellow]".format(total_pages)) console.print("[yellow]Run in an interactive terminal to navigate through all pages.[/yellow]") break - console.print(f"\n[bold cyan]n[/] = next, [bold cyan]p[/] = prev, [bold cyan]q[/] = quit") - # Get user input for navigation try: choice = input(">>> ").strip().lower() @@ -355,7 +334,6 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se # Handle non-interactive environments or user interrupt console.print("\n[yellow]Pagination interrupted.[/yellow]") break - if choice in ("q", "quit"): break elif choice in ("n", "next"): @@ -367,8 +345,7 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se sessions = next_page_data.get('sessions', []) pagination_metadata = next_page_data.get('pagination_metadata', {}) current_api_page = pagination_metadata.get('page', current_api_page + 1) - total_pages = pagination_metadata.get('totalPages', total_pages) - + total_pages = pagination_metadata.get('totalPages', total_pages) # Rebuild rows for the new page rows = [] for session in sessions: @@ -398,7 +375,6 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se pagination_metadata = prev_page_data.get('pagination_metadata', {}) current_api_page = pagination_metadata.get('page', current_api_page - 1) total_pages = pagination_metadata.get('totalPages', total_pages) - # Rebuild rows for the new page rows = [] for session in sessions: @@ -427,7 +403,7 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se def process_interactive_session_list(sessions, all_fields=False): """Process interactive sessions data into a pandas DataFrame. - + Parameters ---------- sessions : list @@ -435,7 +411,7 @@ def process_interactive_session_list(sessions, all_fields=False): all_fields : bool, default=False If True, include all fields from the API response. If False, include only the most relevant fields. - + Returns ------- df : pandas.DataFrame @@ -455,7 +431,7 @@ def process_interactive_session_list(sessions, all_fields=False): first_name = user_obj.get('name', '') last_name = user_obj.get('surname', '') user_name = f'{first_name} {last_name}'.strip() - + row = { '_id': session.get('_id', ''), 'name': session.get('name', ''), @@ -467,20 +443,19 @@ def process_interactive_session_list(sessions, all_fields=False): } rows.append(row) df = pd.DataFrame(rows) - return df def _get_nested_value(obj, path): """Get a nested value from an object using dot notation. - + Parameters ---------- obj : dict The object to extract from path : str Dot-separated path (e.g., 'user.firstName') - + Returns ------- value @@ -498,14 +473,14 @@ def _get_nested_value(obj, path): def _format_session_field(field_name, value): """Format a session field for display. - + Parameters ---------- field_name : str The name of the field value The value to format - + Returns ------- str @@ -513,14 +488,13 @@ def _format_session_field(field_name, value): """ if value == '' or value is None: return '-' - + if field_name == 'status': # Color code status and map display values status_lower = str(value).lower() # Map API statuses to display values # API 'ready' and 'aborted' are mapped to user-friendly names display_status = 'running' if status_lower == 'ready' else ('paused' if status_lower == 'aborted' else value) - if status_lower in ['ready', 'running']: return f'[bold green]{display_status}[/bold green]' elif status_lower in ['paused', 'aborted']: @@ -529,7 +503,6 @@ def _format_session_field(field_name, value): return f'[bold yellow]{display_status}[/bold yellow]' else: return str(display_status) - elif field_name == 'cost': # Format cost with currency symbol try: @@ -537,19 +510,16 @@ def _format_session_field(field_name, value): return f'${cost:.2f}' except (ValueError, TypeError): return str(value) - elif field_name == 'id': # Return full ID without truncation (MongoDB ObjectIds are always 24 chars) # Full ID is needed for status command and other operations return str(value) - elif field_name == 'name': # Truncate long names value_str = str(value) if len(value_str) > 25: return value_str[:22] + '…' return value_str - elif field_name == 'runtime': # Convert seconds to human-readable format (e.g., "1h 52m 52s") try: @@ -565,7 +535,6 @@ def _format_session_field(field_name, value): return f'{seconds}s' except (ValueError, TypeError): return str(value) - elif field_name == 'created_at' or field_name == 'saved_at': # Format ISO8601 datetime to readable format try: @@ -573,17 +542,14 @@ def _format_session_field(field_name, value): return dt.strftime('%Y-%m-%d %H:%M') except (ValueError, TypeError, ImportError): return str(value)[:19] if value else '-' - elif field_name == 'version': # Version is only available for RStudio sessions if value and str(value).lower() != 'none': return f'R {value}' return '-' - elif field_name == 'type': # Map internal type names to user-friendly names return _map_session_type_to_friendly_name(str(value)) - elif field_name == 'spot': # Indicate if instance is cost-saving (spot) if value is True: @@ -592,17 +558,14 @@ def _format_session_field(field_name, value): return 'No' else: return '-' - elif field_name == 'cost_limit': # Calculate remaining cost limit (execution object contains computeCostLimit and computeCostSpent) if isinstance(value, dict): cost_limit = value.get('computeCostLimit', -1) cost_spent = value.get('computeCostSpent', 0) - # -1 means unlimited if cost_limit == -1: return 'Unlimited' - try: remaining = float(cost_limit) - float(cost_spent) if remaining < 0: @@ -611,20 +574,17 @@ def _format_session_field(field_name, value): except (ValueError, TypeError): return '-' return '-' - elif field_name == 'time_left': # Calculate time until auto-shutdown if value and value != 'null' and str(value).strip(): try: shutdown_time = datetime.fromisoformat(str(value).replace('Z', '+00:00')) now = datetime.now(timezone.utc) - if shutdown_time > now: time_diff = shutdown_time - now total_seconds = int(time_diff.total_seconds()) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 - if hours > 24: days = hours // 24 remaining_hours = hours % 24 @@ -638,13 +598,12 @@ def _format_session_field(field_name, value): except (ValueError, TypeError, ImportError): return '-' return '-' - return str(value) def save_interactive_session_list_to_csv(df, outfile, count=None): """Save interactive session list to CSV file. - + Parameters ---------- df : pandas.DataFrame @@ -662,14 +621,14 @@ def save_interactive_session_list_to_csv(df, outfile, count=None): def parse_shutdown_duration(duration_str): """Parse shutdown duration string to ISO8601 datetime string. - + Accepts formats: 30m, 2h, 8h, 1d, 2d - + Parameters ---------- duration_str : str Duration string (e.g., "2h", "30m", "1d") - + Returns ------- str @@ -678,10 +637,8 @@ def parse_shutdown_duration(duration_str): match = re.match(r'^(\d+)([mhd])$', duration_str.lower()) if not match: raise ValueError(f"Invalid duration format: {duration_str}. Use format like '2h', '30m', '1d'") - value = int(match.group(1)) unit = match.group(2) - if unit == 'm': delta = timedelta(minutes=value) elif unit == 'h': @@ -693,14 +650,14 @@ def parse_shutdown_duration(duration_str): def parse_watch_timeout_duration(duration_str): """Parse watch timeout duration string to seconds. - + Accepts formats: 30m, 2h, 1d, 30s - + Parameters ---------- duration_str : str Duration string (e.g., "30m", "2h", "1d", "30s") - + Returns ------- int @@ -709,10 +666,9 @@ def parse_watch_timeout_duration(duration_str): match = re.match(r'^(\d+)([smhd])$', duration_str.lower()) if not match: raise ValueError(f"Invalid duration format: {duration_str}. Use format like '30s', '30m', '2h', '1d'") - + value = int(match.group(1)) unit = match.group(2) - if unit == 's': return value elif unit == 'm': @@ -724,29 +680,29 @@ def parse_watch_timeout_duration(duration_str): def parse_data_file(data_file_str): """Parse data file format: either S3 or CloudOS dataset path. - + Supports mounting both S3 files and CloudOS dataset files into the session. - + Parameters ---------- data_file_str : str Format: - S3 file: s3://bucket_name/path/to/file.txt - CloudOS dataset: project_name/dataset_path or project_name > dataset_path - + Examples: - s3://lifebit-featured-datasets/pipelines/phewas/data.csv - leila-test/Data/3_vcf_list.txt - + Returns ------- dict Parsed data item. For S3: {"type": "s3", "s3_bucket": "...", "s3_prefix": "..."} - + For CloudOS dataset: {"type": "cloudos", "project_name": "...", "dataset_path": "..."} - + Raises ------ ValueError @@ -757,19 +713,15 @@ def parse_data_file(data_file_str): # Parse S3 path: s3://bucket/prefix/file s3_path = data_file_str[5:] # Remove 's3://' parts = s3_path.split('/', 1) - bucket = parts[0] if not bucket: raise ValueError(f"Invalid S3 path: {data_file_str}. Expected: s3://bucket_name/path/to/file") - prefix = parts[1] if len(parts) > 1 else "/" - return { "type": "s3", "s3_bucket": bucket, "s3_prefix": prefix } - # Otherwise, parse as CloudOS dataset path # Determine which separator to use: > takes precedence over / separator = None @@ -783,12 +735,10 @@ def parse_data_file(data_file_str): f" - S3 file: s3://bucket/path/file.txt\n" f" - CloudOS dataset: project_name/dataset_path or project_name > dataset_path" ) - # Split only on the first separator to handle nested paths parts = data_file_str.split(separator, 1) if len(parts) != 2: raise ValueError(f"Invalid data file format: {data_file_str}. Expected: project_name/dataset_path where dataset_path can be nested") - project_name, dataset_path = parts return { "type": "cloudos", @@ -799,11 +749,11 @@ def parse_data_file(data_file_str): def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: """Resolve nested dataset path to actual file ID. - + Searches across all datasets in the project to find the target file. This allows paths like 'Data/file.txt' to work even if 'Data' is a folder within a dataset (not a dataset name itself). - + Parameters ---------- datasets_api : Datasets @@ -811,13 +761,13 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: dataset_path : str Nested path to file within the project (e.g., 'Data/file.txt' or 'Folder/subfolder/file.txt') Can start with a dataset name or a folder name within any dataset. - + Returns ------- dict Data item object with resolved file ID: {"kind": "File", "item": "", "name": ""} - + Raises ------ ValueError @@ -826,11 +776,9 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: try: path_parts = dataset_path.strip('/').split('/') file_name = path_parts[-1] - # First, try the path as-is (assuming first part is a dataset name) try: result = datasets_api.list_folder_content(dataset_path) - # Check if it's in the files list for file_item in result.get('files', []): if file_item.get('name') == file_name: @@ -843,15 +791,12 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: except (Exception): # First path attempt failed, try searching across all datasets pass - # If the quick path didn't work, search across all datasets # This handles the case where the first part is a folder, not a dataset name project_content = datasets_api.list_project_content() datasets = project_content.get('folders', []) - if not datasets: raise ValueError(f"No datasets found in project. Cannot locate path '{dataset_path}'") - # Try to find the file in each dataset found_files = [] for dataset in datasets: @@ -860,7 +805,6 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: # Try with the dataset name prepended to the path full_path = f"{dataset_name}/{dataset_path}" result = datasets_api.list_folder_content(full_path) - # Check files list for file_item in result.get('files', []): if file_item.get('name') == file_name: @@ -874,14 +818,12 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: except Exception: # This dataset doesn't contain the path, continue continue - # Also try searching without dataset prefix (path is from root of datasets) for dataset in datasets: try: dataset_name = dataset.get('name') # List what's in this dataset at the top level dataset_content = datasets_api.list_datasets_content(dataset_name) - # Check if the target file is directly in this dataset's files for file_item in dataset_content.get('files', []): if file_item.get('name') == file_name: @@ -890,7 +832,6 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: "item": file_item.get('_id'), "name": file_item.get('name') }) - # Check folders and navigate if needed for folder in dataset_content.get('folders', []): if folder.get('name') == path_parts[0]: @@ -909,11 +850,9 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: continue except Exception: continue - # If we found files, return the first one if found_files: return found_files[0] - # Nothing found - provide helpful error message available_datasets = [d.get('name') for d in datasets] raise ValueError( @@ -921,7 +860,6 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: f"Available datasets: {available_datasets}. " f"Try using 'cloudos datasets ls' to explore your data structure." ) - except ValueError: raise except Exception as e: @@ -930,9 +868,9 @@ def resolve_data_file_id(datasets_api, dataset_path: str) -> dict: def parse_link_path(link_path_str): """Parse link path format: supports S3, CloudOS, or legacy colon format. - + Links an S3 folder or CloudOS folder to the session for read/write access. - + Parameters ---------- link_path_str : str @@ -940,7 +878,7 @@ def parse_link_path(link_path_str): - S3 path: s3://bucketName/s3Prefix (e.g., s3://my-bucket/data/) - CloudOS folder: project/folder_path (e.g., leila-test/Data) - Legacy format (deprecated): mountName:bucketName:s3Prefix - + Returns ------- dict @@ -955,49 +893,39 @@ def parse_link_path(link_path_str): f"Folder linking is not supported on Azure execution platforms. " f"Please use CloudOS file explorer to access your data directly." ) - # Check for S3 path if link_path_str.startswith('s3://'): # Parse S3 path: s3://bucket/prefix s3_path = link_path_str[5:] # Remove 's3://' parts = s3_path.split('/', 1) - if len(parts) < 1: raise ValueError(f"Invalid S3 path: {link_path_str}. Expected: s3://bucket_name/prefix/") - bucket = parts[0] prefix = parts[1] if len(parts) > 1 else "" - # Ensure prefix ends with / for S3 folders if prefix and not prefix.endswith('/'): prefix = prefix + '/' - return { "type": "s3", "s3_bucket": bucket, "s3_prefix": prefix } - # Check for legacy colon format if ':' in link_path_str and '//' not in link_path_str: # Legacy format: mountName:bucketName:s3Prefix parts = link_path_str.split(':') if len(parts) != 3: raise ValueError(f"Invalid link format: {link_path_str}. Expected: mountName:bucketName:s3Prefix") - mount_name, bucket, prefix = parts - # Ensure prefix ends with / if prefix and not prefix.endswith('/'): prefix = prefix + '/' - return { "type": "s3", "mount_name": mount_name, "s3_bucket": bucket, "s3_prefix": prefix } - # Otherwise, parse as CloudOS folder path # Format: project_name/folder_path or project_name > folder_path separator = None @@ -1012,11 +940,9 @@ def parse_link_path(link_path_str): f" - CloudOS folder: project/folder/path\n" f" - Legacy format (deprecated): mountName:bucketName:prefix" ) - parts = link_path_str.split(separator, 1) if len(parts) != 2: raise ValueError(f"Invalid link path: {link_path_str}") - project_name, folder_path = parts return { "type": "cloudos", @@ -1045,7 +971,7 @@ def build_session_payload( spark_workers=1 ): """Build the complex session creation payload for the API. - + Parameters ---------- name : str @@ -1080,7 +1006,7 @@ def build_session_payload( Spark core instance type (required for spark backend, AWS only) spark_workers : int Initial number of Spark workers (default: 1, AWS only) - + Returns ------- dict @@ -1089,30 +1015,22 @@ def build_session_payload( # Validate inputs if not 1 <= len(name) <= 100: raise ValueError("Session name must be 1-100 characters") - if not 100 <= storage_size <= 5000: raise ValueError("Storage size must be between 100-5000 GB") - if backend not in ['regular', 'vscode', 'spark', 'rstudio']: raise ValueError("Invalid backend type") - if execution_platform not in ['aws', 'azure']: raise ValueError("Execution platform must be 'aws' or 'azure'") - # Spark is AWS only if backend == 'spark' and execution_platform != 'aws': raise ValueError("Spark backend is only available on AWS") - if backend == 'rstudio' and not r_version: raise ValueError("R version (--r-version) is required for RStudio backend") - if backend == 'spark' and (not spark_master_type or not spark_core_type): raise ValueError("Spark master and core instance types are required for Spark backend") - # Default shutdown to 24 hours if not provided if not shutdown_at: shutdown_at = (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat().replace('+00:00', 'Z') - # Build interactiveSessionConfiguration config = { "name": name, @@ -1128,15 +1046,12 @@ def build_session_payload( "autoShutdownAtDate": shutdown_at } } - # Add backend-specific fields if backend == 'rstudio': config['rVersion'] = r_version - if backend == 'spark': master_type = spark_master_type core_type = spark_core_type - config['cluster'] = { "name": f"{name}-cluster", "releaseLabel": "emr-7.3.0", @@ -1173,7 +1088,6 @@ def build_session_payload( }, "id": None } - # Build complete payload # For Azure, S3 mounts are not supported (fuseFileSystems should be empty) payload = { @@ -1183,7 +1097,65 @@ def build_session_payload( "fuseFileSystems": s3_mounts or [] if execution_platform == 'aws' else [], "projectId": project_id } - + return payload + + +def build_resume_payload( + instance_type=None, + storage_size=None, + cost_limit=None, + shutdown_at=None, + data_files=None, + s3_mounts=None +): + """Build the resume session payload for the API. + + Only includes fields that have been specified (all are optional). + + Parameters + ---------- + instance_type : str, optional + New instance type (if changing) + storage_size : int, optional + New storage size in GB (if changing) + cost_limit : float, optional + New compute cost limit (if changing) + shutdown_at : str, optional + New auto-shutdown datetime in ISO8601 format (if changing) + data_files : list, optional + Additional data files to mount + s3_mounts : list, optional + Additional S3 mounts (AWS only) + + Returns + ------- + dict + Resume payload for API request + """ + payload = { + "dataItems": data_files or [], + "fileSystemIds": [] # Always empty (deprecated) + } + # Only include newInteractiveSessionConfiguration if any config changes are specified + config_updates = {} + if instance_type is not None: + config_updates["instanceType"] = instance_type + if storage_size is not None: + config_updates["storageSizeInGb"] = storage_size + # Build execution updates if cost_limit or shutdown_at are specified + execution_updates = {} + if cost_limit is not None: + execution_updates["computeCostLimit"] = cost_limit + if shutdown_at is not None: + execution_updates["autoShutdownAtDate"] = shutdown_at + if execution_updates: + config_updates["execution"] = execution_updates + # Only add config updates if there are any + if config_updates: + payload["newInteractiveSessionConfiguration"] = config_updates + # Add S3 mounts if provided (for AWS) + if s3_mounts: + payload["fuseFileSystems"] = s3_mounts return payload @@ -1192,7 +1164,7 @@ def format_session_creation_table(session_data, instance_type=None, storage_size spark_master=None, spark_core=None, spark_workers=None, data_files=None, s3_mounts=None): """Display session creation result in table format. - + Parameters ---------- session_data : dict @@ -1215,44 +1187,36 @@ def format_session_creation_table(session_data, instance_type=None, storage_size List of parsed data file objects to display s3_mounts : list, optional List of parsed S3 mount objects to display - + Returns ------- str Formatted table output """ console = Console() - table = Table(title="✓ Interactive Session Created Successfully") table.add_column("Property", style="cyan") table.add_column("Value", style="green") - table.add_row("Session ID", session_data.get('_id', 'N/A')) table.add_row("Name", session_data.get('name', 'N/A')) - # Map backend type to friendly name api_backend = session_data.get('interactiveSessionType', 'N/A') backend_display = _map_session_type_to_friendly_name(api_backend) if api_backend != 'N/A' else 'N/A' table.add_row("Backend", backend_display) - table.add_row("Status", session_data.get('status', 'N/A')) - # Try to get instance type from response, fallback to provided value response_instance = session_data.get('resources', {}).get('instanceType') or \ session_data.get('interactiveSessionConfiguration', {}).get('instanceType') instance_display = response_instance or instance_type or 'N/A' table.add_row("Instance Type", instance_display) - # Try to get storage size from response, fallback to provided value response_storage = session_data.get('resources', {}).get('storageSizeInGb') or \ session_data.get('interactiveSessionConfiguration', {}).get('storageSizeInGb') storage_display = f"{response_storage} GB" if response_storage else (f"{storage_size} GB" if storage_size else "N/A") table.add_row("Storage", storage_display) - # Add backend-specific information if backend_type == 'rstudio' and r_version: table.add_row("R Version", r_version) - if backend_type == 'spark': spark_config = [] if spark_master: @@ -1261,10 +1225,9 @@ def format_session_creation_table(session_data, instance_type=None, storage_size spark_config.append(f"Core: {spark_core}") if spark_workers: spark_config.append(f"Workers: {spark_workers}") - if spark_config: table.add_row("Spark Cluster", ", ".join(spark_config)) - + # Display mounted data files if data_files: mounted_files = [] @@ -1279,10 +1242,9 @@ def format_session_creation_table(session_data, instance_type=None, storage_size data = df.get('data', {}) name = data.get('name', 'Unknown') mounted_files.append(f"{name} (S3)") - if mounted_files: table.add_row("Mounted Data", ", ".join(mounted_files)) - + # Display linked S3 buckets if s3_mounts: linked_s3 = [] @@ -1296,10 +1258,9 @@ def format_session_creation_table(session_data, instance_type=None, storage_size linked_s3.append(f"s3://{bucket}/{prefix}") elif bucket: linked_s3.append(f"s3://{bucket}/") - if linked_s3: table.add_row("Linked S3", "\n".join(linked_s3)) - + console.print(table) console.print("\n[yellow]Note:[/yellow] Session provisioning typically takes 3-10 minutes.") console.print("[cyan]Next steps:[/cyan] Use 'cloudos interactive-session status' to monitor status") @@ -1340,17 +1301,16 @@ def format_session_creation_table(session_data, instance_type=None, storage_size def format_duration(seconds: int) -> str: """Convert seconds to human-readable format. - + Examples: "2h 15m", "45m 30s", "30s" """ if not seconds or seconds <= 0: return "Not started" - + seconds = int(seconds) hours = seconds // 3600 minutes = (seconds % 3600) // 60 secs = seconds % 60 - parts = [] if hours > 0: parts.append(f"{hours}h") @@ -1358,13 +1318,12 @@ def format_duration(seconds: int) -> str: parts.append(f"{minutes}m") if secs > 0 or not parts: parts.append(f"{secs}s") - return " ".join(parts) def map_status(api_status: str) -> str: """Map API status value to user-friendly display status. - + Converts API status values (like 'ready', 'aborted') to display values (like 'running', 'paused') matching the list command. """ @@ -1373,12 +1332,11 @@ def map_status(api_status: str) -> str: def format_timestamp(iso_timestamp: str = None) -> str: """Convert ISO8601 timestamp to readable format. - + Example: "2026-03-13 10:30:00" """ if not iso_timestamp: return "N/A" - try: dt = datetime.fromisoformat( iso_timestamp.replace('Z', '+00:00') @@ -1390,7 +1348,7 @@ def format_timestamp(iso_timestamp: str = None) -> str: def format_cost(cost_value: float = None) -> str: """Format cost as currency. - + Examples: "$12.50", "$0.00" """ if cost_value is None: @@ -1403,7 +1361,7 @@ def format_cost(cost_value: float = None) -> str: def format_instance_type(instance_type: str, is_cost_saving: bool = False) -> str: """Format instance type with spot indicator. - + Examples: "c5.xlarge", "c5.xlarge (spot)" """ if is_cost_saving: @@ -1420,12 +1378,10 @@ def validate_session_id(session_id: str) -> bool: class InteractiveSessionAPI: """API client for interactive session operations.""" - REQUEST_TIMEOUT = 30 # seconds - def __init__(self, cloudos_url: str, apikey: str, verify_ssl: bool = True): """Initialize API client. - + Parameters ---------- cloudos_url : str @@ -1439,11 +1395,10 @@ def __init__(self, cloudos_url: str, apikey: str, verify_ssl: bool = True): self.apikey = apikey self.verify_ssl = verify_ssl self.session = self._create_session() - + def _create_session(self) -> requests.Session: """Create requests session with retry strategy.""" session = requests.Session() - # Configure retry strategy with exponential backoff retry_strategy = Retry( total=3, @@ -1451,30 +1406,28 @@ def _create_session(self) -> requests.Session: status_forcelist=[429, 500, 502, 503, 504], allowed_methods=['GET'] ) - adapter = HTTPAdapter(max_retries=retry_strategy) session.mount('http://', adapter) session.mount('https://', adapter) - return session - + def get_session_status(self, session_id: str, team_id: str) -> dict: """Retrieve session status from API endpoint. - + GET /api/v2/interactive-sessions/{sessionId}?teamId={teamId} - + Parameters ---------- session_id : str Session ID (24-character hex) team_id : str Team/workspace ID - + Returns ------- dict Session status response - + Raises ------ PermissionError @@ -1490,7 +1443,6 @@ def get_session_status(self, session_id: str, team_id: str) -> dict: 'apikey': self.apikey, 'Content-Type': 'application/json' } - try: response = retry_requests_get( url, @@ -1498,7 +1450,6 @@ def get_session_status(self, session_id: str, team_id: str) -> dict: headers=headers, verify=self.verify_ssl ) - if response.status_code == 200: return response.json() elif response.status_code == 401: @@ -1516,7 +1467,7 @@ def get_session_status(self, session_id: str, team_id: str) -> dict: raise RuntimeError( f"API error (HTTP {response.status_code}): {response.text}" ) - + except requests.exceptions.Timeout: raise RuntimeError(f"API request timeout after {self.REQUEST_TIMEOUT} seconds") except requests.exceptions.ConnectionError as e: @@ -1525,7 +1476,7 @@ def get_session_status(self, session_id: str, team_id: str) -> dict: class OutputFormatter: """Handles formatting output in different formats.""" - + @staticmethod def format_stdout(session_data: dict, cloudos_url: str) -> None: """Display session status as a rich table with color coding.""" @@ -1535,30 +1486,24 @@ def format_stdout(session_data: dict, cloudos_url: str) -> None: show_header=True, header_style="bold magenta" ) - table.add_column("Property", style="cyan", no_wrap=True) table.add_column("Value", style="green") - # Build session link and embed it in the name session_id = session_data.get('id', 'N/A') session_name = session_data.get('name', 'N/A') - if cloudos_url and session_id != 'N/A': base_url = cloudos_url.rstrip('/') session_link = f"{base_url}/app/data-science/interactive-analysis/view/{session_id}/" session_name_with_link = f"[link={session_link}]{session_name}[/link]" else: session_name_with_link = session_name - table.add_row("Session ID", session_id) table.add_row("Name", session_name_with_link) - # Status with color coding status = session_data.get('status', 'N/A') status_color = STATUS_COLORS.get(status, 'white') status_colored = f"[{status_color}]{status}[/{status_color}]" table.add_row("Status", status_colored) - # Add remaining fields table.add_row("Backend", session_data.get('backend_type', 'N/A')) table.add_row("Owner", session_data.get('owner', 'N/A')) @@ -1568,23 +1513,19 @@ def format_stdout(session_data: dict, cloudos_url: str) -> None: table.add_row("Cost", session_data.get('cost', 'N/A')) table.add_row("Runtime", session_data.get('runtime', 'N/A')) table.add_row("Created At", session_data.get('created_at', 'N/A')) - if session_data.get('last_saved'): table.add_row("Last Saved", session_data.get('last_saved')) - if session_data.get('auto_shutdown'): table.add_row("Auto-Shutdown At", session_data.get('auto_shutdown')) - if session_data.get('r_version'): table.add_row("R Version", session_data.get('r_version')) - console.print(table) - + @staticmethod def format_json(raw_response: dict) -> str: """Return raw API response as formatted JSON.""" return json.dumps(raw_response, indent=2, default=str) - + @staticmethod def format_csv(session_data: dict) -> str: """Export as CSV with key fields.""" @@ -1599,21 +1540,19 @@ def format_csv(session_data: dict) -> str: 'Runtime': session_data.get('runtime', ''), 'Created': session_data.get('created_at', ''), } - lines = [] lines.append(','.join(csv_data.keys())) lines.append(','.join(str(v) if v else '' for v in csv_data.values())) - return '\n'.join(lines) class WatchModeManager: """Manages watch mode polling and display.""" - + def __init__(self, api_client: InteractiveSessionAPI, session_id: str, team_id: str, interval: int = 10): """Initialize watch mode manager. - + Parameters ---------- api_client : InteractiveSessionAPI @@ -1630,30 +1569,27 @@ def __init__(self, api_client: InteractiveSessionAPI, self.team_id = team_id self.interval = interval self.start_time = time.time() - + def watch(self, verbose: bool = False) -> dict: """Continuously poll session status until reaching terminal state. - + Terminal states: running, paused, terminated - + Handles Ctrl+C gracefully. """ spinner_chars = ['◜', '◝', '◞', '◟'] spinner_index = 0 - + try: while True: # Fetch status response = self.api_client.get_session_status( self.session_id, self.team_id ) - status = response.get('status', '') elapsed = int(time.time() - self.start_time) - # Display progress spinner = spinner_chars[spinner_index % len(spinner_chars)] - if verbose: print( f"\r{spinner} Status: {status:<12} | " @@ -1661,7 +1597,6 @@ def watch(self, verbose: bool = False) -> dict: end='', flush=True ) - # Check if reached terminal state if status in TERMINAL_STATES: print() # New line after spinner @@ -1674,15 +1609,13 @@ def watch(self, verbose: bool = False) -> dict: f"⚠ Session reached terminal state: {status}" ) return response - # Wait before next poll spinner_index += 1 time.sleep(self.interval) - except KeyboardInterrupt: print("\n⚠ Watch mode interrupted by user.") raise - + def get_elapsed_time(self) -> str: """Get formatted elapsed time.""" elapsed = int(time.time() - self.start_time) @@ -1695,48 +1628,37 @@ def transform_session_response(api_response: dict) -> dict: name = api_response.get('name', 'N/A') api_status = api_response.get('status', 'N/A') status = map_status(api_status) # Map API status to display status - # Map backend type api_backend = api_response.get('interactiveSessionType', '') backend_type = _map_session_type_to_friendly_name(api_backend) - # Extract user info user = api_response.get('user', {}) owner = f"{user.get('name', '')} {user.get('surname', '')}".strip() if not owner: owner = user.get('email', 'N/A') - # Extract project info project = api_response.get('project', {}) project_name = project.get('name', 'N/A') - # Extract resource info resources = api_response.get('resources', {}) instance_type = resources.get('instanceType', 'N/A') is_spot = resources.get('isCostSaving', False) instance_display = format_instance_type(instance_type, is_spot) - storage_size_gb = resources.get('storageSizeInGb', 'N/A') storage_display = f"{storage_size_gb} GB" if isinstance(storage_size_gb, int) else 'N/A' - # Cost and runtime total_cost = api_response.get('totalCostInUsd', 0) cost = format_cost(total_cost) - total_runtime_seconds = api_response.get('totalRunningTimeInSeconds', 0) runtime = format_duration(total_runtime_seconds) - # Timestamps created_at = format_timestamp(api_response.get('createdAt')) last_saved = format_timestamp(api_response.get('lastSavedAt')) - # Execution info execution = api_response.get('execution', {}) auto_shutdown = format_timestamp(execution.get('autoShutdownAtDate')) - # R version (for RStudio) r_version = api_response.get('rVersion') - return { 'id': session_id, 'name': name, @@ -1757,49 +1679,45 @@ def transform_session_response(api_response: dict) -> dict: def export_session_status_json(session_data: dict, output_file: str = None) -> str: """Export session status as JSON. - + Parameters ---------- session_data : dict Raw API response output_file : str, optional Path to save JSON file. If None, returns JSON string. - + Returns ------- str JSON formatted string """ json_str = json.dumps(session_data, indent=2, default=str) - if output_file: with open(output_file, 'w') as f: f.write(json_str) - return json_str def export_session_status_csv(session_data: dict, output_file: str = None) -> str: """Export session status as CSV. - + Parameters ---------- session_data : dict Transformed session data (from transform_session_response) output_file : str, optional Path to save CSV file. If None, returns CSV string. - + Returns ------- str CSV formatted string """ csv_str = OutputFormatter.format_csv(session_data) - if output_file: with open(output_file, 'w') as f: f.write(csv_str) - return csv_str @@ -1811,7 +1729,7 @@ def get_interactive_session_status(cloudos_url: str, apikey: str, session_id: st team_id: str, verify_ssl: bool = True, verbose: bool = False) -> dict: """Wrapper function to fetch session status from API. - + Parameters ---------- cloudos_url : str @@ -1826,12 +1744,12 @@ def get_interactive_session_status(cloudos_url: str, apikey: str, session_id: st Whether to verify SSL certificates verbose : bool Whether to print verbose output - + Returns ------- dict Raw API response - + Raises ------ ValueError @@ -1846,13 +1764,13 @@ def get_interactive_session_status(cloudos_url: str, apikey: str, session_id: st apikey=apikey, verify_ssl=verify_ssl ) - + return api_client.get_session_status(session_id, team_id) def format_session_status_table(session_data: dict, cloudos_url: str = None) -> None: """Wrapper function to display session status as a rich table. - + Parameters ---------- session_data : dict @@ -1865,7 +1783,7 @@ def format_session_status_table(session_data: dict, cloudos_url: str = None) -> def confirm_session_stop(session_data: dict, no_upload: bool = False, force: bool = False) -> None: """Display session termination confirmation details. - + Parameters ---------- session_data : dict @@ -1876,39 +1794,32 @@ def confirm_session_stop(session_data: dict, no_upload: bool = False, force: boo Whether force abort is enabled """ console = Console() - session_name = session_data.get('name', 'Unknown') session_id = session_data.get('_id', 'Unknown') status = map_status(session_data.get('status', 'unknown')) cost_per_hour = session_data.get('costPerHour', 0) - # Create confirmation table table = Table(title=f"About to stop session: {session_name}", title_style="bold yellow") table.add_column("Property", style="cyan", no_wrap=True) table.add_column("Value", style="green") - table.add_row("Session ID", session_id) table.add_row("Current Status", status) - if not no_upload: table.add_row("Data Action", "Will be saved before stopping") else: table.add_row("Data Action", "⚠ Will NOT be saved (--no-upload)") - if force: table.add_row("Termination", "⚠ FORCED (skip graceful shutdown)") else: table.add_row("Termination", "Graceful shutdown") - if cost_per_hour: table.add_row("Cost/Hour", f"${cost_per_hour:.2f}") - console.print(table) def format_stop_success_output(session_data: dict, wait: bool = False) -> None: """Display successful session stop output. - + Parameters ---------- session_data : dict @@ -1916,30 +1827,22 @@ def format_stop_success_output(session_data: dict, wait: bool = False) -> None: wait : bool Whether the command waited for full termination """ - from rich.console import Console - from rich.panel import Panel - console = Console() session_name = session_data.get('name', 'Unknown') session_id = session_data.get('_id', 'Unknown') status = map_status(session_data.get('status', 'unknown')) total_cost = session_data.get('totalCostInUsd', 0) total_runtime = session_data.get('totalRunningTimeInSeconds', 0) - # Format runtime runtime_str = format_duration(total_runtime) if total_runtime else 'N/A' - # Build message message = f"Session paused successfully\n" message += f" Session ID: {session_id}\n" message += f" Final status: {status}\n" - if total_cost: message += f" Total cost: ${total_cost:.2f}\n" - if total_runtime: message += f" Total runtime: {runtime_str}" - # Display success message console.print(Panel(message, title="✓ Session Stop Complete", style="bold green")) @@ -1947,7 +1850,7 @@ def format_stop_success_output(session_data: dict, wait: bool = False) -> None: def poll_session_termination(cloudos_url: str, apikey: str, session_id: str, team_id: str, max_wait: int = 300, poll_interval: int = 5, verify_ssl: bool = True) -> dict: """Poll session status until it reaches a terminal state. - + Parameters ---------- cloudos_url : str @@ -1964,27 +1867,23 @@ def poll_session_termination(cloudos_url: str, apikey: str, session_id: str, tea Polling interval in seconds (default: 5) verify_ssl : bool Whether to verify SSL certificates - + Returns ------- dict Final session status response - + Raises ------ TimeoutError If session doesn't reach terminal state within max_wait """ - from rich.console import Console - console = Console() start_time = time.time() previous_status = None - with console.status("[bold yellow]Pausing session...", spinner='dots'): while True: elapsed = time.time() - start_time - # Fetch current status session_response = get_interactive_session_status( cloudos_url=cloudos_url, @@ -1994,25 +1893,58 @@ def poll_session_termination(cloudos_url: str, apikey: str, session_id: str, tea verify_ssl=verify_ssl, verbose=False ) - current_status = map_status(session_response.get('status', '')) - # Print status changes if current_status != previous_status: console.log(f"Status: {current_status}") previous_status = current_status - # Check if terminal state reached if current_status in ['paused', 'terminated']: console.print("[bold green]✓ Session paused successfully") return session_response - # Check timeout if elapsed > max_wait: raise TimeoutError( f"Session did not reach terminal state within {max_wait} seconds. " f"Current status: {current_status}" ) - # Wait before next poll time.sleep(poll_interval) + + +def fetch_interactive_session_page(cl, workspace_id, page_num, limit, filter_status, filter_only_mine, archived, verify_ssl): + """Helper function to fetch a specific page of interactive sessions. + + Parameters + ---------- + cl : Cloudos + CloudOS API client instance + workspace_id : str + Workspace ID + page_num : int + Page number to fetch + limit : int + Number of results per page + filter_status : tuple or None + Status filters + filter_only_mine : bool + Whether to filter only user's sessions + archived : bool + Whether to include archived sessions + verify_ssl : bool or str + SSL verification setting + + Returns + ------- + dict + API response with sessions and pagination metadata + """ + return cl.get_interactive_session_list( + workspace_id, + page=page_num, + limit=limit, + status=list(filter_status) if filter_status else None, + owner_only=filter_only_mine, + include_archived=archived, + verify=verify_ssl + )