diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index eaef94a53..a435e007e 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -8,7 +8,7 @@ # from httpx import AsyncClient import dataclasses import json -from datetime import timedelta, tzinfo +from datetime import datetime, timedelta, tzinfo import requests @@ -28,6 +28,24 @@ def get_datetime_with_timezone(): return str(arrow.now().isoformat()) +def get_measurement_timestamp(timestamp=None): + """ + Return the time the measurement was taken, as an offset-aware ISO string. + + EmissionsData.timestamp is naive local time, so it is localised here. + Falls back to now for hand-built payloads without a timestamp. + """ + if timestamp: + try: + return datetime.fromisoformat(timestamp).astimezone().isoformat() + except (TypeError, ValueError): + logger.warning( + f"ApiClient : could not parse emission timestamp {timestamp!r}, " + + "using current time." + ) + return get_datetime_with_timezone() + + class ApiClient: # (AsyncClient) """ This class call the Code Carbon API @@ -190,7 +208,7 @@ def add_emission(self, carbon_emission: dict): ) return False emission = EmissionCreate( - timestamp=get_datetime_with_timezone(), + timestamp=get_measurement_timestamp(carbon_emission.get("timestamp")), run_id=self.run_id, duration=int(carbon_emission["duration"]), emissions_sum=carbon_emission["emissions"], diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..5b3eec529 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -1044,6 +1044,9 @@ def _prepare_emissions_data(self) -> EmissionsData: ) total_emissions = EmissionsData( + # Naive local time. Consumers that need an offset-aware value + # (the API client) may assume the local zone -- `.astimezone()` -- + # precisely because this is `datetime.now()`. timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), project_name=self._project_name, run_id=str(self.run_id), diff --git a/tests/test_api_call.py b/tests/test_api_call.py index d3b5bd96f..fa813d322 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -1,5 +1,6 @@ import dataclasses import unittest +from datetime import datetime from uuid import uuid4 import requests @@ -228,6 +229,40 @@ def test_add_emission_skips_short_duration(self): ) ) + def test_add_emission_keeps_measurement_timestamp(self): + payload = { + "duration": 2, + "emissions": 1.0, + "emissions_rate": 1.0, + "cpu_power": 1.0, + "gpu_power": 0.0, + "ram_power": 0.5, + "cpu_energy": 0.1, + "gpu_energy": 0.0, + "ram_energy": 0.1, + "energy_consumed": 0.2, + } + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", text="ok", status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" + + # The measurement timestamp is kept, not the time of the POST. + api.add_emission({**payload, "timestamp": "2020-01-01T00:00:00"}) + sent = m.last_request.json()["timestamp"] + self.assertTrue(sent.startswith("2020-01-01T00:00:00")) + self.assertIsNotNone(datetime.fromisoformat(sent).tzinfo) + + # No timestamp in the payload : fall back to now. + api.add_emission(payload) + sent = m.last_request.json()["timestamp"] + self.assertIsNotNone(datetime.fromisoformat(sent).tzinfo) + def test_add_emission_raises_on_unsuccessful_post(self): with requests_mock.Mocker() as m: m.post("http://test.com/emissions", text="bad", status_code=500)