From 8e3d8452f78a239725df93c6a5777805dad0f485 Mon Sep 17 00:00:00 2001 From: nawaaaaaAaar <198336470+nawaaaaaAaar@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:18:15 +0000 Subject: [PATCH 1/2] Align appended emissions records to existing CSV headers --- codecarbon/output_methods/file.py | 3 +++ tests/output_methods/test_file.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/codecarbon/output_methods/file.py b/codecarbon/output_methods/file.py index e613d225b..52298fff3 100644 --- a/codecarbon/output_methods/file.py +++ b/codecarbon/output_methods/file.py @@ -104,6 +104,9 @@ def out(self, total: EmissionsData, _): elif self.on_csv_write == "append": # Never drop all-NA columns here: the append is headerless, so column # identity is positional and a missing column shifts every value after it. + with open(self.save_file_path, newline="") as csv_file: + headers = next(csv.reader(csv_file)) + new_df = new_df.reindex(columns=headers) new_df.to_csv(self.save_file_path, mode="a", header=False, index=False) else: df = pd.read_csv(self.save_file_path) diff --git a/tests/output_methods/test_file.py b/tests/output_methods/test_file.py index fd703f608..8956ef407 100644 --- a/tests/output_methods/test_file.py +++ b/tests/output_methods/test_file.py @@ -1,7 +1,9 @@ +import csv import os import shutil import tempfile import unittest +from dataclasses import replace from unittest.mock import patch import pandas as pd @@ -87,6 +89,32 @@ def test_has_valid_headers_different_order_success(self): self.assertTrue(file_output.has_valid_headers(self.emissions_data)) + def test_file_output_preserves_reordered_header_alignment(self): + for mode in ("append", "update"): + with self.subTest(mode=mode): + output = FileOutput(f"{mode}.csv", self.temp_dir, on_csv_write=mode) + output.out(self.emissions_data, None) + df = pd.read_csv(output.save_file_path) + df[list(reversed(df.columns))].to_csv( + output.save_file_path, index=False + ) + next_run = replace( + self.emissions_data, run_id="next_run", project_name="next_project" + ) + + output.out(next_run, None) + + with open(output.save_file_path, newline="") as file: + rows = list(csv.DictReader(file)) + self.assertEqual(len(rows), 2) + self.assertEqual( + rows[-1], + { + key: str(value) if value is not None else "" + for key, value in next_run.values.items() + }, + ) + def test_has_valid_headers_failure(self): file_output = FileOutput("test.csv", self.temp_dir) file_output.out(self.emissions_data, None) From 4458cbfe53349854eff18dce5909cabdf6d67b0d Mon Sep 17 00:00:00 2001 From: benoit-cty Date: Fri, 9 Oct 2026 19:03:42 +0200 Subject: [PATCH 2/2] refactor(file): read CSV header once for check and append reorder Reuse the header row read for has_valid_headers when reordering the appended record, and document that reindex() relies on that check. Co-Authored-By: Claude Opus 5.5 --- codecarbon/output_methods/file.py | 33 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/codecarbon/output_methods/file.py b/codecarbon/output_methods/file.py index 52298fff3..fb7f8eed3 100644 --- a/codecarbon/output_methods/file.py +++ b/codecarbon/output_methods/file.py @@ -1,6 +1,6 @@ import csv import os -from typing import List +from typing import List, Optional import pandas as pd @@ -51,23 +51,31 @@ def __init__( f"Emissions data (if any) will be saved to file {os.path.abspath(self.save_file_path)}" ) - def has_valid_headers(self, data: EmissionsData) -> bool: + def has_valid_headers( + self, data: EmissionsData, headers: Optional[List[str]] = None + ) -> bool: """ Checks self.save_file_path has headers matching those from passed data. Args: data: EmissionsData object with valid headers. + headers: header row already read from the file; read it if None. Returns: True if the file has valid headers, False otherwise. """ - with open(self.save_file_path) as csv_file: - reader = csv.reader(csv_file) - try: - headers = next(reader) - except StopIteration: + if headers is None: + headers = self._read_headers() + if headers is None: return True - return sorted(headers) == sorted(data.values.keys()) + return sorted(headers) == sorted(data.values.keys()) + + def _read_headers(self) -> Optional[List[str]]: + """ + Read the header row of self.save_file_path, or None if the file is empty. + """ + with open(self.save_file_path, newline="") as csv_file: + return next(csv.reader(csv_file), None) def out(self, total: EmissionsData, _): """ @@ -91,7 +99,8 @@ def out(self, total: EmissionsData, _): ) file_exists = False - headers_match = file_exists and self.has_valid_headers(total) + headers = self._read_headers() if file_exists else None + headers_match = file_exists and self.has_valid_headers(total, headers) if file_exists and not headers_match: logger.warning("The CSV format has changed, backing up old emission file.") backup(self.save_file_path) @@ -104,8 +113,10 @@ def out(self, total: EmissionsData, _): elif self.on_csv_write == "append": # Never drop all-NA columns here: the append is headerless, so column # identity is positional and a missing column shifts every value after it. - with open(self.save_file_path, newline="") as csv_file: - headers = next(csv.reader(csv_file)) + # Header names may be in any order (see has_valid_headers), so write the + # values in the file's order. reindex() drops or NaN-fills mismatched + # columns silently: it is only safe because has_valid_headers() above + # guarantees the file and the record have the same set of columns. new_df = new_df.reindex(columns=headers) new_df.to_csv(self.save_file_path, mode="a", header=False, index=False) else: