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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,19 @@
from pathlib import Path
from typing import Optional

import questionary
import requests
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.auth import authorize, get_access_token
from codecarbon.cli.cli_utils import (
create_new_config_file,
get_api_endpoint,
get_config,
get_existing_exp_id,
overwrite_local_config,
)
from codecarbon.cli.monitor import run_and_monitor
from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone
from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker

API_URL = os.environ.get("API_URL", "https://dashboard.codecarbon.io/api")

Expand Down Expand Up @@ -68,6 +61,9 @@ def version(


def show_config(path: Path = Path("./.codecarbon.config")) -> None:
from codecarbon.cli.auth import get_access_token
from codecarbon.core.api_client import ApiClient

d = get_config(path)
print("Current configuration : \n")
print("Config file content : ")
Expand Down Expand Up @@ -114,6 +110,9 @@ def api_get():
"""
ex: test-api
"""
from codecarbon.cli.auth import get_access_token
from codecarbon.core.api_client import ApiClient

api_endpoint = get_api_endpoint()
api = ApiClient(endpoint_url=api_endpoint)
api.set_access_token(get_access_token())
Expand All @@ -123,6 +122,9 @@ def api_get():

@codecarbon.command("login", short_help="Login to CodeCarbon")
def login():
from codecarbon.cli.auth import authorize, get_access_token
from codecarbon.core.api_client import ApiClient

authorize()
api_endpoint = get_api_endpoint()
api = ApiClient(endpoint_url=api_endpoint)
Expand All @@ -132,6 +134,10 @@ def login():


def get_api_key(project_id: str):
import requests

from codecarbon.cli.auth import get_access_token

api_endpoint = get_api_endpoint()
api_endpoint = api_endpoint.rstrip("/")
req = requests.post(
Expand Down Expand Up @@ -161,6 +167,13 @@ def config():
"""
Initialize CodeCarbon, this will prompt you for configuration of Organisation/Team/Project/Experiment.
"""
from codecarbon.cli.auth import get_access_token
from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone
from codecarbon.core.schemas import (
ExperimentCreate,
OrganizationCreate,
ProjectCreate,
)

print("Welcome to CodeCarbon configuration wizard")
home = Path.home()
Expand Down Expand Up @@ -342,13 +355,18 @@ def monitor(
str,
typer.Option(help="Region/province for offline mode"),
] = None,
log_level: Annotated[
str,
typer.Option(help="Log level (critical, error, warning, info, debug)"),
] = "error",
):
"""Monitor your machine's carbon emissions."""

# Shared tracker args so monitor and run_and_monitor behave the same
tracker_args = {
"measure_power_secs": measure_power_secs,
"api_call_interval": api_call_interval,
"log_level": log_level,
}
# Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode
if offline:
Expand All @@ -375,8 +393,12 @@ def monitor(

tracker_args = {**tracker_args, "save_to_api": api}

from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker

# If extra args are provided (e.g. `codecarbon monitor -- my_script.py`), delegate to `run_and_monitor`
if getattr(ctx, "args", None):
from codecarbon.cli.monitor import run_and_monitor

return run_and_monitor(ctx, offline=offline, **tracker_args)

# Instantiate the tracker
Expand Down Expand Up @@ -417,6 +439,8 @@ def detect():
"""
Detects hardware and prints information without running any measurements.
"""
from codecarbon.emissions_tracker import EmissionsTracker

print("Detecting hardware...")
tracker = EmissionsTracker(save_to_file=False)
hardware_info = tracker.get_detected_hardware()
Expand All @@ -438,6 +462,8 @@ def detect():


def questionary_prompt(prompt, list_options, default):
import questionary

value = questionary.select(
prompt,
list_options,
Expand Down
9 changes: 5 additions & 4 deletions codecarbon/cli/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
from rich import print
from typing_extensions import Annotated

from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker


def run_and_monitor(
ctx: typer.Context,
Expand Down Expand Up @@ -50,12 +48,15 @@ def run_and_monitor(
directory. The file path is shown in the final report.
"""
# Suppress all CodeCarbon logs during execution
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
from codecarbon.external.logger import set_logger_level

set_logger_level(log_level)

# Get the command from remaining args
command = ctx.args
# Get the command from remaining args (strip nested subcommand / `--` leftovers)
command = list(getattr(ctx, "args", None) or [])
while command and command[0] in ("monitor", "--"):
command.pop(0)

if not command:
print(
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Displays real-time emissions data for all processes on your machine. Press `Ctrl
| `--no-api` | flag | false | Do not send data to the API (local-only measurement) |
| `--offline` | flag | false | Run without internet access |
| `--country-iso-code` | string | - | ISO 3166-1 alpha-3 country code (required in offline mode) |
| `--log-level` | choice | INFO | Log level: DEBUG, INFO, WARNING, ERROR |
| `--log-level` | choice | ERROR | Log level: DEBUG, INFO, WARNING, ERROR |

**Examples:**
```bash
Expand Down
17 changes: 14 additions & 3 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# MOCK API CLIENT


@patch("codecarbon.cli.main.ApiClient")
@patch("codecarbon.core.api_client.ApiClient")
class TestApp(unittest.TestCase):
def setUp(self):
self.runner = CliRunner()
Expand Down Expand Up @@ -57,7 +57,7 @@ def test_app(self, MockApiClient):
@patch("codecarbon.cli.main.Path.exists")
@patch("codecarbon.cli.main.Confirm.ask")
@patch("codecarbon.cli.main.questionary_prompt")
@patch("codecarbon.cli.main.get_access_token")
@patch("codecarbon.cli.auth.get_access_token")
@patch("typer.prompt")
def test_config_no_local_new_all(
self,
Expand Down Expand Up @@ -147,7 +147,7 @@ def side_effect_wrapper(*args, **kwargs):
except OSError:
pass

@patch("codecarbon.cli.main.get_access_token")
@patch("codecarbon.cli.auth.get_access_token")
@patch("codecarbon.cli.main.Path.exists")
@patch("codecarbon.cli.main.get_config")
@patch("codecarbon.cli.main.questionary_prompt")
Expand Down Expand Up @@ -186,5 +186,16 @@ def custom_questionary_side_effect(*args, **kwargs):
return MagicMock(return_value=default_value)


class TestQuestionaryPrompt(unittest.TestCase):
@patch("questionary.select")
def test_questionary_prompt_returns_selected_value(self, mock_select):
from codecarbon.cli.main import questionary_prompt

mock_select.return_value.ask.return_value = "selected"
result = questionary_prompt("Pick one", ["a", "b"], "a")
self.assertEqual(result, "selected")
mock_select.assert_called_once_with("Pick one", ["a", "b"], "a")


if __name__ == "__main__":
unittest.main()
70 changes: 50 additions & 20 deletions tests/cli/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from types import SimpleNamespace

import pytest
import typer
from typer.testing import CliRunner

from codecarbon.cli import main as cli_main
Expand Down Expand Up @@ -34,8 +35,8 @@ def test_version_flag():

def test_api_get_calls_api_and_prints(monkeypatch):
runner = CliRunner()
monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient)
monkeypatch.setattr(cli_main, "get_access_token", fake_get_access_token)
monkeypatch.setattr("codecarbon.core.api_client.ApiClient", FakeApiClient)
monkeypatch.setattr("codecarbon.cli.auth.get_access_token", fake_get_access_token)

result = runner.invoke(cli_main.codecarbon, ["test-api"])
assert result.exit_code == 0
Expand All @@ -51,11 +52,11 @@ def __init__(self, endpoint_url=None):
super().__init__(endpoint_url=endpoint_url)

runner = CliRunner()
monkeypatch.setattr(cli_main, "ApiClient", CustomApiClient)
monkeypatch.setattr("codecarbon.core.api_client.ApiClient", CustomApiClient)
monkeypatch.setattr(
cli_main, "get_api_endpoint", lambda: "https://custom.codecarbon.io"
)
monkeypatch.setattr(cli_main, "get_access_token", fake_get_access_token)
monkeypatch.setattr("codecarbon.cli.auth.get_access_token", fake_get_access_token)

result = runner.invoke(cli_main.codecarbon, ["test-api"])
assert result.exit_code == 0
Expand Down Expand Up @@ -85,7 +86,7 @@ def get_detected_hardware(self):
"gpu_ids": None,
}

monkeypatch.setattr(cli_main, "EmissionsTracker", FakeTracker)
monkeypatch.setattr("codecarbon.emissions_tracker.EmissionsTracker", FakeTracker)
runner = CliRunner()
result = runner.invoke(cli_main.codecarbon, ["detect"])
assert result.exit_code == 0
Expand Down Expand Up @@ -115,7 +116,7 @@ def set_access_token(self, token):
def fake_get_access_token():
raise ValueError("Not able to retrieve the access token, please run login.")

monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient)
monkeypatch.setattr("codecarbon.core.api_client.ApiClient", FakeApiClient)
monkeypatch.setattr(
cli_main,
"get_config",
Expand All @@ -129,7 +130,7 @@ def fake_get_access_token():
monkeypatch.setattr(
cli_main, "get_api_endpoint", lambda path: "https://api.codecarbon.io"
)
monkeypatch.setattr(cli_main, "get_access_token", fake_get_access_token)
monkeypatch.setattr("codecarbon.cli.auth.get_access_token", fake_get_access_token)

cli_main.show_config(tmp_path / ".codecarbon.config")
captured = capsys.readouterr()
Expand Down Expand Up @@ -165,16 +166,15 @@ def set_access_token(self, token):
def check_auth(self):
calls["check_auth"] += 1

monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient)
monkeypatch.setattr("codecarbon.core.api_client.ApiClient", FakeApiClient)
monkeypatch.setattr(
cli_main,
"authorize",
"codecarbon.cli.auth.authorize",
lambda: calls.__setitem__("authorize", calls["authorize"] + 1),
)
monkeypatch.setattr(
cli_main, "get_api_endpoint", lambda: "https://custom-login.codecarbon.io"
)
monkeypatch.setattr(cli_main, "get_access_token", lambda: "login-token")
monkeypatch.setattr("codecarbon.cli.auth.get_access_token", lambda: "login-token")

runner = CliRunner()
result = runner.invoke(cli_main.codecarbon, ["login"])
Expand All @@ -198,8 +198,8 @@ def fake_post(url, json, headers):
captured["headers"] = headers
return FakeResponse()

monkeypatch.setattr(cli_main, "get_access_token", lambda: "access-token")
monkeypatch.setattr(cli_main.requests, "post", fake_post)
monkeypatch.setattr("codecarbon.cli.auth.get_access_token", lambda: "access-token")
monkeypatch.setattr("requests.post", fake_post)

token = cli_main.get_api_key("proj-123")
assert token == "project-api-token"
Expand Down Expand Up @@ -235,8 +235,8 @@ def get_project(self, project_id):
def get_experiment(self, experiment_id):
return {"id": experiment_id}

monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient)
monkeypatch.setattr(cli_main, "get_access_token", lambda: "fake-token")
monkeypatch.setattr("codecarbon.core.api_client.ApiClient", FakeApiClient)
monkeypatch.setattr("codecarbon.cli.auth.get_access_token", lambda: "fake-token")
monkeypatch.setattr(
cli_main, "get_api_endpoint", lambda path: "https://api.codecarbon.io"
)
Expand Down Expand Up @@ -289,7 +289,9 @@ def start(self):
def stop(self):
return None

monkeypatch.setattr(cli_main, "OfflineEmissionsTracker", FakeOfflineTracker)
monkeypatch.setattr(
"codecarbon.emissions_tracker.OfflineEmissionsTracker", FakeOfflineTracker
)
monkeypatch.setattr(cli_main.signal, "signal", lambda *args, **kwargs: None)

runner = CliRunner()
Expand All @@ -311,7 +313,7 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs):
captured["kwargs"] = kwargs
return "ok"

monkeypatch.setattr(cli_main, "run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor)

ctx = SimpleNamespace(args=["python", "-c", "print(1)"])
result = cli_main.monitor(
Expand All @@ -332,7 +334,7 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs):
captured["kwargs"] = kwargs
return "ok"

monkeypatch.setattr(cli_main, "run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: "exp-1")

ctx = SimpleNamespace(args=["python", "train.py"])
Expand All @@ -350,7 +352,7 @@ def fake_run_and_monitor(ctx, **kwargs):
captured["kwargs"] = kwargs
return "ok"

monkeypatch.setattr(cli_main, "run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: "exp-1")

ctx = SimpleNamespace(args=["python", "train.py"])
Expand All @@ -368,11 +370,39 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs):
captured["kwargs"] = kwargs
return "ok"

monkeypatch.setattr(cli_main, "run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor)
monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: None)

ctx = SimpleNamespace(args=["python", "train.py"])
result = cli_main.monitor(ctx=ctx, api=False)
assert result == "ok"
assert captured["offline"] is False
assert captured["kwargs"]["save_to_api"] is False


def test_monitor_passes_log_level_to_run_and_monitor(monkeypatch):
captured = {}

def fake_run_and_monitor(ctx, offline=False, **kwargs):
captured["kwargs"] = kwargs

monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor)

ctx = SimpleNamespace(args=["echo", "hello"])
cli_main.monitor(
ctx=ctx,
offline=True,
country_iso_code="FRA",
log_level="debug",
)

assert captured["kwargs"]["log_level"] == "debug"


def test_monitor_online_requires_experiment_id_for_wrapped_command(monkeypatch):
monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: None)

ctx = SimpleNamespace(args=["echo", "hi"])
with pytest.raises(typer.Exit) as exc_info:
cli_main.monitor(ctx=ctx, offline=False, api=True)
assert exc_info.value.exit_code == 1
Loading
Loading