Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Release drafter configuration https://github.com/release-drafter/release-drafter#configuration
# Emojis were chosen to match the https://gitmoji.carloscuesta.me/

name-template: "v$NEXT_PATCH_VERSION"
tag-template: "v$NEXT_PATCH_VERSION"
name-template: "v$RESOLVED_VERSION"
tag-template: "v$RESOLVED_VERSION"

categories:
- title: ":rocket: Features"
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ jobs:
pull-requests: write # for release-drafter/release-drafter to add label to PR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The release number comes from codecarbon/_version.py, kept in sync by bumpver
- id: version
run: echo "version=$(python3 -c "import runpy; print(runpy.run_path('codecarbon/_version.py')['__version__'])")" >> "$GITHUB_OUTPUT"
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
with:
version: ${{ steps.version.outputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25 changes: 15 additions & 10 deletions codecarbon/core/cpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
# default W value per core for a CPU if no model is found in the ref csv
DEFAULT_POWER_PER_CORE = 4

_TROUBLESHOOTING_URL = "https://docs.codecarbon.io/latest/how-to/troubleshooting/"
RAPL_PERMISSION_HELP = (
"You can grant read permission with: sudo chmod -R a+r /sys/class/powercap/* "
"(this does not persist across reboots, see "
f"{_TROUBLESHOOTING_URL}#rapl-permission-denied for a permanent setup)"
)


@lru_cache(maxsize=1)
def is_powergadget_available() -> bool:
Expand Down Expand Up @@ -180,18 +187,16 @@ def warn_permission_denied(energy_path: str):
nonlocal already_warned
if not already_warned:
logger.warning(
"\tRAPL - Permission denied reading RAPL file %s. "
"You can grant read permission with: "
"sudo chmod -R a+r /sys/class/powercap/*",
"\tRAPL - Permission denied reading RAPL file %s. %s",
energy_path,
RAPL_PERMISSION_HELP,
)
already_warned = True
else:
logger.debug(
"\tRAPL - Permission denied reading RAPL file %s. "
"You can grant read permission with: "
"sudo chmod -R a+r /sys/class/powercap/*",
"\tRAPL - Permission denied reading RAPL file %s. %s",
energy_path,
RAPL_PERMISSION_HELP,
)

return warn_permission_denied
Expand Down Expand Up @@ -592,8 +597,7 @@ def _validate_domain_readable(
return True, is_required_main
except PermissionError:
msg = f"\tRAPL - Permission denied reading RAPL file {rapl_file}."
suggestion = "You can grant read permission with: sudo chmod -R a+r /sys/class/powercap/*"
logger.warning("%s %s; skipping.", msg, suggestion)
logger.warning("%s %s; skipping.", msg, RAPL_PERMISSION_HELP)
return False, False
except Exception as e:
logger.debug(
Expand Down Expand Up @@ -1088,9 +1092,10 @@ def _main(self) -> Tuple[str, int]:
)
return cpu_model_detected, power
logger.warning(
"We saw that you have a %s but we don't know it."
+ " Please contact us.",
"We saw that you have a %s but we don't know it. "
"Please help us add it, see %s#unknown-cpu-model",
cpu_model_detected,
_TROUBLESHOOTING_URL,
)
if is_psutil_available():
# Count thread of the CPU
Expand Down
2 changes: 1 addition & 1 deletion codecarbon/core/powermetrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _has_powermetrics_sudo() -> bool:
logger.debug("""Not using PowerMetrics, sudo password prompt detected.
If you want to enable Powermetrics please modify your sudoers file
as described in :
https://docs.codecarbon.io/latest/explanation/methodology/#power-usage
https://docs.codecarbon.io/latest/how-to/troubleshooting/#macos
""")
return False
if process.returncode != 0:
Expand Down
20 changes: 18 additions & 2 deletions codecarbon/emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,22 @@ def get_detected_hardware(self) -> Dict[str, Any]:
return hardware_info

def service_shutdown(self, signum, frame):
"""
Signal handler that stops the tracker on SIGTERM/SIGINT, for use when
CodeCarbon runs as a long-lived service. Register it with:

```py
import signal

tracker = EmissionsTracker()
signal.signal(signal.SIGTERM, tracker.service_shutdown)
signal.signal(signal.SIGINT, tracker.service_shutdown)
```

:param signum: Signal number, passed by `signal.signal`
:param frame: Current stack frame, passed by `signal.signal`
:return: None
"""
logger.warning("service_shutdown - Caught signal %d" % signum)
self.stop()

Expand Down Expand Up @@ -1743,9 +1759,9 @@ def track_task_emissions(
):
"""
Decorator to track emissions specific to a task. With a tracker as input, it will add task emissions to global emissions.
:param: tracker: global tracker used in the current execution. If none is provided, instanciates an emission
:param tracker: global tracker used in the current execution. If none is provided, instanciates an emission
tracker which will read default parameter from config to enable tracking
:param: task_name: Task to be tracked. If none is provided, an id will be used.
:param task_name: Task to be tracked. If none is provided, an id will be used.
:return: The decorated function
"""

Expand Down
30 changes: 30 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,28 @@ build-backend = "setuptools.build_meta"
[project]
name = "codecarbon"
dynamic = ["version"]
description = "Track and reduce the CO2 emissions of your computing"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
keywords = [
"carbon",
"co2",
"emissions",
"carbon-footprint",
"sustainability",
"green-ai",
"green-computing",
"energy",
"energy-consumption",
"power-consumption",
"rapl",
"gpu",
"machine-learning",
"mlops",
"monitoring",
]
authors = [
{ name = "Mila" },
{ name = "DataForGood" },
Expand All @@ -17,12 +35,24 @@ authors = [
{ name = "Haverford College" },
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"Intended Audience :: System Administrators",
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: System :: Monitoring",
"Topic :: System :: Benchmark",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"arrow",
Expand Down
33 changes: 33 additions & 0 deletions tests/test_emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
EmissionsTracker,
OfflineEmissionsTracker,
track_emissions,
track_task_emissions,
)
from codecarbon.external.geography import CloudMetadata
from codecarbon.output import BoAmpsOutput, CodeCarbonAPIOutput, OutputMethod
Expand Down Expand Up @@ -609,6 +610,38 @@ def dummy_train_model():
dummy_train_model()
self.verify_output_file(self.emissions_file_path, 2)

def test_track_task_emissions_decorator(
self,
mock_cli_setup,
mock_log_values,
mocked_get_gpu_details,
mocked_env_cloud_details,
mocked_get_gpu_utilization_list,
mocked_is_gpu_details_available,
mocked_is_nvidia_system,
):
tracker = OfflineEmissionsTracker(
country_iso_code="USA",
measure_power_secs=1,
output_dir=self.temp_path,
experiment_id="test",
)
tracker.start()

@track_task_emissions(tracker=tracker, task_name="training")
def dummy_train_model():
heavy_computation(run_time_secs=1)
return 42

# The decorator is transparent: same name, same return value.
self.assertEqual("dummy_train_model", dummy_train_model.__name__)
self.assertEqual(42, dummy_train_model())

# ...and the task has been measured on the tracker we passed in.
self.assertIn("training", tracker._tasks)
self.assertGreater(tracker._tasks["training"].emissions_data.duration, 0)
tracker.stop()

def test_offline_tracker_country_name(
self,
mock_cli_setup,
Expand Down
41 changes: 40 additions & 1 deletion tests/test_rapl_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,49 @@
import os
import stat
import sys
from unittest import mock

import pytest

from codecarbon.core.cpu import IntelRAPL, is_rapl_available
from codecarbon.core.cpu import (
RAPL_PERMISSION_HELP,
IntelRAPL,
_create_warn_function,
is_rapl_available,
)


def test_permission_warning_is_emitted_once(caplog):
"""The scan hits every domain: warn once, then stay quiet on the debug channel."""
warn = _create_warn_function()

with caplog.at_level(logging.DEBUG, logger="codecarbon"):
warn("/sys/class/powercap/intel-rapl:0/energy_uj")
warn("/sys/class/powercap/intel-rapl:1/energy_uj")

levels = [r.levelno for r in caplog.records]
assert levels == [logging.WARNING, logging.DEBUG]
# Both messages tell the user how to fix the permissions.
for record in caplog.records:
assert RAPL_PERMISSION_HELP in record.getMessage()


def test_unreadable_domain_is_skipped_with_actionable_warning(caplog):
rapl = IntelRAPL.__new__(IntelRAPL) # no filesystem scan, we test the check alone

with (
mock.patch("builtins.open", side_effect=PermissionError("nope")),
caplog.at_level(logging.WARNING, logger="codecarbon"),
):
readable, is_main = rapl._validate_domain_readable(
"/sys/class/powercap/intel-rapl:0/energy_uj",
"/sys/class/powercap/intel-rapl:0",
"package-0",
)

# An unreadable domain is skipped, and never reported as the main one.
assert (readable, is_main) == (False, False)
assert RAPL_PERMISSION_HELP in caplog.text


@pytest.mark.skipif(not sys.platform.lower().startswith("lin"), reason="requires Linux")
Expand Down
26 changes: 26 additions & 0 deletions tests/test_readme_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Guard against README indexes referencing files that no longer exist."""

import re
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).parent.parent
# Markdown files whose relative links must all resolve.
CHECKED_FILES = [REPO_ROOT / "examples" / "README.md"]

LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")


@pytest.mark.parametrize("md_file", CHECKED_FILES, ids=lambda p: str(p.name))
def test_relative_links_exist(md_file):
if not md_file.exists():
# The wheel-validation job runs the tests without the rest of the repository.
pytest.skip(f"{md_file} is not present in this checkout")
missing = [
target
for target in LINK_RE.findall(md_file.read_text())
if not target.startswith(("http://", "https://", "mailto:", "#"))
and not (md_file.parent / target.split("#")[0]).exists()
]
assert not missing, f"{md_file} links to missing paths: {missing}"
Loading