From 380255c6733d0a8382e6a9e8ec79ca982d8f5ce9 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Tue, 12 Dec 2023 09:10:27 +0100 Subject: [PATCH 01/32] feat(cli): :sparkles: Rework CLI prompts for Organisation Teams Project and Experiment Creations , uses typer instead of click --- codecarbon/cli/main.py | 242 ++++++++++++++++++++++++++++++++++------- 1 file changed, 204 insertions(+), 38 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 9031703f6..910acab5c 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -1,47 +1,205 @@ import sys import time +from typing import Optional import click +import questionary +import typer +from rich.prompt import Confirm +from typing_extensions import Annotated -from codecarbon import EmissionsTracker +from codecarbon import __app_name__, __version__ from codecarbon.cli.cli_utils import ( get_api_endpoint, get_existing_local_exp_id, write_local_exp_id, ) from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone -from codecarbon.core.schemas import ExperimentCreate +from codecarbon.core.schemas import ( + ExperimentCreate, + OrganizationCreate, + ProjectCreate, + TeamCreate, +) +from codecarbon.emissions_tracker import EmissionsTracker DEFAULT_PROJECT_ID = "e60afa92-17b7-4720-91a0-1ae91e409ba1" +DEFAULT_ORGANIzATION_ID = "e60afa92-17b7-4720-91a0-1ae91e409ba1" + +codecarbon = typer.Typer() + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() -@click.group() -def codecarbon(): - pass + +@codecarbon.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ), +) -> None: + return @codecarbon.command("init", short_help="Create an experiment id in a public project.") def init(): - experiment_id = get_existing_local_exp_id() + """ + Initialize CodeCarbon, this will prompt you for configuration of Organisation/Team/Project/Experiment. + """ + typer.echo("Welcome to CodeCarbon configuration wizard") + use_config = Confirm.ask( + "Use existing /.codecarbonconfig to configure ?", + ) + if use_config is True: + experiment_id = get_existing_local_exp_id() + else: + experiment_id = None new_local = False if experiment_id is None: - api = ApiClient(endpoint_url=get_api_endpoint()) - experiment = ExperimentCreate( - timestamp=get_datetime_with_timezone(), - name="Code Carbon user test", - description="Code Carbon user test with default project", - on_cloud=False, - project_id=DEFAULT_PROJECT_ID, - country_name="France", - country_iso_code="FRA", - region="france", + api_endpoint = get_api_endpoint() + api_endpoint = typer.prompt( + f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", + type=str, + default=api_endpoint, ) - experiment_id = api.add_experiment(experiment) + api = ApiClient(endpoint_url=api_endpoint) + organizations = api.get_list_organizations() + org = questionary.select( + "Pick existing organization from list or Create new organization ?", + [org["name"] for org in organizations] + ["Create New Organization"], + ).ask() + + if org == "Create New Organization": + org_name = typer.prompt( + "Organization name", default="Code Carbon user test" + ) + org_description = typer.prompt( + "Organization description", default="Code Carbon user test" + ) + if org_name in organizations: + typer.echo( + f"Organization {org_name} already exists, using it for this experiment." + ) + organization = [orga for orga in organizations if orga["name"] == org][ + 0 + ] + else: + organization_create = OrganizationCreate( + name=org_name, + description=org_description, + ) + organization = api.create_organization(organization=organization_create) + typer.echo(f"Created organization : {organization}") + else: + organization = [orga for orga in organizations if orga["name"] == org][0] + teams = api.list_teams_from_organization(organization["id"]) + + team = questionary.select( + "Pick existing team from list or create new team in organization ?", + [team["name"] for team in teams] + ["Create New Team"], + ).ask() + if team == "Create New Team": + team_name = typer.prompt("Team name", default="Code Carbon user test") + team_description = typer.prompt( + "Team description", default="Code Carbon user test" + ) + team_create = TeamCreate( + name=team_name, + description=team_description, + organization_id=organization["id"], + ) + team = api.create_team( + team=team_create, + ) + typer.echo(f"Created team : {team}") + else: + team = [t for t in teams if t["name"] == team][0] + projects = api.list_projects_from_team(team["id"]) + project = questionary.select( + "Pick existing project from list or Create new project ?", + [project["name"] for project in projects] + ["Create New Project"], + default="Create New Project", + ).ask() + if project == "Create New Project": + project_name = typer.prompt("Project name", default="Code Carbon user test") + project_description = typer.prompt( + "Project description", default="Code Carbon user test" + ) + project_create = ProjectCreate( + name=project_name, + description=project_description, + team_id=team["id"], + ) + project = api.create_project(project=project_create) + typer.echo(f"Created project : {project}") + else: + project = [p for p in projects if p["name"] == project][0] + + experiments = api.list_experiments_from_project(project["id"]) + experiment = questionary.select( + "Pick existing experiment from list or Create new experiment ?", + [experiment["name"] for experiment in experiments] + + ["Create New Experiment"], + default="Create New Experiment", + ).ask() + if experiment == "Create New Experiment": + typer.echo("Creating new experiment") + exp_name = typer.prompt( + "Experiment name :", default="Code Carbon user test" + ) + exp_description = typer.prompt( + "Experiment description :", + default="Code Carbon user test", + ) + + exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") + if exp_on_cloud is True: + cloud_provider = typer.prompt( + "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" + ) + cloud_region = typer.prompt( + "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" + ) + else: + cloud_provider = None + cloud_region = None + country_name = typer.prompt("Country name :", default="France") + country_iso_code = typer.prompt("Country ISO code :", default="FRA") + region = typer.prompt("Region :", default="france") + experiment_create = ExperimentCreate( + timestamp=get_datetime_with_timezone(), + name=exp_name, + description=exp_description, + on_cloud=exp_on_cloud, + project_id=project["id"], + country_name=country_name, + country_iso_code=country_iso_code, + region=region, + cloud_provider=cloud_provider, + cloud_region=cloud_region, + ) + experiment_id = api.add_experiment(experiment=experiment_create) + + else: + experiment_id = [e for e in experiments if e["name"] == experiment][0]["id"] + + write_to_config = Confirm.ask( + "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" + ) + + if write_to_config is True: write_local_exp_id(experiment_id) new_local = True - - click.echo( - "\nWelcome to CodeCarbon, here is your experiment id:\n" + typer.echo( + "\nCodeCarbon Initialization achieved, here is your experiment id:\n" + click.style(f"{experiment_id}", fg="bright_green") + ( "" @@ -53,32 +211,36 @@ def init(): ) if new_local: click.echo( - "\nCodeCarbon automatically added this id to your local config: " + "\nCodeCarbon added this id to your local config: " + click.style("./.codecarbon.config", fg="bright_blue") + "\n" ) -@codecarbon.command( - "monitor", short_help="Run an infinite loop to monitor this machine." -) -@click.option( - "--measure_power_secs", default=10, help="Interval between two measures. (10)" -) -@click.option( - "--api_call_interval", - default=30, - help="Number of measures before calling API. (30).", -) -@click.option( - "--api/--no-api", default=True, help="Choose to call Code Carbon API or not. (yes)" -) -def monitor(measure_power_secs, api_call_interval, api): +@codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") +def monitor( + measure_power_secs: Annotated[ + int, typer.Argument(help="Interval between two measures.") + ] = 10, + api_call_interval: Annotated[ + int, typer.Argument(help="Number of measures between API calls.") + ] = 30, + api: Annotated[ + bool, typer.Option(help="Choose to call Code Carbon API or not") + ] = True, +): + """Monitor your machine's carbon emissions. + + Args: + measure_power_secs (Annotated[int, typer.Argument, optional): Interval between two measures. Defaults to 10. + api_call_interval (Annotated[int, typer.Argument, optional): Number of measures before calling API. Defaults to 30. + api (Annotated[bool, typer.Option, optional): Choose to call Code Carbon API or not. Defaults to True. + """ experiment_id = get_existing_local_exp_id() if api and experiment_id is None: - click.echo("ERROR: No experiment id, call 'codecarbon init' first.") + typer.echo("ERROR: No experiment id, call 'codecarbon init' first.") sys.exit(1) - click.echo("CodeCarbon is going in an infinite loop to monitor this machine.") + typer.echo("CodeCarbon is going in an infinite loop to monitor this machine.") with EmissionsTracker( measure_power_secs=measure_power_secs, api_call_interval=api_call_interval, @@ -87,3 +249,7 @@ def monitor(measure_power_secs, api_call_interval, api): # Infinite loop while True: time.sleep(300) + + +if __name__ == "__main__": + codecarbon() From 9b3a88f688391195b257d89532d3d53731ba59d5 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Tue, 12 Dec 2023 09:12:04 +0100 Subject: [PATCH 02/32] feat(core): :sparkles: add Organisation Team and Project to APi Client allows to create and list organisations, projects and teams from the CLI --- codecarbon/core/api_client.py | 117 ++++++++++++++++++++++++++++++++-- codecarbon/core/schemas.py | 44 +++++++++++++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index a1ca2cca9..76ef82536 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -13,7 +13,14 @@ import arrow import requests -from codecarbon.core.schemas import EmissionCreate, ExperimentCreate, RunCreate +from codecarbon.core.schemas import ( + EmissionCreate, + ExperimentCreate, + OrganizationCreate, + ProjectCreate, + RunCreate, + TeamCreate, +) from codecarbon.external.logger import logger # from codecarbon.output import EmissionsData @@ -52,6 +59,88 @@ def __init__( if self.experiment_id is not None: self._create_run(self.experiment_id) + def get_list_organizations(self): + """ + List all organizations + """ + url = self.url + "/organizations" + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def create_organization(self, organization: OrganizationCreate): + """ + Create an organization + """ + payload = dataclasses.asdict(organization) + url = self.url + "/organization" + r = requests.post(url=url, json=payload, timeout=2) + if r.status_code != 201: + self._log_error(url, payload, r) + return None + return r.json() + + def get_organization(self, organization_id): + """ + Get an organization + """ + url = self.url + "/organization/" + organization_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def list_teams_from_organization(self, organization_id): + """ + List all teams + """ + url = ( + self.url + "/teams/organization/" + organization_id + ) # TODO : check if this is the right url + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def create_team(self, team: TeamCreate): + """ + Create a team + """ + payload = dataclasses.asdict(team) + url = self.url + "/team" + r = requests.post(url=url, json=payload, timeout=2) + if r.status_code != 201: + self._log_error(url, payload, r) + return None + return r.json() + + def list_projects_from_team(self, team_id): + """ + List all projects + """ + url = self.url + "/projects/team/" + team_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def create_project(self, project: ProjectCreate): + """ + Create a project + """ + payload = dataclasses.asdict(project) + url = self.url + "/project" + r = requests.post(url=url, json=payload, timeout=2) + if r.status_code != 201: + self._log_error(url, payload, r) + return None + return r.json() + def add_emission(self, carbon_emission: dict): assert self.experiment_id is not None self._previous_call = time.time() @@ -148,6 +237,23 @@ def _create_run(self, experiment_id): except Exception as e: logger.error(e, exc_info=True) + def list_experiments_from_project(self, project_id: str): + """ + List all experiments for a project + """ + url = self.url + "/experiments/project/" + project_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def set_experiment(self, experiment_id: str): + """ + Set the experiment id + """ + self.experiment_id = experiment_id + def add_experiment(self, experiment: ExperimentCreate): """ Create an experiment, used by the CLI, not the package. @@ -163,9 +269,12 @@ def add_experiment(self, experiment: ExperimentCreate): return self.experiment_id def _log_error(self, url, payload, response): - logger.error( - f"ApiClient Error when calling the API on {url} with : {json.dumps(payload)}" - ) + if len(payload) > 0: + logger.error( + f"ApiClient Error when calling the API on {url} with : {json.dumps(payload)}" + ) + else: + logger.error(f"ApiClient Error when calling the API on {url}") logger.error( f"ApiClient API return http code {response.status_code} and answer : {response.text}" ) diff --git a/codecarbon/core/schemas.py b/codecarbon/core/schemas.py index 5d62abfae..481199892 100644 --- a/codecarbon/core/schemas.py +++ b/codecarbon/core/schemas.py @@ -78,3 +78,47 @@ class ExperimentCreate(ExperimentBase): class Experiment(ExperimentBase): id: str + + +@dataclass +class OrganizationBase: + name: str + description: str + + +class OrganizationCreate(OrganizationBase): + pass + + +class Organization(OrganizationBase): + id: str + + +@dataclass +class TeamBase: + name: str + description: str + organization_id: str + + +class TeamCreate(TeamBase): + pass + + +class Team(TeamBase): + id: str + + +@dataclass +class ProjectBase: + name: str + description: str + team_id: str + + +class ProjectCreate(ProjectBase): + pass + + +class Project(ProjectBase): + id: str From d913379479a8f46aed12cbd2d6c4b16c16966ae3 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Tue, 12 Dec 2023 09:12:41 +0100 Subject: [PATCH 03/32] build: :arrow_up: add new dependencies for cli --- codecarbon/__init__.py | 1 + setup.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/codecarbon/__init__.py b/codecarbon/__init__.py index 23a5aff39..f602f2635 100644 --- a/codecarbon/__init__.py +++ b/codecarbon/__init__.py @@ -10,3 +10,4 @@ ) __all__ = ["EmissionsTracker", "OfflineEmissionsTracker", "track_emissions"] +__app_name__ = "codecarbon" diff --git a/setup.py b/setup.py index 68c52a814..755157c4a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,9 @@ "psutil", "py-cpuinfo", "fuzzywuzzy", - "click", + "typer", + "questionary", + "rich", "prometheus_client", ] From 951ed7c199807490b054a3f227bb88a7c9dcc66c Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sat, 13 Jan 2024 17:34:47 +0100 Subject: [PATCH 04/32] fix(cli): :art: add a questionary prompt function allows easier testing --- codecarbon/cli/main.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 910acab5c..d7dc28f85 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -72,10 +72,11 @@ def init(): ) api = ApiClient(endpoint_url=api_endpoint) organizations = api.get_list_organizations() - org = questionary.select( + org = questionary_prompt( "Pick existing organization from list or Create new organization ?", [org["name"] for org in organizations] + ["Create New Organization"], - ).ask() + default="Create New Organization", + ) if org == "Create New Organization": org_name = typer.prompt( @@ -102,10 +103,11 @@ def init(): organization = [orga for orga in organizations if orga["name"] == org][0] teams = api.list_teams_from_organization(organization["id"]) - team = questionary.select( + team = questionary_prompt( "Pick existing team from list or create new team in organization ?", [team["name"] for team in teams] + ["Create New Team"], - ).ask() + default="Create New Team", + ) if team == "Create New Team": team_name = typer.prompt("Team name", default="Code Carbon user test") team_description = typer.prompt( @@ -123,11 +125,11 @@ def init(): else: team = [t for t in teams if t["name"] == team][0] projects = api.list_projects_from_team(team["id"]) - project = questionary.select( + project = questionary_prompt( "Pick existing project from list or Create new project ?", [project["name"] for project in projects] + ["Create New Project"], default="Create New Project", - ).ask() + ) if project == "Create New Project": project_name = typer.prompt("Project name", default="Code Carbon user test") project_description = typer.prompt( @@ -144,12 +146,12 @@ def init(): project = [p for p in projects if p["name"] == project][0] experiments = api.list_experiments_from_project(project["id"]) - experiment = questionary.select( + experiment = questionary_prompt( "Pick existing experiment from list or Create new experiment ?", [experiment["name"] for experiment in experiments] + ["Create New Experiment"], default="Create New Experiment", - ).ask() + ) if experiment == "Create New Experiment": typer.echo("Creating new experiment") exp_name = typer.prompt( @@ -157,7 +159,7 @@ def init(): ) exp_description = typer.prompt( "Experiment description :", - default="Code Carbon user test", + default="Code Carbon user test ", ) exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") @@ -191,13 +193,13 @@ def init(): else: experiment_id = [e for e in experiments if e["name"] == experiment][0]["id"] - write_to_config = Confirm.ask( - "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" - ) + write_to_config = Confirm.ask( + "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" + ) - if write_to_config is True: - write_local_exp_id(experiment_id) - new_local = True + if write_to_config is True: + write_local_exp_id(experiment_id) + new_local = True typer.echo( "\nCodeCarbon Initialization achieved, here is your experiment id:\n" + click.style(f"{experiment_id}", fg="bright_green") @@ -251,5 +253,14 @@ def monitor( time.sleep(300) +def questionary_prompt(prompt, list_options, default): + value = questionary.select( + prompt, + list_options, + default, + ).ask() + return value + + if __name__ == "__main__": codecarbon() From 1dbc62b22826806117aaba8dadbbe9c9269de600 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sat, 13 Jan 2024 17:35:03 +0100 Subject: [PATCH 05/32] test(cli): :white_check_mark: pass tests CLI --- tests/test_cli.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..a6e3d6db0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,95 @@ +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from codecarbon import __app_name__, __version__ +from codecarbon.cli.main import codecarbon + +# MOCK API CLIENT + + +@patch("codecarbon.cli.main.ApiClient") +class TestApp(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.mock_api_client = MagicMock() + self.mock_api_client.get_list_organizations.return_value = [ + {"id": 1, "name": "test org Code Carbon"} + ] + self.mock_api_client.list_teams_from_organization.return_value = [ + {"id": 1, "name": "test team Code Carbon"} + ] + + self.mock_api_client.list_projects_from_team.return_value = [ + {"id": 1, "name": "test project Code Carbon"} + ] + self.mock_api_client.list_experiments_from_project.return_value = [ + {"id": 1, "name": "test experiment Code Carbon"} + ] + self.mock_api_client.create_organization.return_value = { + "id": 1, + "name": "test org Code Carbon", + } + self.mock_api_client.create_team.return_value = { + "id": 1, + "name": "test team Code Carbon", + } + self.mock_api_client.create_project.return_value = { + "id": 1, + "name": "test project Code Carbon", + } + self.mock_api_client.create_experiment.return_value = { + "id": 1, + "name": "test experiment Code Carbon", + } + + def test_app(self, MockApiClient): + result = self.runner.invoke(codecarbon, ["--version"]) + self.assertEqual(result.exit_code, 0) + self.assertIn(__app_name__, result.stdout) + self.assertIn(__version__, result.stdout) + + def test_init_aborted(self, MockApiClient): + result = self.runner.invoke(codecarbon, ["init"]) + self.assertEqual(result.exit_code, 1) + self.assertIn("Welcome to CodeCarbon configuration wizard", result.stdout) + + def test_init_use_local(self, MockApiClient): + result = self.runner.invoke(codecarbon, ["init"], input="y") + self.assertEqual(result.exit_code, 0) + self.assertIn( + "CodeCarbon Initialization achieved, here is your experiment id:", + result.stdout, + ) + self.assertIn("(from ./.codecarbon.config)", result.stdout) + + def custom_questionary_side_effect(*args, **kwargs): + default_value = kwargs.get("default") + return MagicMock(return_value=default_value) + + @patch("codecarbon.cli.main.Confirm.ask") + @patch("codecarbon.cli.main.questionary_prompt") + def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): + MockApiClient.return_value = self.mock_api_client + mock_prompt.side_effect = [ + "Create New Organization", + "Create New Team", + "Create New Project", + "Create New Experiment", + ] + mock_confirm.side_effect = [False, False, False] + result = self.runner.invoke( + codecarbon, + ["init"], + input="n", + ) + self.assertEqual(result.exit_code, 0) + self.assertIn( + "CodeCarbon Initialization achieved, here is your experiment id:", + result.stdout, + ) + + +if __name__ == "__main__": + unittest.main() From 3677535809b1378b4a593052e8dd182e783e3906 Mon Sep 17 00:00:00 2001 From: alencon Date: Thu, 29 Feb 2024 11:30:28 +0100 Subject: [PATCH 06/32] [507]:cleaning duplicate data --- .../scripts/spcc_purgeduplicatedata.sql | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql diff --git a/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql b/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql new file mode 100644 index 000000000..78cc6a99e --- /dev/null +++ b/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql @@ -0,0 +1,149 @@ +/* + spname : spcc_purgedata + goal : create a tempory table to display records where there are any information on runs and emissions + delete all records linked by their key referenced uuid from an organizations unused. + date : 20240222 + version : 01 + comment : 01 : initialize procedure spcc_purgeduplicatedata + created by : MARC ALENCON + modified by : MARC ALENCON +*/ + +/* Comments & Comments : + sample for camille organization to delete : + Delete from public.experiments + where project_id in ('6a121901-5fa6-4e37-9ad8-8ec86941feb5'); + + delete from public.projects + where team_id='d8e80b93-50f8-42fc-9280-650954415dbb'; + + delete from teams + where organization_id='92570ce9-1f90-4904-b9e6-80471963b740'; + + delete from organizations + where id ='92570ce9-1f90-4904-b9e6-80471963b740'; +*/ + +CREATE OR REPLACE PROCEDURE public.spcc_purgeduplicatedata() +LANGUAGE 'plpgsql' +AS $BODY$ + +BEGIN -- Start of transaction + +-- Création de la table temporaire +CREATE TEMP TABLE temp_table ( + nb int , + orga_id uuid , + team_id uuid , + project_id uuid , + experiment_id uuid , + run_id uuid , + emission_id uuid +); + + +-- get distinct id from tables experiments , projects , teams , organizations +-- Insertion des données de la table source vers la table temporaire +INSERT INTO temp_table (nb, orga_id,team_id,project_id,experiment_id,run_id,emission_id ) +SELECT count(*) as nb , o.id as orga_id,t.id as team_id, p.id as project_id, e.id as experiment_id , r.id as run_id , em.id as emission_id +from public.organizations o +left outer join public.teams t on o.id=t.organization_id +left outer join public.projects p on t.id = p.team_id +left outer join public.experiments e on p.id = e.project_id +left outer join public.runs r on e.id = r.experiment_id +left outer join public.emissions em on r.id = em.run_id +where r.id is null and em.id is null +group by o.id,t.id, p.id,e.id,r.id ,em.id; + + +/* +select count(*) from temp_table -- 752 +select count(*) from experiments -- 1376 / 653 +select count(*) from projects -- 41 /21 +select count(*) from teams -- 26 /15 +select count(*) from organizations --25 /12 +*/ + +DO $$ +DECLARE + row_data RECORD; + -- variables techniques . + a_count integer; + v_state TEXT; + v_msg TEXT; + v_detail TEXT; + v_hint TEXT; + v_context TEXT; + + +BEGIN + GET DIAGNOSTICS a_count = ROW_COUNT; + RAISE NOTICE '------- START -------'; + FOR row_data IN SELECT orga_id, team_id,project_id,experiment_id,run_id,emission_id FROM temp_table LOOP + + a_count = a_count +1; + + --RAISE NOTICE '------- START -------'; + RAISE NOTICE 'The rows affected by A=%',a_count; + RAISE NOTICE 'Delete experiments which contains any runs affected' + delete FROM public.experiments e + where e.id not in ( select r.experiment_id + from runs r + ) + and e.project_id =row_data.project_id; + + + RAISE NOTICE '--------------'; + RAISE NOTICE 'Delete projects which contains any experiments affected' + + delete FROM public.projects p + where p.id not in ( select e.project_id + from experiments e + ) + and p.team_id =row_data.team_id; + + + RAISE NOTICE '--------------'; + RAISE NOTICE 'Delete teams which contains any project affected ' + DELETE from teams t + where t.id not in (select p.team_id from projects p) + and t.organization_id =row_data.orga_id; + + + + RAISE NOTICE '--------------'; + RAISE NOTICE 'Delete organizations which contains any teams affected' + DELETE from organizations o + where o.id not in (select t.organization_id from teams t ) + and o.id = row_data.orga_id; + + END LOOP; + RAISE NOTICE '-------- END ------'; +EXCEPTION + WHEN others THEN + + -- Handling error:Cancelled transaction when error produced + ROLLBACK; + get stacked diagnostics + v_state = returned_sqlstate, + v_msg = message_text, + v_detail = pg_exception_detail, + v_hint = pg_exception_hint, + v_context = pg_exception_context; + RAISE NOTICE E'Got exception : + state : % + message : % + detail : % + hint : % + context : % ', v_state, v_msg, v_detail, v_hint, v_context ; +END $$; + +DROP TABLE temp_table; + +COMMIT; -- end of transaction +END; +$BODY$; + + + + From 124d0ee8cc429865ce6dc9c2c819f8ff6a2b0c7c Mon Sep 17 00:00:00 2001 From: alencon Date: Thu, 29 Feb 2024 14:27:42 +0100 Subject: [PATCH 07/32] [299]:Prevent duplicate entry on API --- .../infra/repositories/repository_organizations.py | 9 ++++++++- .../api/infra/repositories/repository_projects.py | 10 ++++++++++ .../api/infra/repositories/repository_teams.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py index 22a9b60e9..ce9cfdb30 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py @@ -39,7 +39,14 @@ def add_organization(self, organization: OrganizationCreate) -> Organization: description=organization.description, api_key=generate_api_key(), ) - + existing_organization = ( + session.query(SqlModelOrganization).filter(SqlModelOrganization.name == organization.name).first() + ) + if existing_organization: + raise HTTPException( + status_code=404,detail=f"the organization name {organization.name} is already existed" + ) + session.add(db_organization) session.commit() session.refresh(db_organization) diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py index 4cfdcbe16..5943988f1 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py @@ -24,6 +24,16 @@ def add_project(self, project: ProjectCreate): description=project.description, team_id=project.team_id, ) + existing_project = ( + session.query(SqlModelProject) + .filter(SqlModelProject.name == project.name) + .filter(SqlModelProject.team_id == project.team_id) + .first() + ) + if existing_project: + raise HTTPException( + status_code=404,detail=f"the project name {project.name} of that team {project.team_id} is already existed" + ) session.add(db_project) session.commit() diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_teams.py b/carbonserver/carbonserver/api/infra/repositories/repository_teams.py index 9e79f24d0..65d1089f6 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_teams.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_teams.py @@ -28,6 +28,18 @@ def add_team(self, team: TeamCreate) -> Team: api_key=generate_api_key(), organization_id=team.organization_id, ) + existing_team = ( + session.query(SqlModelTeam) + .filter(SqlModelTeam.name == team.name) + .filter(SqlModelTeam.organization_id == team.organization_id) + .first() + ) + if existing_team: + raise HTTPException( + status_code=404,detail=f"the team name {team.name} of that organization {team.organization_id} is already existed" + ) + + session.add(db_team) session.commit() session.refresh(db_team) From 17dbaf725764564aa108c9165d6f282132ea45ac Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sun, 3 Mar 2024 16:30:34 +0100 Subject: [PATCH 08/32] feat(cli): :sparkles: add more functionalities to python API client and CLI --- codecarbon/cli/cli_utils.py | 38 +++- codecarbon/cli/main.py | 326 +++++++++++++++++++--------------- codecarbon/core/api_client.py | 39 +++- tests/test_cli.py | 34 ++-- 4 files changed, 263 insertions(+), 174 deletions(-) diff --git a/codecarbon/cli/cli_utils.py b/codecarbon/cli/cli_utils.py index 3539c12cf..015bc1379 100644 --- a/codecarbon/cli/cli_utils.py +++ b/codecarbon/cli/cli_utils.py @@ -1,9 +1,20 @@ import configparser from pathlib import Path +from typing import Optional -def get_api_endpoint(): - p = Path.cwd().resolve() / ".codecarbon.config" +def get_config(path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" + if p.exists(): + config = configparser.ConfigParser() + config.read(str(p)) + if "codecarbon" in config.sections(): + d = dict(config["codecarbon"]) + return d + + +def get_api_endpoint(path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" if p.exists(): config = configparser.ConfigParser() config.read(str(p)) @@ -14,8 +25,8 @@ def get_api_endpoint(): return "https://api.codecarbon.io" -def get_existing_local_exp_id(): - p = Path.cwd().resolve() / ".codecarbon.config" +def get_existing_local_exp_id(path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" if p.exists(): config = configparser.ConfigParser() config.read(str(p)) @@ -25,8 +36,9 @@ def get_existing_local_exp_id(): return d["experiment_id"] -def write_local_exp_id(exp_id): - p = Path.cwd().resolve() / ".codecarbon.config" +def write_local_exp_id(exp_id, path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" + config = configparser.ConfigParser() if p.exists(): config.read(str(p)) @@ -37,3 +49,17 @@ def write_local_exp_id(exp_id): with p.open("w") as f: config.write(f) + + +def overwrite_local_config(config_name, value, path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" + + config = configparser.ConfigParser() + if p.exists(): + config.read(str(p)) + if "codecarbon" not in config.sections(): + config.add_section("codecarbon") + + config["codecarbon"][config_name] = value + with p.open("w") as f: + config.write(f) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index d7dc28f85..2c4a35127 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -2,17 +2,18 @@ import time from typing import Optional -import click import questionary import typer +from rich import print from rich.prompt import Confirm from typing_extensions import Annotated from codecarbon import __app_name__, __version__ from codecarbon.cli.cli_utils import ( get_api_endpoint, + get_config, get_existing_local_exp_id, - write_local_exp_id, + overwrite_local_config, ) from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone from codecarbon.core.schemas import ( @@ -49,174 +50,202 @@ def main( return -@codecarbon.command("init", short_help="Create an experiment id in a public project.") -def init(): +def show_config(): + d = get_config() + api_endpoint = get_api_endpoint() + api = ApiClient(endpoint_url=api_endpoint) + try: + org = api.get_organization(d["organization_id"]) + team = api.get_team(d["team_id"]) + project = api.get_project(d["project_id"]) + experiment = api.get_experiment(d["experiment_id"]) + print( + "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n " + ) + print("Experiment: \n ") + print(experiment) + print("Project: \n") + print(project) + print("Team: \n") + print(team) + print("Organization: \n") + print(org) + except: + raise ValueError( + "Your configuration is invalid, please run `codecarbon config --init` first!" + ) + + +@codecarbon.command("config", short_help="Generate or show config") +def config( + init: Annotated[ + bool, typer.Option(help="Initialise or modify configuration") + ] = None, + show: Annotated[bool, typer.Option(help="Show configuration details")] = None, +): """ Initialize CodeCarbon, this will prompt you for configuration of Organisation/Team/Project/Experiment. """ - typer.echo("Welcome to CodeCarbon configuration wizard") - use_config = Confirm.ask( - "Use existing /.codecarbonconfig to configure ?", - ) - if use_config is True: - experiment_id = get_existing_local_exp_id() - else: - experiment_id = None - new_local = False - if experiment_id is None: - api_endpoint = get_api_endpoint() - api_endpoint = typer.prompt( - f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", - type=str, - default=api_endpoint, - ) - api = ApiClient(endpoint_url=api_endpoint) - organizations = api.get_list_organizations() - org = questionary_prompt( - "Pick existing organization from list or Create new organization ?", - [org["name"] for org in organizations] + ["Create New Organization"], - default="Create New Organization", + if show: + show_config() + elif init: + typer.echo("Welcome to CodeCarbon configuration wizard") + use_config = questionary_prompt( + "Use existing /.codecarbonconfig to configure or overwrite ? ", + ["/.codecarbonconfig", "Create New Config"], + default="/.codecarbonconfig", ) - if org == "Create New Organization": - org_name = typer.prompt( - "Organization name", default="Code Carbon user test" + if use_config == "/.codecarbonconfig": + typer.echo("Using existing config file :") + show_config() + pass + + else: + typer.echo("Creating new config file") + api_endpoint = get_api_endpoint() + api_endpoint = typer.prompt( + f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", + type=str, + default=api_endpoint, ) - org_description = typer.prompt( - "Organization description", default="Code Carbon user test" + api = ApiClient(endpoint_url=api_endpoint) + organizations = api.get_list_organizations() + org = questionary_prompt( + "Pick existing organization from list or Create new organization ?", + [org["name"] for org in organizations] + ["Create New Organization"], + default="Create New Organization", ) - if org_name in organizations: - typer.echo( - f"Organization {org_name} already exists, using it for this experiment." + + if org == "Create New Organization": + org_name = typer.prompt( + "Organization name", default="Code Carbon user test" ) + org_description = typer.prompt( + "Organization description", default="Code Carbon user test" + ) + if org_name in organizations: + typer.echo( + f"Organization {org_name} already exists, using it for this experiment." + ) + organization = [ + orga for orga in organizations if orga["name"] == org + ][0] + else: + organization_create = OrganizationCreate( + name=org_name, + description=org_description, + ) + organization = api.create_organization( + organization=organization_create + ) + typer.echo(f"Created organization : {organization}") + else: organization = [orga for orga in organizations if orga["name"] == org][ 0 ] - else: - organization_create = OrganizationCreate( - name=org_name, - description=org_description, - ) - organization = api.create_organization(organization=organization_create) - typer.echo(f"Created organization : {organization}") - else: - organization = [orga for orga in organizations if orga["name"] == org][0] - teams = api.list_teams_from_organization(organization["id"]) + overwrite_local_config("organization_id", organization["id"]) + teams = api.list_teams_from_organization(organization["id"]) - team = questionary_prompt( - "Pick existing team from list or create new team in organization ?", - [team["name"] for team in teams] + ["Create New Team"], - default="Create New Team", - ) - if team == "Create New Team": - team_name = typer.prompt("Team name", default="Code Carbon user test") - team_description = typer.prompt( - "Team description", default="Code Carbon user test" - ) - team_create = TeamCreate( - name=team_name, - description=team_description, - organization_id=organization["id"], - ) - team = api.create_team( - team=team_create, - ) - typer.echo(f"Created team : {team}") - else: - team = [t for t in teams if t["name"] == team][0] - projects = api.list_projects_from_team(team["id"]) - project = questionary_prompt( - "Pick existing project from list or Create new project ?", - [project["name"] for project in projects] + ["Create New Project"], - default="Create New Project", - ) - if project == "Create New Project": - project_name = typer.prompt("Project name", default="Code Carbon user test") - project_description = typer.prompt( - "Project description", default="Code Carbon user test" - ) - project_create = ProjectCreate( - name=project_name, - description=project_description, - team_id=team["id"], + team = questionary_prompt( + "Pick existing team from list or create new team in organization ?", + [team["name"] for team in teams] + ["Create New Team"], + default="Create New Team", ) - project = api.create_project(project=project_create) - typer.echo(f"Created project : {project}") - else: - project = [p for p in projects if p["name"] == project][0] + if team == "Create New Team": + team_name = typer.prompt("Team name", default="Code Carbon user test") + team_description = typer.prompt( + "Team description", default="Code Carbon user test" + ) + team_create = TeamCreate( + name=team_name, + description=team_description, + organization_id=organization["id"], + ) + team = api.create_team( + team=team_create, + ) + typer.echo(f"Created team : {team}") + else: + team = [t for t in teams if t["name"] == team][0] + overwrite_local_config("team_id", team["id"]) - experiments = api.list_experiments_from_project(project["id"]) - experiment = questionary_prompt( - "Pick existing experiment from list or Create new experiment ?", - [experiment["name"] for experiment in experiments] - + ["Create New Experiment"], - default="Create New Experiment", - ) - if experiment == "Create New Experiment": - typer.echo("Creating new experiment") - exp_name = typer.prompt( - "Experiment name :", default="Code Carbon user test" - ) - exp_description = typer.prompt( - "Experiment description :", - default="Code Carbon user test ", + projects = api.list_projects_from_team(team["id"]) + project = questionary_prompt( + "Pick existing project from list or Create new project ?", + [project["name"] for project in projects] + ["Create New Project"], + default="Create New Project", ) - - exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") - if exp_on_cloud is True: - cloud_provider = typer.prompt( - "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" + if project == "Create New Project": + project_name = typer.prompt( + "Project name", default="Code Carbon user test" ) - cloud_region = typer.prompt( - "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" + project_description = typer.prompt( + "Project description", default="Code Carbon user test" ) + project_create = ProjectCreate( + name=project_name, + description=project_description, + team_id=team["id"], + ) + project = api.create_project(project=project_create) + typer.echo(f"Created project : {project}") else: - cloud_provider = None - cloud_region = None - country_name = typer.prompt("Country name :", default="France") - country_iso_code = typer.prompt("Country ISO code :", default="FRA") - region = typer.prompt("Region :", default="france") - experiment_create = ExperimentCreate( - timestamp=get_datetime_with_timezone(), - name=exp_name, - description=exp_description, - on_cloud=exp_on_cloud, - project_id=project["id"], - country_name=country_name, - country_iso_code=country_iso_code, - region=region, - cloud_provider=cloud_provider, - cloud_region=cloud_region, + project = [p for p in projects if p["name"] == project][0] + overwrite_local_config("project_id", project["id"]) + + experiments = api.list_experiments_from_project(project["id"]) + experiment = questionary_prompt( + "Pick existing experiment from list or Create new experiment ?", + [experiment["name"] for experiment in experiments] + + ["Create New Experiment"], + default="Create New Experiment", ) - experiment_id = api.add_experiment(experiment=experiment_create) + if experiment == "Create New Experiment": + typer.echo("Creating new experiment") + exp_name = typer.prompt( + "Experiment name :", default="Code Carbon user test" + ) + exp_description = typer.prompt( + "Experiment description :", + default="Code Carbon user test ", + ) - else: - experiment_id = [e for e in experiments if e["name"] == experiment][0]["id"] + exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") + if exp_on_cloud is True: + cloud_provider = typer.prompt( + "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" + ) + cloud_region = typer.prompt( + "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" + ) + else: + cloud_provider = None + cloud_region = None + country_name = typer.prompt("Country name :", default="France") + country_iso_code = typer.prompt("Country ISO code :", default="FRA") + region = typer.prompt("Region :", default="france") + experiment_create = ExperimentCreate( + timestamp=get_datetime_with_timezone(), + name=exp_name, + description=exp_description, + on_cloud=exp_on_cloud, + project_id=project["id"], + country_name=country_name, + country_iso_code=country_iso_code, + region=region, + cloud_provider=cloud_provider, + cloud_region=cloud_region, + ) + experiment_id = api.create_experiment(experiment=experiment_create) - write_to_config = Confirm.ask( - "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" - ) + else: + experiment_id = [e for e in experiments if e["name"] == experiment][0][ + "id" + ] - if write_to_config is True: - write_local_exp_id(experiment_id) - new_local = True - typer.echo( - "\nCodeCarbon Initialization achieved, here is your experiment id:\n" - + click.style(f"{experiment_id}", fg="bright_green") - + ( - "" - if new_local - else " (from " - + click.style("./.codecarbon.config", fg="bright_blue") - + ")\n" - ) - ) - if new_local: - click.echo( - "\nCodeCarbon added this id to your local config: " - + click.style("./.codecarbon.config", fg="bright_blue") - + "\n" - ) + overwrite_local_config("experiment_id", experiment_id["id"]) + show_config() @codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") @@ -264,3 +293,4 @@ def questionary_prompt(prompt, list_options, default): if __name__ == "__main__": codecarbon() + codecarbon() diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 76ef82536..41d40ebaa 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -97,9 +97,7 @@ def list_teams_from_organization(self, organization_id): """ List all teams """ - url = ( - self.url + "/teams/organization/" + organization_id - ) # TODO : check if this is the right url + url = self.url + "/teams/organization/" + organization_id r = requests.get(url=url, timeout=2) if r.status_code != 200: self._log_error(url, {}, r) @@ -118,6 +116,17 @@ def create_team(self, team: TeamCreate): return None return r.json() + def get_team(self, team_id): + """ + Get a team + """ + url = self.url + "/team/" + team_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + def list_projects_from_team(self, team_id): """ List all projects @@ -141,6 +150,17 @@ def create_project(self, project: ProjectCreate): return None return r.json() + def get_project(self, project_id): + """ + Get a project + """ + url = self.url + "/project/" + project_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + def add_emission(self, carbon_emission: dict): assert self.experiment_id is not None self._previous_call = time.time() @@ -254,7 +274,7 @@ def set_experiment(self, experiment_id: str): """ self.experiment_id = experiment_id - def add_experiment(self, experiment: ExperimentCreate): + def create_experiment(self, experiment: ExperimentCreate): """ Create an experiment, used by the CLI, not the package. ::experiment:: The experiment to create. @@ -268,6 +288,17 @@ def add_experiment(self, experiment: ExperimentCreate): self.experiment_id = r.json()["id"] return self.experiment_id + def get_experiment(self, experiment_id): + """ + Get an experiment by id + """ + url = self.url + "/experiment/" + experiment_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + def _log_error(self, url, payload, response): if len(payload) > 0: logger.error( diff --git a/tests/test_cli.py b/tests/test_cli.py index a6e3d6db0..7d39b0b4b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,32 +15,32 @@ def setUp(self): self.runner = CliRunner() self.mock_api_client = MagicMock() self.mock_api_client.get_list_organizations.return_value = [ - {"id": 1, "name": "test org Code Carbon"} + {"id": "1", "name": "test org Code Carbon"} ] self.mock_api_client.list_teams_from_organization.return_value = [ - {"id": 1, "name": "test team Code Carbon"} + {"id": "1", "name": "test team Code Carbon"} ] self.mock_api_client.list_projects_from_team.return_value = [ - {"id": 1, "name": "test project Code Carbon"} + {"id": "1", "name": "test project Code Carbon"} ] self.mock_api_client.list_experiments_from_project.return_value = [ - {"id": 1, "name": "test experiment Code Carbon"} + {"id": "1", "name": "test experiment Code Carbon"} ] self.mock_api_client.create_organization.return_value = { - "id": 1, + "id": "1", "name": "test org Code Carbon", } self.mock_api_client.create_team.return_value = { - "id": 1, + "id": "1", "name": "test team Code Carbon", } self.mock_api_client.create_project.return_value = { - "id": 1, + "id": "1", "name": "test project Code Carbon", } self.mock_api_client.create_experiment.return_value = { - "id": 1, + "id": "1", "name": "test experiment Code Carbon", } @@ -51,18 +51,19 @@ def test_app(self, MockApiClient): self.assertIn(__version__, result.stdout) def test_init_aborted(self, MockApiClient): - result = self.runner.invoke(codecarbon, ["init"]) + result = self.runner.invoke(codecarbon, ["config", "--init"]) self.assertEqual(result.exit_code, 1) self.assertIn("Welcome to CodeCarbon configuration wizard", result.stdout) - def test_init_use_local(self, MockApiClient): - result = self.runner.invoke(codecarbon, ["init"], input="y") + @patch("codecarbon.cli.main.questionary_prompt") + def test_init_use_local(self, mock_prompt, MockApiClient): + mock_prompt.return_value = "/.codecarbonconfig" + result = self.runner.invoke(codecarbon, ["config", "--init"], input="y") self.assertEqual(result.exit_code, 0) self.assertIn( - "CodeCarbon Initialization achieved, here is your experiment id:", + "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n ", result.stdout, ) - self.assertIn("(from ./.codecarbon.config)", result.stdout) def custom_questionary_side_effect(*args, **kwargs): default_value = kwargs.get("default") @@ -73,6 +74,7 @@ def custom_questionary_side_effect(*args, **kwargs): def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): MockApiClient.return_value = self.mock_api_client mock_prompt.side_effect = [ + "Create New Config", "Create New Organization", "Create New Team", "Create New Project", @@ -81,12 +83,12 @@ def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): mock_confirm.side_effect = [False, False, False] result = self.runner.invoke( codecarbon, - ["init"], - input="n", + ["config", "--init"], + input="y", ) self.assertEqual(result.exit_code, 0) self.assertIn( - "CodeCarbon Initialization achieved, here is your experiment id:", + "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n ", result.stdout, ) From bb1be2d523ecf4ed717fcadebd7c4a7926223ced Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sun, 3 Mar 2024 16:31:46 +0100 Subject: [PATCH 09/32] feat(core): :sparkles: allows picking up API endpoint from conf file for dashboard --- dashboard/data/data_loader.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dashboard/data/data_loader.py b/dashboard/data/data_loader.py index 8fa7fc3af..9f6e6d07f 100644 --- a/dashboard/data/data_loader.py +++ b/dashboard/data/data_loader.py @@ -8,10 +8,16 @@ import requests +from codecarbon.core.config import get_hierarchical_config + API_PATH = os.getenv("CODECARBON_API_URL") if API_PATH is None: + conf = get_hierarchical_config() + if "api_endpoint" in conf: + API_PATH = conf["api_endpoint"] # API_PATH = "http://carbonserver.cleverapps.io" - API_PATH = "https://api.codecarbon.io" + else: + API_PATH = "https://api.codecarbon.io" # API_PATH = "http://localhost:8008" # export CODECARBON_API_URL=http://localhost:8008 # API_PATH = "http://carbonserver.cleverapps.io" USER = "jessica" @@ -218,3 +224,4 @@ def load_run_infos(run_id: str, **kwargs) -> tuple: """ path = f"{API_PATH}/run/{run_id}" return path, kwargs + return path, kwargs From 4b67fe98f3ff620d6e5edf13036cff99a34ccb8d Mon Sep 17 00:00:00 2001 From: alencon Date: Mon, 18 Mar 2024 23:30:44 +0100 Subject: [PATCH 10/32] [#516] : CodeCarbon Dashboard content : - Navig Bar created - Home Page - Admin Page --- dashboard/data/data_functions.py | 5 + dashboard/layout/app.py | 318 ++------------------ dashboard/layout/assets/calculation.png | Bin 0 -> 65033 bytes dashboard/layout/callbacks.py | 91 +++++- dashboard/layout/components.py | 50 +++- dashboard/layout/pages/admin.py | 191 ++++++++++++ dashboard/layout/pages/codecarbon.py | 368 ++++++++++++++++++++++++ dashboard/layout/pages/home.py | 72 +++++ 8 files changed, 791 insertions(+), 304 deletions(-) create mode 100644 dashboard/layout/assets/calculation.png create mode 100644 dashboard/layout/pages/admin.py create mode 100644 dashboard/layout/pages/codecarbon.py create mode 100644 dashboard/layout/pages/home.py diff --git a/dashboard/data/data_functions.py b/dashboard/data/data_functions.py index 8c1c3b89a..f79ea0d15 100644 --- a/dashboard/data/data_functions.py +++ b/dashboard/data/data_functions.py @@ -139,3 +139,8 @@ def get_project_list(organization_id) -> pd.DataFrame: projects_to_add = pd.DataFrame.from_dict(load_team_projects(i)) projects = pd.concat([projects, projects_to_add]) return projects + + +def get_team_list(organization_id) -> pd.DataFrame: + teams = pd.DataFrame.from_dict(load_organization_teams(organization_id)) + return teams diff --git a/dashboard/layout/app.py b/dashboard/layout/app.py index fcecdf3b0..a43846a97 100644 --- a/dashboard/layout/app.py +++ b/dashboard/layout/app.py @@ -44,6 +44,8 @@ # Define application app = dash.Dash( __name__, + pages_folder='pages', + use_pages=True, external_stylesheets=[dbc.themes.BOOTSTRAP], meta_tags=[ {"name": "viewport", "content": "width=device-width, initial-scale=1.0"} @@ -59,302 +61,32 @@ def serve_layout(): return dbc.Container( [ - dbc.Row([components.get_header(), components.get_global_summary()]), - html.Div( - [ # hold project level information - html.Img(src=""), - dbc.Row( - dbc.Col( - [ - html.H5( - "Organization :", - ), - dcc.Dropdown( - id="org-dropdown", - options=[ - {"label": orgName, "value": orgId} - for orgName, orgId in zip( - df_org.name, df_org.id - ) - ], - clearable=False, - value=orga_id, - # value=df_org.id.unique().tolist()[0], - # value="Select your organization", - style={"color": "black"}, - # clearable=False, - ), - html.H5( - "Project :", - ), - dbc.RadioItems( - id="projectPicked", - options=[ - {"label": projectName, "value": projectId} - for projectName, projectId in zip( - df_project.name, df_project.id - ) - ], - value=df_project.id.unique().tolist()[-1] - if len(df_project) > 0 - else "No projects in this organization !", - inline=True, - # label_checked_class_name="text-primary", - # input_checked_class_name="border border-primary bg-primary", + dbc.Row( + [ components.get_header() ] #, components.get_global_summary() ] + ), + dbc.Row([ + dbc.Navbar( + dbc.Container([ + dbc.Nav([ + dbc.NavLink(page['name'] , href=page['path'])\ + for page in dash.page_registry.values() + if not page['path'].startswith("/app") + + ]), + + + ], fluid=True ), - ], - width={"size": 6, "offset": 4}, - ) - ), - dbc.Row( - [ - # holding pieCharts - dbc.Col( - dbc.Spinner(dcc.Graph(id="pieCharts", config=config)) - ), - dbc.Col( - [ - dbc.CardGroup( - [ - components.get_household_equivalent(), - components.get_car_equivalent(), - components.get_tv_equivalent(), - ] - ), - ] - ), - ], - ), - ], - className="shadow", - ), - html.Div( # holding experiment related graph - dbc.Row( - [ - dbc.Col( - dcc.Graph(id="barChart", clickData=None, config=config) - ), # holding barChart - dbc.Col( - dbc.Spinner( - dcc.Graph( - id="bubbleChart", - clickData=None, - hoverData=None, - figure={}, - config=config, - ) - ) + #style={"border":"solid" , "border-color":"#CDCDCD"}, + dark=False, + color="#CDCDCD", + + ), ] - ), - className="shadow", - ), - html.Div( # holding run level graph - dbc.Row( - [ - # holding line chart - dbc.Col( - dbc.Spinner(dcc.Graph(id="lineChart", config=config)), - width=6, - ), - dbc.Col( - dbc.Spinner( - html.Table( - [ - html.Tr([html.Th("Metadata", colSpan=2)]), - html.Tr( - [ - html.Td("O.S."), - html.Td( - id="OS", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Python Version"), - html.Td( - id="python_version", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Number of C.P.U."), - html.Td( - id="CPU_count", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("C.P.U. model"), - html.Td( - id="CPU_model", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Number of G.P.U."), - html.Td( - id="GPU_count", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("G.P.U. model"), - html.Td( - id="GPU_model", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Longitude"), - html.Td( - id="longitude", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Latitude"), - html.Td( - id="latitude", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Region"), - html.Td( - id="region", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Provider"), - html.Td( - id="provider", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("RAM total size"), - html.Td( - id="ram_tot", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - html.Tr( - [ - html.Td("Tracking mode"), - html.Td( - id="tracking_mode", - style={ - "padding-top": "2px", - "padding-bottom": "2px", - "text-align": "right", - }, - ), - ] - ), - ] - ) - ) - ), - ] - ), - className="shadow", - ), - # holding carbon emission map - html.Br(), - dcc.Dropdown( - id="slct_kpi", - options=[ - { - "label": "Global Carbon Intensity", - "value": "Global Carbon Intensity", - }, - {"label": "My Project Emissions", "value": "My Project Emissions"}, - ], - multi=False, - value="Global Carbon Intensity", - style={"width": "50%", "color": "black"}, - clearable=False, - ), - html.Div(id="output_container", children=[]), - dcc.Graph(id="my_emission_map", figure={}, config=config), - html.Div( - [ - html.Span("Powered by "), - html.A( - "Clever Cloud", - href="https://www.clever-cloud.com/", - target="_blank", - ), - html.Span("."), - ], - className="sponsor", ), + dbc.Row( + dash.page_container + ) ] ) diff --git a/dashboard/layout/assets/calculation.png b/dashboard/layout/assets/calculation.png new file mode 100644 index 0000000000000000000000000000000000000000..07a629f461b0a4f375e56ca99b59f1291e538d40 GIT binary patch literal 65033 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%0DGS0*tB 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 Y_!)qow)7P#;7cJSMdd_lg$x4!ABdER^#A|> literal 0 HcmV?d00001 diff --git a/dashboard/layout/callbacks.py b/dashboard/layout/callbacks.py index ba202df87..fe6994b2a 100644 --- a/dashboard/layout/callbacks.py +++ b/dashboard/layout/callbacks.py @@ -2,7 +2,7 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go -from dash.dependencies import Input, Output +from dash.dependencies import Input, Output from data.data_functions import ( get_experiment, get_experiment_runs, @@ -16,15 +16,104 @@ get_run_emissions, get_run_info, get_run_sums, + get_team_list, ) from layout.app import app, df_mix from layout.template import darkgreen, vividgreen from plotly.subplots import make_subplots +from dash import Input, Output + + +import json +import os +import requests + + +API_PATH = os.getenv("CODECARBON_API_URL") +if API_PATH is None: + #API_PATH = "https://api.codecarbon.io" + API_PATH = "http://localhost:8008" +USER = "jessica" +PSSD = "fake-super-secret-token" + + + # callback section: connecting the components # ************************************************************************ # ************************************************************************ +@app.callback( + Output("output","children"), + Output(component_id="organame", component_property="children"), + Output(component_id="orgadesc", component_property="children"), + + Input("input_organame", "value"), + + Input("input_orgadesc", "value"), + + Input('submit_btn','n_clicks'), + +) +def on_button_click(input_organame,input_orgadesc,n_clicks): + try: + + if n_clicks: + path = f"{API_PATH}/organization" + print(path) + payload = {'name': input_organame , 'description' : input_orgadesc} + response = requests.post(path, json=payload) + + if response.status_code == 201: + return f'You have entered "{input_organame}" and "{input_orgadesc}" into the database.' + else: + if response.status_code == 405: + return f'You have entered "{response.status_code}" and reason : "{response.reason}" ' + else: + return f'You have entered error : "{response.status_code}" and reason : "{response.reason}" for path {path} and payload {payload}' + except: + return f'none' + +#@app.callback( +## Output("output2","children"), +# Input("input_teamname", "value"), +# Input("input_teamdesc", "value"), +# Input(component_id="org-dropdown", component_property="value"), +# Input('submit_btn_team','n_clicks'), +#) +#def on_button_click(input_teamname,input_teamdesc,n_clicks): + + #if n_clicks: + # return f'Input1 {input_teamname} and Input2 {input_teamdesc} and nb {n_clicks}' + + +@app.callback( + [ + Output(component_id="teamPicked", component_property="options"), + # Output(component_id="projectPicked", component_property="value"), + ], + [ + Input(component_id="org-dropdown", component_property="value"), + ], +) +def update_team_from_organization(value): + orga_id = value + df_team = get_team_list(orga_id) + if len(df_team) > 0: + # project_id = df_project.id.unique().tolist()[0] + # project_name = df_project.name.unique().tolist()[0] + options = [ + {"label": teamName, "value": teamId} + for teamName, teamId in zip(df_team.name, df_team.id) + ] + else: + # project_id = None + # project_name = "No Project !!!" + options = [] + + return [options] + + # indicators # ------------------------------------------------------------------------- diff --git a/dashboard/layout/components.py b/dashboard/layout/components.py index 18f31f8d4..476994d8e 100644 --- a/dashboard/layout/components.py +++ b/dashboard/layout/components.py @@ -1,5 +1,4 @@ from datetime import date, timedelta - import dash_bootstrap_components as dbc from dash import dcc, html @@ -13,6 +12,31 @@ def get_header(): [html.Img(src="/assets/logo.png")], href="https://codecarbon.io" ), html.P("Track and reduce CO2 emissions from your computing"), + # dcc.Location(id="url-location", refresh=False), + # dcc.DatePickerRange( + # id="periode", + # day_size=39, + # month_format="MMMM Y", + # end_date_placeholder_text="MMMM Y", + # display_format="DD/MM/YYYY", + # # should be calculated from today() like minus 1 week + # start_date=date(2021, 1, 1), + # min_date_allowed=date(2021, 1, 1), + # max_date_allowed=date.today() + timedelta(days=1), + # initial_visible_month=date.today(), + # end_date=date.today() + timedelta(days=1), + #), + ], + xs=12, + sm=12, + md=12, + lg=5, + xl=5, + ) +#### add + def get_daterange(self): + return dbc.Col( + [ dcc.Location(id="url-location", refresh=False), dcc.DatePickerRange( id="periode", @@ -27,13 +51,19 @@ def get_header(): initial_visible_month=date.today(), end_date=date.today() + timedelta(days=1), ), - ], - xs=12, - sm=12, - md=12, - lg=5, - xl=5, + + + ] + + ) + + + + + + +#### def get_global_summary(self): return dbc.Col( @@ -56,7 +86,7 @@ def get_global_summary(self): html.P("kWh", className="text-center"), ] ) - ], + ],style={"color":"white"} ), dbc.Card( [ @@ -76,7 +106,7 @@ def get_global_summary(self): ), ] ) - ], + ],style={"color":"white"} ), dbc.Card( [ @@ -96,7 +126,7 @@ def get_global_summary(self): ), ] ) - ], + ],style={"color":"white"} ), ], className="shadow", diff --git a/dashboard/layout/pages/admin.py b/dashboard/layout/pages/admin.py new file mode 100644 index 000000000..ddd7dc825 --- /dev/null +++ b/dashboard/layout/pages/admin.py @@ -0,0 +1,191 @@ +import dash +from dash import html,dcc +import dash_bootstrap_components as dbc +from data.data_functions import get_organization_list, get_project_list, get_team_list + + + +dash.register_page(__name__, path='/admin', name="Admin",order=1) + + + +##################### Get Data ########################### + +df_org = get_organization_list() +orga_id = df_org.id.unique().tolist()[1] +df_project = get_project_list(orga_id) +df_team = get_team_list(orga_id) + + +##################### PAGE LAYOUT ########################### +def layout(): + return html.Div( + id="app_container", + children= + [ + html.Div( + id="sub_title", + children=[ + html.Br(), + html.H2( "Organization setting", + style={"text-align":"center"}, + + + ), + ] + + ), + + html.Br(), + dbc.Row( + [ + dbc.Col([ + dbc.Card( + [ + + dbc.CardBody( + [ + html.H5( + "Create an Organization :", style={"color":"white"} + ), + html.Hr(), + html.Div(id='output'), + dcc.Input(id="input_organame", type="text", placeholder="Name", debounce=True ), + html.Br(), + dcc.Input(id="input_orgadesc", type="text", placeholder="Description" , debounce=True ), + html.Br(), + html.Br(), + dbc.Button("submit", id="submit_btn", color="primary", n_clicks=0 ), + ], + style={"height": "10%", "border-radius": "10px", "border":"solid" , "border-color":"#CDCDCD" + }, + className="shadow" + ), + + + ] + ) + + ]), + dbc.Col([ + dbc.Card( + [ + + dbc.CardBody( + [ + + html.H5( + "Create a Team :", style={"color":"white"} + ), + + dbc.Label("organization selected : ",width=10 ,style={"color": "white"}, ), + dcc.Dropdown( + id="org-dropdown", + options=[ + {"label": orgName, "value": orgId} + for orgName, orgId in zip( + df_org.name, df_org.id + ) + ], + clearable=False, + value=orga_id, + style={"color": "black"}, + ), + #html.Div(id='output2'), + html.Br(), + + + dcc.Input(id="input_teamname", type="text", placeholder="Name", debounce=True ), + html.Br(), + dcc.Input(id="input_teamdesc", type="text", placeholder="Description" , debounce=True ), + html.Br(), + html.Br(), + dbc.Button("submit", id="submit_btn_team", color="primary", n_clicks=0), + ], + style={"height": "10%", "border-radius": "10px", "border":"solid" , "border-color":"#CDCDCD" + }, + className="shadow" + ), + ] + ) + + ]), + dbc.Col([ + dbc.Card( + [ + + dbc.CardBody( + [ + + html.H5( + "Create a project :", style={"color":"white"} + ), + dbc.Label("organization selected : ", width=10 , style={"color": "white"}), + dcc.Dropdown( + id="org-dropdown", + options=[ + {"label": orgName, "value": orgId} + for orgName, orgId in zip( + df_org.name, df_org.id + ) + ], + clearable=False, + value=orga_id, + # value=df_org.id.unique().tolist()[0], + # value="Select your organization", + style={"color": "black"}, + # clearable=False, + ), + dbc.Label("team selected : ", width=10 , style={"color": "white"} ), + dbc.RadioItems( + id="teamPicked", + options=[ + {"label": teamName, "value": teamId} + for teamName, teamId in zip( + df_team.name, df_team.id + ) + ], + value=df_team.id.unique().tolist()[-1] + if len(df_team) > 0 + else "No team in this organization !", + inline=True, + style={"color": "white"} + # label_checked_class_name="text-primary", + # input_checked_class_name="border border-primary bg-primary", + ), + + dbc.Label("Project ", width=10, style={"color": "white"}), + #html.Div(id='output'), + dbc.Input(id="input_projectname", type="text", placeholder="Name", debounce=True ), + dbc.Input(id="input_projectdesc", type="text", placeholder="Description" , debounce=True ), + html.Br(), + + + + dbc.Button("submit", id="submit_btn_project", color="primary", n_clicks=0), + ], + style={"height": "10%", "border-radius": "10px", "border":"solid" , "border-color":"#CDCDCD" + }, + className="shadow" + ), + ] + ) + + ]), + + + ] + + + + ), + + + + + ] + + ), + + + \ No newline at end of file diff --git a/dashboard/layout/pages/codecarbon.py b/dashboard/layout/pages/codecarbon.py new file mode 100644 index 000000000..1eeceff88 --- /dev/null +++ b/dashboard/layout/pages/codecarbon.py @@ -0,0 +1,368 @@ +#import dash +#from dash import html +import dash +import dash_bootstrap_components as dbc +import pandas as pd +from dash import dcc, html +from data.data_functions import get_organization_list, get_project_list +from dashboard.layout.components import Components + + +dash.register_page(__name__, path='/codecarbon', name="Codecarbon", order=2 ) + + +# Set configuration (prevent default plotly modebar to appears, disable zoom on figures, set a double click reset ~ not working that good IMO ) +config = { + "displayModeBar": True, + "scrollZoom": False, + "doubleClick": "reset", + "displaylogo": False, + "modeBarButtonsToRemove": [ + "zoom", + "pan", + "select", + "zoomIn", + "zoomOut", + "autoScale", + "lasso2d", + ], +} + + +# App +# ******************************************************************************* +# ******************************************************************************* + + +# Get organizations ans associated projects +df_org = get_organization_list() +orga_id = df_org.id.unique().tolist()[1] +df_project = get_project_list(orga_id) + +# Load WorldElectricityMix +df_mix = pd.read_csv("./WorldElectricityMix.csv") + + + +components = Components() + + +##################### PAGE LAYOUT ########################### +layout = html.Div(children=[ + html.Br(), + html.H2("Code carbon Dashboard", style={"text-align":"center"},), + + html.Div( + [ # hold project level information + html.Img(src=""), + dbc.Row( + components.get_global_summary(), + # components.get_daterange(), + + ), + dbc.Row( + components.get_daterange(), + + ), + dbc.Row( + dbc.Col( + [ + html.Br(), + html.H5( + "Organization :", + ), + dcc.Dropdown( + id="org-dropdown", + options=[ + {"label": orgName, "value": orgId} + for orgName, orgId in zip( + df_org.name, df_org.id + ) + ], + clearable=False, + value=orga_id, + # value=df_org.id.unique().tolist()[0], + # value="Select your organization", + style={"color": "black"}, + # clearable=False, + ), + html.H5( + "Project :", + ), + dbc.RadioItems( + id="projectPicked", + options=[ + {"label": projectName, "value": projectId} + for projectName, projectId in zip( + df_project.name, df_project.id + ) + ], + value=df_project.id.unique().tolist()[-1] + if len(df_project) > 0 + else "No projects in this organization !", + inline=True, + # label_checked_class_name="text-primary", + # input_checked_class_name="border border-primary bg-primary", + ), + ], + width={"size": 6, "offset": 4}, + ) + ), + dbc.Row( + [ + # holding pieCharts + dbc.Col( + dbc.Spinner(dcc.Graph(id="pieCharts", config=config)) + ), + dbc.Col( + [ + dbc.CardGroup( + [ + components.get_household_equivalent(), + components.get_car_equivalent(), + components.get_tv_equivalent(), + ] + ), + ] + ), + ], + ), + ], + className="shadow", + ), + html.Div( # holding experiment related graph + dbc.Row( + [ + dbc.Col( + dcc.Graph(id="barChart", clickData=None, config=config) + ), # holding barChart + dbc.Col( + dbc.Spinner( + dcc.Graph( + id="bubbleChart", + clickData=None, + hoverData=None, + figure={}, + config=config, + ) + ) + ), + ] + ), + className="shadow", + ), + html.Div( # holding run level graph + dbc.Row( + [ + # holding line chart + dbc.Col( + dbc.Spinner(dcc.Graph(id="lineChart", config=config)), + width=6, + ), + dbc.Col( + dbc.Spinner( + html.Table( + [ + html.Tr([html.Th("Metadata", colSpan=2)]), + html.Tr( + [ + html.Td("O.S."), + html.Td( + id="OS", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Python Version"), + html.Td( + id="python_version", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Number of C.P.U."), + html.Td( + id="CPU_count", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("C.P.U. model"), + html.Td( + id="CPU_model", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Number of G.P.U."), + html.Td( + id="GPU_count", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("G.P.U. model"), + html.Td( + id="GPU_model", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Longitude"), + html.Td( + id="longitude", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Latitude"), + html.Td( + id="latitude", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Region"), + html.Td( + id="region", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Provider"), + html.Td( + id="provider", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("RAM total size"), + html.Td( + id="ram_tot", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + html.Tr( + [ + html.Td("Tracking mode"), + html.Td( + id="tracking_mode", + style={ + "padding-top": "2px", + "padding-bottom": "2px", + "text-align": "right", + }, + ), + ] + ), + ] + ) + ) + ), + ] + ), + className="shadow", + ), + # holding carbon emission map + html.Br(), + dcc.Dropdown( + id="slct_kpi", + options=[ + { + "label": "Global Carbon Intensity", + "value": "Global Carbon Intensity", + }, + {"label": "My Project Emissions", "value": "My Project Emissions"}, + ], + multi=False, + value="Global Carbon Intensity", + style={"width": "50%", "color": "black"}, + clearable=False, + ), + html.Div(id="output_container", children=[]), + dcc.Graph(id="my_emission_map", figure={}, config=config), + html.Div( + [ + html.Span("Powered by "), + html.A( + "Clever Cloud", + href="https://www.clever-cloud.com/", + target="_blank", + ), + html.Span("."), + ], + className="sponsor", + ), + + + + + ] + ) + + \ No newline at end of file diff --git a/dashboard/layout/pages/home.py b/dashboard/layout/pages/home.py new file mode 100644 index 000000000..1ef0ca00d --- /dev/null +++ b/dashboard/layout/pages/home.py @@ -0,0 +1,72 @@ +import dash +from dash import html,dcc +import dash_bootstrap_components as dbc + +dash.register_page(__name__, path='/', name="Home", order=0) + + +from data.data_functions import get_organization_list, get_project_list, get_team_list + +##################### Get Data ########################### + +df_org = get_organization_list() +orga_id = df_org.id.unique().tolist()[1] +df_project = get_project_list(orga_id) +df_team = get_team_list(orga_id) +##################### PAGE LAYOUT ########################### +def layout(): + return html.Div(children=[ + html.Br(), + html.H2(" About CodeCarbon ", + style={"text-align":"center"}, + #className="my-1" + ), + html.Hr(), + dbc.Row( + [ + dbc.Col([ + html.H3("Description: ",style={"text-align":"center"}, ), + + ]), + dbc.Col([ + html.H6( + "We created a Python package that estimates your hardware electricity power consumption (GPU + CPU + RAM) and we apply to it the carbon intensity of the region where the computing is done." + ) + + ]) + + ], justify="center", + ), + html.Hr(), + dbc.Row([ + dbc.Col([ + dbc.Card( + [ + html.Img(src="/assets/calculation.png"), + ], + ) + + + ]) + + + + ]), + html.Hr(), + dbc.Row( + [ + dbc.Col([ + html.H6( + "We explain more about this calculation in the Methodology section of the documentation. Our hope is that this package will be used widely for estimating the carbon footprint of computing, and for establishing best practices with regards to the disclosure and reduction of this footprint." + + ) + + ]) + + ], justify="center", + ), + + ] + ) + + \ No newline at end of file From 0994802e2fca0094df718b665f77f87dd63aa1f9 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 10:52:54 +0100 Subject: [PATCH 11/32] feat(cli): :sparkles: allow to create a new custom configuration file anywhere --- codecarbon/cli/cli_utils.py | 1 + codecarbon/cli/main.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/codecarbon/cli/cli_utils.py b/codecarbon/cli/cli_utils.py index 015bc1379..727356bc7 100644 --- a/codecarbon/cli/cli_utils.py +++ b/codecarbon/cli/cli_utils.py @@ -5,6 +5,7 @@ def get_config(path: Optional[Path] = None): p = path or Path.cwd().resolve() / ".codecarbon.config" + if p.exists(): config = configparser.ConfigParser() config.read(str(p)) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 2c4a35127..cdb727e1e 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -1,5 +1,6 @@ import sys import time +from pathlib import Path from typing import Optional import questionary @@ -98,11 +99,23 @@ def config( if use_config == "/.codecarbonconfig": typer.echo("Using existing config file :") + file_path = Path("/.codecarbonconfig") show_config() pass else: typer.echo("Creating new config file") + file_path = typer.prompt( + "Where do you want to put your config file ?", + type=str, + default="/.codecarbonconfig", + ) + file_path = Path(file_path) + if not file_path.parent.exists(): + Confirm.ask( + "Parent folder does not exist do you want to create it (and parents) ?" + ) + api_endpoint = get_api_endpoint() api_endpoint = typer.prompt( f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", @@ -144,7 +157,9 @@ def config( organization = [orga for orga in organizations if orga["name"] == org][ 0 ] - overwrite_local_config("organization_id", organization["id"]) + overwrite_local_config( + "organization_id", organization["id"], file_path=file_path + ) teams = api.list_teams_from_organization(organization["id"]) team = questionary_prompt( @@ -244,7 +259,7 @@ def config( "id" ] - overwrite_local_config("experiment_id", experiment_id["id"]) + overwrite_local_config("experiment_id", experiment_id) show_config() From 8b2c39bff5d8ab9e4df05511b528e23b751e0d42 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Tue, 12 Dec 2023 09:10:27 +0100 Subject: [PATCH 12/32] feat(cli): :sparkles: Rework CLI prompts for Organisation Teams Project and Experiment Creations , uses typer instead of click --- codecarbon/cli/main.py | 242 ++++++++++++++++++++++++++++++++++------- 1 file changed, 204 insertions(+), 38 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 9031703f6..910acab5c 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -1,47 +1,205 @@ import sys import time +from typing import Optional import click +import questionary +import typer +from rich.prompt import Confirm +from typing_extensions import Annotated -from codecarbon import EmissionsTracker +from codecarbon import __app_name__, __version__ from codecarbon.cli.cli_utils import ( get_api_endpoint, get_existing_local_exp_id, write_local_exp_id, ) from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone -from codecarbon.core.schemas import ExperimentCreate +from codecarbon.core.schemas import ( + ExperimentCreate, + OrganizationCreate, + ProjectCreate, + TeamCreate, +) +from codecarbon.emissions_tracker import EmissionsTracker DEFAULT_PROJECT_ID = "e60afa92-17b7-4720-91a0-1ae91e409ba1" +DEFAULT_ORGANIzATION_ID = "e60afa92-17b7-4720-91a0-1ae91e409ba1" + +codecarbon = typer.Typer() + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() -@click.group() -def codecarbon(): - pass + +@codecarbon.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ), +) -> None: + return @codecarbon.command("init", short_help="Create an experiment id in a public project.") def init(): - experiment_id = get_existing_local_exp_id() + """ + Initialize CodeCarbon, this will prompt you for configuration of Organisation/Team/Project/Experiment. + """ + typer.echo("Welcome to CodeCarbon configuration wizard") + use_config = Confirm.ask( + "Use existing /.codecarbonconfig to configure ?", + ) + if use_config is True: + experiment_id = get_existing_local_exp_id() + else: + experiment_id = None new_local = False if experiment_id is None: - api = ApiClient(endpoint_url=get_api_endpoint()) - experiment = ExperimentCreate( - timestamp=get_datetime_with_timezone(), - name="Code Carbon user test", - description="Code Carbon user test with default project", - on_cloud=False, - project_id=DEFAULT_PROJECT_ID, - country_name="France", - country_iso_code="FRA", - region="france", + api_endpoint = get_api_endpoint() + api_endpoint = typer.prompt( + f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", + type=str, + default=api_endpoint, ) - experiment_id = api.add_experiment(experiment) + api = ApiClient(endpoint_url=api_endpoint) + organizations = api.get_list_organizations() + org = questionary.select( + "Pick existing organization from list or Create new organization ?", + [org["name"] for org in organizations] + ["Create New Organization"], + ).ask() + + if org == "Create New Organization": + org_name = typer.prompt( + "Organization name", default="Code Carbon user test" + ) + org_description = typer.prompt( + "Organization description", default="Code Carbon user test" + ) + if org_name in organizations: + typer.echo( + f"Organization {org_name} already exists, using it for this experiment." + ) + organization = [orga for orga in organizations if orga["name"] == org][ + 0 + ] + else: + organization_create = OrganizationCreate( + name=org_name, + description=org_description, + ) + organization = api.create_organization(organization=organization_create) + typer.echo(f"Created organization : {organization}") + else: + organization = [orga for orga in organizations if orga["name"] == org][0] + teams = api.list_teams_from_organization(organization["id"]) + + team = questionary.select( + "Pick existing team from list or create new team in organization ?", + [team["name"] for team in teams] + ["Create New Team"], + ).ask() + if team == "Create New Team": + team_name = typer.prompt("Team name", default="Code Carbon user test") + team_description = typer.prompt( + "Team description", default="Code Carbon user test" + ) + team_create = TeamCreate( + name=team_name, + description=team_description, + organization_id=organization["id"], + ) + team = api.create_team( + team=team_create, + ) + typer.echo(f"Created team : {team}") + else: + team = [t for t in teams if t["name"] == team][0] + projects = api.list_projects_from_team(team["id"]) + project = questionary.select( + "Pick existing project from list or Create new project ?", + [project["name"] for project in projects] + ["Create New Project"], + default="Create New Project", + ).ask() + if project == "Create New Project": + project_name = typer.prompt("Project name", default="Code Carbon user test") + project_description = typer.prompt( + "Project description", default="Code Carbon user test" + ) + project_create = ProjectCreate( + name=project_name, + description=project_description, + team_id=team["id"], + ) + project = api.create_project(project=project_create) + typer.echo(f"Created project : {project}") + else: + project = [p for p in projects if p["name"] == project][0] + + experiments = api.list_experiments_from_project(project["id"]) + experiment = questionary.select( + "Pick existing experiment from list or Create new experiment ?", + [experiment["name"] for experiment in experiments] + + ["Create New Experiment"], + default="Create New Experiment", + ).ask() + if experiment == "Create New Experiment": + typer.echo("Creating new experiment") + exp_name = typer.prompt( + "Experiment name :", default="Code Carbon user test" + ) + exp_description = typer.prompt( + "Experiment description :", + default="Code Carbon user test", + ) + + exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") + if exp_on_cloud is True: + cloud_provider = typer.prompt( + "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" + ) + cloud_region = typer.prompt( + "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" + ) + else: + cloud_provider = None + cloud_region = None + country_name = typer.prompt("Country name :", default="France") + country_iso_code = typer.prompt("Country ISO code :", default="FRA") + region = typer.prompt("Region :", default="france") + experiment_create = ExperimentCreate( + timestamp=get_datetime_with_timezone(), + name=exp_name, + description=exp_description, + on_cloud=exp_on_cloud, + project_id=project["id"], + country_name=country_name, + country_iso_code=country_iso_code, + region=region, + cloud_provider=cloud_provider, + cloud_region=cloud_region, + ) + experiment_id = api.add_experiment(experiment=experiment_create) + + else: + experiment_id = [e for e in experiments if e["name"] == experiment][0]["id"] + + write_to_config = Confirm.ask( + "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" + ) + + if write_to_config is True: write_local_exp_id(experiment_id) new_local = True - - click.echo( - "\nWelcome to CodeCarbon, here is your experiment id:\n" + typer.echo( + "\nCodeCarbon Initialization achieved, here is your experiment id:\n" + click.style(f"{experiment_id}", fg="bright_green") + ( "" @@ -53,32 +211,36 @@ def init(): ) if new_local: click.echo( - "\nCodeCarbon automatically added this id to your local config: " + "\nCodeCarbon added this id to your local config: " + click.style("./.codecarbon.config", fg="bright_blue") + "\n" ) -@codecarbon.command( - "monitor", short_help="Run an infinite loop to monitor this machine." -) -@click.option( - "--measure_power_secs", default=10, help="Interval between two measures. (10)" -) -@click.option( - "--api_call_interval", - default=30, - help="Number of measures before calling API. (30).", -) -@click.option( - "--api/--no-api", default=True, help="Choose to call Code Carbon API or not. (yes)" -) -def monitor(measure_power_secs, api_call_interval, api): +@codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") +def monitor( + measure_power_secs: Annotated[ + int, typer.Argument(help="Interval between two measures.") + ] = 10, + api_call_interval: Annotated[ + int, typer.Argument(help="Number of measures between API calls.") + ] = 30, + api: Annotated[ + bool, typer.Option(help="Choose to call Code Carbon API or not") + ] = True, +): + """Monitor your machine's carbon emissions. + + Args: + measure_power_secs (Annotated[int, typer.Argument, optional): Interval between two measures. Defaults to 10. + api_call_interval (Annotated[int, typer.Argument, optional): Number of measures before calling API. Defaults to 30. + api (Annotated[bool, typer.Option, optional): Choose to call Code Carbon API or not. Defaults to True. + """ experiment_id = get_existing_local_exp_id() if api and experiment_id is None: - click.echo("ERROR: No experiment id, call 'codecarbon init' first.") + typer.echo("ERROR: No experiment id, call 'codecarbon init' first.") sys.exit(1) - click.echo("CodeCarbon is going in an infinite loop to monitor this machine.") + typer.echo("CodeCarbon is going in an infinite loop to monitor this machine.") with EmissionsTracker( measure_power_secs=measure_power_secs, api_call_interval=api_call_interval, @@ -87,3 +249,7 @@ def monitor(measure_power_secs, api_call_interval, api): # Infinite loop while True: time.sleep(300) + + +if __name__ == "__main__": + codecarbon() From 13068790d7ae0e8edb4356aae5c644f9cefef322 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Tue, 12 Dec 2023 09:12:04 +0100 Subject: [PATCH 13/32] feat(core): :sparkles: add Organisation Team and Project to APi Client allows to create and list organisations, projects and teams from the CLI --- codecarbon/core/api_client.py | 117 ++++++++++++++++++++++++++++++++-- codecarbon/core/schemas.py | 44 +++++++++++++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 15539d8b6..ca89f27aa 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -14,7 +14,14 @@ import arrow import requests -from codecarbon.core.schemas import EmissionCreate, ExperimentCreate, RunCreate +from codecarbon.core.schemas import ( + EmissionCreate, + ExperimentCreate, + OrganizationCreate, + ProjectCreate, + RunCreate, + TeamCreate, +) from codecarbon.external.logger import logger # from codecarbon.output import EmissionsData @@ -53,6 +60,88 @@ def __init__( if self.experiment_id is not None: self._create_run(self.experiment_id) + def get_list_organizations(self): + """ + List all organizations + """ + url = self.url + "/organizations" + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def create_organization(self, organization: OrganizationCreate): + """ + Create an organization + """ + payload = dataclasses.asdict(organization) + url = self.url + "/organization" + r = requests.post(url=url, json=payload, timeout=2) + if r.status_code != 201: + self._log_error(url, payload, r) + return None + return r.json() + + def get_organization(self, organization_id): + """ + Get an organization + """ + url = self.url + "/organization/" + organization_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def list_teams_from_organization(self, organization_id): + """ + List all teams + """ + url = ( + self.url + "/teams/organization/" + organization_id + ) # TODO : check if this is the right url + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def create_team(self, team: TeamCreate): + """ + Create a team + """ + payload = dataclasses.asdict(team) + url = self.url + "/team" + r = requests.post(url=url, json=payload, timeout=2) + if r.status_code != 201: + self._log_error(url, payload, r) + return None + return r.json() + + def list_projects_from_team(self, team_id): + """ + List all projects + """ + url = self.url + "/projects/team/" + team_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def create_project(self, project: ProjectCreate): + """ + Create a project + """ + payload = dataclasses.asdict(project) + url = self.url + "/project" + r = requests.post(url=url, json=payload, timeout=2) + if r.status_code != 201: + self._log_error(url, payload, r) + return None + return r.json() + def add_emission(self, carbon_emission: dict): assert self.experiment_id is not None self._previous_call = time.time() @@ -149,6 +238,23 @@ def _create_run(self, experiment_id): except Exception as e: logger.error(e, exc_info=True) + def list_experiments_from_project(self, project_id: str): + """ + List all experiments for a project + """ + url = self.url + "/experiments/project/" + project_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + + def set_experiment(self, experiment_id: str): + """ + Set the experiment id + """ + self.experiment_id = experiment_id + def add_experiment(self, experiment: ExperimentCreate): """ Create an experiment, used by the CLI, not the package. @@ -164,9 +270,12 @@ def add_experiment(self, experiment: ExperimentCreate): return self.experiment_id def _log_error(self, url, payload, response): - logger.error( - f"ApiClient Error when calling the API on {url} with : {json.dumps(payload)}" - ) + if len(payload) > 0: + logger.error( + f"ApiClient Error when calling the API on {url} with : {json.dumps(payload)}" + ) + else: + logger.error(f"ApiClient Error when calling the API on {url}") logger.error( f"ApiClient API return http code {response.status_code} and answer : {response.text}" ) diff --git a/codecarbon/core/schemas.py b/codecarbon/core/schemas.py index 2c6043d8d..d8969dfa1 100644 --- a/codecarbon/core/schemas.py +++ b/codecarbon/core/schemas.py @@ -79,3 +79,47 @@ class ExperimentCreate(ExperimentBase): class Experiment(ExperimentBase): id: str + + +@dataclass +class OrganizationBase: + name: str + description: str + + +class OrganizationCreate(OrganizationBase): + pass + + +class Organization(OrganizationBase): + id: str + + +@dataclass +class TeamBase: + name: str + description: str + organization_id: str + + +class TeamCreate(TeamBase): + pass + + +class Team(TeamBase): + id: str + + +@dataclass +class ProjectBase: + name: str + description: str + team_id: str + + +class ProjectCreate(ProjectBase): + pass + + +class Project(ProjectBase): + id: str From cd7afe8735ddbf651b697e4bbb420013eab276bf Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Tue, 12 Dec 2023 09:12:41 +0100 Subject: [PATCH 14/32] build: :arrow_up: add new dependencies for cli --- codecarbon/__init__.py | 1 + setup.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/codecarbon/__init__.py b/codecarbon/__init__.py index 23a5aff39..f602f2635 100644 --- a/codecarbon/__init__.py +++ b/codecarbon/__init__.py @@ -10,3 +10,4 @@ ) __all__ = ["EmissionsTracker", "OfflineEmissionsTracker", "track_emissions"] +__app_name__ = "codecarbon" diff --git a/setup.py b/setup.py index 37b607519..52308f51a 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,9 @@ "py-cpuinfo", "rapidfuzz", "click", + "typer", + "questionary", + "rich", "prometheus_client", ] From b52edabdf9479098b26367eed0db654bc25e8fc8 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sat, 13 Jan 2024 17:34:47 +0100 Subject: [PATCH 15/32] fix(cli): :art: add a questionary prompt function allows easier testing --- codecarbon/cli/main.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 910acab5c..d7dc28f85 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -72,10 +72,11 @@ def init(): ) api = ApiClient(endpoint_url=api_endpoint) organizations = api.get_list_organizations() - org = questionary.select( + org = questionary_prompt( "Pick existing organization from list or Create new organization ?", [org["name"] for org in organizations] + ["Create New Organization"], - ).ask() + default="Create New Organization", + ) if org == "Create New Organization": org_name = typer.prompt( @@ -102,10 +103,11 @@ def init(): organization = [orga for orga in organizations if orga["name"] == org][0] teams = api.list_teams_from_organization(organization["id"]) - team = questionary.select( + team = questionary_prompt( "Pick existing team from list or create new team in organization ?", [team["name"] for team in teams] + ["Create New Team"], - ).ask() + default="Create New Team", + ) if team == "Create New Team": team_name = typer.prompt("Team name", default="Code Carbon user test") team_description = typer.prompt( @@ -123,11 +125,11 @@ def init(): else: team = [t for t in teams if t["name"] == team][0] projects = api.list_projects_from_team(team["id"]) - project = questionary.select( + project = questionary_prompt( "Pick existing project from list or Create new project ?", [project["name"] for project in projects] + ["Create New Project"], default="Create New Project", - ).ask() + ) if project == "Create New Project": project_name = typer.prompt("Project name", default="Code Carbon user test") project_description = typer.prompt( @@ -144,12 +146,12 @@ def init(): project = [p for p in projects if p["name"] == project][0] experiments = api.list_experiments_from_project(project["id"]) - experiment = questionary.select( + experiment = questionary_prompt( "Pick existing experiment from list or Create new experiment ?", [experiment["name"] for experiment in experiments] + ["Create New Experiment"], default="Create New Experiment", - ).ask() + ) if experiment == "Create New Experiment": typer.echo("Creating new experiment") exp_name = typer.prompt( @@ -157,7 +159,7 @@ def init(): ) exp_description = typer.prompt( "Experiment description :", - default="Code Carbon user test", + default="Code Carbon user test ", ) exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") @@ -191,13 +193,13 @@ def init(): else: experiment_id = [e for e in experiments if e["name"] == experiment][0]["id"] - write_to_config = Confirm.ask( - "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" - ) + write_to_config = Confirm.ask( + "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" + ) - if write_to_config is True: - write_local_exp_id(experiment_id) - new_local = True + if write_to_config is True: + write_local_exp_id(experiment_id) + new_local = True typer.echo( "\nCodeCarbon Initialization achieved, here is your experiment id:\n" + click.style(f"{experiment_id}", fg="bright_green") @@ -251,5 +253,14 @@ def monitor( time.sleep(300) +def questionary_prompt(prompt, list_options, default): + value = questionary.select( + prompt, + list_options, + default, + ).ask() + return value + + if __name__ == "__main__": codecarbon() From 3be900514d8ee7bc1697aa23fa72fc814e04896d Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sat, 13 Jan 2024 17:35:03 +0100 Subject: [PATCH 16/32] test(cli): :white_check_mark: pass tests CLI --- tests/test_cli.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..a6e3d6db0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,95 @@ +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from codecarbon import __app_name__, __version__ +from codecarbon.cli.main import codecarbon + +# MOCK API CLIENT + + +@patch("codecarbon.cli.main.ApiClient") +class TestApp(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.mock_api_client = MagicMock() + self.mock_api_client.get_list_organizations.return_value = [ + {"id": 1, "name": "test org Code Carbon"} + ] + self.mock_api_client.list_teams_from_organization.return_value = [ + {"id": 1, "name": "test team Code Carbon"} + ] + + self.mock_api_client.list_projects_from_team.return_value = [ + {"id": 1, "name": "test project Code Carbon"} + ] + self.mock_api_client.list_experiments_from_project.return_value = [ + {"id": 1, "name": "test experiment Code Carbon"} + ] + self.mock_api_client.create_organization.return_value = { + "id": 1, + "name": "test org Code Carbon", + } + self.mock_api_client.create_team.return_value = { + "id": 1, + "name": "test team Code Carbon", + } + self.mock_api_client.create_project.return_value = { + "id": 1, + "name": "test project Code Carbon", + } + self.mock_api_client.create_experiment.return_value = { + "id": 1, + "name": "test experiment Code Carbon", + } + + def test_app(self, MockApiClient): + result = self.runner.invoke(codecarbon, ["--version"]) + self.assertEqual(result.exit_code, 0) + self.assertIn(__app_name__, result.stdout) + self.assertIn(__version__, result.stdout) + + def test_init_aborted(self, MockApiClient): + result = self.runner.invoke(codecarbon, ["init"]) + self.assertEqual(result.exit_code, 1) + self.assertIn("Welcome to CodeCarbon configuration wizard", result.stdout) + + def test_init_use_local(self, MockApiClient): + result = self.runner.invoke(codecarbon, ["init"], input="y") + self.assertEqual(result.exit_code, 0) + self.assertIn( + "CodeCarbon Initialization achieved, here is your experiment id:", + result.stdout, + ) + self.assertIn("(from ./.codecarbon.config)", result.stdout) + + def custom_questionary_side_effect(*args, **kwargs): + default_value = kwargs.get("default") + return MagicMock(return_value=default_value) + + @patch("codecarbon.cli.main.Confirm.ask") + @patch("codecarbon.cli.main.questionary_prompt") + def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): + MockApiClient.return_value = self.mock_api_client + mock_prompt.side_effect = [ + "Create New Organization", + "Create New Team", + "Create New Project", + "Create New Experiment", + ] + mock_confirm.side_effect = [False, False, False] + result = self.runner.invoke( + codecarbon, + ["init"], + input="n", + ) + self.assertEqual(result.exit_code, 0) + self.assertIn( + "CodeCarbon Initialization achieved, here is your experiment id:", + result.stdout, + ) + + +if __name__ == "__main__": + unittest.main() From 2e67fec298e616fd91a8d97db9810ea38bc4bbf9 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sun, 3 Mar 2024 16:30:34 +0100 Subject: [PATCH 17/32] feat(cli): :sparkles: add more functionalities to python API client and CLI --- codecarbon/cli/cli_utils.py | 38 +++- codecarbon/cli/main.py | 326 +++++++++++++++++++--------------- codecarbon/core/api_client.py | 39 +++- tests/test_cli.py | 34 ++-- 4 files changed, 263 insertions(+), 174 deletions(-) diff --git a/codecarbon/cli/cli_utils.py b/codecarbon/cli/cli_utils.py index 3539c12cf..015bc1379 100644 --- a/codecarbon/cli/cli_utils.py +++ b/codecarbon/cli/cli_utils.py @@ -1,9 +1,20 @@ import configparser from pathlib import Path +from typing import Optional -def get_api_endpoint(): - p = Path.cwd().resolve() / ".codecarbon.config" +def get_config(path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" + if p.exists(): + config = configparser.ConfigParser() + config.read(str(p)) + if "codecarbon" in config.sections(): + d = dict(config["codecarbon"]) + return d + + +def get_api_endpoint(path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" if p.exists(): config = configparser.ConfigParser() config.read(str(p)) @@ -14,8 +25,8 @@ def get_api_endpoint(): return "https://api.codecarbon.io" -def get_existing_local_exp_id(): - p = Path.cwd().resolve() / ".codecarbon.config" +def get_existing_local_exp_id(path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" if p.exists(): config = configparser.ConfigParser() config.read(str(p)) @@ -25,8 +36,9 @@ def get_existing_local_exp_id(): return d["experiment_id"] -def write_local_exp_id(exp_id): - p = Path.cwd().resolve() / ".codecarbon.config" +def write_local_exp_id(exp_id, path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" + config = configparser.ConfigParser() if p.exists(): config.read(str(p)) @@ -37,3 +49,17 @@ def write_local_exp_id(exp_id): with p.open("w") as f: config.write(f) + + +def overwrite_local_config(config_name, value, path: Optional[Path] = None): + p = path or Path.cwd().resolve() / ".codecarbon.config" + + config = configparser.ConfigParser() + if p.exists(): + config.read(str(p)) + if "codecarbon" not in config.sections(): + config.add_section("codecarbon") + + config["codecarbon"][config_name] = value + with p.open("w") as f: + config.write(f) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index d7dc28f85..2c4a35127 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -2,17 +2,18 @@ import time from typing import Optional -import click import questionary import typer +from rich import print from rich.prompt import Confirm from typing_extensions import Annotated from codecarbon import __app_name__, __version__ from codecarbon.cli.cli_utils import ( get_api_endpoint, + get_config, get_existing_local_exp_id, - write_local_exp_id, + overwrite_local_config, ) from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone from codecarbon.core.schemas import ( @@ -49,174 +50,202 @@ def main( return -@codecarbon.command("init", short_help="Create an experiment id in a public project.") -def init(): +def show_config(): + d = get_config() + api_endpoint = get_api_endpoint() + api = ApiClient(endpoint_url=api_endpoint) + try: + org = api.get_organization(d["organization_id"]) + team = api.get_team(d["team_id"]) + project = api.get_project(d["project_id"]) + experiment = api.get_experiment(d["experiment_id"]) + print( + "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n " + ) + print("Experiment: \n ") + print(experiment) + print("Project: \n") + print(project) + print("Team: \n") + print(team) + print("Organization: \n") + print(org) + except: + raise ValueError( + "Your configuration is invalid, please run `codecarbon config --init` first!" + ) + + +@codecarbon.command("config", short_help="Generate or show config") +def config( + init: Annotated[ + bool, typer.Option(help="Initialise or modify configuration") + ] = None, + show: Annotated[bool, typer.Option(help="Show configuration details")] = None, +): """ Initialize CodeCarbon, this will prompt you for configuration of Organisation/Team/Project/Experiment. """ - typer.echo("Welcome to CodeCarbon configuration wizard") - use_config = Confirm.ask( - "Use existing /.codecarbonconfig to configure ?", - ) - if use_config is True: - experiment_id = get_existing_local_exp_id() - else: - experiment_id = None - new_local = False - if experiment_id is None: - api_endpoint = get_api_endpoint() - api_endpoint = typer.prompt( - f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", - type=str, - default=api_endpoint, - ) - api = ApiClient(endpoint_url=api_endpoint) - organizations = api.get_list_organizations() - org = questionary_prompt( - "Pick existing organization from list or Create new organization ?", - [org["name"] for org in organizations] + ["Create New Organization"], - default="Create New Organization", + if show: + show_config() + elif init: + typer.echo("Welcome to CodeCarbon configuration wizard") + use_config = questionary_prompt( + "Use existing /.codecarbonconfig to configure or overwrite ? ", + ["/.codecarbonconfig", "Create New Config"], + default="/.codecarbonconfig", ) - if org == "Create New Organization": - org_name = typer.prompt( - "Organization name", default="Code Carbon user test" + if use_config == "/.codecarbonconfig": + typer.echo("Using existing config file :") + show_config() + pass + + else: + typer.echo("Creating new config file") + api_endpoint = get_api_endpoint() + api_endpoint = typer.prompt( + f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", + type=str, + default=api_endpoint, ) - org_description = typer.prompt( - "Organization description", default="Code Carbon user test" + api = ApiClient(endpoint_url=api_endpoint) + organizations = api.get_list_organizations() + org = questionary_prompt( + "Pick existing organization from list or Create new organization ?", + [org["name"] for org in organizations] + ["Create New Organization"], + default="Create New Organization", ) - if org_name in organizations: - typer.echo( - f"Organization {org_name} already exists, using it for this experiment." + + if org == "Create New Organization": + org_name = typer.prompt( + "Organization name", default="Code Carbon user test" ) + org_description = typer.prompt( + "Organization description", default="Code Carbon user test" + ) + if org_name in organizations: + typer.echo( + f"Organization {org_name} already exists, using it for this experiment." + ) + organization = [ + orga for orga in organizations if orga["name"] == org + ][0] + else: + organization_create = OrganizationCreate( + name=org_name, + description=org_description, + ) + organization = api.create_organization( + organization=organization_create + ) + typer.echo(f"Created organization : {organization}") + else: organization = [orga for orga in organizations if orga["name"] == org][ 0 ] - else: - organization_create = OrganizationCreate( - name=org_name, - description=org_description, - ) - organization = api.create_organization(organization=organization_create) - typer.echo(f"Created organization : {organization}") - else: - organization = [orga for orga in organizations if orga["name"] == org][0] - teams = api.list_teams_from_organization(organization["id"]) + overwrite_local_config("organization_id", organization["id"]) + teams = api.list_teams_from_organization(organization["id"]) - team = questionary_prompt( - "Pick existing team from list or create new team in organization ?", - [team["name"] for team in teams] + ["Create New Team"], - default="Create New Team", - ) - if team == "Create New Team": - team_name = typer.prompt("Team name", default="Code Carbon user test") - team_description = typer.prompt( - "Team description", default="Code Carbon user test" - ) - team_create = TeamCreate( - name=team_name, - description=team_description, - organization_id=organization["id"], - ) - team = api.create_team( - team=team_create, - ) - typer.echo(f"Created team : {team}") - else: - team = [t for t in teams if t["name"] == team][0] - projects = api.list_projects_from_team(team["id"]) - project = questionary_prompt( - "Pick existing project from list or Create new project ?", - [project["name"] for project in projects] + ["Create New Project"], - default="Create New Project", - ) - if project == "Create New Project": - project_name = typer.prompt("Project name", default="Code Carbon user test") - project_description = typer.prompt( - "Project description", default="Code Carbon user test" - ) - project_create = ProjectCreate( - name=project_name, - description=project_description, - team_id=team["id"], + team = questionary_prompt( + "Pick existing team from list or create new team in organization ?", + [team["name"] for team in teams] + ["Create New Team"], + default="Create New Team", ) - project = api.create_project(project=project_create) - typer.echo(f"Created project : {project}") - else: - project = [p for p in projects if p["name"] == project][0] + if team == "Create New Team": + team_name = typer.prompt("Team name", default="Code Carbon user test") + team_description = typer.prompt( + "Team description", default="Code Carbon user test" + ) + team_create = TeamCreate( + name=team_name, + description=team_description, + organization_id=organization["id"], + ) + team = api.create_team( + team=team_create, + ) + typer.echo(f"Created team : {team}") + else: + team = [t for t in teams if t["name"] == team][0] + overwrite_local_config("team_id", team["id"]) - experiments = api.list_experiments_from_project(project["id"]) - experiment = questionary_prompt( - "Pick existing experiment from list or Create new experiment ?", - [experiment["name"] for experiment in experiments] - + ["Create New Experiment"], - default="Create New Experiment", - ) - if experiment == "Create New Experiment": - typer.echo("Creating new experiment") - exp_name = typer.prompt( - "Experiment name :", default="Code Carbon user test" - ) - exp_description = typer.prompt( - "Experiment description :", - default="Code Carbon user test ", + projects = api.list_projects_from_team(team["id"]) + project = questionary_prompt( + "Pick existing project from list or Create new project ?", + [project["name"] for project in projects] + ["Create New Project"], + default="Create New Project", ) - - exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") - if exp_on_cloud is True: - cloud_provider = typer.prompt( - "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" + if project == "Create New Project": + project_name = typer.prompt( + "Project name", default="Code Carbon user test" ) - cloud_region = typer.prompt( - "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" + project_description = typer.prompt( + "Project description", default="Code Carbon user test" ) + project_create = ProjectCreate( + name=project_name, + description=project_description, + team_id=team["id"], + ) + project = api.create_project(project=project_create) + typer.echo(f"Created project : {project}") else: - cloud_provider = None - cloud_region = None - country_name = typer.prompt("Country name :", default="France") - country_iso_code = typer.prompt("Country ISO code :", default="FRA") - region = typer.prompt("Region :", default="france") - experiment_create = ExperimentCreate( - timestamp=get_datetime_with_timezone(), - name=exp_name, - description=exp_description, - on_cloud=exp_on_cloud, - project_id=project["id"], - country_name=country_name, - country_iso_code=country_iso_code, - region=region, - cloud_provider=cloud_provider, - cloud_region=cloud_region, + project = [p for p in projects if p["name"] == project][0] + overwrite_local_config("project_id", project["id"]) + + experiments = api.list_experiments_from_project(project["id"]) + experiment = questionary_prompt( + "Pick existing experiment from list or Create new experiment ?", + [experiment["name"] for experiment in experiments] + + ["Create New Experiment"], + default="Create New Experiment", ) - experiment_id = api.add_experiment(experiment=experiment_create) + if experiment == "Create New Experiment": + typer.echo("Creating new experiment") + exp_name = typer.prompt( + "Experiment name :", default="Code Carbon user test" + ) + exp_description = typer.prompt( + "Experiment description :", + default="Code Carbon user test ", + ) - else: - experiment_id = [e for e in experiments if e["name"] == experiment][0]["id"] + exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") + if exp_on_cloud is True: + cloud_provider = typer.prompt( + "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" + ) + cloud_region = typer.prompt( + "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" + ) + else: + cloud_provider = None + cloud_region = None + country_name = typer.prompt("Country name :", default="France") + country_iso_code = typer.prompt("Country ISO code :", default="FRA") + region = typer.prompt("Region :", default="france") + experiment_create = ExperimentCreate( + timestamp=get_datetime_with_timezone(), + name=exp_name, + description=exp_description, + on_cloud=exp_on_cloud, + project_id=project["id"], + country_name=country_name, + country_iso_code=country_iso_code, + region=region, + cloud_provider=cloud_provider, + cloud_region=cloud_region, + ) + experiment_id = api.create_experiment(experiment=experiment_create) - write_to_config = Confirm.ask( - "Write experiment_id to /.codecarbonconfig ? (Press enter to continue)" - ) + else: + experiment_id = [e for e in experiments if e["name"] == experiment][0][ + "id" + ] - if write_to_config is True: - write_local_exp_id(experiment_id) - new_local = True - typer.echo( - "\nCodeCarbon Initialization achieved, here is your experiment id:\n" - + click.style(f"{experiment_id}", fg="bright_green") - + ( - "" - if new_local - else " (from " - + click.style("./.codecarbon.config", fg="bright_blue") - + ")\n" - ) - ) - if new_local: - click.echo( - "\nCodeCarbon added this id to your local config: " - + click.style("./.codecarbon.config", fg="bright_blue") - + "\n" - ) + overwrite_local_config("experiment_id", experiment_id["id"]) + show_config() @codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") @@ -264,3 +293,4 @@ def questionary_prompt(prompt, list_options, default): if __name__ == "__main__": codecarbon() + codecarbon() diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index ca89f27aa..2727a3d33 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -98,9 +98,7 @@ def list_teams_from_organization(self, organization_id): """ List all teams """ - url = ( - self.url + "/teams/organization/" + organization_id - ) # TODO : check if this is the right url + url = self.url + "/teams/organization/" + organization_id r = requests.get(url=url, timeout=2) if r.status_code != 200: self._log_error(url, {}, r) @@ -119,6 +117,17 @@ def create_team(self, team: TeamCreate): return None return r.json() + def get_team(self, team_id): + """ + Get a team + """ + url = self.url + "/team/" + team_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + def list_projects_from_team(self, team_id): """ List all projects @@ -142,6 +151,17 @@ def create_project(self, project: ProjectCreate): return None return r.json() + def get_project(self, project_id): + """ + Get a project + """ + url = self.url + "/project/" + project_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + def add_emission(self, carbon_emission: dict): assert self.experiment_id is not None self._previous_call = time.time() @@ -255,7 +275,7 @@ def set_experiment(self, experiment_id: str): """ self.experiment_id = experiment_id - def add_experiment(self, experiment: ExperimentCreate): + def create_experiment(self, experiment: ExperimentCreate): """ Create an experiment, used by the CLI, not the package. ::experiment:: The experiment to create. @@ -269,6 +289,17 @@ def add_experiment(self, experiment: ExperimentCreate): self.experiment_id = r.json()["id"] return self.experiment_id + def get_experiment(self, experiment_id): + """ + Get an experiment by id + """ + url = self.url + "/experiment/" + experiment_id + r = requests.get(url=url, timeout=2) + if r.status_code != 200: + self._log_error(url, {}, r) + return None + return r.json() + def _log_error(self, url, payload, response): if len(payload) > 0: logger.error( diff --git a/tests/test_cli.py b/tests/test_cli.py index a6e3d6db0..7d39b0b4b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,32 +15,32 @@ def setUp(self): self.runner = CliRunner() self.mock_api_client = MagicMock() self.mock_api_client.get_list_organizations.return_value = [ - {"id": 1, "name": "test org Code Carbon"} + {"id": "1", "name": "test org Code Carbon"} ] self.mock_api_client.list_teams_from_organization.return_value = [ - {"id": 1, "name": "test team Code Carbon"} + {"id": "1", "name": "test team Code Carbon"} ] self.mock_api_client.list_projects_from_team.return_value = [ - {"id": 1, "name": "test project Code Carbon"} + {"id": "1", "name": "test project Code Carbon"} ] self.mock_api_client.list_experiments_from_project.return_value = [ - {"id": 1, "name": "test experiment Code Carbon"} + {"id": "1", "name": "test experiment Code Carbon"} ] self.mock_api_client.create_organization.return_value = { - "id": 1, + "id": "1", "name": "test org Code Carbon", } self.mock_api_client.create_team.return_value = { - "id": 1, + "id": "1", "name": "test team Code Carbon", } self.mock_api_client.create_project.return_value = { - "id": 1, + "id": "1", "name": "test project Code Carbon", } self.mock_api_client.create_experiment.return_value = { - "id": 1, + "id": "1", "name": "test experiment Code Carbon", } @@ -51,18 +51,19 @@ def test_app(self, MockApiClient): self.assertIn(__version__, result.stdout) def test_init_aborted(self, MockApiClient): - result = self.runner.invoke(codecarbon, ["init"]) + result = self.runner.invoke(codecarbon, ["config", "--init"]) self.assertEqual(result.exit_code, 1) self.assertIn("Welcome to CodeCarbon configuration wizard", result.stdout) - def test_init_use_local(self, MockApiClient): - result = self.runner.invoke(codecarbon, ["init"], input="y") + @patch("codecarbon.cli.main.questionary_prompt") + def test_init_use_local(self, mock_prompt, MockApiClient): + mock_prompt.return_value = "/.codecarbonconfig" + result = self.runner.invoke(codecarbon, ["config", "--init"], input="y") self.assertEqual(result.exit_code, 0) self.assertIn( - "CodeCarbon Initialization achieved, here is your experiment id:", + "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n ", result.stdout, ) - self.assertIn("(from ./.codecarbon.config)", result.stdout) def custom_questionary_side_effect(*args, **kwargs): default_value = kwargs.get("default") @@ -73,6 +74,7 @@ def custom_questionary_side_effect(*args, **kwargs): def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): MockApiClient.return_value = self.mock_api_client mock_prompt.side_effect = [ + "Create New Config", "Create New Organization", "Create New Team", "Create New Project", @@ -81,12 +83,12 @@ def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): mock_confirm.side_effect = [False, False, False] result = self.runner.invoke( codecarbon, - ["init"], - input="n", + ["config", "--init"], + input="y", ) self.assertEqual(result.exit_code, 0) self.assertIn( - "CodeCarbon Initialization achieved, here is your experiment id:", + "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n ", result.stdout, ) From c09d39bac1bc7e34f755d06622bac6953ebc88e9 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Sun, 3 Mar 2024 16:31:46 +0100 Subject: [PATCH 18/32] feat(core): :sparkles: allows picking up API endpoint from conf file for dashboard --- dashboard/data/data_loader.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dashboard/data/data_loader.py b/dashboard/data/data_loader.py index e19d66000..15721e8dd 100644 --- a/dashboard/data/data_loader.py +++ b/dashboard/data/data_loader.py @@ -7,10 +7,16 @@ import requests +from codecarbon.core.config import get_hierarchical_config + API_PATH = os.getenv("CODECARBON_API_URL") if API_PATH is None: + conf = get_hierarchical_config() + if "api_endpoint" in conf: + API_PATH = conf["api_endpoint"] # API_PATH = "http://carbonserver.cleverapps.io" - API_PATH = "https://api.codecarbon.io" + else: + API_PATH = "https://api.codecarbon.io" # API_PATH = "http://localhost:8008" # export CODECARBON_API_URL=http://localhost:8008 # API_PATH = "http://carbonserver.cleverapps.io" USER = "jessica" @@ -217,3 +223,4 @@ def load_run_infos(run_id: str, **kwargs) -> tuple: """ path = f"{API_PATH}/run/{run_id}" return path, kwargs + return path, kwargs From f86a666f0d74be0abd577fca9e1d580af4244e07 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 10:52:54 +0100 Subject: [PATCH 19/32] feat(cli): :sparkles: allow to create a new custom configuration file anywhere --- codecarbon/cli/cli_utils.py | 1 + codecarbon/cli/main.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/codecarbon/cli/cli_utils.py b/codecarbon/cli/cli_utils.py index 015bc1379..727356bc7 100644 --- a/codecarbon/cli/cli_utils.py +++ b/codecarbon/cli/cli_utils.py @@ -5,6 +5,7 @@ def get_config(path: Optional[Path] = None): p = path or Path.cwd().resolve() / ".codecarbon.config" + if p.exists(): config = configparser.ConfigParser() config.read(str(p)) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 2c4a35127..cdb727e1e 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -1,5 +1,6 @@ import sys import time +from pathlib import Path from typing import Optional import questionary @@ -98,11 +99,23 @@ def config( if use_config == "/.codecarbonconfig": typer.echo("Using existing config file :") + file_path = Path("/.codecarbonconfig") show_config() pass else: typer.echo("Creating new config file") + file_path = typer.prompt( + "Where do you want to put your config file ?", + type=str, + default="/.codecarbonconfig", + ) + file_path = Path(file_path) + if not file_path.parent.exists(): + Confirm.ask( + "Parent folder does not exist do you want to create it (and parents) ?" + ) + api_endpoint = get_api_endpoint() api_endpoint = typer.prompt( f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", @@ -144,7 +157,9 @@ def config( organization = [orga for orga in organizations if orga["name"] == org][ 0 ] - overwrite_local_config("organization_id", organization["id"]) + overwrite_local_config( + "organization_id", organization["id"], file_path=file_path + ) teams = api.list_teams_from_organization(organization["id"]) team = questionary_prompt( @@ -244,7 +259,7 @@ def config( "id" ] - overwrite_local_config("experiment_id", experiment_id["id"]) + overwrite_local_config("experiment_id", experiment_id) show_config() From fd906dfa276694a14a76554d7f2c14943829cba4 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 12:39:52 +0100 Subject: [PATCH 20/32] feat(CLI): :sparkles: allow to use or modify existing config file or create one from cli --- codecarbon/cli/cli_utils.py | 34 +++- codecarbon/cli/main.py | 369 ++++++++++++++++++---------------- codecarbon/core/api_client.py | 3 +- tests/test_cli.py | 14 +- 4 files changed, 236 insertions(+), 184 deletions(-) diff --git a/codecarbon/cli/cli_utils.py b/codecarbon/cli/cli_utils.py index 727356bc7..82b58a205 100644 --- a/codecarbon/cli/cli_utils.py +++ b/codecarbon/cli/cli_utils.py @@ -2,6 +2,9 @@ from pathlib import Path from typing import Optional +import typer +from rich.prompt import Confirm + def get_config(path: Optional[Path] = None): p = path or Path.cwd().resolve() / ".codecarbon.config" @@ -11,7 +14,12 @@ def get_config(path: Optional[Path] = None): config.read(str(p)) if "codecarbon" in config.sections(): d = dict(config["codecarbon"]) - return d + return d + + else: + raise FileNotFoundError( + "No .codecarbon.config file found in the current directory." + ) def get_api_endpoint(path: Optional[Path] = None): @@ -23,6 +31,9 @@ def get_api_endpoint(path: Optional[Path] = None): d = dict(config["codecarbon"]) if "api_endpoint" in d: return d["api_endpoint"] + else: + with p.open("a") as f: + f.write("api_endpoint=https://api.codecarbon.io\n") return "https://api.codecarbon.io" @@ -64,3 +75,24 @@ def overwrite_local_config(config_name, value, path: Optional[Path] = None): config["codecarbon"][config_name] = value with p.open("w") as f: config.write(f) + + +def create_new_config_file(): + typer.echo("Creating new config file") + file_path = typer.prompt( + "Where do you want to put your config file ?", + type=str, + default="./.codecarbon.config", + ) + file_path = Path(file_path) + if not file_path.parent.exists(): + create = Confirm.ask( + "Parent folder does not exist do you want to create it (and parents) ?" + ) + if create: + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.touch() + with open(file_path, "w") as f: + f.write("[codecarbon]\n") + typer.echo(f"Config file created at {file_path}") + return file_path diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index cdb727e1e..b41d84a48 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -1,4 +1,3 @@ -import sys import time from pathlib import Path from typing import Optional @@ -11,6 +10,7 @@ from codecarbon import __app_name__, __version__ from codecarbon.cli.cli_utils import ( + create_new_config_file, get_api_endpoint, get_config, get_existing_local_exp_id, @@ -33,7 +33,7 @@ def _version_callback(value: bool) -> None: if value: - typer.echo(f"{__app_name__} v{__version__}") + print(f"{__app_name__} v{__version__}") raise typer.Exit() @@ -51,29 +51,54 @@ def main( return -def show_config(): - d = get_config() - api_endpoint = get_api_endpoint() +def show_config(path: Path = Path("./.codecarbon.config")) -> None: + d = get_config(path) + api_endpoint = get_api_endpoint(path) api = ApiClient(endpoint_url=api_endpoint) + print("Current configuration : \n") + print("Config file content : ") + print(d) try: - org = api.get_organization(d["organization_id"]) - team = api.get_team(d["team_id"]) - project = api.get_project(d["project_id"]) - experiment = api.get_experiment(d["experiment_id"]) - print( - "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n " - ) - print("Experiment: \n ") - print(experiment) - print("Project: \n") - print(project) - print("Team: \n") - print(team) - print("Organization: \n") - print(org) - except: + if "organization_id" not in d: + print( + "No organization_id in config, follow setup instruction to complete your configuration file!", + color="red", + ) + else: + org = api.get_organization(d["organization_id"]) + + if "team_id" not in d: + print( + "No team_id in config, follow setup instruction to complete your configuration file!", + color="red", + ) + else: + team = api.get_team(d["team_id"]) + if "project_id" not in d: + print( + "No project_id in config, follow setup instruction to complete your configuration file!", + color="red", + ) + else: + project = api.get_project(d["project_id"]) + if "experiment_id" not in d: + print( + "No experiment_id in config, follow setup instruction to complete your configuration file!", + color="red", + ) + else: + experiment = api.get_experiment(d["experiment_id"]) + print("\nExperiment :") + print(experiment) + print("\nProject :") + print(project) + print("\nTeam :") + print(team) + print("\nOrganization :") + print(org) + except Exception as e: raise ValueError( - "Your configuration is invalid, please run `codecarbon config --init` first!" + f"Your configuration is invalid, please run `codecarbon config --init` first! (error: {e})" ) @@ -90,177 +115,171 @@ def config( if show: show_config() elif init: - typer.echo("Welcome to CodeCarbon configuration wizard") - use_config = questionary_prompt( - "Use existing /.codecarbonconfig to configure or overwrite ? ", - ["/.codecarbonconfig", "Create New Config"], - default="/.codecarbonconfig", - ) + print("Welcome to CodeCarbon configuration wizard") + default_path = Path("./.codecarbon.config") - if use_config == "/.codecarbonconfig": - typer.echo("Using existing config file :") - file_path = Path("/.codecarbonconfig") - show_config() - pass + if default_path.exists(): + print("Existing config file found :") + show_config(default_path) - else: - typer.echo("Creating new config file") - file_path = typer.prompt( - "Where do you want to put your config file ?", - type=str, - default="/.codecarbonconfig", + use_config = questionary_prompt( + "Use existing ./.codecarbon.config to configure or create a new file somwhere else ? ", + ["./.codecarbon.config", "Create New Config"], + default="./.codecarbon.config", ) - file_path = Path(file_path) - if not file_path.parent.exists(): - Confirm.ask( - "Parent folder does not exist do you want to create it (and parents) ?" - ) - api_endpoint = get_api_endpoint() - api_endpoint = typer.prompt( - f"Default API endpoint is {api_endpoint}. You can change it in /.codecarbonconfig. Press enter to continue or input other url", - type=str, - default=api_endpoint, + if use_config == "./.codecarbon.config": + modify = Confirm.ask("Do you want to modify the existing config file ?") + if modify: + print(f"Modifying existing config file {default_path}:") + file_path = default_path + else: + print(f"Using already existing config file {default_path}") + return + else: + file_path = create_new_config_file() + else: + file_path = create_new_config_file() + + api_endpoint = get_api_endpoint(file_path) + api_endpoint = typer.prompt( + f"Current API endpoint is {api_endpoint}. Press enter to continue or input other url", + type=str, + default=api_endpoint, + ) + overwrite_local_config("api_endpoint", api_endpoint, path=file_path) + api = ApiClient(endpoint_url=api_endpoint) + organizations = api.get_list_organizations() + org = questionary_prompt( + "Pick existing organization from list or Create new organization ?", + [org["name"] for org in organizations] + ["Create New Organization"], + default="Create New Organization", + ) + + if org == "Create New Organization": + org_name = typer.prompt( + "Organization name", default="Code Carbon user test" ) - api = ApiClient(endpoint_url=api_endpoint) - organizations = api.get_list_organizations() - org = questionary_prompt( - "Pick existing organization from list or Create new organization ?", - [org["name"] for org in organizations] + ["Create New Organization"], - default="Create New Organization", + org_description = typer.prompt( + "Organization description", default="Code Carbon user test" ) - - if org == "Create New Organization": - org_name = typer.prompt( - "Organization name", default="Code Carbon user test" - ) - org_description = typer.prompt( - "Organization description", default="Code Carbon user test" + if org_name in organizations: + print( + f"Organization {org_name} already exists, using it for this experiment." ) - if org_name in organizations: - typer.echo( - f"Organization {org_name} already exists, using it for this experiment." - ) - organization = [ - orga for orga in organizations if orga["name"] == org - ][0] - else: - organization_create = OrganizationCreate( - name=org_name, - description=org_description, - ) - organization = api.create_organization( - organization=organization_create - ) - typer.echo(f"Created organization : {organization}") - else: organization = [orga for orga in organizations if orga["name"] == org][ 0 ] - overwrite_local_config( - "organization_id", organization["id"], file_path=file_path + else: + organization_create = OrganizationCreate( + name=org_name, + description=org_description, + ) + organization = api.create_organization(organization=organization_create) + print(f"Created organization : {organization}") + else: + organization = [orga for orga in organizations if orga["name"] == org][0] + overwrite_local_config("organization_id", organization["id"], path=file_path) + teams = api.list_teams_from_organization(organization["id"]) + + team = questionary_prompt( + "Pick existing team from list or create new team in organization ?", + [team["name"] for team in teams] + ["Create New Team"], + default="Create New Team", + ) + if team == "Create New Team": + team_name = typer.prompt("Team name", default="Code Carbon user test") + team_description = typer.prompt( + "Team description", default="Code Carbon user test" + ) + team_create = TeamCreate( + name=team_name, + description=team_description, + organization_id=organization["id"], ) - teams = api.list_teams_from_organization(organization["id"]) + team = api.create_team( + team=team_create, + ) + print(f"Created team : {team}") + else: + team = [t for t in teams if t["name"] == team][0] + overwrite_local_config("team_id", team["id"], path=file_path) - team = questionary_prompt( - "Pick existing team from list or create new team in organization ?", - [team["name"] for team in teams] + ["Create New Team"], - default="Create New Team", + projects = api.list_projects_from_team(team["id"]) + project = questionary_prompt( + "Pick existing project from list or Create new project ?", + [project["name"] for project in projects] + ["Create New Project"], + default="Create New Project", + ) + if project == "Create New Project": + project_name = typer.prompt("Project name", default="Code Carbon user test") + project_description = typer.prompt( + "Project description", default="Code Carbon user test" ) - if team == "Create New Team": - team_name = typer.prompt("Team name", default="Code Carbon user test") - team_description = typer.prompt( - "Team description", default="Code Carbon user test" - ) - team_create = TeamCreate( - name=team_name, - description=team_description, - organization_id=organization["id"], - ) - team = api.create_team( - team=team_create, - ) - typer.echo(f"Created team : {team}") - else: - team = [t for t in teams if t["name"] == team][0] - overwrite_local_config("team_id", team["id"]) + project_create = ProjectCreate( + name=project_name, + description=project_description, + team_id=team["id"], + ) + project = api.create_project(project=project_create) + print(f"Created project : {project}") + else: + project = [p for p in projects if p["name"] == project][0] + overwrite_local_config("project_id", project["id"], path=file_path) - projects = api.list_projects_from_team(team["id"]) - project = questionary_prompt( - "Pick existing project from list or Create new project ?", - [project["name"] for project in projects] + ["Create New Project"], - default="Create New Project", + experiments = api.list_experiments_from_project(project["id"]) + experiment = questionary_prompt( + "Pick existing experiment from list or Create new experiment ?", + [experiment["name"] for experiment in experiments] + + ["Create New Experiment"], + default="Create New Experiment", + ) + if experiment == "Create New Experiment": + print("Creating new experiment") + exp_name = typer.prompt( + "Experiment name :", default="Code Carbon user test" ) - if project == "Create New Project": - project_name = typer.prompt( - "Project name", default="Code Carbon user test" - ) - project_description = typer.prompt( - "Project description", default="Code Carbon user test" + exp_description = typer.prompt( + "Experiment description :", + default="Code Carbon user test ", + ) + + exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") + if exp_on_cloud is True: + cloud_provider = typer.prompt( + "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" ) - project_create = ProjectCreate( - name=project_name, - description=project_description, - team_id=team["id"], + cloud_region = typer.prompt( + "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" ) - project = api.create_project(project=project_create) - typer.echo(f"Created project : {project}") else: - project = [p for p in projects if p["name"] == project][0] - overwrite_local_config("project_id", project["id"]) - - experiments = api.list_experiments_from_project(project["id"]) - experiment = questionary_prompt( - "Pick existing experiment from list or Create new experiment ?", - [experiment["name"] for experiment in experiments] - + ["Create New Experiment"], - default="Create New Experiment", + cloud_provider = None + cloud_region = None + country_name = typer.prompt("Country name :", default="France") + country_iso_code = typer.prompt("Country ISO code :", default="FRA") + region = typer.prompt("Region :", default="france") + experiment_create = ExperimentCreate( + timestamp=get_datetime_with_timezone(), + name=exp_name, + description=exp_description, + on_cloud=exp_on_cloud, + project_id=project["id"], + country_name=country_name, + country_iso_code=country_iso_code, + region=region, + cloud_provider=cloud_provider, + cloud_region=cloud_region, ) - if experiment == "Create New Experiment": - typer.echo("Creating new experiment") - exp_name = typer.prompt( - "Experiment name :", default="Code Carbon user test" - ) - exp_description = typer.prompt( - "Experiment description :", - default="Code Carbon user test ", - ) - - exp_on_cloud = Confirm.ask("Is this experiment running on the cloud ?") - if exp_on_cloud is True: - cloud_provider = typer.prompt( - "Cloud provider (AWS, GCP, Azure, ...)", default="AWS" - ) - cloud_region = typer.prompt( - "Cloud region (eu-west-1, us-east-1, ...)", default="eu-west-1" - ) - else: - cloud_provider = None - cloud_region = None - country_name = typer.prompt("Country name :", default="France") - country_iso_code = typer.prompt("Country ISO code :", default="FRA") - region = typer.prompt("Region :", default="france") - experiment_create = ExperimentCreate( - timestamp=get_datetime_with_timezone(), - name=exp_name, - description=exp_description, - on_cloud=exp_on_cloud, - project_id=project["id"], - country_name=country_name, - country_iso_code=country_iso_code, - region=region, - cloud_provider=cloud_provider, - cloud_region=cloud_region, - ) - experiment_id = api.create_experiment(experiment=experiment_create) + experiment = api.create_experiment(experiment=experiment_create) - else: - experiment_id = [e for e in experiments if e["name"] == experiment][0][ - "id" - ] + else: + experiment = [e for e in experiments if e["name"] == experiment][0] - overwrite_local_config("experiment_id", experiment_id) - show_config() + overwrite_local_config("experiment_id", experiment["id"], path=file_path) + show_config(file_path) + print( + "Consult [link=https://mlco2.github.io/codecarbon/usage.html#configuration]configuration documentation[/link] for more configuration options" + ) @codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") @@ -284,9 +303,8 @@ def monitor( """ experiment_id = get_existing_local_exp_id() if api and experiment_id is None: - typer.echo("ERROR: No experiment id, call 'codecarbon init' first.") - sys.exit(1) - typer.echo("CodeCarbon is going in an infinite loop to monitor this machine.") + print("ERROR: No experiment id, call 'codecarbon init' first.", err=True) + print("CodeCarbon is going in an infinite loop to monitor this machine.") with EmissionsTracker( measure_power_secs=measure_power_secs, api_call_interval=api_call_interval, @@ -308,4 +326,3 @@ def questionary_prompt(prompt, list_options, default): if __name__ == "__main__": codecarbon() - codecarbon() diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 2727a3d33..db735ba2f 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -286,8 +286,7 @@ def create_experiment(self, experiment: ExperimentCreate): if r.status_code != 201: self._log_error(url, payload, r) return None - self.experiment_id = r.json()["id"] - return self.experiment_id + return r.json() def get_experiment(self, experiment_id): """ diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d39b0b4b..71559361d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,11 +57,11 @@ def test_init_aborted(self, MockApiClient): @patch("codecarbon.cli.main.questionary_prompt") def test_init_use_local(self, mock_prompt, MockApiClient): - mock_prompt.return_value = "/.codecarbonconfig" - result = self.runner.invoke(codecarbon, ["config", "--init"], input="y") + mock_prompt.return_value = "./.codecarbon.config" + result = self.runner.invoke(codecarbon, ["config", "--init"], input="n") self.assertEqual(result.exit_code, 0) self.assertIn( - "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n ", + "Using already existing config file ", result.stdout, ) @@ -80,7 +80,7 @@ def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): "Create New Project", "Create New Experiment", ] - mock_confirm.side_effect = [False, False, False] + mock_confirm.side_effect = [True, False, False, False] result = self.runner.invoke( codecarbon, ["config", "--init"], @@ -88,7 +88,11 @@ def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): ) self.assertEqual(result.exit_code, 0) self.assertIn( - "Succesfully initiated Code Carbon ! \n Here is your detailed config : \n ", + "Creating new experiment", + result.stdout, + ) + self.assertIn( + "Consult configuration documentation for more configuration options", result.stdout, ) From ae09ce33cf70013ab69eb9607497488e46b755af Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 13:32:10 +0100 Subject: [PATCH 21/32] test(CLI): :white_check_mark: fix tests --- codecarbon/cli/main.py | 4 --- codecarbon/cli/test_cli_utils.py | 32 ++++++++++++++++++++++++ tests/test_cli.py | 42 ++++++++++++++++++++------------ 3 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 codecarbon/cli/test_cli_utils.py diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index b41d84a48..5f8927f10 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -62,7 +62,6 @@ def show_config(path: Path = Path("./.codecarbon.config")) -> None: if "organization_id" not in d: print( "No organization_id in config, follow setup instruction to complete your configuration file!", - color="red", ) else: org = api.get_organization(d["organization_id"]) @@ -70,21 +69,18 @@ def show_config(path: Path = Path("./.codecarbon.config")) -> None: if "team_id" not in d: print( "No team_id in config, follow setup instruction to complete your configuration file!", - color="red", ) else: team = api.get_team(d["team_id"]) if "project_id" not in d: print( "No project_id in config, follow setup instruction to complete your configuration file!", - color="red", ) else: project = api.get_project(d["project_id"]) if "experiment_id" not in d: print( "No experiment_id in config, follow setup instruction to complete your configuration file!", - color="red", ) else: experiment = api.get_experiment(d["experiment_id"]) diff --git a/codecarbon/cli/test_cli_utils.py b/codecarbon/cli/test_cli_utils.py new file mode 100644 index 000000000..c67e95722 --- /dev/null +++ b/codecarbon/cli/test_cli_utils.py @@ -0,0 +1,32 @@ +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from codecarbon.cli.cli_utils import create_new_config_file + + +def test_create_new_config_file(): + runner = CliRunner() + + # Mock the typer.prompt function + with patch("codecarbon.cli.cli_utils.typer.prompt") as mock_prompt: + mock_prompt.return_value = "./.codecarbon.config" + + result = runner.invoke(create_new_config_file) + + assert result.exit_code == 0 + assert "Config file created at" in result.stdout + + # Verify that the prompt was called with the correct arguments + mock_prompt.assert_called_once_with( + "Where do you want to put your config file ?", + type=str, + default="./.codecarbon.config", + ) + + # Verify that the file was created + file_path = Path("./.codecarbon.config") + assert file_path.exists() + assert file_path.is_file() + assert file_path.read_text() == "[codecarbon]\n" diff --git a/tests/test_cli.py b/tests/test_cli.py index 71559361d..4c5ce3cd1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ +import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -55,36 +57,24 @@ def test_init_aborted(self, MockApiClient): self.assertEqual(result.exit_code, 1) self.assertIn("Welcome to CodeCarbon configuration wizard", result.stdout) - @patch("codecarbon.cli.main.questionary_prompt") - def test_init_use_local(self, mock_prompt, MockApiClient): - mock_prompt.return_value = "./.codecarbon.config" - result = self.runner.invoke(codecarbon, ["config", "--init"], input="n") - self.assertEqual(result.exit_code, 0) - self.assertIn( - "Using already existing config file ", - result.stdout, - ) - - def custom_questionary_side_effect(*args, **kwargs): - default_value = kwargs.get("default") - return MagicMock(return_value=default_value) - @patch("codecarbon.cli.main.Confirm.ask") @patch("codecarbon.cli.main.questionary_prompt") def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): + temp_codecarbon_config = tempfile.NamedTemporaryFile(mode="w+t", delete=False) + MockApiClient.return_value = self.mock_api_client mock_prompt.side_effect = [ - "Create New Config", "Create New Organization", "Create New Team", "Create New Project", "Create New Experiment", ] mock_confirm.side_effect = [True, False, False, False] + result = self.runner.invoke( codecarbon, ["config", "--init"], - input="y", + input=f"{temp_codecarbon_config.name}\n", ) self.assertEqual(result.exit_code, 0) self.assertIn( @@ -96,6 +86,26 @@ def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): result.stdout, ) + @patch("codecarbon.cli.main.Path") + @patch("codecarbon.cli.main.questionary_prompt") + def test_init_use_local(self, mock_prompt, mock_path, MockApiClient): + temp_codecarbon_config = tempfile.NamedTemporaryFile(mode="w+t", delete=False) + mock_path.return_value = Path(temp_codecarbon_config.name) + test_data = "[codecarbon]\nexperiment_id = 12345" + temp_codecarbon_config.write(test_data) + temp_codecarbon_config.seek(0) + mock_prompt.return_value = "./.codecarbon.config" + result = self.runner.invoke(codecarbon, ["config", "--init"], input="n") + self.assertEqual(result.exit_code, 0) + self.assertIn( + "Using already existing config file ", + result.stdout, + ) + + def custom_questionary_side_effect(*args, **kwargs): + default_value = kwargs.get("default") + return MagicMock(return_value=default_value) + if __name__ == "__main__": unittest.main() From 315542d32d4f695723a3a71e8d77880777bd8bd5 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 13:39:53 +0100 Subject: [PATCH 22/32] fix(CLI): :white_check_mark: use gihut action TEMP DIR --- tests/test_cli.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c5ce3cd1..f364a6339 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import os import tempfile import unittest from pathlib import Path @@ -60,7 +61,11 @@ def test_init_aborted(self, MockApiClient): @patch("codecarbon.cli.main.Confirm.ask") @patch("codecarbon.cli.main.questionary_prompt") def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): - temp_codecarbon_config = tempfile.NamedTemporaryFile(mode="w+t", delete=False) + temp_dir = os.getenv("RUNNER_TEMP", tempfile.gettempdir()) + + temp_codecarbon_config = tempfile.NamedTemporaryFile( + mode="w+t", delete=False, dir=temp_dir + ) MockApiClient.return_value = self.mock_api_client mock_prompt.side_effect = [ @@ -89,7 +94,11 @@ def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): @patch("codecarbon.cli.main.Path") @patch("codecarbon.cli.main.questionary_prompt") def test_init_use_local(self, mock_prompt, mock_path, MockApiClient): - temp_codecarbon_config = tempfile.NamedTemporaryFile(mode="w+t", delete=False) + temp_dir = os.getenv("RUNNER_TEMP", tempfile.gettempdir()) + + temp_codecarbon_config = tempfile.NamedTemporaryFile( + mode="w+t", delete=False, dir=temp_dir + ) mock_path.return_value = Path(temp_codecarbon_config.name) test_data = "[codecarbon]\nexperiment_id = 12345" temp_codecarbon_config.write(test_data) From bb1672c0d68861981b2a132ca54e5cef4edc0fe6 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 13:54:25 +0100 Subject: [PATCH 23/32] ci(CLI): :green_heart: debu CI --- .github/workflows/build.yml | 4 ++++ tests/test_cli.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a898d75f3..39ff70f25 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,10 @@ jobs: python-version: ["3.8", "3.11"] steps: + - name: List environment variables and directory permissions + run: | + printenv + ls -ld /tmp - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 diff --git a/tests/test_cli.py b/tests/test_cli.py index f364a6339..89d7e0dbe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -62,7 +62,7 @@ def test_init_aborted(self, MockApiClient): @patch("codecarbon.cli.main.questionary_prompt") def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): temp_dir = os.getenv("RUNNER_TEMP", tempfile.gettempdir()) - + print("temp_dir", temp_dir) temp_codecarbon_config = tempfile.NamedTemporaryFile( mode="w+t", delete=False, dir=temp_dir ) From 7818242f0bcab9f28a93428abc4e994341da9710 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 13:59:05 +0100 Subject: [PATCH 24/32] ci(CLI): :green_heart: try modifying tox ini --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 1f2c8f779..34db1f7bf 100644 --- a/tox.ini +++ b/tox.ini @@ -17,6 +17,7 @@ deps = pytest -rrequirements-dev.txt -rrequirements-test.txt +passenv = RUNNER_TEMP commands = pip install -e . @@ -32,3 +33,4 @@ python = 3.9: py39 3.10: py310 3.11: py311 + From d813861026687ccb8f9d1ea045d2b7f1f9dc24ef Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 17:58:51 +0100 Subject: [PATCH 25/32] fix(CLI): :white_check_mark: remove useless test that created a .codecarbon.config file --- tests/test_cli.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 89d7e0dbe..c28c88308 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,16 +53,10 @@ def test_app(self, MockApiClient): self.assertIn(__app_name__, result.stdout) self.assertIn(__version__, result.stdout) - def test_init_aborted(self, MockApiClient): - result = self.runner.invoke(codecarbon, ["config", "--init"]) - self.assertEqual(result.exit_code, 1) - self.assertIn("Welcome to CodeCarbon configuration wizard", result.stdout) - @patch("codecarbon.cli.main.Confirm.ask") @patch("codecarbon.cli.main.questionary_prompt") def test_init_no_local_new_all(self, mock_prompt, mock_confirm, MockApiClient): temp_dir = os.getenv("RUNNER_TEMP", tempfile.gettempdir()) - print("temp_dir", temp_dir) temp_codecarbon_config = tempfile.NamedTemporaryFile( mode="w+t", delete=False, dir=temp_dir ) From f3f28c336a1c9c81bb2bbb8f3f7aca8e63a890f6 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 17:59:34 +0100 Subject: [PATCH 26/32] ci(CLI): :rewind: remove debug step --- .github/workflows/build.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39ff70f25..a898d75f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,10 +13,6 @@ jobs: python-version: ["3.8", "3.11"] steps: - - name: List environment variables and directory permissions - run: | - printenv - ls -ld /tmp - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 From 3d8ab509d3b5f1929c48ff9b57712c40430af128 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 18:54:58 +0100 Subject: [PATCH 27/32] docs(CLI): :memo: test asciinema --- .gitignore | 4 ++++ docs/_sources/usage.rst.txt | 16 +++++++++++++++- docs/_static/pygments.css | 1 + docs/api.html | 8 +++----- docs/comet.html | 8 +++----- docs/edit/usage.rst | 17 ++++++++++++++++- docs/examples.html | 8 +++----- docs/faq.html | 8 +++----- docs/genindex.html | 8 +++----- docs/index.html | 8 +++----- docs/installation.html | 8 +++----- docs/methodology.html | 8 +++----- docs/model_examples.html | 8 +++----- docs/motivation.html | 8 +++----- docs/objects.inv | Bin 818 -> 575 bytes docs/output.html | 8 +++----- docs/parameters.html | 8 +++----- docs/search.html | 8 +++----- docs/searchindex.js | 2 +- docs/to_logger.html | 8 +++----- docs/usage.html | 27 +++++++++++++++++++-------- docs/visualize.html | 8 +++----- 22 files changed, 101 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index 03321b53b..ec6e5c99b 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,7 @@ code_carbon.db # Local file emissions*.csv* tests/test_data/rapl/* + + +#asciinema +*.cast \ No newline at end of file diff --git a/docs/_sources/usage.rst.txt b/docs/_sources/usage.rst.txt index e9dafe568..52ad93b5d 100644 --- a/docs/_sources/usage.rst.txt +++ b/docs/_sources/usage.rst.txt @@ -16,12 +16,26 @@ Command line If you want to track the emissions of a computer without having to modify your code, you can use the command line interface: +Create a minimal configuration file (just follow the prompts) .. code-block:: console + + codecarbon config --init - codecarbon monitor --no-api +Start monitoring the emissions of the computer +.. code-block:: console + + codecarbon monitor You have to stop the monitoring manually with ``Ctrl+C``. +In the following example you will see how to use the CLI to monitor all the emissions of you computer and sending everything +to an API running on localhost:8008 (that you can start with the docke-compose) +.. raw:: html + + + + + Implementing CodeCarbon in your code allows you to track the emissions of a specific block of code. Explicit Object diff --git a/docs/_static/pygments.css b/docs/_static/pygments.css index 691aeb82d..0d49244ed 100644 --- a/docs/_static/pygments.css +++ b/docs/_static/pygments.css @@ -17,6 +17,7 @@ span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: .highlight .cs { color: #408090; background-color: #fff0f0 } /* Comment.Special */ .highlight .gd { color: #A00000 } /* Generic.Deleted */ .highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ .highlight .gr { color: #FF0000 } /* Generic.Error */ .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ .highlight .gi { color: #00A000 } /* Generic.Inserted */ diff --git a/docs/api.html b/docs/api.html index 6fc0cf945..27530aec9 100644 --- a/docs/api.html +++ b/docs/api.html @@ -1,14 +1,12 @@ - + CodeCarbon API — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/comet.html b/docs/comet.html index 459117b3b..07ed9f02b 100644 --- a/docs/comet.html +++ b/docs/comet.html @@ -1,14 +1,12 @@ - + Comet Integration — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/edit/usage.rst b/docs/edit/usage.rst index e9dafe568..6b258399c 100644 --- a/docs/edit/usage.rst +++ b/docs/edit/usage.rst @@ -16,12 +16,27 @@ Command line If you want to track the emissions of a computer without having to modify your code, you can use the command line interface: +Create a minimal configuration file (just follow the prompts) .. code-block:: console - codecarbon monitor --no-api + codecarbon config --init + +Start monitoring the emissions of the computer +.. code-block:: console + + codecarbon monitor You have to stop the monitoring manually with ``Ctrl+C``. +In the following example you will see how to use the CLI to monitor all the emissions of you computer and sending everything +to an API running on localhost:8008 (that you can start with the docke-compose) + +.. raw:: html + + + + + Implementing CodeCarbon in your code allows you to track the emissions of a specific block of code. Explicit Object diff --git a/docs/examples.html b/docs/examples.html index 76ebeeee2..5d4d38041 100644 --- a/docs/examples.html +++ b/docs/examples.html @@ -1,14 +1,12 @@ - + Examples — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/faq.html b/docs/faq.html index 06792d78c..1c6eb15f6 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -1,14 +1,12 @@ - + Frequently Asked Questions — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/genindex.html b/docs/genindex.html index 16e62aac7..428e18372 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -1,13 +1,11 @@ - + Index — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/index.html b/docs/index.html index ae51971dc..fe185fd00 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,14 +1,12 @@ - + CodeCarbon — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/installation.html b/docs/installation.html index 1e4c56f20..f4386d55b 100644 --- a/docs/installation.html +++ b/docs/installation.html @@ -1,14 +1,12 @@ - + Installing CodeCarbon — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/methodology.html b/docs/methodology.html index ee1212bf1..b2191b9fe 100644 --- a/docs/methodology.html +++ b/docs/methodology.html @@ -1,14 +1,12 @@ - + Methodology — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/model_examples.html b/docs/model_examples.html index ebe1fdb3b..e3d9cfc48 100644 --- a/docs/model_examples.html +++ b/docs/model_examples.html @@ -1,14 +1,12 @@ - + Model Comparisons — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/motivation.html b/docs/motivation.html index c1c16cc6a..ca88a5c96 100644 --- a/docs/motivation.html +++ b/docs/motivation.html @@ -1,14 +1,12 @@ - + Motivation — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/objects.inv b/docs/objects.inv index 0251f17b6909708d6646a7fc27d9e75d9d7fc4bb..3ca2bac60c0c1116f350fc8ee471a522c19e4577 100644 GIT binary patch delta 465 zcmV;?0WSWs2EPQ5cz=bI%Wi`(5JmTVg{5{&rEa^6qE^bHDp93wBL}9$iowK>DD>+) zVEh7R*7j+FX&C%juWt$e@+{3vRIn5_{`+p!E%Qv9oD~*D?)bMQA zYN#dEh-Pr3O~*mSfYU7VKAlpl?>{l)FueVjUgA(Z+Pn`57Xz*4J=v%;RAg^J4ZQ@! zhdT&!?_f2wtgYadvQD*Ccf-#t*ru)`N7y3Tl%|}I6H47dcd?@~Y~vp9n_48`&DJw; zQ72IeTxuV;5P!+GgU=u=7+-cyVbz4gviz|2Dx^=Qii0-1N*$F<4GUP|L+NG0)2QX% zzZ$r*(XP+k1K)|^FzvPxnq3zw3#vohc@9J!C&)0X012Zzll;A`V$;9I-?7Q3Mm9{G zjCf&+y8)RDJ*YOd@aH)YMw22~A%?=-9t^bT@ug_fxk@UavOJ}r)hzREpBJ=-%zw<{ z>!e?}#~(1GFe(lh1q<&DE6@0Mkvg*Zwt^7{5LvHG8OqsTlIlg9{Ns)@w+~PKSRfGp HJ}p7{#{B7x delta 710 zcmV;%0y+J^1hNK@cz?ZCU2obj6n*DcSn3|Oha_uP?O~cGRj8Ag+KsAJUn`T`f`?2V8&--hlboM2aDT&TWe5zSI(>W(!g?}WPX%628M>yWna$Xhp z^ckXyUQ#@4*dVtIADvLNeqH{r_V3d4R|QEqRq8FPXac->8M(|V{-)C~5&m7s z=gVG~Nq4($DSvicD5qI=OSRilImz*;wX!clKZ~W;2;G|B({kxI#D6Gd&w}YW##S-3 zV18UY8p=>+Fr?)~3UW0bSsJGk;6Cx}-6l1aoI|Ram=egDQcMa(v?P>pIh{fo`e^xN zVB_Y*krhW;Ml@QBhX>p-d=A<#sR7!e>bsH9FfFM@>3;y06q^OXd(>h^bG<%=RGWW5 z#%5UhGkx(*@o4lRxI$%pdS?Qt*J}-k9_g~GkPqMBa4IS4+ESTbKpbVPsP~$qb=S_iY zO(?+#zR30MKtspPrVab5fR^bj6dKHE?#=xeH$U>gE;GNJaKjy5V3k5E-=G1E_^x8< s96y&*do-JtBVq$Q>Vm0?a{LG4yIRmVKeopWwlxp=7{Dq10zKtYqRbXtb^rhX diff --git a/docs/output.html b/docs/output.html index 3c3445a75..72ac89dd3 100644 --- a/docs/output.html +++ b/docs/output.html @@ -1,14 +1,12 @@ - + Output — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/parameters.html b/docs/parameters.html index 4ead0433d..0bf6ba1b0 100644 --- a/docs/parameters.html +++ b/docs/parameters.html @@ -1,14 +1,12 @@ - + Parameters — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/search.html b/docs/search.html index 1b492a54c..bd518185a 100644 --- a/docs/search.html +++ b/docs/search.html @@ -1,13 +1,11 @@ - + Search — CodeCarbon 2.3.4 documentation - - - - + + diff --git a/docs/usage.html b/docs/usage.html index d08332d34..2acf6cb2b 100644 --- a/docs/usage.html +++ b/docs/usage.html @@ -1,14 +1,12 @@ - + Quickstart — CodeCarbon 2.3.4 documentation - - - - + + @@ -122,10 +120,23 @@

Online Mode

Command line

If you want to track the emissions of a computer without having to modify your code, you can use the command line interface:

-
codecarbon monitor --no-api
-
-
+

Create a minimal configuration file (just follow the prompts) +.. code-block:: console

+
+

codecarbon config –init

+
+

Start monitoring the emissions of the computer +.. code-block:: console

+
+

codecarbon monitor

+

You have to stop the monitoring manually with Ctrl+C.

+

In the following example you will see how to use the CLI to monitor all the emissions of you computer and sending everything +to an API running on localhost:8008 (that you can start with the docke-compose) +.. raw:: html

+
+

<script src=”https://asciinema.org/a/bJMOlPe5F4mFLY0Rl6fiJSOp3.js” id=”asciicast-bJMOlPe5F4mFLY0Rl6fiJSOp3” async></script>

+

Implementing CodeCarbon in your code allows you to track the emissions of a specific block of code.

diff --git a/docs/visualize.html b/docs/visualize.html index 8007bd041..06c33468c 100644 --- a/docs/visualize.html +++ b/docs/visualize.html @@ -1,14 +1,12 @@ - + Visualize — CodeCarbon 2.3.4 documentation - - - - + + From 903e3f3c89e66c7ce8d006fc11be0be968188c50 Mon Sep 17 00:00:00 2001 From: LuisBlanche Date: Fri, 22 Mar 2024 19:01:44 +0100 Subject: [PATCH 28/32] docs(CLI): :memo: fix code block --- docs/edit/usage.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/edit/usage.rst b/docs/edit/usage.rst index 6b258399c..6510fd0fd 100644 --- a/docs/edit/usage.rst +++ b/docs/edit/usage.rst @@ -17,9 +17,10 @@ Command line If you want to track the emissions of a computer without having to modify your code, you can use the command line interface: Create a minimal configuration file (just follow the prompts) + .. code-block:: console - codecarbon config --init + codecarbon config --init Start monitoring the emissions of the computer .. code-block:: console From 8c3219e2f32624a9c1fc36e9fbe396ce47b72014 Mon Sep 17 00:00:00 2001 From: alencon Date: Wed, 27 Mar 2024 09:44:22 +0100 Subject: [PATCH 29/32] [#299]:Fix API messages + add link for home page --- .../infra/repositories/repository_organizations.py | 2 +- .../api/infra/repositories/repository_projects.py | 2 +- .../api/infra/repositories/repository_teams.py | 2 +- dashboard/layout/pages/home.py | 11 +++++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py index ce9cfdb30..c8510940a 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_organizations.py @@ -44,7 +44,7 @@ def add_organization(self, organization: OrganizationCreate) -> Organization: ) if existing_organization: raise HTTPException( - status_code=404,detail=f"the organization name {organization.name} is already existed" + status_code=404,detail=f"the organization name {organization.name} already exists" ) session.add(db_organization) diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py index 5943988f1..216ae6a40 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_projects.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_projects.py @@ -32,7 +32,7 @@ def add_project(self, project: ProjectCreate): ) if existing_project: raise HTTPException( - status_code=404,detail=f"the project name {project.name} of that team {project.team_id} is already existed" + status_code=404,detail=f"the project name {project.name} of team {project.team_id} already exists" ) session.add(db_project) diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_teams.py b/carbonserver/carbonserver/api/infra/repositories/repository_teams.py index 65d1089f6..53e7891ae 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_teams.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_teams.py @@ -36,7 +36,7 @@ def add_team(self, team: TeamCreate) -> Team: ) if existing_team: raise HTTPException( - status_code=404,detail=f"the team name {team.name} of that organization {team.organization_id} is already existed" + status_code=404,detail=f"the team name {team.name} of organization {team.organization_id} already exists" ) diff --git a/dashboard/layout/pages/home.py b/dashboard/layout/pages/home.py index 1ef0ca00d..4b4e46372 100644 --- a/dashboard/layout/pages/home.py +++ b/dashboard/layout/pages/home.py @@ -56,10 +56,13 @@ def layout(): dbc.Row( [ dbc.Col([ - html.H6( - "We explain more about this calculation in the Methodology section of the documentation. Our hope is that this package will be used widely for estimating the carbon footprint of computing, and for establishing best practices with regards to the disclosure and reduction of this footprint." - - ) + html.H6([ + "We explain more about this calculation in the ", + dcc.Link(" Methodology ", href="https://mlco2.github.io/codecarbon/methodology.html#", target="_blank"), + "section of the documentation. Our hope is that this package will be used widely for estimating the carbon footprint of computing, and for establishing best practices with regards to the disclosure and reduction of this footprint." + + ]) + ]) From 24a1c17c2b0107590c5c093a7ebd46ae87eb3689 Mon Sep 17 00:00:00 2001 From: alencon Date: Wed, 27 Mar 2024 22:34:08 +0100 Subject: [PATCH 30/32] [#507]: Fix stored procedure raise notice --- .../database/scripts/spcc_purgeduplicatedata.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql b/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql index 78cc6a99e..33f2b0d1b 100644 --- a/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql +++ b/carbonserver/carbonserver/database/scripts/spcc_purgeduplicatedata.sql @@ -85,7 +85,7 @@ BEGIN --RAISE NOTICE '------- START -------'; RAISE NOTICE 'The rows affected by A=%',a_count; - RAISE NOTICE 'Delete experiments which contains any runs affected' + RAISE NOTICE 'Delete experiments which contains any runs affected'; delete FROM public.experiments e where e.id not in ( select r.experiment_id from runs r @@ -94,7 +94,7 @@ BEGIN RAISE NOTICE '--------------'; - RAISE NOTICE 'Delete projects which contains any experiments affected' + RAISE NOTICE 'Delete projects which contains any experiments affected'; delete FROM public.projects p where p.id not in ( select e.project_id @@ -104,7 +104,7 @@ BEGIN RAISE NOTICE '--------------'; - RAISE NOTICE 'Delete teams which contains any project affected ' + RAISE NOTICE 'Delete teams which contains any project affected '; DELETE from teams t where t.id not in (select p.team_id from projects p) and t.organization_id =row_data.orga_id; @@ -112,7 +112,7 @@ BEGIN RAISE NOTICE '--------------'; - RAISE NOTICE 'Delete organizations which contains any teams affected' + RAISE NOTICE 'Delete organizations which contains any teams affected'; DELETE from organizations o where o.id not in (select t.organization_id from teams t ) and o.id = row_data.orga_id; From 56d4f968f46d63d3b8339721becd583bc25b7610 Mon Sep 17 00:00:00 2001 From: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev> Date: Fri, 29 Mar 2024 21:22:16 +0100 Subject: [PATCH 31/32] empty line at end of file --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index ec6e5c99b..0967e2314 100644 --- a/.gitignore +++ b/.gitignore @@ -126,6 +126,5 @@ code_carbon.db emissions*.csv* tests/test_data/rapl/* - #asciinema -*.cast \ No newline at end of file +*.cast From 3c9c7405ee99176b012e283cd4a0c2981c5ac39d Mon Sep 17 00:00:00 2001 From: alencon Date: Sun, 14 Apr 2024 00:22:42 +0200 Subject: [PATCH 32/32] [#516] : CodeCarbon Dashboard content : - Admin Page which allow to create an organization into database. --- dashboard/data/data_loader.py | 9 +- dashboard/layout/callbacks.py | 191 ++++++++++++++++++--------- dashboard/layout/pages/admin.py | 146 +++++++++----------- dashboard/layout/pages/codecarbon.py | 2 +- 4 files changed, 198 insertions(+), 150 deletions(-) diff --git a/dashboard/data/data_loader.py b/dashboard/data/data_loader.py index 8fa7fc3af..a7e932543 100644 --- a/dashboard/data/data_loader.py +++ b/dashboard/data/data_loader.py @@ -10,10 +10,11 @@ API_PATH = os.getenv("CODECARBON_API_URL") if API_PATH is None: - # API_PATH = "http://carbonserver.cleverapps.io" - API_PATH = "https://api.codecarbon.io" -# API_PATH = "http://localhost:8008" # export CODECARBON_API_URL=http://localhost:8008 -# API_PATH = "http://carbonserver.cleverapps.io" + #API_PATH = "http://carbonserver.cleverapps.io" + #API_PATH = "https://api.codecarbon.io" + API_PATH = "http://localhost:8008" + #export CODECARBON_API_URL=http://localhost:8008 + #API_PATH = "http://carbonserver.cleverapps.io" USER = "jessica" PSSD = "fake-super-secret-token" diff --git a/dashboard/layout/callbacks.py b/dashboard/layout/callbacks.py index fe6994b2a..536068827 100644 --- a/dashboard/layout/callbacks.py +++ b/dashboard/layout/callbacks.py @@ -2,7 +2,7 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go -from dash.dependencies import Input, Output +from dash.dependencies import Input, Output ,State from data.data_functions import ( get_experiment, get_experiment_runs, @@ -22,12 +22,14 @@ from layout.template import darkgreen, vividgreen from plotly.subplots import make_subplots -from dash import Input, Output +from dash import Input, Output,State , no_update +from dash.exceptions import PreventUpdate import json import os import requests +import time API_PATH = os.getenv("CODECARBON_API_URL") @@ -43,75 +45,138 @@ # ************************************************************************ # ************************************************************************ +# @app.callback( +# Output('body-div', 'children'), +# #Output('err','children'), +# Input('show-secret', 'n_clicks'), + +# State('input_organame','value'), +# State('input_orgadesc','value'), +# #prevent_initial_call=True, +# running=[(Output("show-secret", "disabled"), True, False)] +# ) +# def update_output(n_clicks,input_organame,input_orgadesc): +# try: +# #time.sleep(5) +# if n_clicks is None: +# raise PreventUpdate +# if not input_organame and not input_orgadesc: +# return [""] +# else: +# print(input_organame) +# print(input_orgadesc) +# return [f'votre saisie est {input_organame} and {input_orgadesc} '] +# except Exception as e: +# return no_update, ''.format(e.args) + +# def checkText(input_organame): +# for char in input_organame: +# if not char.isalpha(): +# return False +# return True + @app.callback( - Output("output","children"), - Output(component_id="organame", component_property="children"), - Output(component_id="orgadesc", component_property="children"), + Output("Orgacard_to_hidden", "style"), + Output("Output_text","style"), + Output("Output_data","children"), + Output("Teamcard_to_hidden","style"), + [Input('submit_btn','n_clicks')], + [State("input_organame", "value"), + State("input_orgadesc", "value")], + +) +def toggle_card_visibility(n_clicks,input_organame,input_orgadesc): + try: + if n_clicks is None: + raise PreventUpdate + if not input_organame or not input_orgadesc: + return {"display":"block"},{"display":"none"}, [""] ,{"display":"none"} + else: + Output_data= save_organization(input_organame,input_orgadesc) + print(Output_data) + print(n_clicks) + print(input_organame) + print(input_orgadesc) + return {"display":"none"},{"display":"block"},[Output_data],{"display":"block"} + except Exception as e: + return {"display":"block"},{"display":"block"}, e.args,{"display":"none"} + +def save_organization(input_organame, input_orgadesc) -> str: + try: + path = f"{API_PATH}/organization" + print(path) + payload = {'name': input_organame , 'description' : input_orgadesc} + response = requests.post(path, json=payload) + message = "" + if response.status_code == 201: + return f'You have entered "{input_organame}" and "{input_orgadesc}" into the database' + else: + if response.status_code == 405: + return f'You have entered "{response.status_code}" and reason : "{response.reason}" ' + else: + return f'You have entered error : "{response.status_code}" and reason : "{response.reason}" for path {path} and payload {payload}' + except: + return f'none' - Input("input_organame", "value"), +####################### - Input("input_orgadesc", "value"), +# Refresh organizations list +# @app.callback( +# [ +# Output(component_id="dropdown-div", component_property="options"), +# Output(component_id="dropdown-div", component_property="value"), +# ], +# [Input("url-location", "pathname")], +# ) +# def refresh_org_list(url): +# df_org = get_organization_list() +# org_id = df_org.id.unique().tolist()[1] +# options = [ +# {"label": orgName, "value": orgId} +# for orgName, orgId in zip(df_org.name, df_org.id) +# ] +# return options, org_id - Input('submit_btn','n_clicks'), - -) -def on_button_click(input_organame,input_orgadesc,n_clicks): - try: - - if n_clicks: - path = f"{API_PATH}/organization" - print(path) - payload = {'name': input_organame , 'description' : input_orgadesc} - response = requests.post(path, json=payload) - - if response.status_code == 201: - return f'You have entered "{input_organame}" and "{input_orgadesc}" into the database.' - else: - if response.status_code == 405: - return f'You have entered "{response.status_code}" and reason : "{response.reason}" ' - else: - return f'You have entered error : "{response.status_code}" and reason : "{response.reason}" for path {path} and payload {payload}' - except: - return f'none' -#@app.callback( -## Output("output2","children"), -# Input("input_teamname", "value"), -# Input("input_teamdesc", "value"), -# Input(component_id="org-dropdown", component_property="value"), -# Input('submit_btn_team','n_clicks'), -#) -#def on_button_click(input_teamname,input_teamdesc,n_clicks): - #if n_clicks: - # return f'Input1 {input_teamname} and Input2 {input_teamdesc} and nb {n_clicks}' - +# def update_dropdown(n_clicks, value1, value2): +# if n_clicks > 0 and value1 and value2: +# # Charger les options à partir de la base de données +# options = get_options_from_database() +# # Retourner la liste déroulante avec les options chargées +# return dcc.Dropdown(id='dropdown', options=options, placeholder='Sélectionnez une option') +# else: +# return None -@app.callback( - [ - Output(component_id="teamPicked", component_property="options"), - # Output(component_id="projectPicked", component_property="value"), - ], - [ - Input(component_id="org-dropdown", component_property="value"), - ], -) -def update_team_from_organization(value): - orga_id = value - df_team = get_team_list(orga_id) - if len(df_team) > 0: - # project_id = df_project.id.unique().tolist()[0] - # project_name = df_project.name.unique().tolist()[0] - options = [ - {"label": teamName, "value": teamId} - for teamName, teamId in zip(df_team.name, df_team.id) - ] - else: - # project_id = None - # project_name = "No Project !!!" - options = [] - return [options] + + + +#@app.callback( +# [ +# Output(component_id="teamPicked", component_property="options"), +# # Output(component_id="projectPicked", component_property="value"), +# ], +# [ +# Input(component_id="org-dropdown", component_property="value"), +# ], +#) +#def update_team_from_organization(value): +# orga_id = value +# df_team = get_team_list(orga_id) +# if len(df_team) > 0: +# # project_id = df_project.id.unique().tolist()[0] + # project_name = df_project.name.unique().tolist()[0] + # options = [ + # {"label": teamName, "value": teamId} + # for teamName, teamId in zip(df_team.name, df_team.id) + # ] + # else: + # # project_id = None + # # project_name = "No Project !!!" + # options = [] + + # return [options] diff --git a/dashboard/layout/pages/admin.py b/dashboard/layout/pages/admin.py index ddd7dc825..1a013d8aa 100644 --- a/dashboard/layout/pages/admin.py +++ b/dashboard/layout/pages/admin.py @@ -4,11 +4,9 @@ from data.data_functions import get_organization_list, get_project_list, get_team_list - dash.register_page(__name__, path='/admin', name="Admin",order=1) - ##################### Get Data ########################### df_org = get_organization_list() @@ -37,38 +35,73 @@ def layout(): ), html.Br(), - dbc.Row( + dbc.Row( [ dbc.Col([ dbc.Card( + id="Orgacard_to_hidden", + children= [ - + dbc.CardHeader("Formulaire",style={"color":"#CDCDCD"}), dbc.CardBody( + children= [ html.H5( "Create an Organization :", style={"color":"white"} ), html.Hr(), - html.Div(id='output'), - dcc.Input(id="input_organame", type="text", placeholder="Name", debounce=True ), + dcc.Input(id="input_organame", type="text", placeholder="Name"), html.Br(), - dcc.Input(id="input_orgadesc", type="text", placeholder="Description" , debounce=True ), + dcc.Input(id="input_orgadesc", type="text", placeholder="Description" ), html.Br(), html.Br(), dbc.Button("submit", id="submit_btn", color="primary", n_clicks=0 ), ], - style={"height": "10%", "border-radius": "10px", "border":"solid" , "border-color":"#CDCDCD" + style={"height": "10%", "border-radius": "10px", "border":"solid" , "border-color":"#CDCDCD" }, - className="shadow" + className="shadow", + #value="on" + ) + + + + ],style={"display":"block"} #make the card visible starting + ), + dbc.Card( + id="Output_text", + children= + [ + dbc.CardHeader("Result",style={"color":"#CDCDCD"} ), + dbc.CardBody( + [ + + html.Div(id='Output_data'), + + ],style={"color":"#CDCDCD"} + ), + + + ],style={"display":"none","color":"#CDCDCD"} + ), + + - ] - ) ]), - dbc.Col([ + + + ] + + + + ), + html.Br(), + dbc.Col([ dbc.Card( + id="Teamcard_to_hidden", + children= [ dbc.CardBody( @@ -79,8 +112,9 @@ def layout(): ), dbc.Label("organization selected : ",width=10 ,style={"color": "white"}, ), + #dcc.Dropdown(id='dropdown-div'), dcc.Dropdown( - id="org-dropdown", + #id="dropdown-div", options=[ {"label": orgName, "value": orgId} for orgName, orgId in zip( @@ -94,7 +128,6 @@ def layout(): #html.Div(id='output2'), html.Br(), - dcc.Input(id="input_teamname", type="text", placeholder="Name", debounce=True ), html.Br(), dcc.Input(id="input_teamdesc", type="text", placeholder="Description" , debounce=True ), @@ -106,80 +139,27 @@ def layout(): }, className="shadow" ), - ] + ],style={"display":"none"} ) ]), - dbc.Col([ - dbc.Card( - [ - - dbc.CardBody( - [ - - html.H5( - "Create a project :", style={"color":"white"} - ), - dbc.Label("organization selected : ", width=10 , style={"color": "white"}), - dcc.Dropdown( - id="org-dropdown", - options=[ - {"label": orgName, "value": orgId} - for orgName, orgId in zip( - df_org.name, df_org.id - ) - ], - clearable=False, - value=orga_id, - # value=df_org.id.unique().tolist()[0], - # value="Select your organization", - style={"color": "black"}, - # clearable=False, - ), - dbc.Label("team selected : ", width=10 , style={"color": "white"} ), - dbc.RadioItems( - id="teamPicked", - options=[ - {"label": teamName, "value": teamId} - for teamName, teamId in zip( - df_team.name, df_team.id - ) - ], - value=df_team.id.unique().tolist()[-1] - if len(df_team) > 0 - else "No team in this organization !", - inline=True, - style={"color": "white"} - # label_checked_class_name="text-primary", - # input_checked_class_name="border border-primary bg-primary", - ), - dbc.Label("Project ", width=10, style={"color": "white"}), - #html.Div(id='output'), - dbc.Input(id="input_projectname", type="text", placeholder="Name", debounce=True ), - dbc.Input(id="input_projectdesc", type="text", placeholder="Description" , debounce=True ), - html.Br(), - - - dbc.Button("submit", id="submit_btn_project", color="primary", n_clicks=0), - ], - style={"height": "10%", "border-radius": "10px", "border":"solid" , "border-color":"#CDCDCD" - }, - className="shadow" - ), - ] - ) - - ]), - - - ] - - ), - + + + # html.Div([ + + # dcc.Input(id="input_organame", type="text", placeholder="Name"), + # html.Br(), + # dcc.Input(id="input_orgadesc", type="text", placeholder="Description"), + # dbc.Button("submit", id="show-secret", color="primary",n_clicks=0), + # #html.Div(id='err', style={'color':'red'}), + # html.Div(id='body-div', children='Enter a value and press submit') + + # ]) + @@ -188,4 +168,6 @@ def layout(): ), - \ No newline at end of file + + + diff --git a/dashboard/layout/pages/codecarbon.py b/dashboard/layout/pages/codecarbon.py index 1eeceff88..732181eac 100644 --- a/dashboard/layout/pages/codecarbon.py +++ b/dashboard/layout/pages/codecarbon.py @@ -8,7 +8,7 @@ from dashboard.layout.components import Components -dash.register_page(__name__, path='/codecarbon', name="Codecarbon", order=2 ) +dash.register_page(__name__, path='/codecarbon', name="Codecarbon", order=2) # Set configuration (prevent default plotly modebar to appears, disable zoom on figures, set a double click reset ~ not working that good IMO )