diff --git a/codecarbon/cli/main.py b/codecarbon/cli/main.py index c0f1c0fec..6f28c2309 100644 --- a/codecarbon/cli/main.py +++ b/codecarbon/cli/main.py @@ -22,6 +22,7 @@ get_existing_local_exp_id, overwrite_local_config, ) +from codecarbon.cli.monitor import run_and_monitor from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker @@ -339,13 +340,18 @@ def config(): ) -@codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.") +@codecarbon.command( + "monitor", + short_help="Monitor your machine's carbon emissions.", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) def monitor( + ctx: typer.Context, measure_power_secs: Annotated[ - int, typer.Argument(help="Interval between two measures.") + int, typer.Option(help="Interval between two measures.") ] = 10, api_call_interval: Annotated[ - int, typer.Argument(help="Number of measures between API calls.") + int, typer.Option(help="Number of measures between API calls.") ] = 30, api: Annotated[ bool, typer.Option(help="Choose to call Code Carbon API or not") @@ -359,6 +365,13 @@ def monitor( ] = None, ): """Monitor your machine's carbon emissions.""" + + # Shared tracker args so monitor and run_and_monitor behave the same + tracker_args = { + "measure_power_secs": measure_power_secs, + "api_call_interval": api_call_interval, + } + # Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode if offline: if not country_iso_code: print( @@ -366,11 +379,11 @@ def monitor( ) raise typer.Exit(1) - tracker = OfflineEmissionsTracker( - measure_power_secs=measure_power_secs, - country_iso_code=country_iso_code, - region=region, - ) + tracker_args = { + **tracker_args, + "country_iso_code": country_iso_code, + "region": region, + } else: experiment_id = get_existing_local_exp_id() if api and experiment_id is None: @@ -380,11 +393,17 @@ def monitor( ) raise typer.Exit(1) - tracker = EmissionsTracker( - measure_power_secs=measure_power_secs, - api_call_interval=api_call_interval, - save_to_api=api, - ) + tracker_args = {**tracker_args, "save_to_api": api} + + # If extra args are provided (e.g. `codecarbon monitor -- my_script.py`), delegate to `run_and_monitor` + if getattr(ctx, "args", None): + return run_and_monitor(ctx, **tracker_args) + + # Instantiate the tracker + if offline: + tracker = OfflineEmissionsTracker(**tracker_args) + else: + tracker = EmissionsTracker(**tracker_args) def signal_handler(signum, frame): print("\nReceived signal to stop. Saving emissions data...") diff --git a/codecarbon/cli/monitor.py b/codecarbon/cli/monitor.py new file mode 100644 index 000000000..ce606cfb5 --- /dev/null +++ b/codecarbon/cli/monitor.py @@ -0,0 +1,127 @@ +"""CodeCarbon CLI - Monitor Command""" + +import os +import subprocess +import sys + +import typer +from rich import print +from typing_extensions import Annotated + +from codecarbon.emissions_tracker import EmissionsTracker + + +def run_and_monitor( + ctx: typer.Context, + log_level: Annotated[ + str, typer.Option(help="Log level (critical, error, warning, info, debug)") + ] = "error", + **tracker_args, +): + """ + Run a command and track its carbon emissions. + + This command wraps any executable and measures the process's total power + consumption during its execution. When the command completes, a summary + report is displayed and emissions data is saved to a CSV file. + + Note: This tracks process-level emissions (only the specific command), not the + entire machine. For machine-level tracking, use the `monitor` command. + + Examples: + + Do not use quotes around the command. Use -- to separate CodeCarbon args. + + # Run any shell command: + codecarbon monitor -- ./benchmark.sh + + # Commands with arguments (use single quotes for special chars): + codecarbon monitor -- python -c 'print("Hello World!")' + + # Pipe the command output: + codecarbon monitor -- npm run test > output.txt + + # Display the CodeCarbon detailed logs: + codecarbon monitor --log-level debug -- python --version + + The emissions data is appended to emissions.csv (default) in the current + directory. The file path is shown in the final report. + """ + # Suppress all CodeCarbon logs during execution + from codecarbon.external.logger import set_logger_level + + set_logger_level(log_level) + + # Get the command from remaining args + command = ctx.args + + if not command: + print( + "ERROR: No command provided. Use: codecarbon monitor -- ", + file=sys.stderr, + ) + raise typer.Exit(1) + + # Initialize tracker with specified logging level and shared args + tracker = EmissionsTracker( + log_level=log_level, + save_to_logger=False, + tracking_mode="process", + **tracker_args, + ) + + print("🌱 CodeCarbon: Starting emissions tracking...") + print(f" Command: {' '.join(command)}") + print() + + tracker.start() + + process = None + try: + # Run the command, streaming output to console + # Let the child inherit the parent's std streams so Click's + # `CliRunner` can capture output (don't pass StringIO objects). + process = subprocess.Popen(command, text=True) + + # Wait for completion + exit_code = process.wait() + + except FileNotFoundError: + print(f"āŒ Error: Command not found: {command[0]}", file=sys.stderr) + exit_code = 127 + except KeyboardInterrupt: + print("\nāš ļø Interrupted by user", file=sys.stderr) + if process is not None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + exit_code = 130 + except Exception as e: + print(f"āŒ Error running command: {e}", file=sys.stderr) + exit_code = 1 + finally: + emissions = tracker.stop() + print() + print("=" * 60) + print("🌱 CodeCarbon Emissions Report") + print("=" * 60) + print(f" Command: {' '.join(command)}") + if emissions is not None: + print(f" Emissions: {emissions * 1000:.4f} g CO2eq") + else: + print(" Emissions: N/A") + + # Show where the data was saved + if hasattr(tracker, "_conf") and "output_file" in tracker._conf: + output_path = tracker._conf["output_file"] + # Make it absolute if it's relative + if not os.path.isabs(output_path): + output_path = os.path.abspath(output_path) + print(f" Saved to: {output_path}") + + print(" āš ļø Note: Tracked the command process and its children") + print("=" * 60) + + raise typer.Exit(exit_code) diff --git a/codecarbon/external/hardware.py b/codecarbon/external/hardware.py index 30ce125ee..aaa548617 100644 --- a/codecarbon/external/hardware.py +++ b/codecarbon/external/hardware.py @@ -4,6 +4,7 @@ import math import re +import time from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple @@ -182,6 +183,9 @@ def __init__( self._pid = psutil.Process().pid self._cpu_count = count_cpus() self._process = psutil.Process(self._pid) + # For process tracking: store last measurement time and CPU times + self._last_measurement_time: Optional[float] = None + self._last_cpu_times: Dict[int, float] = {} # pid -> total cpu time if self._mode == "intel_power_gadget": self._intel_interface = IntelPowerGadget(self._output_dir) @@ -245,11 +249,62 @@ def _get_power_from_cpu_load(self): f"CPU load {self._tdp} W and {cpu_load:.1f}% {load_factor=} => estimation of {power} W for whole machine." ) elif self._tracking_mode == "process": + # Use CPU times for accurate process tracking + current_time = time.time() + current_cpu_times: Dict[int, float] = {} + + # Get CPU time for main process and all children + try: + processes = [self._process] + self._process.children(recursive=True) + except (psutil.NoSuchProcess, psutil.AccessDenied): + processes = [self._process] + + for proc in processes: + try: + cpu_times = proc.cpu_times() + # Total CPU time = user + system time + total_cpu_time = cpu_times.user + cpu_times.system + current_cpu_times[proc.pid] = total_cpu_time + except (psutil.NoSuchProcess, psutil.AccessDenied): + logger.debug( + f"Process {proc.pid} disappeared or access denied when getting CPU times." + ) + + # Calculate CPU usage based on delta + if self._last_measurement_time is not None: + time_delta = current_time - self._last_measurement_time + if time_delta > 0: + total_cpu_delta = 0.0 + for pid, cpu_time in current_cpu_times.items(): + last_cpu_time = self._last_cpu_times.get(pid, cpu_time) + cpu_delta = cpu_time - last_cpu_time + if cpu_delta > 0: + total_cpu_delta += cpu_delta + logger.debug( + f"Process {pid} CPU time delta: {cpu_delta:.3f}s" + ) + + # CPU load as percentage (can be > 100% with multiple cores) + # total_cpu_delta is the CPU time used, time_delta is wall clock time + cpu_load = (total_cpu_delta / time_delta) * 100 + logger.debug( + f"Total CPU delta: {total_cpu_delta:.3f}s over {time_delta:.3f}s = {cpu_load:.1f}% (across {self._cpu_count} cores)" + ) + else: + cpu_load = 0.0 + else: + cpu_load = 0.0 + logger.debug("First measurement, no CPU delta available yet") - cpu_load = self._process.cpu_percent(interval=0.5) / self._cpu_count - power = self._tdp * cpu_load / 100 + # Store for next measurement + self._last_measurement_time = current_time + self._last_cpu_times = current_cpu_times + + # Normalize to percentage of total CPU capacity + cpu_load_normalized = cpu_load / self._cpu_count + power = self._tdp * cpu_load_normalized / 100 logger.debug( - f"CPU load {self._tdp} W and {cpu_load * 100:.1f}% => estimation of {power} W for process {self._pid}." + f"CPU load {self._tdp} W and {cpu_load:.1f}% ({cpu_load_normalized:.1f}% normalized) => estimation of {power:.2f} W for process {self._pid} and {len(current_cpu_times) - 1} children." ) else: raise Exception(f"Unknown tracking_mode {self._tracking_mode}") @@ -318,9 +373,13 @@ def measure_power_and_energy(self, last_duration: float) -> Tuple[Power, Energy] def start(self): if self._mode in ["intel_power_gadget", "intel_rapl", "apple_powermetrics"]: self._intel_interface.start() + # Reset process tracking state for fresh measurements + self._last_measurement_time = None + self._last_cpu_times = {} if self._mode == MODE_CPU_LOAD: # The first time this is called it will return a meaningless 0.0 value which you are supposed to ignore. _ = self._get_power_from_cpu_load() + _ = self._get_power_from_cpu_load() def monitor_power(self): cpu_power = self._get_power_from_cpus() diff --git a/docs/edit/usage.rst b/docs/edit/usage.rst index f4b77a777..63774359a 100644 --- a/docs/edit/usage.rst +++ b/docs/edit/usage.rst @@ -66,7 +66,67 @@ The command line could also works without internet by providing the country code codecarbon monitor --offline --country-iso-code FRA -Implementing CodeCarbon in your code allows you to track the emissions of a specific block of code. + +Running Any Command with CodeCarbon +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to track emissions while running any command or program (not just Python scripts), you can use the ``codecarbon monitor --`` command. +This allows non-Python users to measure machine emissions during the execution of any command: + +.. code-block:: console + + codecarbon monitor -- + +Do not surround ```` with quotes. The double hyphen ``--`` indicates the end of CodeCarbon options and the beginning of the command to run. + +**Examples:** + +.. code-block:: console + + # Run a shell script + codecarbon monitor -- ./benchmark.sh + + # Run a command with arguments (use quotes for special characters) + codecarbon monitor -- bash -c 'echo "Processing..."; sleep 30; echo "Done!"' + + # Run Python scripts + codecarbon monitor -- python train_model.py + + # Run Node.js applications + codecarbon monitor -- node app.js + + # Run tests with output redirection + codecarbon monitor -- npm run test > output.txt + + # Display the CodeCarbon detailed logs + codecarbon monitor --log-level debug -- python --version + +**Output:** + +When the command completes, CodeCarbon displays a summary report and saves the emissions data to a CSV file: + +.. code-block:: console + + 🌱 CodeCarbon: Starting emissions tracking... + Command: bash -c echo "Processing..."; sleep 30; echo "Done!" + + Processing... + Done! + + ============================================================ + 🌱 CodeCarbon Emissions Report + ============================================================ + Command: bash -c echo "Processing..."; sleep 30; echo "Done!" + Emissions: 0.0317 g CO2eq + Saved to: /home/user/emissions.csv + āš ļø Note: Measured entire machine (includes all system processes) + ============================================================ + +.. note:: + The ``codecarbon monitor --`` command tracks process-level emissions (only the specific command), not the + entire machine. For machine-level tracking, use the ``codecarbon monitor`` command. + +For more fine-grained tracking, implementing CodeCarbon in your code allows you to track the emissions of a specific block of code. Explicit Object ~~~~~~~~~~~~~~~ diff --git a/examples/command_line_tool.py b/examples/command_line_tool.py index da6f6584e..5308f3dfe 100644 --- a/examples/command_line_tool.py +++ b/examples/command_line_tool.py @@ -1,8 +1,16 @@ """ This example demonstrates how to use CodeCarbon with command line tools. -Here we measure the emissions of an speech-to-text with WhisperX. +āš ļø IMPORTANT LIMITATION: +CodeCarbon tracks emissions at the MACHINE level when monitoring external commands +via subprocess. It measures total system power during the command execution, which +includes the command itself AND all other system processes. +For accurate process-level tracking, the tracking code must be embedded in the +application being measured (not possible with external binaries like WhisperX). + +This example measures emissions during WhisperX execution, but cannot isolate +WhisperX's exact contribution from other system activity. """ import subprocess diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 000000000..0796b4218 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,81 @@ +"""Tests for the CodeCarbon CLI main function.""" + +from typer.testing import CliRunner + +from codecarbon.cli import main as cli_main + + +class FakeApiClient: + def __init__(self, endpoint_url=None): + self.endpoint_url = endpoint_url + self.token = None + + def set_access_token(self, token): + self.token = token + + def get_list_organizations(self): + return [{"id": "1", "name": "fake-org"}] + + +def fake_get_access_token(): + return "fake-token" + + +def test_version_flag(): + runner = CliRunner() + result = runner.invoke(cli_main.codecarbon, ["--version"]) + assert result.exit_code == 0 + assert cli_main.__app_name__ in result.output + assert str(cli_main.__version__) in result.output + + +def test_api_get_calls_api_and_prints(monkeypatch): + runner = CliRunner() + monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient) + monkeypatch.setattr(cli_main, "_get_access_token", fake_get_access_token) + + result = runner.invoke(cli_main.codecarbon, ["test-api"]) + assert result.exit_code == 0 + assert "fake-org" in result.output + + +def test_monitor_offline_requires_country_iso_code(): + runner = CliRunner() + result = runner.invoke(cli_main.codecarbon, ["monitor", "--offline"]) + assert result.exit_code != 0 + assert "country_iso_code is required for offline mode" in result.output + + +def test_detect_monkeypatched_tracker(monkeypatch): + class FakeTracker: + def __init__(self, save_to_file=False, **kwargs): + pass + + def get_detected_hardware(self): + return { + "ram_total_size": 8.0, + "cpu_count": 4, + "cpu_physical_count": 2, + "cpu_model": "Fake CPU", + "gpu_count": 1, + "gpu_model": "Fake GPU", + "gpu_ids": None, + } + + monkeypatch.setattr(cli_main, "EmissionsTracker", FakeTracker) + runner = CliRunner() + result = runner.invoke(cli_main.codecarbon, ["detect"]) + assert result.exit_code == 0 + assert "Detected Hardware" in result.output + assert "Fake CPU" in result.output + + +def test_monitor_run_and_monitor(monkeypatch): + runner = CliRunner() + + # Test with a simple command + result = runner.invoke( + cli_main.codecarbon, ["monitor", "--no-api", "--", "echo", "Hello, World!"] + ) + assert result.exit_code == 0 + assert "Hello, World!" in result.output