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
47 changes: 45 additions & 2 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -422,7 +455,17 @@ def monitor(
)
raise typer.Exit(1)

tracker_args = {**tracker_args, "save_to_api": api}
from codecarbon.output_methods.base_output import OutputMethod

# `--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

Expand Down Expand Up @@ -473,7 +516,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:")
Expand Down
1 change: 0 additions & 1 deletion codecarbon/cli/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
29 changes: 28 additions & 1 deletion codecarbon/emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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")
Expand Down
48 changes: 45 additions & 3 deletions tests/cli/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -418,11 +419,50 @@ 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"
assert captured["offline"] is False
assert captured["kwargs"]["save_to_api"] is True
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):
Expand All @@ -440,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 captured["kwargs"]["save_to_api"] is False
# --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):
Expand All @@ -458,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 captured["kwargs"]["save_to_api"] is False
# --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):
Expand Down
26 changes: 26 additions & 0 deletions tests/cli/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_")] == []
19 changes: 19 additions & 0 deletions tests/test_emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import tempfile
import time
import unittest
import warnings
from pathlib import Path
from unittest import mock

Expand Down Expand Up @@ -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__
Loading