Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions codecarbon/core/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
3 changes: 3 additions & 0 deletions codecarbon/emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
35 changes: 35 additions & 0 deletions tests/test_api_call.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dataclasses
import unittest
from datetime import datetime
from uuid import uuid4

import requests
Expand Down Expand Up @@ -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)
Expand Down
Loading