Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
## lifebit-ai/cloudos-cli: changelog

## v2.91.1 (2026-06-09)

### Patch

- Adds `cloudos project members` command to collect project members from `/api/v1/projects/{id}/members`.

## v2.91.0 (2026-05-28)

### Feat:
Expand Down
2 changes: 1 addition & 1 deletion cloudos_cli/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '2.91.0'
__version__ = '2.91.1'
29 changes: 28 additions & 1 deletion cloudos_cli/clos.py
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,34 @@ def get_project_list(self, workspace_id, verify=True, get_all=True,
else:
return content['projects']

def get_project_members(self, project_id, verify=True):
"""Get members from a Lifebit Platform project.

Parameters
----------
project_id : string
The Lifebit Platform project id to collect members from.
verify: [bool|string]
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
-------
list | dict
The parsed server response with project members.
"""
headers = {
"Content-type": "application/json",
"apikey": self.apikey
}
r = retry_requests_get(
"{}/api/v1/projects/{}/members".format(self.cloudos_url, project_id),
headers=headers, verify=verify)
if r.status_code >= 400:
raise BadRequestException(r)
return json.loads(r.content)

@staticmethod
def process_project_list(r, all_fields=False):
"""Process a server response from a self.get_project_list call.
Expand Down Expand Up @@ -2615,4 +2643,3 @@ def mount_fuse_filesystem_v2(self, session_id, team_id, payload, verify=True):
# Return the status code (204 No Content is success)
return r.status_code


43 changes: 43 additions & 0 deletions cloudos_cli/projects/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,46 @@ def create_project(ctx,
except Exception as e:
print(f'\tError creating project: {str(e)}')
sys.exit(1)


@project.command('members')
@click.option('-k',
'--apikey',
help='Your Lifebit Platform API key',
required=True)
@click.option('-c',
'--cloudos-url',
help=(f'The Lifebit Platform url you are trying to access to. Default={CLOUDOS_URL}.'),
default=CLOUDOS_URL,
required=True)
@click.option('--project-id',
help='The specific Lifebit Platform project id.',
required=True)
@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'])
def list_project_members(ctx,
apikey,
cloudos_url,
project_id,
verbose,
disable_ssl_verification,
ssl_cert,
profile):
"""Collect and display all members from a Lifebit Platform project."""
# apikey and cloudos_url are now automatically resolved by the decorator
verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert)
if verbose:
print('\t...Preparing objects')
cl = Cloudos(cloudos_url, apikey, None)
response = cl.get_project_members(project_id, verify=verify_ssl)
print(json.dumps(response))
26 changes: 26 additions & 0 deletions supported-endpoints.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"endpoints": [
{
"id": "projects-create",
"method": "POST",
"path": "/api/v1/projects",
"query_params": [
"teamId"
],
"cli_command": "cloudos project create",
"source_file": "cloudos_cli/clos.py",
"added_in_version": "2.91.0",
"notes": null
},
{
"id": "projects-members-list",
"method": "GET",
"path": "/api/v1/projects/{id}/members",
"query_params": [],
"cli_command": "cloudos project members",
"source_file": "cloudos_cli/projects/cli.py",
"added_in_version": "2.91.1",
"notes": null
}
]
}
28 changes: 28 additions & 0 deletions tests/test_cli_project_members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from click.testing import CliRunner
from cloudos_cli.__main__ import run_cloudos_cli


def test_project_members_command_exists():
"""
Test that the 'project members' command exists and shows proper help
"""
runner = CliRunner()
result = runner.invoke(run_cloudos_cli, ['project', 'members', '--help'])

assert result.exit_code == 0
assert 'Collect and display all members from a Lifebit Platform project' in result.output
assert '--project-id' in result.output
assert '--apikey' in result.output
assert '--cloudos-url' in result.output


def test_project_group_contains_members_command():
"""
Test that the 'project' group contains the 'members' command
"""
runner = CliRunner()
result = runner.invoke(run_cloudos_cli, ['project', '--help'])

assert result.exit_code == 0
assert 'members' in result.output
assert 'Collect and display all members from a Lifebit Platform project' in result.output
60 changes: 60 additions & 0 deletions tests/test_clos/test_get_project_members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import mock
import json
import pytest
import responses
from cloudos_cli.clos import Cloudos
from cloudos_cli.utils.errors import BadRequestException

APIKEY = 'vnoiweur89u2ongs'
CLOUDOS_URL = 'http://cloudos.lifebit.ai'
PROJECT_ID = '64a7b1c2f8d9e1a2b3c4d5e6'


@mock.patch('cloudos_cli.clos', mock.MagicMock())
@responses.activate
def test_get_project_members_correct_response():
"""
Test 'get_project_members' to work as intended
"""
members_payload = [
{"id": "user-1", "email": "user-1@example.com", "role": "owner"},
{"id": "user-2", "email": "user-2@example.com", "role": "member"}
]
responses.add(
responses.GET,
url=f"{CLOUDOS_URL}/api/v1/projects/{PROJECT_ID}/members",
body=json.dumps(members_payload),
status=200
)

clos = Cloudos(apikey=APIKEY, cromwell_token=None, cloudos_url=CLOUDOS_URL)
response = clos.get_project_members(PROJECT_ID)

assert isinstance(response, list)
assert len(response) == 2
assert response[0]['role'] == 'owner'


@mock.patch('cloudos_cli.clos', mock.MagicMock())
@responses.activate
def test_get_project_members_incorrect_response():
"""
Test 'get_project_members' to fail with '400' response
"""
error_message = {
"statusCode": 400,
"code": "BadRequest",
"message": "Invalid project id",
"time": "2026-06-09_17:18:00"
}
responses.add(
responses.GET,
url=f"{CLOUDOS_URL}/api/v1/projects/{PROJECT_ID}/members",
body=json.dumps(error_message),
status=400
)

with pytest.raises(BadRequestException) as error:
clos = Cloudos(apikey=APIKEY, cromwell_token=None, cloudos_url=CLOUDOS_URL)
clos.get_project_members(PROJECT_ID)
assert "Server returned status 400." in str(error)
Loading