From 55855fb046c3d65d2fa0bd5e5d2694bd0823e256 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 19 Aug 2026 16:21:03 +0200 Subject: [PATCH 1/2] fix: make the save_to_* deprecation a FutureWarning The warning is raised inside _resolve_output_methods, so it is attributed to emissions_tracker.py and Python's default filter -- which only shows DeprecationWarning from __main__ -- drops it for every user. FutureWarning exists for exactly this: deprecations aimed at end users that must always be shown. It makes stacklevel irrelevant to visibility. (skip_file_prefixes= would be the stdlib answer, but it is 3.12+ and requires-python is >=3.10.) Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/emissions_tracker.py | 5 ++++- tests/test_emissions_tracker.py | 23 +++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..de4a1a6e9 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -223,7 +223,10 @@ def _resolve_output_methods( warnings.warn( "The save_to_* parameters are deprecated and will be removed in a " "future version. Use output_methods=[OutputMethod.CSV, ...] instead.", - DeprecationWarning, + # FutureWarning, not DeprecationWarning: this one is aimed at + # end users, and the default filter hides DeprecationWarning + # unless it comes from __main__. + FutureWarning, stacklevel=2, ) diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 8ab12e5d8..fd330aa78 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 @@ -383,7 +384,7 @@ def test_save_to_flags_map_to_output_methods_and_warn( mocked_is_gpu_details_available, mocked_is_nvidia_system, ): - with self.assertWarns(DeprecationWarning): + with self.assertWarns(FutureWarning): tracker = EmissionsTracker( output_dir=self.temp_path, output_handlers=[], @@ -408,7 +409,7 @@ def test_output_methods_overrides_save_to_flags( mocked_is_gpu_details_available, mocked_is_nvidia_system, ): - with self.assertWarns(DeprecationWarning): + with self.assertWarns(FutureWarning): tracker = EmissionsTracker( output_dir=self.temp_path, output_handlers=[], @@ -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_survives_the_default_filter(): + """The save_to_* deprecation is aimed at end users, so it must not be a + DeprecationWarning: the default filter would silently drop it.""" + for tracker_cls, extra in ( + (EmissionsTracker, {}), + (OfflineEmissionsTracker, {"country_iso_code": "FRA"}), + ): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("default") + # What Python installs by default, and what hid this warning before. + warnings.simplefilter("ignore", DeprecationWarning) + tracker_cls(save_to_file=False, **extra) + + assert [ + w for w in recorded if issubclass(w.category, FutureWarning) + ], f"no FutureWarning raised by {tracker_cls.__name__}" From 99b6c55b0e20ac66d5fd58568ff36e7e25ad642b Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 23 Sep 2026 16:44:43 +0900 Subject: [PATCH 2/2] fix: attribute save_to_* warning to the caller and move the CLI to output_methods Co-Authored-By: Claude Opus 5.5 (1M context) --- codecarbon/cli/main.py | 41 +++++++++++++++++++++++++++++++-- codecarbon/cli/monitor.py | 1 - codecarbon/emissions_tracker.py | 28 +++++++++++++++++++++- tests/cli/test_cli_main.py | 35 +++++++++++++++++++++++++--- tests/cli/test_monitor.py | 5 ++-- tests/test_emissions_tracker.py | 7 +++--- 6 files changed, 105 insertions(+), 12 deletions(-) diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index 93f627e5b..4dacae865 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -26,6 +26,34 @@ codecarbon = typer.Typer(no_args_is_help=True) +# save_to_* configuration key -> (output method, default), as the tracker reads them +_SAVE_TO_KEYS = { + "save_to_file": ("csv", True), + "save_to_api": ("api", False), + "save_to_logger": ("logger", False), + "save_to_prometheus": ("prometheus", False), + "save_to_logfire": ("logfire", False), +} + + +def _configured_output_methods() -> list: + """Output methods resolved from configuration alone, like the tracker does.""" + from codecarbon.core.config import get_hierarchical_config + from codecarbon.output_methods.base_output import OutputMethod + + conf = get_hierarchical_config() + if conf.get("output_methods"): + return [ + OutputMethod(method.strip()) + for method in conf["output_methods"].split(",") + if method.strip() + ] + return [ + OutputMethod(method) + for key, (method, default) in _SAVE_TO_KEYS.items() + if str(conf.get(key, default)).lower() == "true" + ] + def main(): """ @@ -422,7 +450,16 @@ def monitor( ) raise typer.Exit(1) - tracker_args = {**tracker_args, "save_to_api": api} + # output_methods replaces the configured outputs, so start from them and + # only add or remove the API, as save_to_api=api used to. + from codecarbon.output_methods.base_output import OutputMethod + + output_methods = [ + m for m in _configured_output_methods() if m != OutputMethod.API + ] + if api: + output_methods.append(OutputMethod.API) + tracker_args = {**tracker_args, "output_methods": output_methods} from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker @@ -473,7 +510,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 de4a1a6e9..c3e46cb03 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 @@ -50,6 +52,30 @@ # # To fix this, a complex move would be to have default values set to the sentinel: # _sentinel = object() + +_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep +# The `@suppress(...)` decorator adds a `contextlib` frame to the stack. +_CONTEXTLIB_FILE = os.path.abspath(contextlib.__file__) + + +def _caller_stacklevel() -> int: + """ + ``stacklevel`` of the first frame outside the codecarbon package, so a warning + raised deep inside tracker initialization points at the user's code whatever + the entry point (``EmissionsTracker``, ``OfflineEmissionsTracker``, + ``@track_emissions``, ...). + """ + frame = sys._getframe(1) # the function calling warnings.warn() + level = 1 + while frame is not None: + filename = os.path.abspath(frame.f_code.co_filename) + if not filename.startswith(_PACKAGE_DIR) and filename != _CONTEXTLIB_FILE: + return level + frame = frame.f_back + level += 1 + return 2 + + # see: https://stackoverflow.com/questions/67202314/ # python-distinguish-default-argument-and-argument-provided-with-default-value @@ -227,7 +253,7 @@ def _resolve_output_methods( # end users, and the default filter hides DeprecationWarning # unless it comes from __main__. FutureWarning, - 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..0cd402199 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,8 @@ 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 OutputMethod.API in captured["kwargs"]["output_methods"] + assert "save_to_api" not in captured["kwargs"] def test_monitor_delegates_to_run_and_monitor_with_extra_args(monkeypatch): @@ -440,7 +442,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 OutputMethod.API not in captured["kwargs"]["output_methods"] def test_monitor_no_api_skips_experiment_id_requirement(monkeypatch): @@ -458,7 +460,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 OutputMethod.API not in captured["kwargs"]["output_methods"] def test_monitor_passes_log_level_to_run_and_monitor(monkeypatch): @@ -487,3 +489,30 @@ def test_monitor_online_requires_experiment_id_for_wrapped_command(monkeypatch): with pytest.raises(typer.Exit) as exc_info: cli_main.monitor(ctx=ctx, offline=False, api=True) assert exc_info.value.exit_code == 1 + + +@pytest.mark.parametrize( + "conf, api, expected", + [ + ({"output_methods": "prometheus"}, True, ["prometheus", "api"]), + ({"output_methods": "csv,api"}, False, ["csv"]), + ({"save_to_file": "false", "save_to_logger": "true"}, True, ["logger", "api"]), + ({"save_to_api": "true"}, False, ["csv"]), + ], +) +def test_monitor_api_flag_adds_to_configured_output_methods( + monkeypatch, conf, api, 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) + + cli_main.monitor(ctx=SimpleNamespace(args=["python", "train.py"]), api=api) + + assert captured["kwargs"]["output_methods"] == [OutputMethod(m) for m in expected] diff --git a/tests/cli/test_monitor.py b/tests/cli/test_monitor.py index 0a9bda365..124dd879c 100644 --- a/tests/cli/test_monitor.py +++ b/tests/cli/test_monitor.py @@ -142,12 +142,13 @@ def wait(self): with pytest.raises(typer.Exit) as exc_info: monitor_module.run_and_monitor( SimpleNamespace(args=["echo", "hi"]), - save_to_api=True, + output_methods=["api"], ) assert exc_info.value.exit_code == 0 assert captured["kwargs"]["tracking_mode"] == "process" - assert captured["kwargs"]["save_to_api"] is True + assert captured["kwargs"]["output_methods"] == ["api"] + assert not any(k.startswith("save_to_") for k in captured["kwargs"]) def test_run_and_monitor_handles_keyboard_interrupt(monkeypatch): diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index fd330aa78..2384108fb 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -1124,6 +1124,7 @@ def test_deprecation_warning_survives_the_default_filter(): warnings.simplefilter("ignore", DeprecationWarning) tracker_cls(save_to_file=False, **extra) - assert [ - w for w in recorded if issubclass(w.category, FutureWarning) - ], f"no FutureWarning raised by {tracker_cls.__name__}" + future = [w for w in recorded if issubclass(w.category, FutureWarning)] + assert future, f"no FutureWarning raised by {tracker_cls.__name__}" + # THEN the warning points at the caller, not at codecarbon internals. + assert future[0].filename == __file__