diff --git a/codecarbon/__init__.py b/codecarbon/__init__.py index 15fc25cd0..9061aafb0 100644 --- a/codecarbon/__init__.py +++ b/codecarbon/__init__.py @@ -8,7 +8,7 @@ OfflineEmissionsTracker, track_emissions, ) -from .output import OutputMethod +from .output_methods.base_output import OutputMethod __all__ = [ "EmissionsTracker", diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index 9349b9dc1..58f4932cb 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -10,7 +10,6 @@ import json from datetime import timedelta, tzinfo -import arrow import requests from codecarbon.core.schemas import ( @@ -22,12 +21,11 @@ ) from codecarbon.external.logger import logger -# from codecarbon.output import EmissionsData - def get_datetime_with_timezone(): - timestamp = str(arrow.now().isoformat()) - return timestamp + import arrow + + return str(arrow.now().isoformat()) class ApiClient: # (AsyncClient) diff --git a/codecarbon/core/util.py b/codecarbon/core/util.py index 744b2e3e5..3bb0ca39c 100644 --- a/codecarbon/core/util.py +++ b/codecarbon/core/util.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import Optional, Union -import cpuinfo import psutil from codecarbon.external.logger import logger @@ -76,6 +75,8 @@ def backup(file_path: Union[str, Path], ext: Optional[str] = ".bak") -> None: @lru_cache(maxsize=1) def detect_cpu_model() -> Optional[str]: + import cpuinfo + cpu_info = cpuinfo.get_cpu_info() if cpu_info: cpu_model_detected = cpu_info.get("brand_raw", "") diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 88da92628..efd3830ea 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -3,6 +3,8 @@ OfflineEmissionsTracker, context manager and decorator @track_emissions """ +from __future__ import annotations + import dataclasses import os import platform @@ -13,17 +15,14 @@ from abc import ABC, abstractmethod from datetime import datetime from functools import wraps -from typing import Any, Callable, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union import psutil from codecarbon._version import __version__ from codecarbon.core.config import get_hierarchical_config, normalize_gpu_ids -from codecarbon.core.emissions import Emissions -from codecarbon.core.resource_tracker import ResourceTracker from codecarbon.core.units import Energy, Power, Time, Water from codecarbon.core.util import count_cpus, count_physical_cpus, suppress -from codecarbon.external.geography import CloudMetadata, GeoMetadata from codecarbon.external.hardware import CPU, GPU, AppleSiliconChip from codecarbon.external.logger import logger, set_logger_format, set_logger_level from codecarbon.external.ram import RAM @@ -31,18 +30,12 @@ from codecarbon.external.task import Task from codecarbon.input import DataSource from codecarbon.lock import Lock -from codecarbon.output import ( - BaseOutput, - BoAmpsOutput, - CodeCarbonAPIOutput, - EmissionsData, - FileOutput, - HTTPOutput, - LogfireOutput, - LoggerOutput, - OutputMethod, - PrometheusOutput, -) +from codecarbon.output_methods.base_output import BaseOutput, OutputMethod +from codecarbon.output_methods.emissions_data import EmissionsData + +if TYPE_CHECKING: + from codecarbon.external.geography import CloudMetadata, GeoMetadata + from codecarbon.output_methods.logger import LoggerOutput # /!\ Warning: current implementation prevents the user from setting any value to None # from the script call @@ -304,6 +297,15 @@ def _initialize_runtime_state(self) -> None: self._active_task: Optional[str] = None self._active_task_emissions_at_start: Optional[EmissionsData] = None self._hardware = [] + self._hardware_initialized = False + + def _ensure_hardware_ready(self) -> None: + if self._hardware_initialized: + return + self._populate_system_metadata() + self._initialize_hardware_tracking() + self._hardware_initialized = True + self._log_tracker_metadata() def _populate_system_metadata(self) -> None: self._conf["os"] = platform.platform() @@ -312,6 +314,8 @@ def _populate_system_metadata(self) -> None: self._conf["cpu_physical_count"] = count_physical_cpus() def _initialize_hardware_tracking(self) -> None: + from codecarbon.core.resource_tracker import ResourceTracker + resource_tracker = ResourceTracker(self) resource_tracker.set_CPU_GPU_ram_tracking() self._conf["hardware"] = [item.description() for item in self._hardware] @@ -349,21 +353,38 @@ def _initialize_scheduler_state(self) -> None: def _initialize_emissions_context(self) -> None: self._data_source = DataSource() - cloud: CloudMetadata = self._get_cloud_metadata() - self._geo = self._get_geo_metadata() - - if cloud.is_on_private_infra: - self._conf["longitude"] = self._geo.longitude - self._conf["latitude"] = self._geo.latitude + self._geo = None + self._emissions = None + def _ensure_cloud_conf(self) -> None: + if self._conf.get("_cloud_conf_initialized"): + return + cloud = self._get_cloud_metadata() self._conf["region"] = cloud.region self._conf["provider"] = cloud.provider - self._emissions: Emissions = Emissions( + self._conf["_cloud_conf_initialized"] = True + + def _ensure_emissions_engine(self) -> None: + if self._emissions is not None: + return + from codecarbon.core.emissions import Emissions + + self._emissions = Emissions( self._data_source, self._electricitymaps_api_token, force_carbon_intensity_g_co2e_kwh=self.force_carbon_intensity_g_co2e_kwh, ) + def _ensure_geo_metadata(self) -> None: + """Load geo metadata on first use to avoid blocking tracker construction.""" + if self._geo is not None: + return + self._geo = self._get_geo_metadata() + cloud: CloudMetadata = self._get_cloud_metadata() + if cloud.is_on_private_infra: + self._conf["longitude"] = self._geo.longitude + self._conf["latitude"] = self._geo.latitude + def __init__( self, project_name: Optional[str] = _sentinel, @@ -578,9 +599,6 @@ def __init__( set_logger_level(self._log_level) set_logger_format(self._logger_preamble) self._initialize_runtime_state() - self._populate_system_metadata() - self._initialize_hardware_tracking() - self._log_tracker_metadata() self._initialize_scheduler_state() self._initialize_emissions_context() self._init_output_methods(api_key=self._api_key) @@ -591,6 +609,18 @@ def _init_output_methods(self, *, api_key: str = None): """ methods = set(self._output_methods) if self._output_methods else set() + if not methods and not self._emissions_endpoint: + self.run_id = uuid.uuid4() + return + + from codecarbon.output_methods.boamps import BoAmpsOutput + from codecarbon.output_methods.file import FileOutput + from codecarbon.output_methods.http import CodeCarbonAPIOutput, HTTPOutput + from codecarbon.output_methods.metrics.logfire import LogfireOutput + from codecarbon.output_methods.metrics.prometheus import PrometheusOutput + + methods = set(self._output_methods) if self._output_methods else set() + if OutputMethod.CSV in methods: self._output_handlers.append( FileOutput( @@ -641,6 +671,7 @@ def get_detected_hardware(self) -> Dict[str, Any]: Get the detected hardware. :return: A dictionary containing hardware data. """ + self._ensure_hardware_ready() hardware_info = { "ram_total_size": self._conf.get("ram_total_size"), "cpu_count": self._conf.get("cpu_count"), @@ -672,15 +703,11 @@ def start(self) -> None: "Another instance of codecarbon is already running. Exiting." ) return - try: - _ = self._emissions - except AttributeError: - logger.error("Tracker not initialized. Please check the logs.") - return if self._start_time is not None: logger.warning("Already started tracking") return + self._ensure_hardware_ready() self._last_measured_time = self._start_time = time.perf_counter() # Clear utilization history for fresh measurements @@ -694,7 +721,9 @@ def start(self) -> None: hardware.start() self._scheduler.start() - self._scheduler_monitor_power.start() + if self._output_handlers: + self._scheduler_monitor_power.start() + self._measure_power_and_energy() def start_task(self, task_name=None) -> None: """ @@ -712,11 +741,13 @@ def start_task(self, task_name=None) -> None: ) return try: - _ = self._emissions - except AttributeError: + self._ensure_emissions_engine() + except Exception: logger.error("Tracker not initialized. Please check the logs.") return + self._ensure_hardware_ready() + # Stop scheduler as we do not want it to interfere with the task measurement if self._scheduler: self._scheduler.stop() @@ -839,7 +870,7 @@ def flush(self) -> Optional[float]: # Run to calculate the power used from last # scheduled measurement to shutdown - self._measure_power_and_energy() + self._measure_power_and_energy_if_stale() emissions_data = self._prepare_emissions_data() emissions_data_delta = self._compute_emissions_delta(emissions_data) @@ -887,7 +918,7 @@ def stop(self) -> Optional[float]: # Run to calculate the power used from last # scheduled measurement to shutdown # or if scheduler interval was longer than the run - self._measure_power_and_energy() + self._measure_power_and_energy_if_stale() emissions_data = self._prepare_emissions_data() emissions_data_delta = self._compute_emissions_delta(emissions_data) @@ -926,6 +957,8 @@ def _update_emissions(self) -> None: Compute emissions for the energy consumed since the last update and add them to the total emissions. """ + self._ensure_geo_metadata() + self._ensure_emissions_engine() delta_energy = self._total_energy - self._last_energy_covered if delta_energy.kWh > 0: cloud: CloudMetadata = self._get_cloud_metadata() @@ -946,7 +979,8 @@ def _prepare_emissions_data(self) -> EmissionsData: :return: EmissionsData object with the total emissions data. """ self._update_emissions() - cloud: CloudMetadata = self._get_cloud_metadata() + self._ensure_cloud_conf() + cloud = self._get_cloud_metadata() duration: Time = Time.from_seconds(time.perf_counter() - self._start_time) emissions = self._total_emissions @@ -1204,6 +1238,11 @@ def _do_measurements(self) -> None: f"{self._total_energy.kWh:.6f} kWh of electricity and {self._total_water.litres:.6f} L of water were used since the beginning." ) + def _measure_power_and_energy_if_stale(self, min_interval_s: float = 0.05) -> None: + """Measure only if the last sample is older than ``min_interval_s``.""" + if time.perf_counter() - self._last_measured_time >= min_interval_s: + self._measure_power_and_energy() + def _measure_power_and_energy(self) -> None: """ A function that is periodically run by the `BackgroundScheduler` @@ -1317,40 +1356,48 @@ def __init__( "Cloud Region must be provided " + " if cloud provider is set" ) - df = DataSource().get_cloud_emissions_data() - if ( - len( - df.loc[ - (df["provider"] == self._cloud_provider) - & (df["region"] == self._cloud_region) - ] - ) - == 0 - ): - logger.error( - "Cloud Provider/Region " - f"{self._cloud_provider} {self._cloud_region} " - "not found in cloud emissions data." - ) - if self._country_iso_code: - try: - self._country_name: str = DataSource().get_global_energy_mix_data()[ - self._country_iso_code - ]["country_name"] - except KeyError as e: - logger.error( - "Does not support country" - + f" with ISO code {self._country_iso_code} " - f"Exception occurred {e}" - ) - if self._country_2letter_iso_code: assert isinstance(self._country_2letter_iso_code, str) self._country_2letter_iso_code: str = self._country_2letter_iso_code.upper() super().__init__(*args, **kwargs) + def _resolve_offline_country_name(self) -> None: + if self._country_name is not None or not self._country_iso_code: + return + try: + self._country_name = DataSource().get_global_energy_mix_data()[ + self._country_iso_code + ]["country_name"] + except KeyError as e: + logger.error( + "Does not support country" + f" with ISO code {self._country_iso_code} " + f"Exception occurred {e}" + ) + + def _validate_offline_cloud_provider(self) -> None: + if not self._cloud_provider: + return + df = DataSource().get_cloud_emissions_data() + if ( + len( + df.loc[ + (df["provider"] == self._cloud_provider) + & (df["region"] == self._cloud_region) + ] + ) + == 0 + ): + logger.error( + "Cloud Provider/Region " + f"{self._cloud_provider} {self._cloud_region} " + "not found in cloud emissions data." + ) + def _get_geo_metadata(self) -> GeoMetadata: + from codecarbon.external.geography import GeoMetadata + + self._resolve_offline_country_name() return GeoMetadata( country_iso_code=self._country_iso_code, country_name=self._country_name, @@ -1359,6 +1406,9 @@ def _get_geo_metadata(self) -> GeoMetadata: ) def _get_cloud_metadata(self) -> CloudMetadata: + from codecarbon.external.geography import CloudMetadata + + self._validate_offline_cloud_provider() if self._cloud is None: self._cloud = CloudMetadata( provider=self._cloud_provider, region=self._cloud_region @@ -1373,9 +1423,13 @@ class EmissionsTracker(BaseEmissionsTracker): """ def _get_geo_metadata(self) -> GeoMetadata: + from codecarbon.external.geography import GeoMetadata + return GeoMetadata.from_geo_js(self._data_source.geo_js_url) def _get_cloud_metadata(self) -> CloudMetadata: + from codecarbon.external.geography import CloudMetadata + if self._cloud is None: self._cloud = CloudMetadata.from_utils() return self._cloud diff --git a/codecarbon/external/task.py b/codecarbon/external/task.py index e3d7ecbae..b8945960e 100644 --- a/codecarbon/external/task.py +++ b/codecarbon/external/task.py @@ -1,7 +1,7 @@ import time from uuid import uuid4 -from codecarbon.output import EmissionsData, TaskEmissionsData +from codecarbon.output_methods.emissions_data import EmissionsData, TaskEmissionsData class Task: diff --git a/codecarbon/input.py b/codecarbon/input.py index 93a96c988..4ed23db2b 100644 --- a/codecarbon/input.py +++ b/codecarbon/input.py @@ -1,19 +1,21 @@ """ App configuration and static reference data loading. -Data files are static reference data that never change during runtime. -They are loaded once at module import to avoid repeated file I/O on the hot path -(start_task/stop_task calls for instance). +Static CSV/JSON reference data is loaded lazily on first DataSource access +to keep `import codecarbon` fast for measurement startup. """ +from __future__ import annotations + import atexit import json from contextlib import ExitStack from importlib.resources import as_file as importlib_resources_as_file from importlib.resources import files as importlib_resources_files -from typing import Any, Dict +from typing import TYPE_CHECKING, Any, Dict -import pandas as pd +if TYPE_CHECKING: + import pandas as pd _CACHE: Dict[str, Any] = {} _MODULE_NAME = "codecarbon" @@ -35,6 +37,8 @@ def _load_static_data() -> None: Called once when codecarbon is imported. All data loaded here is immutable and shared across all tracker instances. """ + import pandas as pd + # Global energy mix - used for emissions calculations path = _get_resource_path("data/private_infra/global_energy_mix.json") with open(path) as f: @@ -59,8 +63,16 @@ def _load_static_data() -> None: _CACHE["nordic_country_energy_mix"] = json.load(f) -# Load static data at module import -_load_static_data() +_STATIC_DATA_LOADED = False + + +def _ensure_static_data_loaded() -> None: + """Load immutable reference data on first use instead of at import.""" + global _STATIC_DATA_LOADED + if _STATIC_DATA_LOADED: + return + _load_static_data() + _STATIC_DATA_LOADED = True class DataSource: @@ -130,15 +142,17 @@ def cpu_power_path(self): def get_global_energy_mix_data(self) -> Dict: """ Returns Global Energy Mix Data. - Data is pre-loaded at module import for performance. + Data is loaded on first access and cached for all tracker instances. """ + _ensure_static_data_loaded() return _CACHE["global_energy_mix"] def get_cloud_emissions_data(self) -> pd.DataFrame: """ Returns Cloud Regions Impact Data. - Data is pre-loaded at module import for performance. + Data is loaded on first access and cached for all tracker instances. """ + _ensure_static_data_loaded() return _CACHE["cloud_emissions"] def get_country_emissions_data(self, country_iso_code: str) -> Dict: @@ -176,22 +190,25 @@ def get_country_energy_mix_data(self, country_iso_code: str) -> Dict: def get_carbon_intensity_per_source_data(self) -> Dict: """ Returns Carbon intensity per source. In gCO2.eq/kWh. - Data is pre-loaded at module import for performance. + Data is loaded on first access and cached for all tracker instances. """ + _ensure_static_data_loaded() return _CACHE["carbon_intensity_per_source"] def get_cpu_power_data(self) -> pd.DataFrame: """ Returns CPU power Data. - Data is pre-loaded at module import for performance. + Data is loaded on first access and cached for all tracker instances. """ + _ensure_static_data_loaded() return _CACHE["cpu_power"] def get_nordic_country_energy_mix_data(self) -> Dict: """ Returns Nordic Country Energy Mix Data. - Data is cached on first access per country. + Data is loaded on first access and cached for all tracker instances. """ + _ensure_static_data_loaded() return _CACHE["nordic_country_energy_mix"] diff --git a/tests/test_config.py b/tests/test_config.py index ef66f66f8..5efb0821d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,9 +27,13 @@ def setUp(self): "CODECARBON_API_KEY", "CODECARBON_EXPERIMENT_ID", "CODECARBON_API_ENDPOINT", + "CODECARBON_TELEMETRY", + "CODECARBON_TELEMETRY_PROJECT_TOKEN", "codecarbon_api_key", "codecarbon_experiment_id", "codecarbon_api_endpoint", + "codecarbon_telemetry", + "codecarbon_telemetry_project_token", ]: os.environ.pop(key, None) os.environ.setdefault("CODECARBON_ALLOW_MULTIPLE_RUNS", "True") diff --git a/tests/test_cpu_load.py b/tests/test_cpu_load.py index f5cdf7e46..ecb9b2d27 100644 --- a/tests/test_cpu_load.py +++ b/tests/test_cpu_load.py @@ -49,13 +49,18 @@ def test_cpu_total_power( self.assertEqual(power.W, 50) self.assertEqual(cpu.total_power().W, 50) + @mock.patch( + "codecarbon.core.powermetrics.is_powermetrics_available", return_value=False + ) def test_cpu_load_detection( self, + mocked_is_powermetrics_available, mocked_is_psutil_available, mocked_is_powergadget_available, mocked_is_rapl_available, ): tracker = OfflineEmissionsTracker(country_iso_code="FRA") + tracker._ensure_hardware_ready() for hardware in tracker._hardware: if ( isinstance(hardware, CPU) and hardware._mode == MODE_CPU_LOAD diff --git a/tests/test_custom_handler.py b/tests/test_custom_handler.py index 570d2df73..8adcf7c37 100644 --- a/tests/test_custom_handler.py +++ b/tests/test_custom_handler.py @@ -32,7 +32,8 @@ def test_carbon_tracker_custom_handler(self): tracker = EmissionsTracker( project_name=self.project_name, output_handlers=[handler_0, handler_1], - api_call_interval=1, + api_call_interval=2, + measure_power_secs=999, ) tracker.start() heavy_computation(run_time_secs=1) @@ -52,7 +53,8 @@ def test_decorator_flush(self): project_name=self.project_name, save_to_logger=True, output_handlers=[handler_0, handler_1], - api_call_interval=1, + api_call_interval=2, + measure_power_secs=999, ) def dummy_train_model(): heavy_computation(run_time_secs=1) diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 37f1000de..0715b665c 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -605,6 +605,30 @@ def test_carbon_tracker_online_context_manager_TWO_GPU_PRIVATE_INFRA_CANADA( self.assertIsInstance(tracker.final_emissions, float) self.assertAlmostEqual(tracker.final_emissions, 6.262572537957655e-05, places=2) + def test_start_task_returns_when_engine_initialization_fails( + self, + mock_cli_setup, + mock_log_values, + mocked_get_gpu_details, + mocked_env_cloud_details, + mocked_is_gpu_details_available, + mocked_is_nvidia_system, + ): + tracker = EmissionsTracker(save_to_file=False) + with ( + mock.patch.object( + tracker, + "_ensure_emissions_engine", + side_effect=Exception("init failed"), + ), + self.assertLogs("codecarbon", level="ERROR") as logs, + ): + tracker.start_task("failed-task") + + self.assertTrue( + any("Tracker not initialized" in message for message in logs.output) + ) + @mock.patch("codecarbon.external.ram.RAM.measure_power_and_energy") @mock.patch("codecarbon.external.hardware.CPU.measure_power_and_energy") @mock.patch( @@ -851,7 +875,7 @@ def test_get_detected_hardware( @mock.patch("codecarbon.emissions_tracker.EmissionsTracker._get_geo_metadata") @mock.patch("codecarbon.emissions_tracker.EmissionsTracker._get_cloud_metadata") @mock.patch("codecarbon.core.electricitymaps_api.requests.get") - @mock.patch("codecarbon.emissions_tracker.ResourceTracker") + @mock.patch("codecarbon.core.resource_tracker.ResourceTracker") @mock.patch( "codecarbon.emissions_tracker.BaseEmissionsTracker.get_detected_hardware" ) @@ -919,10 +943,9 @@ def test_cumulative_emissions_with_varying_intensity( ) tracker._hardware = [mock_cpu] - # Start tracking + # Start tracking (includes an immediate first measurement) tracker.start() - tracker._measure_power_and_energy() # total_energy = 1.0, intensity = 100 => emissions = 0.1 kg data1 = tracker._prepare_emissions_data() self.assertAlmostEqual(data1.emissions, 0.1) diff --git a/tests/test_emissions_tracker_constant.py b/tests/test_emissions_tracker_constant.py index 65b17c666..724cbe9ab 100644 --- a/tests/test_emissions_tracker_constant.py +++ b/tests/test_emissions_tracker_constant.py @@ -5,7 +5,6 @@ from unittest import mock import pandas as pd -import psutil from codecarbon.core import cpu from codecarbon.emissions_tracker import ( @@ -89,14 +88,15 @@ def test_carbon_tracker_offline_constant_force_cpu_power( assertdf = pd.read_csv(self.emissions_file_path) self.assertEqual(USER_INPUT_CPU_POWER / 2, assertdf["cpu_power"][0]) + @mock.patch("codecarbon.external.hardware.psutil.cpu_percent", return_value=50.0) @mock.patch.object(cpu.TDP, "_get_cpu_power_from_registry") @mock.patch.object(cpu, "is_psutil_available") - def test_carbon_tracker_offline_load_force_cpu_power(self, mock_tdp, mock_psutil): - # Same as test_carbon_tracker_offline_constant test but this time forcing the default cpu power + def test_carbon_tracker_offline_load_force_cpu_power( + self, mock_psutil_available, mock_tdp, mock_cpu_percent + ): USER_INPUT_CPU_POWER = 1_000 - # Mock the output of tdp mock_tdp.return_value = 500 - mock_psutil.return_value = True + mock_psutil_available.return_value = True tracker = OfflineEmissionsTracker( country_iso_code="USA", output_dir=self.emissions_path, @@ -108,17 +108,11 @@ def test_carbon_tracker_offline_load_force_cpu_power(self, mock_tdp, mock_psutil emissions = tracker.stop() assert isinstance(emissions, float) self.assertNotEqual(emissions, 0.0) - # Get CPU load (measured after test; may differ from load during test) - cpu_load = psutil.cpu_percent(interval=1) / 100.0 - # Assert the content stored. cpu_power should be approximately load * min(TDP, forced CPU power) + cpu_load = 0.5 assertdf = pd.read_csv(self.emissions_file_path) - tolerance = 350 - self.assertLess( - assertdf["cpu_power"][0], USER_INPUT_CPU_POWER * cpu_load + tolerance - ) - self.assertGreater( - assertdf["cpu_power"][0], USER_INPUT_CPU_POWER * cpu_load - tolerance - ) + load_factor = 0.1 + 0.9 * (cpu_load**3) + expected_power = USER_INPUT_CPU_POWER * load_factor + self.assertAlmostEqual(assertdf["cpu_power"][0], expected_power, delta=50) def test_decorator_constant(self): @track_emissions( @@ -151,6 +145,7 @@ def test_carbon_tracker_offline_region_error(self): ) tracker.start() tracker._measure_power_and_energy() + tracker._ensure_emissions_engine() cloud: CloudMetadata = tracker._get_cloud_metadata() try: diff --git a/tests/test_input.py b/tests/test_input.py index 89739d490..875e7e99a 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -12,10 +12,13 @@ class TestDataSourceCaching(unittest.TestCase): """Test that DataSource uses module-level cache for static data.""" def test_cache_populated_at_import(self): - """Verify that _CACHE is populated when module is imported.""" - from codecarbon.input import _CACHE + """Verify that _CACHE is populated on first data access.""" + from codecarbon.input import _CACHE, DataSource + + ds = DataSource() + ds.get_global_energy_mix_data() - # All static data should be pre-loaded + # Static data should be loaded after first access self.assertIn("global_energy_mix", _CACHE) self.assertIn("cloud_emissions", _CACHE) self.assertIn("carbon_intensity_per_source", _CACHE) diff --git a/tests/test_offline_emissions_tracker.py b/tests/test_offline_emissions_tracker.py index 07adf403c..36447409d 100644 --- a/tests/test_offline_emissions_tracker.py +++ b/tests/test_offline_emissions_tracker.py @@ -67,3 +67,14 @@ def test_offline_tracker_task(self): self.assertGreater(task_emission_data.emissions, 0.0) self.assertEqual(task_emission_data.country_name, None) + + def test_resolve_offline_country_name_logs_on_invalid_iso(self): + tracker = OfflineEmissionsTracker( + country_iso_code="INVALID", + save_to_file=False, + ) + with self.assertLogs("codecarbon", level="ERROR") as logs: + tracker._resolve_offline_country_name() + self.assertTrue( + any("Does not support country" in message for message in logs.output) + )