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
41 changes: 39 additions & 2 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand Down Expand Up @@ -463,7 +491,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

Expand Down Expand Up @@ -514,7 +551,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 @@ -72,7 +72,6 @@ def run_and_monitor(

tracker_cls = OfflineEmissionsTracker if offline else EmissionsTracker
tracker = tracker_cls(
save_to_logger=False,
tracking_mode="process",
**tracker_args,
)
Expand Down
33 changes: 31 additions & 2 deletions 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 @@ -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

Expand Down Expand Up @@ -223,8 +249,11 @@ 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,
stacklevel=2,
# FutureWarning, not DeprecationWarning: this one is aimed at
# end users, and the default filter hides DeprecationWarning
# unless it comes from __main__.
FutureWarning,
stacklevel=_caller_stacklevel(),
)

self._set_from_conf(output_methods, "output_methods")
Expand Down
35 changes: 32 additions & 3 deletions tests/cli/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 @@ -541,7 +542,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):
Expand All @@ -559,7 +561,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):
Expand All @@ -577,7 +579,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):
Expand Down Expand Up @@ -616,3 +618,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]
5 changes: 3 additions & 2 deletions tests/cli/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _run_and_monitor_capturing(monkeypatch, **kwargs):
Expand Down
24 changes: 22 additions & 2 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 @@ -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=[],
Expand All @@ -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=[],
Expand Down Expand Up @@ -1153,3 +1154,22 @@ 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)

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__
Loading