Skip to content

Commit f13351a

Browse files
inimazbenoit-cty
andauthored
feat: codecarbon monitor -- any_command (#1004)
* feat: codecarbon run -- any_command * Process mode * Monitor sub-process * fix: rename to codecarbon monitor * style: pre-commit changes * tests: tests for the cli --------- Co-authored-by: benoit-cty <4-benoit-cty@users.noreply.git.leximpact.dev>
1 parent c4be423 commit f13351a

6 files changed

Lines changed: 372 additions & 18 deletions

File tree

codecarbon/cli/main.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_existing_local_exp_id,
2323
overwrite_local_config,
2424
)
25+
from codecarbon.cli.monitor import run_and_monitor
2526
from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone
2627
from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate
2728
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
@@ -339,13 +340,18 @@ def config():
339340
)
340341

341342

342-
@codecarbon.command("monitor", short_help="Monitor your machine's carbon emissions.")
343+
@codecarbon.command(
344+
"monitor",
345+
short_help="Monitor your machine's carbon emissions.",
346+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
347+
)
343348
def monitor(
349+
ctx: typer.Context,
344350
measure_power_secs: Annotated[
345-
int, typer.Argument(help="Interval between two measures.")
351+
int, typer.Option(help="Interval between two measures.")
346352
] = 10,
347353
api_call_interval: Annotated[
348-
int, typer.Argument(help="Number of measures between API calls.")
354+
int, typer.Option(help="Number of measures between API calls.")
349355
] = 30,
350356
api: Annotated[
351357
bool, typer.Option(help="Choose to call Code Carbon API or not")
@@ -359,18 +365,25 @@ def monitor(
359365
] = None,
360366
):
361367
"""Monitor your machine's carbon emissions."""
368+
369+
# Shared tracker args so monitor and run_and_monitor behave the same
370+
tracker_args = {
371+
"measure_power_secs": measure_power_secs,
372+
"api_call_interval": api_call_interval,
373+
}
374+
# Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode
362375
if offline:
363376
if not country_iso_code:
364377
print(
365378
"ERROR: country_iso_code is required for offline mode", file=sys.stderr
366379
)
367380
raise typer.Exit(1)
368381

369-
tracker = OfflineEmissionsTracker(
370-
measure_power_secs=measure_power_secs,
371-
country_iso_code=country_iso_code,
372-
region=region,
373-
)
382+
tracker_args = {
383+
**tracker_args,
384+
"country_iso_code": country_iso_code,
385+
"region": region,
386+
}
374387
else:
375388
experiment_id = get_existing_local_exp_id()
376389
if api and experiment_id is None:
@@ -380,11 +393,17 @@ def monitor(
380393
)
381394
raise typer.Exit(1)
382395

383-
tracker = EmissionsTracker(
384-
measure_power_secs=measure_power_secs,
385-
api_call_interval=api_call_interval,
386-
save_to_api=api,
387-
)
396+
tracker_args = {**tracker_args, "save_to_api": api}
397+
398+
# If extra args are provided (e.g. `codecarbon monitor -- my_script.py`), delegate to `run_and_monitor`
399+
if getattr(ctx, "args", None):
400+
return run_and_monitor(ctx, **tracker_args)
401+
402+
# Instantiate the tracker
403+
if offline:
404+
tracker = OfflineEmissionsTracker(**tracker_args)
405+
else:
406+
tracker = EmissionsTracker(**tracker_args)
388407

389408
def signal_handler(signum, frame):
390409
print("\nReceived signal to stop. Saving emissions data...")

codecarbon/cli/monitor.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""CodeCarbon CLI - Monitor Command"""
2+
3+
import os
4+
import subprocess
5+
import sys
6+
7+
import typer
8+
from rich import print
9+
from typing_extensions import Annotated
10+
11+
from codecarbon.emissions_tracker import EmissionsTracker
12+
13+
14+
def run_and_monitor(
15+
ctx: typer.Context,
16+
log_level: Annotated[
17+
str, typer.Option(help="Log level (critical, error, warning, info, debug)")
18+
] = "error",
19+
**tracker_args,
20+
):
21+
"""
22+
Run a command and track its carbon emissions.
23+
24+
This command wraps any executable and measures the process's total power
25+
consumption during its execution. When the command completes, a summary
26+
report is displayed and emissions data is saved to a CSV file.
27+
28+
Note: This tracks process-level emissions (only the specific command), not the
29+
entire machine. For machine-level tracking, use the `monitor` command.
30+
31+
Examples:
32+
33+
Do not use quotes around the command. Use -- to separate CodeCarbon args.
34+
35+
# Run any shell command:
36+
codecarbon monitor -- ./benchmark.sh
37+
38+
# Commands with arguments (use single quotes for special chars):
39+
codecarbon monitor -- python -c 'print("Hello World!")'
40+
41+
# Pipe the command output:
42+
codecarbon monitor -- npm run test > output.txt
43+
44+
# Display the CodeCarbon detailed logs:
45+
codecarbon monitor --log-level debug -- python --version
46+
47+
The emissions data is appended to emissions.csv (default) in the current
48+
directory. The file path is shown in the final report.
49+
"""
50+
# Suppress all CodeCarbon logs during execution
51+
from codecarbon.external.logger import set_logger_level
52+
53+
set_logger_level(log_level)
54+
55+
# Get the command from remaining args
56+
command = ctx.args
57+
58+
if not command:
59+
print(
60+
"ERROR: No command provided. Use: codecarbon monitor -- <command>",
61+
file=sys.stderr,
62+
)
63+
raise typer.Exit(1)
64+
65+
# Initialize tracker with specified logging level and shared args
66+
tracker = EmissionsTracker(
67+
log_level=log_level,
68+
save_to_logger=False,
69+
tracking_mode="process",
70+
**tracker_args,
71+
)
72+
73+
print("🌱 CodeCarbon: Starting emissions tracking...")
74+
print(f" Command: {' '.join(command)}")
75+
print()
76+
77+
tracker.start()
78+
79+
process = None
80+
try:
81+
# Run the command, streaming output to console
82+
# Let the child inherit the parent's std streams so Click's
83+
# `CliRunner` can capture output (don't pass StringIO objects).
84+
process = subprocess.Popen(command, text=True)
85+
86+
# Wait for completion
87+
exit_code = process.wait()
88+
89+
except FileNotFoundError:
90+
print(f"❌ Error: Command not found: {command[0]}", file=sys.stderr)
91+
exit_code = 127
92+
except KeyboardInterrupt:
93+
print("\n⚠️ Interrupted by user", file=sys.stderr)
94+
if process is not None:
95+
process.terminate()
96+
try:
97+
process.wait(timeout=5)
98+
except subprocess.TimeoutExpired:
99+
process.kill()
100+
exit_code = 130
101+
except Exception as e:
102+
print(f"❌ Error running command: {e}", file=sys.stderr)
103+
exit_code = 1
104+
finally:
105+
emissions = tracker.stop()
106+
print()
107+
print("=" * 60)
108+
print("🌱 CodeCarbon Emissions Report")
109+
print("=" * 60)
110+
print(f" Command: {' '.join(command)}")
111+
if emissions is not None:
112+
print(f" Emissions: {emissions * 1000:.4f} g CO2eq")
113+
else:
114+
print(" Emissions: N/A")
115+
116+
# Show where the data was saved
117+
if hasattr(tracker, "_conf") and "output_file" in tracker._conf:
118+
output_path = tracker._conf["output_file"]
119+
# Make it absolute if it's relative
120+
if not os.path.isabs(output_path):
121+
output_path = os.path.abspath(output_path)
122+
print(f" Saved to: {output_path}")
123+
124+
print(" ⚠️ Note: Tracked the command process and its children")
125+
print("=" * 60)
126+
127+
raise typer.Exit(exit_code)

codecarbon/external/hardware.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import math
66
import re
7+
import time
78
from abc import ABC, abstractmethod
89
from dataclasses import dataclass
910
from typing import Dict, Iterable, List, Optional, Tuple
@@ -182,6 +183,9 @@ def __init__(
182183
self._pid = psutil.Process().pid
183184
self._cpu_count = count_cpus()
184185
self._process = psutil.Process(self._pid)
186+
# For process tracking: store last measurement time and CPU times
187+
self._last_measurement_time: Optional[float] = None
188+
self._last_cpu_times: Dict[int, float] = {} # pid -> total cpu time
185189

186190
if self._mode == "intel_power_gadget":
187191
self._intel_interface = IntelPowerGadget(self._output_dir)
@@ -245,11 +249,62 @@ def _get_power_from_cpu_load(self):
245249
f"CPU load {self._tdp} W and {cpu_load:.1f}% {load_factor=} => estimation of {power} W for whole machine."
246250
)
247251
elif self._tracking_mode == "process":
252+
# Use CPU times for accurate process tracking
253+
current_time = time.time()
254+
current_cpu_times: Dict[int, float] = {}
255+
256+
# Get CPU time for main process and all children
257+
try:
258+
processes = [self._process] + self._process.children(recursive=True)
259+
except (psutil.NoSuchProcess, psutil.AccessDenied):
260+
processes = [self._process]
261+
262+
for proc in processes:
263+
try:
264+
cpu_times = proc.cpu_times()
265+
# Total CPU time = user + system time
266+
total_cpu_time = cpu_times.user + cpu_times.system
267+
current_cpu_times[proc.pid] = total_cpu_time
268+
except (psutil.NoSuchProcess, psutil.AccessDenied):
269+
logger.debug(
270+
f"Process {proc.pid} disappeared or access denied when getting CPU times."
271+
)
272+
273+
# Calculate CPU usage based on delta
274+
if self._last_measurement_time is not None:
275+
time_delta = current_time - self._last_measurement_time
276+
if time_delta > 0:
277+
total_cpu_delta = 0.0
278+
for pid, cpu_time in current_cpu_times.items():
279+
last_cpu_time = self._last_cpu_times.get(pid, cpu_time)
280+
cpu_delta = cpu_time - last_cpu_time
281+
if cpu_delta > 0:
282+
total_cpu_delta += cpu_delta
283+
logger.debug(
284+
f"Process {pid} CPU time delta: {cpu_delta:.3f}s"
285+
)
286+
287+
# CPU load as percentage (can be > 100% with multiple cores)
288+
# total_cpu_delta is the CPU time used, time_delta is wall clock time
289+
cpu_load = (total_cpu_delta / time_delta) * 100
290+
logger.debug(
291+
f"Total CPU delta: {total_cpu_delta:.3f}s over {time_delta:.3f}s = {cpu_load:.1f}% (across {self._cpu_count} cores)"
292+
)
293+
else:
294+
cpu_load = 0.0
295+
else:
296+
cpu_load = 0.0
297+
logger.debug("First measurement, no CPU delta available yet")
248298

249-
cpu_load = self._process.cpu_percent(interval=0.5) / self._cpu_count
250-
power = self._tdp * cpu_load / 100
299+
# Store for next measurement
300+
self._last_measurement_time = current_time
301+
self._last_cpu_times = current_cpu_times
302+
303+
# Normalize to percentage of total CPU capacity
304+
cpu_load_normalized = cpu_load / self._cpu_count
305+
power = self._tdp * cpu_load_normalized / 100
251306
logger.debug(
252-
f"CPU load {self._tdp} W and {cpu_load * 100:.1f}% => estimation of {power} W for process {self._pid}."
307+
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."
253308
)
254309
else:
255310
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]
318373
def start(self):
319374
if self._mode in ["intel_power_gadget", "intel_rapl", "apple_powermetrics"]:
320375
self._intel_interface.start()
376+
# Reset process tracking state for fresh measurements
377+
self._last_measurement_time = None
378+
self._last_cpu_times = {}
321379
if self._mode == MODE_CPU_LOAD:
322380
# The first time this is called it will return a meaningless 0.0 value which you are supposed to ignore.
323381
_ = self._get_power_from_cpu_load()
382+
_ = self._get_power_from_cpu_load()
324383

325384
def monitor_power(self):
326385
cpu_power = self._get_power_from_cpus()

docs/edit/usage.rst

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,67 @@ The command line could also works without internet by providing the country code
6666
6767
codecarbon monitor --offline --country-iso-code FRA
6868
69-
Implementing CodeCarbon in your code allows you to track the emissions of a specific block of code.
69+
70+
Running Any Command with CodeCarbon
71+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
72+
73+
If you want to track emissions while running any command or program (not just Python scripts), you can use the ``codecarbon monitor --`` command.
74+
This allows non-Python users to measure machine emissions during the execution of any command:
75+
76+
.. code-block:: console
77+
78+
codecarbon monitor -- <your_command>
79+
80+
Do not surround ``<your_command>`` with quotes. The double hyphen ``--`` indicates the end of CodeCarbon options and the beginning of the command to run.
81+
82+
**Examples:**
83+
84+
.. code-block:: console
85+
86+
# Run a shell script
87+
codecarbon monitor -- ./benchmark.sh
88+
89+
# Run a command with arguments (use quotes for special characters)
90+
codecarbon monitor -- bash -c 'echo "Processing..."; sleep 30; echo "Done!"'
91+
92+
# Run Python scripts
93+
codecarbon monitor -- python train_model.py
94+
95+
# Run Node.js applications
96+
codecarbon monitor -- node app.js
97+
98+
# Run tests with output redirection
99+
codecarbon monitor -- npm run test > output.txt
100+
101+
# Display the CodeCarbon detailed logs
102+
codecarbon monitor --log-level debug -- python --version
103+
104+
**Output:**
105+
106+
When the command completes, CodeCarbon displays a summary report and saves the emissions data to a CSV file:
107+
108+
.. code-block:: console
109+
110+
🌱 CodeCarbon: Starting emissions tracking...
111+
Command: bash -c echo "Processing..."; sleep 30; echo "Done!"
112+
113+
Processing...
114+
Done!
115+
116+
============================================================
117+
🌱 CodeCarbon Emissions Report
118+
============================================================
119+
Command: bash -c echo "Processing..."; sleep 30; echo "Done!"
120+
Emissions: 0.0317 g CO2eq
121+
Saved to: /home/user/emissions.csv
122+
⚠️ Note: Measured entire machine (includes all system processes)
123+
============================================================
124+
125+
.. note::
126+
The ``codecarbon monitor --`` command tracks process-level emissions (only the specific command), not the
127+
entire machine. For machine-level tracking, use the ``codecarbon monitor`` command.
128+
129+
For more fine-grained tracking, implementing CodeCarbon in your code allows you to track the emissions of a specific block of code.
70130

71131
Explicit Object
72132
~~~~~~~~~~~~~~~

examples/command_line_tool.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
"""
22
This example demonstrates how to use CodeCarbon with command line tools.
33
4-
Here we measure the emissions of an speech-to-text with WhisperX.
4+
⚠️ IMPORTANT LIMITATION:
5+
CodeCarbon tracks emissions at the MACHINE level when monitoring external commands
6+
via subprocess. It measures total system power during the command execution, which
7+
includes the command itself AND all other system processes.
58
9+
For accurate process-level tracking, the tracking code must be embedded in the
10+
application being measured (not possible with external binaries like WhisperX).
11+
12+
This example measures emissions during WhisperX execution, but cannot isolate
13+
WhisperX's exact contribution from other system activity.
614
"""
715

816
import subprocess

0 commit comments

Comments
 (0)