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
55 changes: 48 additions & 7 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,34 @@ def config():
)


def _cli_provided(ctx, name: str) -> bool:
"""
Whether the option `name` was typed on the command line (or set through its
environment variable) rather than left at its Typer default.

Options left at their default must not be forwarded to the tracker: doing so
would silently override the values coming from `.codecarbon.config` and the
`CODECARBON_*` environment variables, which the tracker reads itself.
"""
get_source = getattr(ctx, "get_parameter_source", None)
if get_source is None:
# `monitor` called directly from Python, not through Click: every value
# given is explicit.
return True
source = get_source(name)
return source is None or source.name in ("COMMANDLINE", "ENVIRONMENT")


def _external_config() -> dict:
"""The configuration files and CODECARBON_* variables, as a plain dict."""
from codecarbon.core.config import get_hierarchical_config

try:
return dict(get_hierarchical_config())
except Exception:
return {}


@codecarbon.command(
"monitor",
short_help="Monitor your machine's carbon emissions.",
Expand Down Expand Up @@ -393,15 +421,28 @@ def monitor(
):
"""Monitor your machine's carbon emissions."""

# Shared tracker args so monitor and run_and_monitor behave the same
external_conf = _external_config()

# Shared tracker args so monitor and run_and_monitor behave the same.
# Only the options actually given are forwarded: the others are left to the
# tracker, which resolves them from the configuration file and environment.
cli_defaults = (
("measure_power_secs", measure_power_secs),
("api_call_interval", api_call_interval),
("log_level", log_level),
)
tracker_args = {
"measure_power_secs": measure_power_secs,
"api_call_interval": api_call_interval,
"log_level": log_level,
name: value for name, value in cli_defaults if _cli_provided(ctx, name)
}
for name, value in cli_defaults:
if name not in tracker_args and name not in external_conf:
# Nothing configures it: keep the defaults advertised by `--help`
# (and an unattended monitor quiet) instead of the tracker's own.
tracker_args[name] = value

# Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode
if offline:
if not country_iso_code:
if not country_iso_code and not external_conf.get("country_iso_code"):
print(
"ERROR: Country ISO code is required for offline mode. Add it to your configuration or provide it via the command line: `--country-iso-code FRA`",
file=sys.stderr,
Expand All @@ -410,8 +451,8 @@ def monitor(

tracker_args = {
**tracker_args,
"country_iso_code": country_iso_code,
"region": region,
**({"country_iso_code": country_iso_code} if country_iso_code else {}),
**({"region": region} if region else {}),
}
else:
experiment_id = get_existing_exp_id()
Expand Down
12 changes: 8 additions & 4 deletions codecarbon/cli/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import subprocess
import sys
from typing import Optional

import typer
from rich import print
Expand All @@ -12,9 +13,9 @@
def run_and_monitor(
ctx: typer.Context,
log_level: Annotated[
str,
Optional[str],
typer.Option(help="Log level (critical, error, warning, info, debug)"),
] = "error",
] = None,
offline: bool = False,
**tracker_args,
):
Expand Down Expand Up @@ -51,7 +52,11 @@ def run_and_monitor(
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
from codecarbon.external.logger import set_logger_level

set_logger_level(log_level)
# `log_level` is None when nothing set it: leave it to the tracker, which
# resolves it from the configuration file and the environment.
if log_level is not None:
set_logger_level(log_level)
tracker_args["log_level"] = log_level

# Get the command from remaining args (strip nested subcommand / `--` leftovers)
command = list(getattr(ctx, "args", None) or [])
Expand All @@ -67,7 +72,6 @@ def run_and_monitor(

tracker_cls = OfflineEmissionsTracker if offline else EmissionsTracker
tracker = tracker_cls(
log_level=log_level,
save_to_logger=False,
tracking_mode="process",
**tracker_args,
Expand Down
129 changes: 129 additions & 0 deletions tests/cli/test_cli_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the CodeCarbon CLI main function."""

import os
from types import SimpleNamespace

import pytest
Expand Down Expand Up @@ -386,6 +387,124 @@ def stop(self):
assert calls["kwargs"]["region"] == "IDF"


def _fake_offline_monitor(monkeypatch, tmp_path):
"""Patch the offline tracker and run the monitor loop in `tmp_path`."""
calls = {}

class FakeOfflineTracker:
def __init__(self, **kwargs):
calls["kwargs"] = kwargs
self._another_instance_already_running = True

def start(self):
pass

def stop(self):
return None

monkeypatch.setattr(
"codecarbon.emissions_tracker.OfflineEmissionsTracker", FakeOfflineTracker
)
monkeypatch.setattr(cli_main.signal, "signal", lambda *args, **kwargs: None)
# Isolate from the config file *and* the CODECARBON_* variables of the user
# running the tests: `get_hierarchical_config()` merges both.
monkeypatch.setenv("HOME", str(tmp_path))
for name in [n for n in os.environ if n.startswith("CODECARBON_")]:
monkeypatch.delenv(name)
monkeypatch.chdir(tmp_path)
return calls


def test_monitor_does_not_override_config_with_cli_defaults(monkeypatch, tmp_path):
"""Options left at their default must not shadow the config file."""
calls = _fake_offline_monitor(monkeypatch, tmp_path)
(tmp_path / ".codecarbon.config").write_text(
"[codecarbon]\n"
"log_level = DEBUG\n"
"measure_power_secs = 30\n"
"api_call_interval = 10\n"
"country_iso_code = FRA\n"
)

runner = CliRunner()
result = runner.invoke(cli_main.codecarbon, ["monitor", "--offline"])

assert result.exit_code == 0
# Nothing is forwarded: the tracker reads those values from the config itself
for name in ("log_level", "measure_power_secs", "api_call_interval"):
assert name not in calls["kwargs"]


def test_monitor_cli_options_win_over_config(monkeypatch, tmp_path):
calls = _fake_offline_monitor(monkeypatch, tmp_path)
(tmp_path / ".codecarbon.config").write_text(
"[codecarbon]\nlog_level = DEBUG\nmeasure_power_secs = 30\n"
"country_iso_code = FRA\n"
)

runner = CliRunner()
result = runner.invoke(
cli_main.codecarbon,
["monitor", "--offline", "--log-level", "warning", "--measure-power-secs", "5"],
)

assert result.exit_code == 0
assert calls["kwargs"]["log_level"] == "warning"
assert calls["kwargs"]["measure_power_secs"] == 5


def test_monitor_stays_quiet_without_configured_log_level(monkeypatch, tmp_path):
calls = _fake_offline_monitor(monkeypatch, tmp_path)

runner = CliRunner()
result = runner.invoke(
cli_main.codecarbon, ["monitor", "--offline", "--country-iso-code", "FRA"]
)

assert result.exit_code == 0
assert calls["kwargs"]["log_level"] == "error"


def test_monitor_keeps_documented_defaults_without_config(monkeypatch, tmp_path):
"""With nothing configured, the defaults advertised by `--help` are used."""
calls = _fake_offline_monitor(monkeypatch, tmp_path)

runner = CliRunner()
result = runner.invoke(
cli_main.codecarbon, ["monitor", "--offline", "--country-iso-code", "FRA"]
)

assert result.exit_code == 0
assert calls["kwargs"]["measure_power_secs"] == 10
assert calls["kwargs"]["api_call_interval"] == 30


def test_monitor_offline_rejects_empty_country_iso_code_in_config(
monkeypatch, tmp_path
):
"""An empty value in the config is not a country: the CLI must still refuse."""
_fake_offline_monitor(monkeypatch, tmp_path)
(tmp_path / ".codecarbon.config").write_text("[codecarbon]\ncountry_iso_code =\n")

runner = CliRunner()
result = runner.invoke(cli_main.codecarbon, ["monitor", "--offline"])

assert result.exit_code == 1


def test_monitor_offline_accepts_country_iso_code_from_config(monkeypatch, tmp_path):
calls = _fake_offline_monitor(monkeypatch, tmp_path)
(tmp_path / ".codecarbon.config").write_text(
"[codecarbon]\ncountry_iso_code = FRA\n"
)

runner = CliRunner()
result = runner.invoke(cli_main.codecarbon, ["monitor", "--offline"])

assert result.exit_code == 0
assert "country_iso_code" not in calls["kwargs"]


def test_monitor_delegates_offline_flag_to_run_and_monitor(monkeypatch):
captured = {}

Expand Down Expand Up @@ -480,6 +599,16 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs):
assert captured["kwargs"]["log_level"] == "debug"


def test_external_config_returns_empty_dict_on_error(monkeypatch):
"""A malformed config file must not crash `monitor`: fall back to `{}`."""

def raise_error():
raise ValueError("malformed config file")

monkeypatch.setattr("codecarbon.core.config.get_hierarchical_config", raise_error)
assert cli_main._external_config() == {}


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

Expand Down
48 changes: 48 additions & 0 deletions tests/cli/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,54 @@ def wait(self):
assert captured["kwargs"]["save_to_api"] is True


def _run_and_monitor_capturing(monkeypatch, **kwargs):
"""Run `run_and_monitor` on a dummy command, capturing what it does."""
captured = {"levels": []}

class FakeCapturingTracker(FakeTracker):
def __init__(self, **tracker_kwargs):
captured["kwargs"] = tracker_kwargs
super().__init__()

class FakePopen:
def __init__(self, command, text=True):
pass

def wait(self):
return 0

_patch_trackers(
monkeypatch, online_cls=FakeCapturingTracker, offline_cls=FakeCapturingTracker
)
monkeypatch.setattr(monitor_module.subprocess, "Popen", FakePopen)
monkeypatch.setattr(monitor_module, "print", lambda *args, **kwargs: None)
monkeypatch.setattr(
"codecarbon.external.logger.set_logger_level",
lambda level: captured["levels"].append(level),
)

with pytest.raises(typer.Exit) as exc_info:
monitor_module.run_and_monitor(SimpleNamespace(args=["echo", "hi"]), **kwargs)

assert exc_info.value.exit_code == 0
return captured


def test_run_and_monitor_leaves_log_level_to_the_config_by_default(monkeypatch):
"""No log level given: the tracker resolves it from the config, not from us."""
captured = _run_and_monitor_capturing(monkeypatch)

assert captured["levels"] == []
assert "log_level" not in captured["kwargs"]


def test_run_and_monitor_applies_given_log_level(monkeypatch):
captured = _run_and_monitor_capturing(monkeypatch, log_level="debug")

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


def test_run_and_monitor_handles_keyboard_interrupt(monkeypatch):
process_info = {"terminated": 0, "killed": 0}

Expand Down
Loading