From 3719f923c7fa61b819ab39c67db4739ae17cd485 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Fri, 28 Aug 2026 16:34:12 +0200 Subject: [PATCH 1/5] The CLI was ignoring config file --- codecarbon/cli/main.py | 54 ++++++++++++++++++++--- codecarbon/cli/monitor.py | 12 ++++-- tests/cli/test_cli_main.py | 88 ++++++++++++++++++++++++++++++++++++++ tests/cli/test_monitor.py | 48 +++++++++++++++++++++ 4 files changed, 191 insertions(+), 11 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..477be9904 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -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.", @@ -393,15 +421,27 @@ 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. tracker_args = { - "measure_power_secs": measure_power_secs, - "api_call_interval": api_call_interval, - "log_level": log_level, + name: value + for name, value in ( + ("measure_power_secs", measure_power_secs), + ("api_call_interval", api_call_interval), + ("log_level", log_level), + ) + if _cli_provided(ctx, name) } + if "log_level" not in tracker_args and "log_level" not in external_conf: + # Nothing configures it: keep the unattended monitor quiet. + tracker_args["log_level"] = log_level + # 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 "country_iso_code" not in external_conf: 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, @@ -410,8 +450,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() diff --git a/codecarbon/cli/monitor.py b/codecarbon/cli/monitor.py index 41b3ca353..dbecf7855 100644 --- a/codecarbon/cli/monitor.py +++ b/codecarbon/cli/monitor.py @@ -3,6 +3,7 @@ import os import subprocess import sys +from typing import Optional import typer from rich import print @@ -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, ): @@ -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 []) @@ -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, diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 8bb4d66f4..6016b1db1 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -386,6 +386,94 @@ 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 any config file of the user running the tests + monkeypatch.setenv("HOME", str(tmp_path)) + 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_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 = {} diff --git a/tests/cli/test_monitor.py b/tests/cli/test_monitor.py index 0a9bda365..f3fa4852c 100644 --- a/tests/cli/test_monitor.py +++ b/tests/cli/test_monitor.py @@ -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} From d0d83937357224c2e09360f9d85cbd8e988fe06a Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Fri, 28 Aug 2026 17:10:21 +0200 Subject: [PATCH 2/5] coverage --- tests/cli/test_cli_main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 6016b1db1..64efe4770 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -568,6 +568,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) From d1f87ff082fdec8f04a1ff00a616017aa330ccd8 Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Fri, 28 Aug 2026 16:34:12 +0200 Subject: [PATCH 3/5] The CLI was ignoring config file --- codecarbon/cli/main.py | 54 ++++++++++++++++++++--- codecarbon/cli/monitor.py | 12 ++++-- tests/cli/test_cli_main.py | 88 ++++++++++++++++++++++++++++++++++++++ tests/cli/test_monitor.py | 48 +++++++++++++++++++++ 4 files changed, 191 insertions(+), 11 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..477be9904 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -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.", @@ -393,15 +421,27 @@ 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. tracker_args = { - "measure_power_secs": measure_power_secs, - "api_call_interval": api_call_interval, - "log_level": log_level, + name: value + for name, value in ( + ("measure_power_secs", measure_power_secs), + ("api_call_interval", api_call_interval), + ("log_level", log_level), + ) + if _cli_provided(ctx, name) } + if "log_level" not in tracker_args and "log_level" not in external_conf: + # Nothing configures it: keep the unattended monitor quiet. + tracker_args["log_level"] = log_level + # 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 "country_iso_code" not in external_conf: 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, @@ -410,8 +450,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() diff --git a/codecarbon/cli/monitor.py b/codecarbon/cli/monitor.py index 41b3ca353..dbecf7855 100644 --- a/codecarbon/cli/monitor.py +++ b/codecarbon/cli/monitor.py @@ -3,6 +3,7 @@ import os import subprocess import sys +from typing import Optional import typer from rich import print @@ -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, ): @@ -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 []) @@ -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, diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 8bb4d66f4..6016b1db1 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -386,6 +386,94 @@ 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 any config file of the user running the tests + monkeypatch.setenv("HOME", str(tmp_path)) + 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_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 = {} diff --git a/tests/cli/test_monitor.py b/tests/cli/test_monitor.py index 0a9bda365..f3fa4852c 100644 --- a/tests/cli/test_monitor.py +++ b/tests/cli/test_monitor.py @@ -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} From 509a73ce047bbdf78f72cf632d90b5404685160d Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Fri, 28 Aug 2026 17:10:21 +0200 Subject: [PATCH 4/5] coverage --- tests/cli/test_cli_main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 6016b1db1..64efe4770 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -568,6 +568,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) From ee9b413d684d91a5bce8f5f7b3771cf07d6cff7e Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Sat, 5 Sep 2026 08:35:40 +0200 Subject: [PATCH 5/5] review --- codecarbon/cli/main.py | 23 ++++++++++++----------- tests/cli/test_cli_main.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 477be9904..f81fb302a 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -426,22 +426,23 @@ def monitor( # 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 = { - name: value - for name, value in ( - ("measure_power_secs", measure_power_secs), - ("api_call_interval", api_call_interval), - ("log_level", log_level), - ) - if _cli_provided(ctx, name) + name: value for name, value in cli_defaults if _cli_provided(ctx, name) } - if "log_level" not in tracker_args and "log_level" not in external_conf: - # Nothing configures it: keep the unattended monitor quiet. - tracker_args["log_level"] = log_level + 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 and "country_iso_code" not in external_conf: + 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, diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 64efe4770..09585126f 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -1,5 +1,6 @@ """Tests for the CodeCarbon CLI main function.""" +import os from types import SimpleNamespace import pytest @@ -405,8 +406,11 @@ def stop(self): "codecarbon.emissions_tracker.OfflineEmissionsTracker", FakeOfflineTracker ) monkeypatch.setattr(cli_main.signal, "signal", lambda *args, **kwargs: None) - # Isolate from any config file of the user running the tests + # 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 @@ -461,6 +465,33 @@ def test_monitor_stays_quiet_without_configured_log_level(monkeypatch, tmp_path) 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(