From 6cc8da8a562f819b3eedd820af8f28d4fc72eaf9 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 17:26:49 +0200 Subject: [PATCH 1/2] fix: attribute save_to_* deprecation to caller The warning was emitted with stacklevel=2 from _resolve_output_methods, which points at BaseEmissionsTracker.__init__ itself, so Python's default ignore::DeprecationWarning filter dropped it for every caller outside __main__. Compute the stack level dynamically instead, which also handles the extra frames added by OfflineEmissionsTracker and the @suppress decorator. Migrate the CLI's own call sites to output_methods= so codecarbon no longer trips its own deprecation check. Closes #1323 Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/cli/main.py | 10 ++++++++-- codecarbon/cli/monitor.py | 1 - codecarbon/emissions_tracker.py | 29 ++++++++++++++++++++++++++++- tests/cli/test_cli_main.py | 7 ++++--- tests/cli/test_monitor.py | 26 ++++++++++++++++++++++++++ tests/test_emissions_tracker.py | 19 +++++++++++++++++++ 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..c05007c9f 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -422,7 +422,13 @@ def monitor( ) raise typer.Exit(1) - tracker_args = {**tracker_args, "save_to_api": api} + if api: + from codecarbon.output_methods.base_output import OutputMethod + + tracker_args = { + **tracker_args, + "output_methods": [OutputMethod.CSV, OutputMethod.API], + } from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker @@ -473,7 +479,7 @@ def detect(): from codecarbon.emissions_tracker import EmissionsTracker print("Detecting hardware...") - tracker = EmissionsTracker(save_to_file=False) + tracker = EmissionsTracker(output_methods=[]) hardware_info = tracker.get_detected_hardware() print("\nDetected Hardware and System Information:") diff --git a/codecarbon/cli/monitor.py b/codecarbon/cli/monitor.py index 41b3ca353..7369ae76f 100644 --- a/codecarbon/cli/monitor.py +++ b/codecarbon/cli/monitor.py @@ -68,7 +68,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/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..8391f0423 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -5,10 +5,12 @@ from __future__ import annotations +import contextlib import dataclasses import os import platform import re +import sys import time import uuid import warnings @@ -55,6 +57,31 @@ _sentinel = object() +_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) +# `contextlib` shows up in the stack because of the `@suppress(...)` decorator +_INTERNAL_FILE = os.path.abspath(contextlib.__file__) + + +def _caller_stacklevel() -> int: + """ + Stack level of the first frame outside of the codecarbon package. + + Deprecation warnings are raised deep inside the tracker initialization, and the + number of intermediate frames depends on the entry point (``EmissionsTracker``, + ``OfflineEmissionsTracker``, the ``@track_emissions`` decorator, ...). Counting + frames dynamically attributes the warning to the user code that triggered it, so + that Python's default ``DeprecationWarning`` filter does not hide it. + """ + frame = sys._getframe(1) + level = 1 + while frame is not None: + filename = os.path.abspath(frame.f_code.co_filename) + if not filename.startswith(_PACKAGE_DIR) and filename != _INTERNAL_FILE: + return level + frame = frame.f_back + level += 1 + return 2 + class BaseEmissionsTracker(ABC): """ @@ -224,7 +251,7 @@ def _resolve_output_methods( "The save_to_* parameters are deprecated and will be removed in a " "future version. Use output_methods=[OutputMethod.CSV, ...] instead.", DeprecationWarning, - stacklevel=2, + stacklevel=_caller_stacklevel(), ) self._set_from_conf(output_methods, "output_methods") diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 8bb4d66f4..3ca53351e 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -8,6 +8,7 @@ from typer.testing import CliRunner from codecarbon.cli import main as cli_main +from codecarbon.output_methods.base_output import OutputMethod class FakeApiClient: @@ -422,7 +423,7 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs): result = cli_main.monitor(ctx=ctx, api=True) assert result == "ok" assert captured["offline"] is False - assert captured["kwargs"]["save_to_api"] is True + assert captured["kwargs"]["output_methods"] == [OutputMethod.CSV, OutputMethod.API] def test_monitor_delegates_to_run_and_monitor_with_extra_args(monkeypatch): @@ -440,7 +441,7 @@ def fake_run_and_monitor(ctx, **kwargs): result = cli_main.monitor(ctx=ctx, api=False) assert result == "ok" assert captured["args"] == ["python", "train.py"] - assert captured["kwargs"]["save_to_api"] is False + assert "output_methods" not in captured["kwargs"] def test_monitor_no_api_skips_experiment_id_requirement(monkeypatch): @@ -458,7 +459,7 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs): result = cli_main.monitor(ctx=ctx, api=False) assert result == "ok" assert captured["offline"] is False - assert captured["kwargs"]["save_to_api"] is False + assert "output_methods" not in captured["kwargs"] def test_monitor_passes_log_level_to_run_and_monitor(monkeypatch): diff --git a/tests/cli/test_monitor.py b/tests/cli/test_monitor.py index 0a9bda365..e9c5ec085 100644 --- a/tests/cli/test_monitor.py +++ b/tests/cli/test_monitor.py @@ -178,3 +178,29 @@ def kill(self): assert exc_info.value.exit_code == 130 assert process_info["terminated"] == 1 assert process_info["killed"] == 1 + + +def test_run_and_monitor_does_not_pass_deprecated_flags(monkeypatch): + """The CLI must not trigger codecarbon's own save_to_* deprecation.""" + captured = {} + + class CapturingTracker(FakeTracker): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + captured.update(kwargs) + + class FakePopen: + def __init__(self, command, text=True): + pass + + def wait(self): + return 0 + + _patch_trackers(monkeypatch, online_cls=CapturingTracker) + monkeypatch.setattr(monitor_module.subprocess, "Popen", FakePopen) + monkeypatch.setattr(monitor_module, "print", lambda *args, **kwargs: None) + + with pytest.raises(typer.Exit): + monitor_module.run_and_monitor(SimpleNamespace(args=["--", "echo", "hi"])) + + assert [key for key in captured if key.startswith("save_to_")] == [] diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 8ab12e5d8..9918b6c22 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -4,6 +4,7 @@ import tempfile import time import unittest +import warnings from pathlib import Path from unittest import mock @@ -1108,3 +1109,21 @@ def test_cumulative_emissions_with_varying_intensity( # Verification: If it wasn't cumulative, it would be 3.0 kWh * 300 g/kWh = 0.9 kg self.assertLess(data3.emissions, 0.8) + + +def test_deprecation_warning_points_at_caller(): + """The save_to_* deprecation must be attributed to the calling code, not to + codecarbon internals: otherwise Python's default filter silently drops it.""" + for tracker_cls, extra in ( + (EmissionsTracker, {}), + (OfflineEmissionsTracker, {"country_iso_code": "FRA"}), + ): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + tracker_cls(save_to_file=False, **extra) + + deprecations = [ + w for w in recorded if issubclass(w.category, DeprecationWarning) + ] + assert deprecations, f"no DeprecationWarning raised by {tracker_cls.__name__}" + assert deprecations[0].filename == __file__ From 91f3285ec81571aa6bc4e1c4cf7869af58d47f6b Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 21:12:52 +0200 Subject: [PATCH 2/2] fix(cli): keep --api/--no-api additive against the user's configuration output_methods= replaces where save_to_api= added, so a hardcoded [CSV, API] dropped configured outputs and --no-api became a no-op. --- codecarbon/cli/main.py | 49 +++++++++++++++++++++++++++++++++----- tests/cli/test_cli_main.py | 45 ++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index c05007c9f..6920db01d 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -358,6 +358,39 @@ def config(): ) +def _configured_output_methods(): + """ + The output methods a tracker would pick from configuration alone. + + Mirrors ``BaseEmissionsTracker._resolve_output_methods`` for the case where no + output method is passed to the constructor, so that the CLI can *add* to the + user's configuration instead of replacing it. + """ + from codecarbon.core.config import get_hierarchical_config + from codecarbon.output_methods.base_output import OutputMethod + + conf = get_hierarchical_config() + + configured = conf.get("output_methods") + if configured: + if isinstance(configured, str): + configured = configured.split(",") + return [OutputMethod(method.strip()) for method in configured if method.strip()] + + defaults = { + OutputMethod.CSV: ("save_to_file", True), + OutputMethod.API: ("save_to_api", False), + OutputMethod.LOGGER: ("save_to_logger", False), + OutputMethod.PROMETHEUS: ("save_to_prometheus", False), + OutputMethod.LOGFIRE: ("save_to_logfire", False), + } + return [ + method + for method, (key, default) in defaults.items() + if str(conf.get(key, default)).lower() == "true" + ] + + @codecarbon.command( "monitor", short_help="Monitor your machine's carbon emissions.", @@ -422,13 +455,17 @@ def monitor( ) raise typer.Exit(1) - if api: - from codecarbon.output_methods.base_output import OutputMethod + from codecarbon.output_methods.base_output import OutputMethod - tracker_args = { - **tracker_args, - "output_methods": [OutputMethod.CSV, OutputMethod.API], - } + # `--api` / `--no-api` add or remove the API output on top of whatever the + # user configured, the way the deprecated `save_to_api=True/False` used to. + # Hardcoding [CSV, API] here would silently drop a configured Prometheus or + # Logfire output and add a CSV file the user never asked for, and passing + # nothing for `--no-api` would leave the flag unable to turn the API off. + methods = [m for m in _configured_output_methods() if m is not OutputMethod.API] + if api: + methods.append(OutputMethod.API) + tracker_args = {**tracker_args, "output_methods": methods} from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py index 3ca53351e..bdb152716 100644 --- a/tests/cli/test_cli_main.py +++ b/tests/cli/test_cli_main.py @@ -419,6 +419,10 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs): monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor) monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: "exp-1") + monkeypatch.setattr( + "codecarbon.core.config.get_hierarchical_config", lambda: {}, raising=False + ) + ctx = SimpleNamespace(args=["python", "train.py"]) result = cli_main.monitor(ctx=ctx, api=True) assert result == "ok" @@ -426,6 +430,41 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs): assert captured["kwargs"]["output_methods"] == [OutputMethod.CSV, OutputMethod.API] +@pytest.mark.parametrize( + "conf, expected", + [ + # `--api` must add to the configured methods, not replace them. + ( + {"output_methods": "prometheus"}, + [OutputMethod.PROMETHEUS, OutputMethod.API], + ), + ( + {"save_to_file": "false", "save_to_logger": "true"}, + [OutputMethod.LOGGER, OutputMethod.API], + ), + # Already configured for the API: do not duplicate it. + ({"output_methods": "csv,api"}, [OutputMethod.CSV, OutputMethod.API]), + ], +) +def test_monitor_api_flag_adds_to_configured_output_methods( + monkeypatch, conf, expected +): + captured = {} + + def fake_run_and_monitor(ctx, offline=False, **kwargs): + captured["kwargs"] = kwargs + return "ok" + + monkeypatch.setattr("codecarbon.cli.monitor.run_and_monitor", fake_run_and_monitor) + monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: "exp-1") + monkeypatch.setattr( + "codecarbon.core.config.get_hierarchical_config", lambda: conf, raising=False + ) + + cli_main.monitor(ctx=SimpleNamespace(args=["python", "train.py"]), api=True) + assert captured["kwargs"]["output_methods"] == expected + + def test_monitor_delegates_to_run_and_monitor_with_extra_args(monkeypatch): captured = {} @@ -441,7 +480,8 @@ def fake_run_and_monitor(ctx, **kwargs): result = cli_main.monitor(ctx=ctx, api=False) assert result == "ok" assert captured["args"] == ["python", "train.py"] - assert "output_methods" not in captured["kwargs"] + # --no-api must be able to turn the API output off, not merely stay silent. + assert OutputMethod.API not in captured["kwargs"]["output_methods"] def test_monitor_no_api_skips_experiment_id_requirement(monkeypatch): @@ -459,7 +499,8 @@ def fake_run_and_monitor(ctx, offline=False, **kwargs): result = cli_main.monitor(ctx=ctx, api=False) assert result == "ok" assert captured["offline"] is False - assert "output_methods" not in captured["kwargs"] + # --no-api must be able to turn the API output off, not merely stay silent. + assert OutputMethod.API not in captured["kwargs"]["output_methods"] def test_monitor_passes_log_level_to_run_and_monitor(monkeypatch):