codecarbon 3.3.0__tar.gz → 3.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. {codecarbon-3.3.0 → codecarbon-3.3.1}/PKG-INFO +1 -1
  2. codecarbon-3.3.1/codecarbon/_version.py +1 -0
  3. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/auth.py +10 -2
  4. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/main.py +48 -7
  5. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/monitor.py +8 -4
  6. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/api_client.py +7 -2
  7. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/config.py +85 -30
  8. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/cpu.py +80 -1
  9. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/powermetrics.py +91 -46
  10. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/rapl.py +48 -0
  11. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/windows_emi.py +4 -23
  12. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_power.csv +13 -0
  13. codecarbon-3.3.1/codecarbon/data/private_infra/nordic_emissions.json +89 -0
  14. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/emissions_tracker.py +37 -7
  15. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/file.py +2 -1
  16. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/PKG-INFO +1 -1
  17. {codecarbon-3.3.0 → codecarbon-3.3.1}/pyproject.toml +5 -6
  18. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_api_call.py +33 -0
  19. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_config.py +61 -0
  20. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_cpu.py +55 -0
  21. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions.py +4 -2
  22. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions_tracker.py +45 -0
  23. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions_tracker_constant.py +33 -0
  24. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_powermetrics.py +81 -8
  25. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_rapl_mmio_scanning.py +179 -0
  26. codecarbon-3.3.0/codecarbon/_version.py +0 -1
  27. codecarbon-3.3.0/codecarbon/data/private_infra/nordic_emissions.json +0 -69
  28. {codecarbon-3.3.0 → codecarbon-3.3.1}/LICENSE +0 -0
  29. {codecarbon-3.3.0 → codecarbon-3.3.1}/README.md +0 -0
  30. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/__init__.py +0 -0
  31. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/__init__.py +0 -0
  32. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/cli_utils.py +0 -0
  33. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/__init__.py +0 -0
  34. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/cloud.py +0 -0
  35. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/electricitymaps_api.py +0 -0
  36. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/emissions.py +0 -0
  37. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu.py +0 -0
  38. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu_amd.py +0 -0
  39. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu_device.py +0 -0
  40. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu_nvidia.py +0 -0
  41. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/hardware_cache.py +0 -0
  42. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/measure.py +0 -0
  43. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/resource_tracker.py +0 -0
  44. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/schemas.py +0 -0
  45. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/telemetry_client.py +0 -0
  46. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/telemetry_schemas.py +0 -0
  47. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/units.py +0 -0
  48. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/util.py +0 -0
  49. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/canada_provinces.geojson +0 -0
  50. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/cloud/impact.csv +0 -0
  51. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_dataset_builder/amd_cpu_scrapper.py +0 -0
  52. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_dataset_builder/intel_cpu_scrapper.py +0 -0
  53. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_dataset_builder/merge_scrapped_cpu_power.py +0 -0
  54. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/2016/usa_emissions.json +0 -0
  55. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/2023/canada_energy_mix.json +0 -0
  56. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/carbon_intensity_per_source.json +0 -0
  57. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/global_energy_mix.json +0 -0
  58. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/__init__.py +0 -0
  59. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/geography.py +0 -0
  60. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/hardware.py +0 -0
  61. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/logger.py +0 -0
  62. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/ram.py +0 -0
  63. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/scheduler.py +0 -0
  64. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/task.py +0 -0
  65. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/input.py +0 -0
  66. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/lock.py +0 -0
  67. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output.py +0 -0
  68. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/__init__.py +0 -0
  69. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/base_output.py +0 -0
  70. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/__init__.py +0 -0
  71. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/mapper.py +0 -0
  72. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/models.py +0 -0
  73. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/output.py +0 -0
  74. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/emissions_data.py +0 -0
  75. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/http.py +0 -0
  76. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/logger.py +0 -0
  77. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/__init__.py +0 -0
  78. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/logfire.py +0 -0
  79. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/metric_docs.py +0 -0
  80. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/prometheus.py +0 -0
  81. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/py.typed +0 -0
  82. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/__init__.py +0 -0
  83. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/__init__.py +0 -0
  84. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/car_icon.png +0 -0
  85. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/house_icon.png +0 -0
  86. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/tv_icon.png +0 -0
  87. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/carbonboard.py +0 -0
  88. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/carbonboard_on_api.py +0 -0
  89. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/components.py +0 -0
  90. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/data.py +0 -0
  91. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/units.py +0 -0
  92. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/SOURCES.txt +0 -0
  93. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/dependency_links.txt +0 -0
  94. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/entry_points.txt +0 -0
  95. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/requires.txt +0 -0
  96. {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/top_level.txt +0 -0
  97. {codecarbon-3.3.0 → codecarbon-3.3.1}/setup.cfg +0 -0
  98. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_boamps_output.py +0 -0
  99. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_cloud.py +0 -0
  100. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_core_util.py +0 -0
  101. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_cpu_load.py +0 -0
  102. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_custom_handler.py +0 -0
  103. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_docs_examples.py +0 -0
  104. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_electricitymaps_api.py +0 -0
  105. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_electricitymaps_backward_compatibility.py +0 -0
  106. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_electricitymaps_config_backward_compatibility.py +0 -0
  107. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions_tracker_flush.py +0 -0
  108. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_energy.py +0 -0
  109. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_geography.py +0 -0
  110. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_gpu.py +0 -0
  111. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_gpu_amd.py +0 -0
  112. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_gpu_nvidia.py +0 -0
  113. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_hardware_cache.py +0 -0
  114. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_input.py +0 -0
  115. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_lock.py +0 -0
  116. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_logger.py +0 -0
  117. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_logging_output.py +0 -0
  118. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_offline_emissions_tracker.py +0 -0
  119. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_package_integrity.py +0 -0
  120. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_ram.py +0 -0
  121. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_rapl_parameters.py +0 -0
  122. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_rapl_permissions.py +0 -0
  123. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_resource_tracker.py +0 -0
  124. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_telemetry_client.py +0 -0
  125. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_tracking_inference.py +0 -0
  126. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_unsupported_gpu.py +0 -0
  127. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_utilization_tracking.py +0 -0
  128. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_viz_data.py +0 -0
  129. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_viz_units.py +0 -0
  130. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_windows_emi.py +0 -0
  131. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/testdata.py +0 -0
  132. {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/testutils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecarbon
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Author: Mila, DataForGood, BCG GAMMA, Comet.ml, Haverford College
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://codecarbon.io/
@@ -0,0 +1 @@
1
+ __version__ = "3.3.1"
@@ -86,8 +86,15 @@ def _discover_endpoints():
86
86
 
87
87
  def _save_credentials(tokens):
88
88
  """Save OAuth tokens to the local credentials file."""
89
- with open(_CREDENTIALS_FILE, "w") as f:
90
- json.dump(tokens, f)
89
+ try:
90
+ with open(_CREDENTIALS_FILE, "w") as f:
91
+ json.dump(tokens, f)
92
+ except OSError as e:
93
+ raise ValueError(
94
+ f"Could not write the credentials file {_CREDENTIALS_FILE.resolve()} "
95
+ f"(error: {e}). Please run the command from a directory you can "
96
+ "write to."
97
+ )
91
98
 
92
99
 
93
100
  def _load_credentials():
@@ -169,6 +176,7 @@ def authorize():
169
176
  server = HTTPServer(("localhost", _REDIRECT_PORT), _CallbackHandler)
170
177
 
171
178
  print("Opening browser for authentication...")
179
+ print(f"If no browser opens, copy this URL into your browser:\n{uri}")
172
180
  webbrowser.open(uri)
173
181
 
174
182
  server.handle_request()
@@ -358,6 +358,34 @@ def config():
358
358
  )
359
359
 
360
360
 
361
+ def _cli_provided(ctx, name: str) -> bool:
362
+ """
363
+ Whether the option `name` was typed on the command line (or set through its
364
+ environment variable) rather than left at its Typer default.
365
+
366
+ Options left at their default must not be forwarded to the tracker: doing so
367
+ would silently override the values coming from `.codecarbon.config` and the
368
+ `CODECARBON_*` environment variables, which the tracker reads itself.
369
+ """
370
+ get_source = getattr(ctx, "get_parameter_source", None)
371
+ if get_source is None:
372
+ # `monitor` called directly from Python, not through Click: every value
373
+ # given is explicit.
374
+ return True
375
+ source = get_source(name)
376
+ return source is None or source.name in ("COMMANDLINE", "ENVIRONMENT")
377
+
378
+
379
+ def _external_config() -> dict:
380
+ """The configuration files and CODECARBON_* variables, as a plain dict."""
381
+ from codecarbon.core.config import get_hierarchical_config
382
+
383
+ try:
384
+ return dict(get_hierarchical_config())
385
+ except Exception:
386
+ return {}
387
+
388
+
361
389
  @codecarbon.command(
362
390
  "monitor",
363
391
  short_help="Monitor your machine's carbon emissions.",
@@ -393,15 +421,28 @@ def monitor(
393
421
  ):
394
422
  """Monitor your machine's carbon emissions."""
395
423
 
396
- # Shared tracker args so monitor and run_and_monitor behave the same
424
+ external_conf = _external_config()
425
+
426
+ # Shared tracker args so monitor and run_and_monitor behave the same.
427
+ # Only the options actually given are forwarded: the others are left to the
428
+ # tracker, which resolves them from the configuration file and environment.
429
+ cli_defaults = (
430
+ ("measure_power_secs", measure_power_secs),
431
+ ("api_call_interval", api_call_interval),
432
+ ("log_level", log_level),
433
+ )
397
434
  tracker_args = {
398
- "measure_power_secs": measure_power_secs,
399
- "api_call_interval": api_call_interval,
400
- "log_level": log_level,
435
+ name: value for name, value in cli_defaults if _cli_provided(ctx, name)
401
436
  }
437
+ for name, value in cli_defaults:
438
+ if name not in tracker_args and name not in external_conf:
439
+ # Nothing configures it: keep the defaults advertised by `--help`
440
+ # (and an unattended monitor quiet) instead of the tracker's own.
441
+ tracker_args[name] = value
442
+
402
443
  # Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode
403
444
  if offline:
404
- if not country_iso_code:
445
+ if not country_iso_code and not external_conf.get("country_iso_code"):
405
446
  print(
406
447
  "ERROR: Country ISO code is required for offline mode. Add it to your configuration or provide it via the command line: `--country-iso-code FRA`",
407
448
  file=sys.stderr,
@@ -410,8 +451,8 @@ def monitor(
410
451
 
411
452
  tracker_args = {
412
453
  **tracker_args,
413
- "country_iso_code": country_iso_code,
414
- "region": region,
454
+ **({"country_iso_code": country_iso_code} if country_iso_code else {}),
455
+ **({"region": region} if region else {}),
415
456
  }
416
457
  else:
417
458
  experiment_id = get_existing_exp_id()
@@ -3,6 +3,7 @@
3
3
  import os
4
4
  import subprocess
5
5
  import sys
6
+ from typing import Optional
6
7
 
7
8
  import typer
8
9
  from rich import print
@@ -12,9 +13,9 @@ from typing_extensions import Annotated
12
13
  def run_and_monitor(
13
14
  ctx: typer.Context,
14
15
  log_level: Annotated[
15
- str,
16
+ Optional[str],
16
17
  typer.Option(help="Log level (critical, error, warning, info, debug)"),
17
- ] = "error",
18
+ ] = None,
18
19
  offline: bool = False,
19
20
  **tracker_args,
20
21
  ):
@@ -51,7 +52,11 @@ def run_and_monitor(
51
52
  from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
52
53
  from codecarbon.external.logger import set_logger_level
53
54
 
54
- set_logger_level(log_level)
55
+ # `log_level` is None when nothing set it: leave it to the tracker, which
56
+ # resolves it from the configuration file and the environment.
57
+ if log_level is not None:
58
+ set_logger_level(log_level)
59
+ tracker_args["log_level"] = log_level
55
60
 
56
61
  # Get the command from remaining args (strip nested subcommand / `--` leftovers)
57
62
  command = list(getattr(ctx, "args", None) or [])
@@ -67,7 +72,6 @@ def run_and_monitor(
67
72
 
68
73
  tracker_cls = OfflineEmissionsTracker if offline else EmissionsTracker
69
74
  tracker = tracker_cls(
70
- log_level=log_level,
71
75
  save_to_logger=False,
72
76
  tracking_mode="process",
73
77
  **tracker_args,
@@ -22,6 +22,11 @@ from codecarbon.core.schemas import (
22
22
  from codecarbon.external.logger import logger
23
23
 
24
24
 
25
+ def _round_or_none(value: float | None) -> float | None:
26
+ """Round a coordinate, keeping None when it is unknown."""
27
+ return None if value is None else round(value, 1)
28
+
29
+
25
30
  def get_datetime_with_timezone():
26
31
  import arrow
27
32
 
@@ -242,8 +247,8 @@ class ApiClient: # (AsyncClient)
242
247
  gpu_count=self.conf.get("gpu_count"),
243
248
  gpu_model=self.conf.get("gpu_model"),
244
249
  # Reduce precision for Privacy
245
- longitude=round(self.conf.get("longitude", 0), 1),
246
- latitude=round(self.conf.get("latitude", 0), 1),
250
+ longitude=_round_or_none(self.conf.get("longitude")),
251
+ latitude=_round_or_none(self.conf.get("latitude")),
247
252
  region=self.conf.get("region"),
248
253
  provider=self.conf.get("provider"),
249
254
  ram_total_size=self.conf.get("ram_total_size"),
@@ -1,10 +1,13 @@
1
1
  import configparser
2
2
  import os
3
3
  from pathlib import Path
4
- from typing import List, Optional, Union
4
+ from typing import Dict, List, Optional, Tuple, Union
5
5
 
6
6
  from codecarbon.external.logger import logger
7
7
 
8
+ CONFIG_FILE_NAME = ".codecarbon.config"
9
+ CONFIG_SECTION = "codecarbon"
10
+
8
11
 
9
12
  def clean_env_key(k: str) -> str:
10
13
  """
@@ -36,7 +39,7 @@ def parse_env_config() -> dict:
36
39
  dict: existing relevant environment variables mapped into a dict
37
40
  """
38
41
  return {
39
- "codecarbon": {
42
+ CONFIG_SECTION: {
40
43
  clean_env_key(k): v
41
44
  for k, v in os.environ.items()
42
45
  if k.lower().startswith("codecarbon_")
@@ -110,10 +113,81 @@ def normalize_gpu_ids(
110
113
  return None
111
114
 
112
115
 
116
+ def read_config_file(path: str) -> dict:
117
+ """
118
+ Read the `[codecarbon]` section of a single configuration file.
119
+
120
+ A file that does not exist, or that cannot be read, is ignored, like
121
+ configparser.ConfigParser.read does.
122
+
123
+ Args:
124
+ path (str): Path of the configuration file to read.
125
+
126
+ Returns:
127
+ dict: The keys defined in this file only, empty if there is nothing to read.
128
+ """
129
+ config = configparser.ConfigParser()
130
+ config.read(path)
131
+ if not config.has_section(CONFIG_SECTION):
132
+ return {}
133
+ return dict(config[CONFIG_SECTION])
134
+
135
+
136
+ def log_config_sources(sources: Dict[str, str]) -> None:
137
+ """
138
+ Log a single line telling, for every configuration key, which layer set it.
139
+
140
+ Only the key names and their source are logged, never the values, because
141
+ this configuration holds secrets such as `api_key` and `experiment_id`.
142
+
143
+ Args:
144
+ sources (dict): The key to source mapping built by
145
+ get_hierarchical_config_with_sources.
146
+ """
147
+ if not sources:
148
+ return
149
+ logger.info(
150
+ "Codecarbon configuration: "
151
+ + ", ".join(f"{key} from {source}" for key, source in sorted(sources.items()))
152
+ )
153
+
154
+
155
+ def get_hierarchical_config_with_sources() -> Tuple[dict, Dict[str, str]]:
156
+ """
157
+ Same as get_hierarchical_config, but also tells where each key comes from.
158
+
159
+ The layers are read from the lowest to the highest priority: the global
160
+ config file, then the local config file, then the environment variables.
161
+ The last layer defining a key wins, and is the one reported as its source.
162
+ A source is either the path of the config file that set the key, or the
163
+ name of the environment variable, e.g. `CODECARBON_PROJECT_NAME`.
164
+
165
+ Returns:
166
+ tuple: The (config, sources) dicts, which share the same keys. `config`
167
+ maps a key to its value (**all values are strings**) and `sources` maps
168
+ a key to the layer it was read from.
169
+ """
170
+ global_path = str((Path.home() / CONFIG_FILE_NAME).expanduser().resolve())
171
+ local_path = str((Path.cwd() / CONFIG_FILE_NAME).expanduser().resolve())
172
+
173
+ config = {}
174
+ sources = {}
175
+
176
+ for path in (global_path, local_path):
177
+ for key, value in read_config_file(path).items():
178
+ config[key] = value
179
+ sources[key] = path
180
+
181
+ for key, value in parse_env_config()[CONFIG_SECTION].items():
182
+ config[key] = value
183
+ sources[key] = f"CODECARBON_{key.upper()}"
184
+
185
+ return config, sources
186
+
187
+
113
188
  def get_hierarchical_config():
114
189
  """
115
- Get the user-defined codecarbon configuration ConfigParser dictionnary
116
- (actually a configparser.SectionProxy instance).
190
+ Get the user-defined codecarbon configuration as a dict.
117
191
 
118
192
  ```
119
193
  >>> from codecarbon.core.config import get_hierarchical_config
@@ -121,10 +195,7 @@ def get_hierarchical_config():
121
195
  >>> print(conf)
122
196
  ```
123
197
 
124
- `conf` works like a regular dict + methods getint(key) getfloat(key)
125
- and getboolean(key) to automatically parse strings into those types.
126
-
127
- All values (outputs of get(key)) are strings.
198
+ All values are strings.
128
199
 
129
200
  It looks for, and reads, a config file .codecarbon.config in the user's $HOME.
130
201
  It then looks for, reads, and updates the previous configuration from a config
@@ -133,29 +204,13 @@ def get_hierarchical_config():
133
204
  with `CODECARBON_` (for instance if `CODECARBON_PROJECT_NAME` is `your-project`
134
205
  then the resulting configuration key `project_name` will have value `your-project`)
135
206
 
207
+ The source of every resolved key is logged, so that a surprising value can be
208
+ traced back to the file or the environment variable that set it.
209
+
136
210
  Returns:
137
211
  dict: The final configuration dict parsed from global,
138
212
  local and environment configurations. **All values are strings**.
139
213
  """
140
-
141
- config = configparser.ConfigParser()
142
-
143
- cwd = Path.cwd()
144
- home = Path.home()
145
- global_path = str((home / ".codecarbon.config").expanduser().resolve())
146
- local_path = str((cwd / ".codecarbon.config").expanduser().resolve())
147
- if Path(global_path).exists():
148
- logger.info(
149
- f"Codecarbon is taking the configuration from global file: {global_path}"
150
- )
151
- if Path(local_path).exists():
152
- logger.info(f"Some variables are overriden by the local file: {local_path}")
153
- elif Path(local_path).exists():
154
- logger.info(
155
- f"Codecarbon is taking the configuration from the local file {local_path}"
156
- )
157
-
158
- config.read([global_path, local_path])
159
- config.read_dict(parse_env_config())
160
-
161
- return dict(config["codecarbon"])
214
+ config, sources = get_hierarchical_config_with_sources()
215
+ log_config_sources(sources)
216
+ return config
@@ -17,7 +17,12 @@ from typing import TYPE_CHECKING, Dict, Optional, Tuple
17
17
  import psutil
18
18
  from rapidfuzz import fuzz, process, utils
19
19
 
20
- from codecarbon.core.rapl import RAPLFile
20
+ from codecarbon.core.rapl import (
21
+ SEQUENTIAL_READ_TOLERANCE_KWH,
22
+ RAPLFile,
23
+ counters_match,
24
+ find_mirrored_counters,
25
+ )
21
26
  from codecarbon.core.units import Time
22
27
  from codecarbon.core.util import count_cpus, detect_cpu_model
23
28
  from codecarbon.external.logger import logger
@@ -450,6 +455,10 @@ class IntelRAPL:
450
455
  self._lin_rapl_dir = rapl_dir
451
456
  self._system = sys.platform.lower()
452
457
  self._rapl_files = []
458
+ # Files that look like a duplicate of another counter, but whose
459
+ # energy deltas have not confirmed it yet. They are left out of the
460
+ # measurement while pending. Maps file path -> mirrored file path.
461
+ self._mirrored_candidates: Dict[str, str] = {}
453
462
  self.rapl_include_dram = rapl_include_dram
454
463
  self.rapl_prefer_psys = rapl_prefer_psys
455
464
  self._setup_rapl()
@@ -842,7 +851,12 @@ class IntelRAPL:
842
851
  for rapl_file in self._rapl_files:
843
852
  rapl_file.delta(duration)
844
853
 
854
+ self._confirm_mirrored_files()
855
+
845
856
  for rapl_file in self._rapl_files:
857
+ if rapl_file.path in self._mirrored_candidates:
858
+ # Still suspected of duplicating another counter
859
+ continue
846
860
  logger.debug(rapl_file)
847
861
  cpu_details[rapl_file.name] = rapl_file.energy_delta.kWh
848
862
  # We fake the name used by Power Gadget when using RAPL
@@ -874,6 +888,71 @@ class IntelRAPL:
874
888
  """
875
889
  for rapl_file in self._rapl_files:
876
890
  rapl_file.start()
891
+ # DRAM domains are a different measurement, never a duplicate
892
+ counters = [
893
+ (rapl_file.path, float(rapl_file.last_energy))
894
+ for rapl_file in self._rapl_files
895
+ if "dram" not in rapl_file.name.lower()
896
+ ]
897
+ self._mirrored_candidates = find_mirrored_counters(
898
+ counters, SEQUENTIAL_READ_TOLERANCE_KWH
899
+ )
900
+ for path, reference_path in self._mirrored_candidates.items():
901
+ logger.warning(
902
+ "\tRAPL - Energy counter at %s looks like a mirror of %s, it is "
903
+ "left out of the measurement to avoid double-counting",
904
+ path,
905
+ reference_path,
906
+ )
907
+
908
+ def _confirm_mirrored_files(self) -> None:
909
+ """
910
+ Decide the fate of the files flagged as duplicates at ``start()``.
911
+
912
+ Two independent meters could hold indistinguishable counters at the
913
+ single instant of the initial reading, so a candidate is only dropped
914
+ for good once it has also accumulated the very same energy over a
915
+ measurement interval. A candidate whose energy delta differs is a real
916
+ meter and is restored, and a candidate that has not accumulated
917
+ anything yet stays pending until an interval is conclusive.
918
+ """
919
+ if not self._mirrored_candidates:
920
+ return
921
+ files_by_path = {rapl_file.path: rapl_file for rapl_file in self._rapl_files}
922
+ pending = {}
923
+ confirmed = set()
924
+ for path, reference_path in self._mirrored_candidates.items():
925
+ rapl_file = files_by_path.get(path)
926
+ reference = files_by_path.get(reference_path)
927
+ if rapl_file is None or reference is None:
928
+ continue
929
+ delta = float(rapl_file.energy_delta)
930
+ reference_delta = float(reference.energy_delta)
931
+ if delta <= 0 and reference_delta <= 0:
932
+ # Nothing was accumulated: the interval is not conclusive
933
+ pending[path] = reference_path
934
+ elif counters_match(delta, reference_delta, SEQUENTIAL_READ_TOLERANCE_KWH):
935
+ confirmed.add(path)
936
+ logger.warning(
937
+ "\tRAPL - Energy counter at %s mirrors the one at %s, it is "
938
+ "dropped to avoid double-counting the CPU power (multi-die "
939
+ "CPUs mirror the package counter on each die)",
940
+ path,
941
+ reference_path,
942
+ )
943
+ else:
944
+ logger.info(
945
+ "\tRAPL - Energy counter at %s accumulates energy on its own, "
946
+ "it is an independent meter and is measured again",
947
+ path,
948
+ )
949
+ self._mirrored_candidates = pending
950
+ if confirmed:
951
+ self._rapl_files = [
952
+ rapl_file
953
+ for rapl_file in self._rapl_files
954
+ if rapl_file.path not in confirmed
955
+ ]
877
956
 
878
957
 
879
958
  class TDP:
@@ -30,6 +30,26 @@ def clear_powermetrics_cache() -> None:
30
30
  is_powermetrics_available.cache_clear()
31
31
 
32
32
 
33
+ def _kill_process(process: subprocess.Popen) -> None:
34
+ """
35
+ Kill a Powermetrics subprocess, tolerating a failure to do so.
36
+
37
+ The command runs through `sudo`, so the child may be owned by root and
38
+ refuse our signal. Letting that raise would replace the timeout we are
39
+ handling with a PermissionError, and waiting on it without a bound would
40
+ hang for as long as the child lives.
41
+ """
42
+ try:
43
+ process.kill()
44
+ process.wait(timeout=1)
45
+ except (OSError, subprocess.SubprocessError):
46
+ logger.debug("Could not kill the Powermetrics process.")
47
+ finally:
48
+ for stream in (process.stdout, process.stderr):
49
+ if stream is not None:
50
+ stream.close()
51
+
52
+
33
53
  def _has_powermetrics_sudo() -> bool:
34
54
  if shutil.which("sudo") is None:
35
55
  logger.debug("sudo not available, we won't use Apple PowerMetrics.")
@@ -40,7 +60,9 @@ def _has_powermetrics_sudo() -> bool:
40
60
  )
41
61
  return False
42
62
 
43
- with subprocess.Popen(
63
+ # No context manager here: Popen.__exit__ would wait() without a timeout,
64
+ # which is exactly the hang we are trying to avoid when the kill fails.
65
+ process = subprocess.Popen(
44
66
  [
45
67
  "sudo",
46
68
  "powermetrics",
@@ -53,30 +75,31 @@ def _has_powermetrics_sudo() -> bool:
53
75
  "-o",
54
76
  "/dev/null",
55
77
  ],
78
+ stdin=subprocess.DEVNULL,
56
79
  stdout=subprocess.PIPE,
57
80
  stderr=subprocess.PIPE,
58
81
  text=True,
59
- ) as process:
60
- deadline = time.time() + 3
61
- while process.poll() is None and time.time() < deadline:
62
- time.sleep(0.05)
63
- if process.poll() is None:
64
- process.kill()
65
- logger.debug("PowerMetrics sudo check timed out.")
66
- return False
67
- _, stderr = process.communicate()
68
-
69
- if re.search(r"[sudo].*password", stderr):
70
- logger.debug("""Not using PowerMetrics, sudo password prompt detected.
71
- If you want to enable Powermetrics please modify your sudoers file
72
- as described in :
73
- https://docs.codecarbon.io/latest/explanation/methodology/#power-usage
74
- """)
75
- return False
76
- if process.returncode != 0:
77
- raise Exception("Return code != 0")
82
+ )
83
+ deadline = time.time() + 3
84
+ while process.poll() is None and time.time() < deadline:
85
+ time.sleep(0.05)
86
+ if process.poll() is None:
87
+ logger.debug("PowerMetrics sudo check timed out.")
88
+ _kill_process(process)
89
+ return False
90
+ _, stderr = process.communicate()
78
91
 
79
- return True
92
+ if re.search(r"[sudo].*password", stderr):
93
+ logger.debug("""Not using PowerMetrics, sudo password prompt detected.
94
+ If you want to enable Powermetrics please modify your sudoers file
95
+ as described in :
96
+ https://docs.codecarbon.io/latest/explanation/methodology/#power-usage
97
+ """)
98
+ return False
99
+ if process.returncode != 0:
100
+ raise Exception("Return code != 0")
101
+
102
+ return True
80
103
 
81
104
 
82
105
  class ApplePowermetrics:
@@ -130,47 +153,69 @@ class ApplePowermetrics:
130
153
  else:
131
154
  raise SystemError("Platform not supported by Powermetrics")
132
155
 
133
- def _log_values(self) -> None:
156
+ def _log_values(self) -> bool:
134
157
  """
135
158
  Logs output from Powermetrics to a file
136
- """
137
- returncode = None
138
159
 
139
- if self._system.startswith("darwin"):
140
- # Run the powermetrics command with sudo and capture its output
141
- cmd = [
142
- "sudo",
143
- "powermetrics",
144
- "-n",
145
- str(self._n_points),
146
- "",
147
- "--samplers",
148
- "cpu_power",
149
- "--format",
150
- "csv",
151
- "-i",
152
- str(self._interval),
153
- "-o",
154
- self._log_file_path,
155
- ]
156
- returncode = subprocess.call(cmd, universal_newlines=True)
160
+ :return: False when the command did not run, so that the caller knows
161
+ the log file was not refreshed and still holds the previous
162
+ measure.
163
+ """
164
+ if not self._system.startswith("darwin"):
165
+ return False
157
166
 
158
- else:
159
- return None
167
+ # Run the powermetrics command with sudo and capture its output
168
+ cmd = [
169
+ "sudo",
170
+ "powermetrics",
171
+ "-n",
172
+ str(self._n_points),
173
+ "",
174
+ "--samplers",
175
+ "cpu_power",
176
+ "--format",
177
+ "csv",
178
+ "-i",
179
+ str(self._interval),
180
+ "-o",
181
+ self._log_file_path,
182
+ ]
183
+ # _n_points samples of _interval ms is the nominal runtime of the
184
+ # command. Double it to absorb sampler overhead and scheduling jitter,
185
+ # and add a floor so short configurations keep a usable margin. This is
186
+ # a heuristic: it only has to be loose enough that a healthy run never
187
+ # trips it, because its only job is to bound a hang.
188
+ timeout = self._n_points * self._interval / 1000 * 2 + 5
189
+ # DEVNULL so that sudo gets EOF and fails fast instead of blocking
190
+ # forever on a password prompt when it has no cached credential.
191
+ # No context manager: Popen.__exit__ would wait() without a timeout.
192
+ process = subprocess.Popen(
193
+ cmd, universal_newlines=True, stdin=subprocess.DEVNULL
194
+ )
195
+ try:
196
+ returncode = process.wait(timeout=timeout)
197
+ except subprocess.TimeoutExpired:
198
+ logger.warning(
199
+ f"Powermetrics did not complete within {timeout:g} seconds, "
200
+ "skipping this measure."
201
+ )
202
+ _kill_process(process)
203
+ return False
160
204
 
161
205
  if returncode != 0:
162
206
  logger.warning(
163
207
  "Returncode while logging power values using "
164
208
  + f"Powermetrics: {returncode}"
165
209
  )
166
- return
210
+ return True
167
211
 
168
212
  def get_details(self) -> Dict:
169
213
  """
170
214
  Fetches the CPU Power Details by fetching values from a logged csv file
171
215
  in _log_values function
172
216
  """
173
- self._log_values()
217
+ if not self._log_values():
218
+ return dict()
174
219
  details = dict()
175
220
  try:
176
221
  with open(self._log_file_path) as f:
@@ -3,6 +3,54 @@ from dataclasses import dataclass, field
3
3
  from codecarbon.core.units import Energy, Power, Time
4
4
  from codecarbon.external.logger import logger
5
5
 
6
+ # Maximum relative difference between two cumulative energy counters for them
7
+ # to be considered two views of the same hardware counter. Multi-die CPUs
8
+ # (e.g. AMD Ryzen Threadripper / EPYC) can expose one package domain per die
9
+ # that all mirror the same socket-wide counter, which would multiply the
10
+ # reported CPU power by the number of dies.
11
+ # See https://github.com/mlco2/codecarbon/issues/1274
12
+ MIRRORED_COUNTER_TOLERANCE = 1e-6
13
+
14
+ # Unlike EMI, which snapshots all the channels of a device atomically, the
15
+ # powercap sysfs files are read one after the other: two views of the same
16
+ # hardware counter differ by the energy the package accrued between the two
17
+ # reads. One joule covers several milliseconds at full load, while staying
18
+ # far below what independent packages drift apart by over a measurement
19
+ # interval.
20
+ SEQUENTIAL_READ_TOLERANCE_KWH = float(Energy.from_ujoules(1_000_000)) # 1 J
21
+
22
+
23
+ def counters_match(first, second, abs_tolerance=0.0) -> bool:
24
+ """Tell whether two cumulative counter values are indistinguishable."""
25
+ return abs(first - second) <= max(
26
+ MIRRORED_COUNTER_TOLERANCE * max(first, second), abs_tolerance
27
+ )
28
+
29
+
30
+ def find_mirrored_counters(counters, abs_tolerance=0.0) -> dict:
31
+ """
32
+ Identify the counters that report the same underlying energy value.
33
+
34
+ ``counters`` is an ordered list of ``(key, absolute_energy)`` pairs whose
35
+ energies share a unit. Two independent meters are extremely unlikely to
36
+ hold the very same cumulative count, so equal counters point at duplicated
37
+ ones. A zero counter carries no information and is never flagged.
38
+
39
+ Returns a mapping of each duplicating key to the earlier key it mirrors.
40
+ """
41
+ references = []
42
+ mirrored = {}
43
+ for key, energy in counters:
44
+ if energy <= 0:
45
+ continue
46
+ for reference_key, reference in references:
47
+ if counters_match(energy, reference, abs_tolerance):
48
+ mirrored[key] = reference_key
49
+ break
50
+ else:
51
+ references.append((key, energy))
52
+ return mirrored
53
+
6
54
 
7
55
  @dataclass
8
56
  class RAPLFile: