From 1f23f1dd954254c804061974edefa170d44fda86 Mon Sep 17 00:00:00 2001 From: Felipe Bonchristiano Date: Mon, 20 Jul 2026 23:01:08 -0300 Subject: [PATCH 1/3] CPBench google sheets writer --- benchmarks/__init__.py | 0 benchmarks/cpbench/__init__.py | 0 benchmarks/cpbench/writers/__init__.py | 9 ++ benchmarks/cpbench/writers/cli.py | 159 ++++++++++++++++++++ benchmarks/cpbench/writers/rows.py | 67 +++++++++ benchmarks/cpbench/writers/sheets_writer.py | 50 ++++++ 6 files changed, 285 insertions(+) create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/cpbench/__init__.py create mode 100644 benchmarks/cpbench/writers/__init__.py create mode 100644 benchmarks/cpbench/writers/cli.py create mode 100644 benchmarks/cpbench/writers/rows.py create mode 100644 benchmarks/cpbench/writers/sheets_writer.py diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmarks/cpbench/__init__.py b/benchmarks/cpbench/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmarks/cpbench/writers/__init__.py b/benchmarks/cpbench/writers/__init__.py new file mode 100644 index 000000000..56743f241 --- /dev/null +++ b/benchmarks/cpbench/writers/__init__.py @@ -0,0 +1,9 @@ +from .rows import aggregated_rows, column_order, per_seed_rows +from .sheets_writer import SheetsWriter + +__all__ = [ + "SheetsWriter", + "aggregated_rows", + "column_order", + "per_seed_rows", +] diff --git a/benchmarks/cpbench/writers/cli.py b/benchmarks/cpbench/writers/cli.py new file mode 100644 index 000000000..7c0a860db --- /dev/null +++ b/benchmarks/cpbench/writers/cli.py @@ -0,0 +1,159 @@ +"""Push CPBench result JSON files to a Google Sheet, or preview them. + +Each run file becomes one row per seed, or use --aggregated for one mean/std row per alpha. +- Run with --dry-run to print the rows without touching a sheet or needing credentials. +- To upload, pass --sheet-id and --credentials, where credentials is a Google service account +JSON file that has edit access to the sheet. +- Rows are appended, so re-running a config adds new rows rather than replacing old ones. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from .rows import ( + aggregated_rows, + column_order, + per_seed_rows, + run_context, + to_values, +) +from .sheets_writer import SheetsWriter + +_EXAMPLES = """\ +examples: + preview one file, one row per seed + python -m benchmarks.cpbench.writers.cli results/run.json --dry-run + + preview the aggregated mean/std rows + python -m benchmarks.cpbench.writers.cli results/run.json --aggregated --dry-run + + upload every result file to a sheet + python -m benchmarks.cpbench.writers.cli results/*.json \\ + --sheet-id 1AbC...xyz --credentials service_account.json +""" + + +def load_rows(paths: list[Path], aggregated: bool) -> list[dict[str, Any]]: + flatten = aggregated_rows if aggregated else per_seed_rows + rows: list[dict[str, Any]] = [] + + for path in paths: + with path.open() as handle: + record = json.load(handle) + rows.extend(flatten(record)) + + return rows + + +def print_preview(path: Path, record: dict[str, Any], rows: list[dict[str, Any]]) -> None: + context = run_context(record) + result_columns = [column for column in column_order(rows) if column not in context] + + print(f"── {path.name} ──") + _print_block(context) + + print() + _print_grid(result_columns, rows) + + +def _print_block(context: dict[str, Any]) -> None: + label_width = max(len(key) for key in context) + for key, value in zip(context, to_values(context, list(context))): + print(f" {key.ljust(label_width)} {value}") + + +def _print_grid(columns: list[str], rows: list[dict[str, Any]]) -> None: + cells = [[str(value) for value in to_values(row, columns)] for row in rows] + widths = [ + max(len(columns[i]), *(len(row[i]) for row in cells)) + for i in range(len(columns)) + ] + + header = " ".join(columns[i].ljust(widths[i]) for i in range(len(columns))) + print(header) + print("-" * len(header)) + + for row in cells: + print(" ".join(row[i].ljust(widths[i]) for i in range(len(columns)))) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, + epilog=_EXAMPLES, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("results", nargs="+", type=Path, help="result JSON files") + parser.add_argument( + "--aggregated", + action="store_true", + help="Use the aggregated (mean/std) block instead of one row per seed.", + ) + parser.add_argument("--sheet-id", help="Target spreadsheet id.") + parser.add_argument("--worksheet", default="results") + parser.add_argument("--credentials", help="Path to service account JSON.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the rows that would be written and exit.", + ) + + return parser.parse_args(argv) + + +def _dry_run(paths: list[Path], aggregated: bool) -> int: + flatten = aggregated_rows if aggregated else per_seed_rows + total = 0 + + for path in paths: + with path.open() as handle: + record = json.load(handle) + rows = flatten(record) + if not rows: + continue + + if total: + print() + print_preview(path, record, rows) + total += len(rows) + + if not total: + print("No rows found in the given files.", file=sys.stderr) + return 1 + + print(f"\n{total} rows (dry run, nothing written)", file=sys.stderr) + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + if args.dry_run: + return _dry_run(args.results, args.aggregated) + + rows = load_rows(args.results, args.aggregated) + if not rows: + print("No rows found in the given files.", file=sys.stderr) + return 1 + + if not args.sheet_id or not args.credentials: + print( + "--sheet-id and --credentials are required unless --dry-run is set.", + file=sys.stderr, + ) + return 1 + + writer = SheetsWriter(args.sheet_id, args.credentials, args.worksheet) + appended = writer.append(rows, column_order(rows)) + print(f"{appended} rows appended") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/cpbench/writers/rows.py b/benchmarks/cpbench/writers/rows.py new file mode 100644 index 000000000..85993c051 --- /dev/null +++ b/benchmarks/cpbench/writers/rows.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from typing import Any + +# Everything at the top level except these two blocks is run-level context that gets copied onto every row. +_RESULT_BLOCKS = ("per_seed", "aggregated") + + +def _flatten(prefix: str, value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {prefix: value} + + flat: dict[str, Any] = {} + for key, inner in value.items(): + flat.update(_flatten(f"{prefix}.{key}", inner)) + + return flat + + +def run_context(record: dict[str, Any]) -> dict[str, Any]: + """The run-level fields shared by every row, flattened to dotted keys.""" + context: dict[str, Any] = {} + for key, value in record.items(): + if key not in _RESULT_BLOCKS: + context.update(_flatten(key, value)) + + return context + + +def per_seed_rows(record: dict[str, Any]) -> list[dict[str, Any]]: + context = run_context(record) + rows: list[dict[str, Any]] = [] + + for seed, results in sorted(record.get("per_seed", {}).items()): + for result in results: + rows.append({**context, "seed": int(seed), **result}) + + return rows + + +def aggregated_rows(record: dict[str, Any]) -> list[dict[str, Any]]: + context = run_context(record) + + return [{**context, **entry} for entry in record.get("aggregated", [])] + + +def column_order(rows: list[dict[str, Any]]) -> list[str]: + order: dict[str, None] = {} + for row in rows: + for key in row: + order.setdefault(key, None) + + return list(order) + + +def to_values(row: dict[str, Any], columns: list[str]) -> list[Any]: + return [_cell(row.get(column)) for column in columns] + + +def _cell(value: Any) -> Any: + if value is None: + return "" + if isinstance(value, (str, int, float, bool)): + return value + + return json.dumps(value) diff --git a/benchmarks/cpbench/writers/sheets_writer.py b/benchmarks/cpbench/writers/sheets_writer.py new file mode 100644 index 000000000..34afa25ca --- /dev/null +++ b/benchmarks/cpbench/writers/sheets_writer.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +from .rows import to_values + + +class SheetsWriter: + def __init__( + self, + spreadsheet_id: str, + credentials_path: str, + worksheet_name: str = "results", + ) -> None: + self.spreadsheet_id = spreadsheet_id + self.credentials_path = credentials_path + self.worksheet_name = worksheet_name + + def _open_worksheet(self) -> Any: + try: + import gspread + except ImportError as exc: + raise ImportError( + "gspread is required to write to Sheets. Install it with " + "'pip install gspread', or use --dry-run to preview rows." + ) from exc + + client = gspread.service_account(filename=self.credentials_path) + spreadsheet = client.open_by_key(self.spreadsheet_id) + try: + return spreadsheet.worksheet(self.worksheet_name) + except gspread.WorksheetNotFound: + return spreadsheet.add_worksheet(title=self.worksheet_name, rows=1, cols=26) + + def append(self, rows: list[dict[str, Any]], columns: list[str]) -> int: + if not rows: + return 0 + + worksheet = self._open_worksheet() + header = worksheet.row_values(1) + merged = header + [column for column in columns if column not in header] + if merged != header: + worksheet.update([merged], "A1") + + worksheet.append_rows( + [to_values(row, merged) for row in rows], + value_input_option="USER_ENTERED", + ) + + return len(rows) From 5cdd4dffa1e6b6db84a6fe9f5d44533f70835ef9 Mon Sep 17 00:00:00 2001 From: Felipe Bonchristiano Date: Wed, 22 Jul 2026 15:45:39 -0300 Subject: [PATCH 2/3] writer done --- benchmarks/cpbench/writers/__init__.py | 7 +- benchmarks/cpbench/writers/cli.py | 289 +++++++++++++------- benchmarks/cpbench/writers/parse_json.py | 71 +++++ benchmarks/cpbench/writers/rows.py | 67 ----- benchmarks/cpbench/writers/sheets_writer.py | 43 ++- 5 files changed, 295 insertions(+), 182 deletions(-) create mode 100644 benchmarks/cpbench/writers/parse_json.py delete mode 100644 benchmarks/cpbench/writers/rows.py diff --git a/benchmarks/cpbench/writers/__init__.py b/benchmarks/cpbench/writers/__init__.py index 56743f241..2152fb990 100644 --- a/benchmarks/cpbench/writers/__init__.py +++ b/benchmarks/cpbench/writers/__init__.py @@ -1,9 +1,8 @@ -from .rows import aggregated_rows, column_order, per_seed_rows +from .parse_json import flatten, load_json from .sheets_writer import SheetsWriter __all__ = [ "SheetsWriter", - "aggregated_rows", - "column_order", - "per_seed_rows", + "flatten", + "load_json", ] diff --git a/benchmarks/cpbench/writers/cli.py b/benchmarks/cpbench/writers/cli.py index 7c0a860db..e5f5bf50c 100644 --- a/benchmarks/cpbench/writers/cli.py +++ b/benchmarks/cpbench/writers/cli.py @@ -1,157 +1,244 @@ -"""Push CPBench result JSON files to a Google Sheet, or preview them. +"""Append a CPBench result JSON file to a Google Sheet, or preview what would be written. -Each run file becomes one row per seed, or use --aggregated for one mean/std row per alpha. -- Run with --dry-run to print the rows without touching a sheet or needing credentials. -- To upload, pass --sheet-id and --credentials, where credentials is a Google service account -JSON file that has edit access to the sheet. -- Rows are appended, so re-running a config adds new rows rather than replacing old ones. +The aggregated and per-seed blocks go to separate worksheets. Rows are appended, so +re-running a config adds rows rather than replacing them. """ from __future__ import annotations import argparse -import json import sys -from pathlib import Path from typing import Any -from .rows import ( - aggregated_rows, - column_order, - per_seed_rows, - run_context, - to_values, -) +from .parse_json import flatten, load_json from .sheets_writer import SheetsWriter -_EXAMPLES = """\ -examples: - preview one file, one row per seed - python -m benchmarks.cpbench.writers.cli results/run.json --dry-run +_AGGREGATED_WORKSHEET = "Aggregated Worksheet" +_PER_SEED_WORKSHEET = "Per-seed Results" - preview the aggregated mean/std rows - python -m benchmarks.cpbench.writers.cli results/run.json --aggregated --dry-run - upload every result file to a sheet - python -m benchmarks.cpbench.writers.cli results/*.json \\ - --sheet-id 1AbC...xyz --credentials service_account.json -""" +def _format_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, dict): + return ", ".join(f"{k}={_format_value(v)}" for k, v in value.items()) + if isinstance(value, (list, tuple)): + return "[" + ", ".join(_format_value(v) for v in value) + "]" + return str(value) + +def _render_table(header: list[Any], rows: list[list[Any]]) -> str: + columns = [_format_value(column) for column in header] + cells = [[_format_value(value) for value in row] for row in rows] -def load_rows(paths: list[Path], aggregated: bool) -> list[dict[str, Any]]: - flatten = aggregated_rows if aggregated else per_seed_rows - rows: list[dict[str, Any]] = [] + widths = [len(column) for column in columns] + for row in cells: + for index, cell in enumerate(row): + if index < len(widths): + widths[index] = max(widths[index], len(cell)) - for path in paths: - with path.open() as handle: - record = json.load(handle) - rows.extend(flatten(record)) + def line(values: list[str]) -> str: + padded = [value.ljust(widths[i]) for i, value in enumerate(values[: len(widths)])] + return " ".join(padded).rstrip() - return rows + body = [line(columns)] + [line(row) for row in cells] + rule = "-" * max(len(text) for text in body) + return "\n".join([body[0], rule] + body[1:]) -def print_preview(path: Path, record: dict[str, Any], rows: list[dict[str, Any]]) -> None: - context = run_context(record) - result_columns = [column for column in column_order(rows) if column not in context] - print(f"── {path.name} ──") - _print_block(context) +def _metadata_rows( + header: list[Any], values: list[Any], width: int = 70 +) -> list[list[str]]: + import textwrap + + rows = [] + for field, value in zip(header, values): + chunks = textwrap.wrap(_format_value(value), width=width) or [""] + rows.append([_format_value(field), chunks[0]]) + rows.extend([["", chunk] for chunk in chunks[1:]]) + + return rows - print() - _print_grid(result_columns, rows) +def _render_section(title: str, body: str) -> str: + rule = "=" * max(len(title), 40) -def _print_block(context: dict[str, Any]) -> None: - label_width = max(len(key) for key in context) - for key, value in zip(context, to_values(context, list(context))): - print(f" {key.ljust(label_width)} {value}") + return f"\n{rule}\n{title}\n{rule}\n{body}" -def _print_grid(columns: list[str], rows: list[dict[str, Any]]) -> None: - cells = [[str(value) for value in to_values(row, columns)] for row in rows] - widths = [ - max(len(columns[i]), *(len(row[i]) for row in cells)) - for i in range(len(columns)) +def _clip(text: str, width: int) -> str: + return text if len(text) <= width else text[: max(width - 3, 1)] + "..." + + +def _target_block( + worksheet_name: str, + header: list[Any], + rows: list[list[Any]], + sample: int, + width: int, +) -> str: + columns = [_format_value(column) for column in header] + lines = [ + f"worksheet {worksheet_name}", + _clip(f"columns {len(columns)} ({', '.join(columns)})", width), + f"rows {len(rows)}", ] - header = " ".join(columns[i].ljust(widths[i]) for i in range(len(columns))) - print(header) - print("-" * len(header)) + if not rows: + lines.append("") + lines.append(" nothing to append") + + return "\n".join(lines) - for row in cells: - print(" ".join(row[i].ljust(widths[i]) for i in range(len(columns)))) + preview = _render_table(header, rows[:sample]).splitlines() + lines.append("") + lines.extend(" " + _clip(line, width - 2) for line in preview) + + remaining = len(rows) - sample + if remaining > 0: + lines.append(f" ... {remaining} more row{'' if remaining == 1 else 's'}") + + return "\n".join(lines) + + +def print_write_preview( + data: dict[str, tuple[list[Any], list[Any]]], + worksheets: dict[str, str] | None = None, + sample: int = 3, + width: int | None = None, +) -> None: + if width is None: + import shutil + + width = shutil.get_terminal_size((100, 24)).columns + + worksheets = worksheets or {} + + metadata_header, metadata_values = data.get("metadata", ([], [])) + metadata_table = ( + _render_table(["Field", "Value"], _metadata_rows(metadata_header, metadata_values)) + if metadata_header + else "(no data)" + ) + print(_render_section("METADATA", metadata_table)) + + for section in ("aggregated", "per_seed"): + header, rows = data.get(section, ([], [])) + worksheet_name = worksheets.get(section) or section + print( + _render_section( + f"WRITE PREVIEW: {section}", + _target_block(worksheet_name, header, rows, sample, width), + ) + ) + + +def _sheet_value(value: Any) -> Any: + """Keep scalars as they are; flatten anything a cell cannot hold to text.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + + return _format_value(value) + + +def _with_metadata( + metadata: tuple[list[Any], list[Any]], + header: list[Any], + rows: list[list[Any]], +) -> tuple[list[Any], list[list[Any]]]: + """Prefix every row with the run's metadata so each sheet row stands alone.""" + metadata_header, metadata_values = metadata + prefix = [_sheet_value(value) for value in metadata_values] + + return list(metadata_header) + list(header), [prefix + row for row in rows] def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( + prog="python -m benchmarks.cpbench.writers.cli", description=__doc__, - epilog=_EXAMPLES, formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument("results", nargs="+", type=Path, help="result JSON files") + parser.add_argument("--results", required=True, metavar="PATH", help="Result JSON.") parser.add_argument( - "--aggregated", - action="store_true", - help="Use the aggregated (mean/std) block instead of one row per seed.", + "--sheet-id", + metavar="ID", + help="Spreadsheet id. Required unless --preview.", ) - parser.add_argument("--sheet-id", help="Target spreadsheet id.") - parser.add_argument("--worksheet", default="results") - parser.add_argument("--credentials", help="Path to service account JSON.") parser.add_argument( - "--dry-run", + "--per-seed-worksheet", + default=_PER_SEED_WORKSHEET, + metavar="NAME", + help=f'Per-seed worksheet (default: "{_PER_SEED_WORKSHEET}").', + ) + parser.add_argument( + "--aggregated-worksheet", + default=_AGGREGATED_WORKSHEET, + metavar="NAME", + help=f'Aggregated worksheet (default: "{_AGGREGATED_WORKSHEET}").', + ) + parser.add_argument( + "--credentials", + metavar="PATH", + help="Service account JSON. Required unless --preview.", + ) + parser.add_argument( + "--preview", action="store_true", - help="Print the rows that would be written and exit.", + help="Print what would be written; write nothing.", ) - return parser.parse_args(argv) + args = parser.parse_args(argv) + if not args.preview: + missing = [ + flag + for flag, value in ( + ("--sheet-id", args.sheet_id), + ("--credentials", args.credentials), + ) + if not value + ] + if missing: + parser.error(f"{', '.join(missing)} required unless --preview is set.") -def _dry_run(paths: list[Path], aggregated: bool) -> int: - flatten = aggregated_rows if aggregated else per_seed_rows - total = 0 + return args - for path in paths: - with path.open() as handle: - record = json.load(handle) - rows = flatten(record) - if not rows: - continue - if total: - print() - print_preview(path, record, rows) - total += len(rows) +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) - if not total: - print("No rows found in the given files.", file=sys.stderr) + try: + data = flatten(load_json(args.results)) + except ValueError as exc: + print(exc, file=sys.stderr) return 1 - print(f"\n{total} rows (dry run, nothing written)", file=sys.stderr) - return 0 + worksheets = { + "aggregated": args.aggregated_worksheet, + "per_seed": args.per_seed_worksheet, + } + if args.preview: + print_write_preview(data, worksheets) + print("\n(preview only, nothing written)", file=sys.stderr) + return 0 -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv) - - if args.dry_run: - return _dry_run(args.results, args.aggregated) - - rows = load_rows(args.results, args.aggregated) - if not rows: - print("No rows found in the given files.", file=sys.stderr) - return 1 + writer = SheetsWriter(args.sheet_id, args.credentials) + metadata = data.get("metadata", ([], [])) + total = 0 + for section, worksheet_name in worksheets.items(): + header, rows = data.get(section, ([], [])) + columns, rows = _with_metadata(metadata, header, rows) + appended = writer.append(worksheet_name, rows, columns) + print(f"{appended} rows appended to {worksheet_name}") + total += appended - if not args.sheet_id or not args.credentials: - print( - "--sheet-id and --credentials are required unless --dry-run is set.", - file=sys.stderr, - ) + if not total: + print("No rows found in the given file.", file=sys.stderr) return 1 - writer = SheetsWriter(args.sheet_id, args.credentials, args.worksheet) - appended = writer.append(rows, column_order(rows)) - print(f"{appended} rows appended") - return 0 diff --git a/benchmarks/cpbench/writers/parse_json.py b/benchmarks/cpbench/writers/parse_json.py new file mode 100644 index 000000000..848a2c587 --- /dev/null +++ b/benchmarks/cpbench/writers/parse_json.py @@ -0,0 +1,71 @@ +import json +from typing import Any + +def load_json(path: str) -> list[Any] | dict: + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + raise ValueError(f"File not found: {path}") + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON: {e}") from e + except OSError as e: + raise ValueError(f"Could not read {path}: {e}") from e + + +def flatten(data: dict[Any, Any]) -> dict[str, tuple[list[Any], list[list[Any]]]]: + aggregated = data.pop("aggregated") + per_seed = data.pop("per_seed") + + aggregated_header, aggregated_rows = _flatten_rows(aggregated) + per_seed_header, per_seed_rows = _flatten_per_seed(per_seed) + + metadata_header, metadata_values = _flatten_metadata(data) + + return { + "aggregated" : (aggregated_header, aggregated_rows), + "per_seed" : (per_seed_header, per_seed_rows), + "metadata" : (metadata_header, metadata_values) + } + + +def _flatten_metadata(metadata: dict): + header = list(metadata.keys()) + values = list(metadata.values()) + + return header, values + + +def _flatten_rows(rows: list[dict]): + if not rows: + return [], [] + + header = list(rows[0].keys()) + flattened_rows = [] + + for row in rows: + flattened_rows.append(list(row.values())) + + return header, flattened_rows + + +def _flatten_per_seed(seed_to_rows: dict[Any, list[dict]]): + header, aggregated_flattened_rows = [], [] + + for seed, rows in seed_to_rows.items(): + seed_header, flattened_rows = _flatten_rows(rows) + + if not header: + header = seed_header + + aggregated_flattened_rows.extend(flattened_rows) + + return header, aggregated_flattened_rows + + +if __name__ == "__main__": + from pprint import pprint + + data = load_json("benchmarks/cpbench/writers/los_dev_4alpha.json") + flattened_data = flatten(data) + pprint(flattened_data) \ No newline at end of file diff --git a/benchmarks/cpbench/writers/rows.py b/benchmarks/cpbench/writers/rows.py deleted file mode 100644 index 85993c051..000000000 --- a/benchmarks/cpbench/writers/rows.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any - -# Everything at the top level except these two blocks is run-level context that gets copied onto every row. -_RESULT_BLOCKS = ("per_seed", "aggregated") - - -def _flatten(prefix: str, value: Any) -> dict[str, Any]: - if not isinstance(value, dict): - return {prefix: value} - - flat: dict[str, Any] = {} - for key, inner in value.items(): - flat.update(_flatten(f"{prefix}.{key}", inner)) - - return flat - - -def run_context(record: dict[str, Any]) -> dict[str, Any]: - """The run-level fields shared by every row, flattened to dotted keys.""" - context: dict[str, Any] = {} - for key, value in record.items(): - if key not in _RESULT_BLOCKS: - context.update(_flatten(key, value)) - - return context - - -def per_seed_rows(record: dict[str, Any]) -> list[dict[str, Any]]: - context = run_context(record) - rows: list[dict[str, Any]] = [] - - for seed, results in sorted(record.get("per_seed", {}).items()): - for result in results: - rows.append({**context, "seed": int(seed), **result}) - - return rows - - -def aggregated_rows(record: dict[str, Any]) -> list[dict[str, Any]]: - context = run_context(record) - - return [{**context, **entry} for entry in record.get("aggregated", [])] - - -def column_order(rows: list[dict[str, Any]]) -> list[str]: - order: dict[str, None] = {} - for row in rows: - for key in row: - order.setdefault(key, None) - - return list(order) - - -def to_values(row: dict[str, Any], columns: list[str]) -> list[Any]: - return [_cell(row.get(column)) for column in columns] - - -def _cell(value: Any) -> Any: - if value is None: - return "" - if isinstance(value, (str, int, float, bool)): - return value - - return json.dumps(value) diff --git a/benchmarks/cpbench/writers/sheets_writer.py b/benchmarks/cpbench/writers/sheets_writer.py index 34afa25ca..781d02d2e 100644 --- a/benchmarks/cpbench/writers/sheets_writer.py +++ b/benchmarks/cpbench/writers/sheets_writer.py @@ -2,21 +2,18 @@ from typing import Any -from .rows import to_values - class SheetsWriter: def __init__( self, spreadsheet_id: str, credentials_path: str, - worksheet_name: str = "results", ) -> None: self.spreadsheet_id = spreadsheet_id self.credentials_path = credentials_path - self.worksheet_name = worksheet_name - def _open_worksheet(self) -> Any: + + def _open_worksheet(self, worksheet_name, columns: int = 26) -> Any: try: import gspread except ImportError as exc: @@ -28,23 +25,49 @@ def _open_worksheet(self) -> Any: client = gspread.service_account(filename=self.credentials_path) spreadsheet = client.open_by_key(self.spreadsheet_id) try: - return spreadsheet.worksheet(self.worksheet_name) + return spreadsheet.worksheet(worksheet_name) except gspread.WorksheetNotFound: - return spreadsheet.add_worksheet(title=self.worksheet_name, rows=1, cols=26) + return spreadsheet.add_worksheet( + title=worksheet_name, rows=1, cols=max(columns, 26) + ) - def append(self, rows: list[dict[str, Any]], columns: list[str]) -> int: + + def append(self, worksheet_name, rows: list[list[Any]], columns: list[str]) -> int: if not rows: return 0 - worksheet = self._open_worksheet() + duplicates = {column for column in columns if columns.count(column) > 1} + if duplicates: + raise ValueError( + f"Duplicate columns would collapse into one: {sorted(duplicates)}" + ) + + worksheet = self._open_worksheet(worksheet_name, len(columns)) header = worksheet.row_values(1) merged = header + [column for column in columns if column not in header] + # A sheet that predates these columns may be too narrow to hold the header. + if len(merged) > worksheet.col_count: + worksheet.resize(cols=len(merged)) if merged != header: worksheet.update([merged], "A1") worksheet.append_rows( - [to_values(row, merged) for row in rows], + [self._align(row, columns, merged) for row in rows], value_input_option="USER_ENTERED", ) return len(rows) + + + @staticmethod + def _align(row: list[Any], columns: list[str], merged: list[str]) -> list[Any]: + """Reorder one row to the worksheet's header, blanking columns it lacks. + """ + if len(row) > len(columns): + raise ValueError( + f"Row has {len(row)} values but only {len(columns)} columns are named" + ) + + by_column = dict(zip(columns, row)) + + return [by_column.get(column, "") for column in merged] From 7e4b204e69b186f6c8696dd261f369c5bff369c0 Mon Sep 17 00:00:00 2001 From: Felipe Bonchristiano Date: Wed, 22 Jul 2026 16:05:28 -0300 Subject: [PATCH 3/3] deleted __init__.py to avoid conflict --- benchmarks/__init__.py | 0 benchmarks/cpbench/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 benchmarks/__init__.py delete mode 100644 benchmarks/cpbench/__init__.py diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/benchmarks/cpbench/__init__.py b/benchmarks/cpbench/__init__.py deleted file mode 100644 index e69de29bb..000000000