diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 0ce0984f5..10993f68f 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -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" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 5bcb818be..63a9e166f 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -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 }} diff --git a/codecarbon/core/cpu.py b/codecarbon/core/cpu.py index 4ad8d839e..37ba3651d 100644 --- a/codecarbon/core/cpu.py +++ b/codecarbon/core/cpu.py @@ -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: @@ -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 @@ -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( @@ -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 diff --git a/codecarbon/core/powermetrics.py b/codecarbon/core/powermetrics.py index 8a86b753b..a6f834be6 100644 --- a/codecarbon/core/powermetrics.py +++ b/codecarbon/core/powermetrics.py @@ -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: diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 4bd0ea99d..3f5851de3 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -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() @@ -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 """ diff --git a/pyproject.toml b/pyproject.toml index 9f5d2fef6..7510aba48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }, @@ -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", diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index a2d6b5966..1d69d81d5 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -16,6 +16,7 @@ EmissionsTracker, OfflineEmissionsTracker, track_emissions, + track_task_emissions, ) from codecarbon.external.geography import CloudMetadata from codecarbon.output import BoAmpsOutput, CodeCarbonAPIOutput, OutputMethod @@ -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, diff --git a/tests/test_rapl_permissions.py b/tests/test_rapl_permissions.py index 4c522435d..f44579e9a 100644 --- a/tests/test_rapl_permissions.py +++ b/tests/test_rapl_permissions.py @@ -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") diff --git a/tests/test_readme_links.py b/tests/test_readme_links.py new file mode 100644 index 000000000..3614c3b44 --- /dev/null +++ b/tests/test_readme_links.py @@ -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}"