From f0bb58286b8dd36922e8cb1e53826e5e798a615d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:55:32 +0000 Subject: [PATCH 1/2] Initial plan From 4577cd6662892adfe91f73937e4a8745a2007082 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:00:29 +0000 Subject: [PATCH 2/2] Add cloudos analytics team-summary command for GET /api/v1/analytics/team/summary --- CHANGELOG.md | 8 ++ cloudos_cli/__main__.py | 2 + cloudos_cli/_version.py | 2 +- cloudos_cli/analytics/__init__.py | 8 ++ cloudos_cli/analytics/analytics.py | 72 +++++++++++ cloudos_cli/analytics/cli.py | 90 ++++++++++++++ supported-endpoints.json | 15 +++ tests/test_analytics/__init__.py | 0 tests/test_analytics/test_cli_analytics.py | 106 +++++++++++++++++ tests/test_analytics/test_get_team_summary.py | 112 ++++++++++++++++++ tests/test_data/analytics/team_summary.json | 23 ++++ 11 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 cloudos_cli/analytics/__init__.py create mode 100644 cloudos_cli/analytics/analytics.py create mode 100644 cloudos_cli/analytics/cli.py create mode 100644 supported-endpoints.json create mode 100644 tests/test_analytics/__init__.py create mode 100644 tests/test_analytics/test_cli_analytics.py create mode 100644 tests/test_analytics/test_get_team_summary.py create mode 100644 tests/test_data/analytics/team_summary.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f2df07..298096f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.91.1 (2026-06-19) + +### Feat: + +- Adds `cloudos analytics team-summary` command for `GET /api/v1/analytics/team/summary` +- Returns aggregated team usage analytics (compute hours, job counts, spend) over a date range +- Supports optional query parameters: `--start-date`, `--end-date`, `--granularity` + ## v2.91.0 (2026-05-28) ### Feat: diff --git a/cloudos_cli/__main__.py b/cloudos_cli/__main__.py index 58236fd0..86b8ad76 100644 --- a/cloudos_cli/__main__.py +++ b/cloudos_cli/__main__.py @@ -26,6 +26,7 @@ from cloudos_cli.configure.cli import configure from cloudos_cli.link.cli import link from cloudos_cli.interactive_session.cli import interactive_session +from cloudos_cli.analytics.cli import analytics # Install the custom exception handler @@ -65,6 +66,7 @@ def run_cloudos_cli(ctx): run_cloudos_cli.add_command(configure) run_cloudos_cli.add_command(link) run_cloudos_cli.add_command(interactive_session) +run_cloudos_cli.add_command(analytics) if __name__ == '__main__': run_cloudos_cli() diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index 1271f796..4d94094d 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.91.0' +__version__ = '2.91.1' diff --git a/cloudos_cli/analytics/__init__.py b/cloudos_cli/analytics/__init__.py new file mode 100644 index 00000000..9d97850b --- /dev/null +++ b/cloudos_cli/analytics/__init__.py @@ -0,0 +1,8 @@ +""" +Functions and classes related to analytics. +""" + +from .analytics import Analytics + + +__all__ = ['Analytics'] diff --git a/cloudos_cli/analytics/analytics.py b/cloudos_cli/analytics/analytics.py new file mode 100644 index 00000000..e07738c6 --- /dev/null +++ b/cloudos_cli/analytics/analytics.py @@ -0,0 +1,72 @@ +""" +This is the main class for analytics operations. +""" + +import requests +import json +from dataclasses import dataclass +from typing import Union, Optional +from cloudos_cli.clos import Cloudos +from cloudos_cli.utils.errors import BadRequestException + + +@dataclass +class Analytics(Cloudos): + """Class to store and operate analytics data. + + Parameters + ---------- + cloudos_url : string + The Lifebit Platform service url. + apikey : string + Your Lifebit Platform API key. + cromwell_token : string + Cromwell server token. + 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. + """ + verify: Union[bool, str] = True + + def get_team_summary(self, + team_id: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + granularity: Optional[str] = None): + """Get aggregated team usage analytics over a date range. + + Parameters + ---------- + team_id : str + The Lifebit Platform team (workspace) id. + start_date : str, optional + The start date for the analytics range (e.g. '2024-01-01'). + end_date : str, optional + The end date for the analytics range (e.g. '2024-12-31'). + granularity : str, optional + The time granularity for the analytics (e.g. 'daily', 'weekly', 'monthly'). + + Returns + ------- + r : dict + A dict containing aggregated team usage analytics (compute hours, + job counts, spend) over the requested date range. + """ + headers = {"apikey": self.apikey} + params = {"teamId": team_id} + if start_date is not None: + params["startDate"] = start_date + if end_date is not None: + params["endDate"] = end_date + if granularity is not None: + params["granularity"] = granularity + r = requests.get( + "{}/api/v1/analytics/team/summary".format(self.cloudos_url), + headers=headers, + params=params, + verify=self.verify + ) + if r.status_code >= 400: + raise BadRequestException(r) + return json.loads(r.content) diff --git a/cloudos_cli/analytics/cli.py b/cloudos_cli/analytics/cli.py new file mode 100644 index 00000000..3cb2052e --- /dev/null +++ b/cloudos_cli/analytics/cli.py @@ -0,0 +1,90 @@ +"""CLI commands for Lifebit Platform analytics.""" + +import rich_click as click +import json +from cloudos_cli.analytics.analytics import Analytics +from cloudos_cli.utils.resources import ssl_selector +from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL +from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands + + +# Create the analytics group +@click.group(cls=pass_debug_to_subcommands()) +def analytics(): + """Lifebit Platform analytics functionality.""" + print(analytics.__doc__ + '\n') + + +@analytics.command('team-summary') +@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('--team-id', + help='The Lifebit Platform team (workspace) id.', + required=True) +@click.option('--start-date', + help='The start date for the analytics range (e.g. 2024-01-01).', + required=False, + default=None) +@click.option('--end-date', + help='The end date for the analytics range (e.g. 2024-12-31).', + required=False, + default=None) +@click.option('--granularity', + help='The time granularity for the analytics (e.g. daily, weekly, monthly).', + required=False, + default=None) +@click.option('--output-format', + help=('The desired display for the output, either directly in standard output or saved as file. ' + 'Default=stdout.'), + type=click.Choice(['stdout', 'json'], case_sensitive=False), + default='stdout') +@click.option('--output-basename', + help=('Output file base name to save team summary. ' + 'Default=team_summary'), + default='team_summary', + required=False) +@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', 'team_id']) +def team_summary(ctx, + apikey, + cloudos_url, + team_id, + start_date, + end_date, + granularity, + output_format, + output_basename, + disable_ssl_verification, + ssl_cert, + profile): + """Retrieve aggregated team usage analytics (compute hours, job counts, spend) over a date range.""" + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + print('Executing team-summary...') + a = Analytics(cloudos_url, apikey, None, verify=verify_ssl) + result = a.get_team_summary( + team_id=team_id, + start_date=start_date, + end_date=end_date, + granularity=granularity + ) + if output_format == 'stdout': + print(json.dumps(result, indent=2)) + elif output_format == 'json': + outfile = output_basename + '.json' + with open(outfile, 'w') as o: + o.write(json.dumps(result)) + print(f'\tTeam summary saved to {outfile}') diff --git a/supported-endpoints.json b/supported-endpoints.json new file mode 100644 index 00000000..4dfcac9a --- /dev/null +++ b/supported-endpoints.json @@ -0,0 +1,15 @@ +{ + "cli_version": "2.91.1", + "endpoints": [ + { + "id": "analytics-team-summary", + "method": "GET", + "path": "/api/v1/analytics/team/summary", + "cli_command": "cloudos analytics team-summary", + "cli_source_file": "cloudos_cli/analytics/analytics.py", + "query_params": ["teamId", "startDate", "endDate", "granularity"], + "auth": [], + "summary": "Returns aggregated team usage analytics (compute hours, job counts, spend) over a date range." + } + ] +} diff --git a/tests/test_analytics/__init__.py b/tests/test_analytics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_analytics/test_cli_analytics.py b/tests/test_analytics/test_cli_analytics.py new file mode 100644 index 00000000..db048c64 --- /dev/null +++ b/tests/test_analytics/test_cli_analytics.py @@ -0,0 +1,106 @@ +"""Test the CLI analytics team-summary command functionality.""" + +import pytest +import json +import requests_mock +from click.testing import CliRunner +from cloudos_cli.__main__ import run_cloudos_cli + +# Test data +APIKEY = 'test_api_key_12345' +CLOUDOS_URL = 'https://cloudos.lifebit.ai' +TEAM_ID = 'test_team_id_123' + +# Load test analytics data +with open("tests/test_data/analytics/team_summary.json") as f: + TEAM_SUMMARY_JSON_STR = f.read() + TEAM_SUMMARY_JSON_DICT = json.loads(TEAM_SUMMARY_JSON_STR) + + +def test_analytics_group_exists(): + """Test that the analytics group exists in the CLI.""" + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['analytics', '--help']) + assert result.exit_code == 0 + assert 'analytics' in result.output.lower() or 'Lifebit Platform' in result.output + + +def test_analytics_team_summary_help(): + """Test that the analytics team-summary command help works.""" + runner = CliRunner() + result = runner.invoke(run_cloudos_cli, ['analytics', 'team-summary', '--help']) + assert result.exit_code == 0 + assert '--team-id' in result.output + assert '--apikey' in result.output + assert '--start-date' in result.output + assert '--end-date' in result.output + assert '--granularity' in result.output + assert '--output-format' in result.output + + +def test_analytics_team_summary_stdout(): + """Test analytics team-summary with stdout output format.""" + runner = CliRunner() + + with requests_mock.Mocker() as m: + m.get( + f"{CLOUDOS_URL}/api/v1/analytics/team/summary", + text=TEAM_SUMMARY_JSON_STR, + status_code=200 + ) + result = runner.invoke(run_cloudos_cli, [ + 'analytics', 'team-summary', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--team-id', TEAM_ID, + ]) + assert result.exit_code == 0 + assert 'computeHours' in result.output or 'jobCount' in result.output or 'spend' in result.output + + +def test_analytics_team_summary_with_optional_params(): + """Test analytics team-summary with all optional parameters.""" + runner = CliRunner() + + with requests_mock.Mocker() as m: + m.get( + f"{CLOUDOS_URL}/api/v1/analytics/team/summary", + text=TEAM_SUMMARY_JSON_STR, + status_code=200 + ) + result = runner.invoke(run_cloudos_cli, [ + 'analytics', 'team-summary', + '--apikey', APIKEY, + '--cloudos-url', CLOUDOS_URL, + '--team-id', TEAM_ID, + '--start-date', '2024-01-01', + '--end-date', '2024-12-31', + '--granularity', 'monthly', + ]) + assert result.exit_code == 0 + # Verify optional params were passed in the request + assert len(m.request_history) == 1 + request_url = m.request_history[0].url + assert 'startDate=2024-01-01' in request_url + assert 'endDate=2024-12-31' in request_url + assert 'granularity=monthly' in request_url + + +def test_analytics_team_summary_api_error(): + """Test analytics team-summary raises an error on API failure.""" + runner = CliRunner() + error_body = json.dumps({"statusCode": 401, "message": "Unauthorized"}) + + with requests_mock.Mocker() as m: + m.get( + f"{CLOUDOS_URL}/api/v1/analytics/team/summary", + text=error_body, + status_code=401 + ) + result = runner.invoke(run_cloudos_cli, [ + 'analytics', 'team-summary', + '--apikey', 'bad_key', + '--cloudos-url', CLOUDOS_URL, + '--team-id', TEAM_ID, + ]) + assert result.exit_code != 0 diff --git a/tests/test_analytics/test_get_team_summary.py b/tests/test_analytics/test_get_team_summary.py new file mode 100644 index 00000000..c1fb0b49 --- /dev/null +++ b/tests/test_analytics/test_get_team_summary.py @@ -0,0 +1,112 @@ +"""Tests for Analytics.get_team_summary.""" + +import json +import pytest +import responses +from cloudos_cli.analytics import Analytics +from cloudos_cli.utils.errors import BadRequestException +from tests.functions_for_pytest import load_json_file + +INPUT = "tests/test_data/analytics/team_summary.json" +APIKEY = 'vnoiweur89u2ongs' +CLOUDOS_URL = 'http://cloudos.lifebit.ai' +TEAM_ID = 'lv89ufc838sdig' + + +@responses.activate +def test_get_team_summary_correct_response(): + """ + Test 'get_team_summary' to work as intended. + API request is mocked and replicated with a json file. + """ + body = load_json_file(INPUT) + header = { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json;charset=UTF-8", + "apikey": APIKEY + } + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/analytics/team/summary", + body=body, + headers=header, + status=200, + match_querystring=False + ) + a = Analytics(cloudos_url=CLOUDOS_URL, apikey=APIKEY, cromwell_token=None) + result = a.get_team_summary( + team_id=TEAM_ID, + start_date='2024-01-01', + end_date='2024-12-31', + granularity='monthly' + ) + assert isinstance(result, dict) + assert result['teamId'] == TEAM_ID + assert result['computeHours'] == 1234.56 + assert result['jobCount'] == 42 + assert result['spend'] == 987.65 + # Verify query params were sent + assert len(responses.calls) == 1 + request_url = responses.calls[0].request.url + assert 'teamId=' in request_url + assert 'startDate=' in request_url + assert 'endDate=' in request_url + assert 'granularity=' in request_url + + +@responses.activate +def test_get_team_summary_minimal_params(): + """ + Test 'get_team_summary' with only the required teamId parameter. + Optional params (startDate, endDate, granularity) should be omitted. + """ + body = load_json_file(INPUT) + header = { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json;charset=UTF-8", + "apikey": APIKEY + } + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/analytics/team/summary", + body=body, + headers=header, + status=200, + match_querystring=False + ) + a = Analytics(cloudos_url=CLOUDOS_URL, apikey=APIKEY, cromwell_token=None) + result = a.get_team_summary(team_id=TEAM_ID) + assert isinstance(result, dict) + # Verify only teamId was sent (no optional params) + request_url = responses.calls[0].request.url + assert 'teamId=' in request_url + assert 'startDate=' not in request_url + assert 'endDate=' not in request_url + assert 'granularity=' not in request_url + + +@responses.activate +def test_get_team_summary_bad_request(): + """ + Test 'get_team_summary' raises BadRequestException on a 400 response. + """ + error_message = {"statusCode": 400, "code": "BadRequest", + "message": "Bad Request.", "time": "2024-01-01_00:00:00"} + error_json = json.dumps(error_message) + header = { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json;charset=UTF-8", + "apikey": APIKEY + } + responses.add( + responses.GET, + url=f"{CLOUDOS_URL}/api/v1/analytics/team/summary", + body=error_json, + headers=header, + status=400, + match_querystring=False + ) + with pytest.raises(BadRequestException) as error: + a = Analytics(cloudos_url=CLOUDOS_URL, apikey=APIKEY, cromwell_token=None) + a.get_team_summary(team_id=TEAM_ID) + assert "Server returned status 400." in str(error) diff --git a/tests/test_data/analytics/team_summary.json b/tests/test_data/analytics/team_summary.json new file mode 100644 index 00000000..9a814341 --- /dev/null +++ b/tests/test_data/analytics/team_summary.json @@ -0,0 +1,23 @@ +{ + "teamId": "lv89ufc838sdig", + "startDate": "2024-01-01", + "endDate": "2024-12-31", + "granularity": "monthly", + "computeHours": 1234.56, + "jobCount": 42, + "spend": 987.65, + "breakdown": [ + { + "period": "2024-01", + "computeHours": 100.5, + "jobCount": 3, + "spend": 80.0 + }, + { + "period": "2024-02", + "computeHours": 200.1, + "jobCount": 5, + "spend": 160.5 + } + ] +}