maybeai-sheet-cli 0.19.0__tar.gz → 0.19.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/PKG-INFO +38 -1
  2. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/README.md +37 -0
  3. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/pyproject.toml +1 -1
  4. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/dashboard.py +215 -15
  5. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/sheet.py +186 -1
  6. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/endpoints.py +10 -0
  7. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/sheet.py +11 -0
  8. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_cli.py +295 -0
  9. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_phase2_cli.py +385 -2
  10. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/.gitignore +0 -0
  11. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/__init__.py +0 -0
  12. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/chart_config.py +0 -0
  13. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/chart_style_summary.py +0 -0
  14. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/cli.py +0 -0
  15. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/client.py +0 -0
  16. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/__init__.py +0 -0
  17. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/_phase2.py +0 -0
  18. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/cell.py +0 -0
  19. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/chart.py +0 -0
  20. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/column.py +0 -0
  21. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/db_table.py +0 -0
  22. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/excel_table.py +0 -0
  23. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/excel_worksheet.py +0 -0
  24. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/file.py +0 -0
  25. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/formula.py +0 -0
  26. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/history.py +0 -0
  27. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/image.py +0 -0
  28. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/media.py +0 -0
  29. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/pivot.py +0 -0
  30. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/range.py +0 -0
  31. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/raw.py +0 -0
  32. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/row.py +0 -0
  33. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/share.py +0 -0
  34. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/style.py +0 -0
  35. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/table.py +0 -0
  36. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/workbook.py +0 -0
  37. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/worksheet.py +0 -0
  38. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/config.py +0 -0
  39. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/dashboard_interactions.py +0 -0
  40. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/dashboard_plan.py +0 -0
  41. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/errors.py +0 -0
  42. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/formatters.py +0 -0
  43. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/__init__.py +0 -0
  44. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/share.py +0 -0
  45. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/workbook.py +0 -0
  46. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/options.py +0 -0
  47. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/protocol.py +0 -0
  48. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/resolver.py +0 -0
  49. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/table_resolver.py +0 -0
  50. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/update.py +0 -0
  51. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/build_fixtures.py +0 -0
  52. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/flat-records.xlsx +0 -0
  53. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/mixed-engine.xlsx +0 -0
  54. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/multi-table-layout.xlsx +0 -0
  55. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/rows.json +0 -0
  56. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/simple-grid.xlsx +0 -0
  57. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/simple-values.json +0 -0
  58. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_benchmark_runner.py +0 -0
  59. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_dashboard_plan.py +0 -0
  60. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_endpoints.py +0 -0
  61. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_formatters.py +0 -0
  62. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_resolver.py +0 -0
  63. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_two_engine_cli.py +0 -0
  64. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_update.py +0 -0
  65. {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maybeai-sheet-cli
3
- Version: 0.19.0
3
+ Version: 0.19.2
4
4
  Summary: CLI for common MaybeAI spreadsheet operations
5
5
  Project-URL: Homepage, https://github.com/OmniMCP-AI/maybeai-sheet-cli
6
6
  Project-URL: Repository, https://github.com/OmniMCP-AI/maybeai-sheet-cli
@@ -112,6 +112,43 @@ mbs excel-worksheet read --doc-id abc123 --worksheet-name Sheet1
112
112
  mbs excel-worksheet read --doc-id abc123 --worksheet-name Sheet1 --range A1:H1
113
113
  ```
114
114
 
115
+ Replace worksheet data while keeping row 1, existing header order, styles, and
116
+ formula columns:
117
+
118
+ ```bash
119
+ mbs sheet update-data-keep-headers \
120
+ --doc-id abc123 \
121
+ --worksheet-name Students \
122
+ --data students.json \
123
+ --verify
124
+ ```
125
+
126
+ `students.json` must be a non-empty JSON array of objects whose keys exactly
127
+ match existing worksheet headers:
128
+
129
+ ```json
130
+ [
131
+ {"Name": "Alice", "Score": 88},
132
+ {"Name": "Bob", "Score": 92}
133
+ ]
134
+ ```
135
+
136
+ The command reads the worksheet headers before writing and rejects unknown
137
+ keys. It preserves formula columns and rebuilds the workbook calculation chain
138
+ by default. A formula column is preserved only when its key is omitted from the
139
+ first input object and row 2 contains its formula template.
140
+
141
+ Use `--no-preserve-formulas` to replace formula columns from JSON,
142
+ `--skip-recalculation` to skip rebuilding the calculation chain, or
143
+ `--start-row N` to begin below row 2. `--dry-run` validates and prints the
144
+ request without updating data. `--verify` reads back the header, updated rows,
145
+ and one trailing row; it cannot be combined with `--dry-run`.
146
+
147
+ This full-refresh endpoint parses numeric-looking strings as Excel values. A
148
+ long value such as `"46215.95520833333"` can read back as `"46215.95521"`.
149
+ Use `sheet write-range` / `range write` with RAW value handling when exact text
150
+ preservation matters more than header-keyed replacement semantics.
151
+
115
152
  List worksheets:
116
153
 
117
154
  ```bash
@@ -88,6 +88,43 @@ mbs excel-worksheet read --doc-id abc123 --worksheet-name Sheet1
88
88
  mbs excel-worksheet read --doc-id abc123 --worksheet-name Sheet1 --range A1:H1
89
89
  ```
90
90
 
91
+ Replace worksheet data while keeping row 1, existing header order, styles, and
92
+ formula columns:
93
+
94
+ ```bash
95
+ mbs sheet update-data-keep-headers \
96
+ --doc-id abc123 \
97
+ --worksheet-name Students \
98
+ --data students.json \
99
+ --verify
100
+ ```
101
+
102
+ `students.json` must be a non-empty JSON array of objects whose keys exactly
103
+ match existing worksheet headers:
104
+
105
+ ```json
106
+ [
107
+ {"Name": "Alice", "Score": 88},
108
+ {"Name": "Bob", "Score": 92}
109
+ ]
110
+ ```
111
+
112
+ The command reads the worksheet headers before writing and rejects unknown
113
+ keys. It preserves formula columns and rebuilds the workbook calculation chain
114
+ by default. A formula column is preserved only when its key is omitted from the
115
+ first input object and row 2 contains its formula template.
116
+
117
+ Use `--no-preserve-formulas` to replace formula columns from JSON,
118
+ `--skip-recalculation` to skip rebuilding the calculation chain, or
119
+ `--start-row N` to begin below row 2. `--dry-run` validates and prints the
120
+ request without updating data. `--verify` reads back the header, updated rows,
121
+ and one trailing row; it cannot be combined with `--dry-run`.
122
+
123
+ This full-refresh endpoint parses numeric-looking strings as Excel values. A
124
+ long value such as `"46215.95520833333"` can read back as `"46215.95521"`.
125
+ Use `sheet write-range` / `range write` with RAW value handling when exact text
126
+ preservation matters more than header-keyed replacement semantics.
127
+
91
128
  List worksheets:
92
129
 
93
130
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "maybeai-sheet-cli"
7
- version = "0.19.0"
7
+ version = "0.19.2"
8
8
  description = "CLI for common MaybeAI spreadsheet operations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,6 +4,7 @@ from dataclasses import replace
4
4
  from pathlib import Path
5
5
  from typing import Any, Optional
6
6
 
7
+ import httpx
7
8
  import typer
8
9
 
9
10
  from ..chart_style_summary import enrich_chart_inventory, summarize_dashboard_plan_items
@@ -15,6 +16,7 @@ from ..errors import APIRequestError, UsageError
15
16
  from ..formatters import build_command_output, handle_cli_error, render_output
16
17
  from ..options import output_option, timeout_option, verbose_option
17
18
  from ..resolver import ResolvedTarget
19
+ from ..table_resolver import list_worksheets_items
18
20
  from ._phase2 import base_payload, command_target, common_options, load_json_object_argument, require_worksheet
19
21
 
20
22
  app = typer.Typer(
@@ -201,8 +203,6 @@ def _is_batch_unsupported(error: Exception) -> bool:
201
203
  return False
202
204
  if error.status_code in {404, 405, 501}:
203
205
  return True
204
- if error.status_code is not None and error.status_code >= 500:
205
- return True
206
206
  text = " ".join(str(part).lower() for part in (error, error.body, error.context) if part is not None)
207
207
  return any(marker in text for marker in ("unsupported", "not supported", "no such", "unknown endpoint", "unknown route"))
208
208
 
@@ -224,34 +224,231 @@ def _execute_chart_list(client: MaybeAIClient, operation: dict[str, Any]) -> dic
224
224
  return enrich_chart_inventory(response) if isinstance(response, dict) else response
225
225
 
226
226
 
227
+ def _extract_charts(payload: Any) -> list[dict[str, Any]]:
228
+ if not isinstance(payload, dict):
229
+ return []
230
+ for key in ("charts", "data", "results"):
231
+ value = payload.get(key)
232
+ if isinstance(value, list):
233
+ return [item for item in value if isinstance(item, dict)]
234
+ return []
235
+
236
+
237
+ def _verification_has_expected_charts(verification: dict[str, Any] | None, plan: dict[str, Any]) -> bool:
238
+ charts = _extract_charts(verification)
239
+ if not charts:
240
+ return False
241
+ return all(_match_verified_chart(item, charts) is not None for item in plan.get("charts", []))
242
+
243
+
244
+ def _match_verified_chart(item: dict[str, Any], charts: list[dict[str, Any]]) -> dict[str, Any] | None:
245
+ expected_chart = item.get("chart")
246
+ if not isinstance(expected_chart, dict):
247
+ return None
248
+ expected_cell = str(item.get("cell") or "")
249
+ expected_chart_id = expected_chart.get("chart_id") or expected_chart.get("id")
250
+ expected_title = expected_chart.get("title")
251
+ expected_html = expected_chart.get("html")
252
+
253
+ for chart in charts:
254
+ chart_id = chart.get("chart_id") or chart.get("id")
255
+ if expected_chart_id and chart_id != expected_chart_id:
256
+ continue
257
+ if not expected_chart_id and expected_cell and str(chart.get("cell") or "") != expected_cell:
258
+ continue
259
+ if expected_title and chart.get("title") and chart.get("title") != expected_title:
260
+ continue
261
+ if expected_html and chart.get("html") and chart.get("html") != expected_html:
262
+ continue
263
+ return chart
264
+ return None
265
+
266
+
267
+ def _execute_chart_list_with_retries(
268
+ client: MaybeAIClient,
269
+ operation: dict[str, Any],
270
+ *,
271
+ attempts: int = 2,
272
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
273
+ errors: list[dict[str, Any]] = []
274
+ last_error: Exception | None = None
275
+ for attempt in range(1, attempts + 1):
276
+ try:
277
+ response = _execute_chart_list(client, operation)
278
+ return response, errors
279
+ except Exception as error:
280
+ last_error = error
281
+ errors.append(operation | {"attempt": attempt, "result": _error_result(error)})
282
+ client.reset_session()
283
+ if last_error is None:
284
+ raise RuntimeError("chart list retry failed without an exception")
285
+ raise last_error
286
+
287
+
288
+ def _worksheet_list_operation(target: ResolvedTarget) -> dict[str, Any]:
289
+ return {
290
+ "name": "worksheet.list",
291
+ "endpoint": "/api/v1/excel/list_worksheets",
292
+ "payload": {"uri": target.uri, "include_dimensions": True},
293
+ }
294
+
295
+
296
+ def _execute_worksheet_list_with_retries(
297
+ client: MaybeAIClient,
298
+ operation: dict[str, Any],
299
+ *,
300
+ attempts: int = 2,
301
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
302
+ errors: list[dict[str, Any]] = []
303
+ last_error: Exception | None = None
304
+ for attempt in range(1, attempts + 1):
305
+ try:
306
+ response = client.post_json(operation["endpoint"], operation["payload"])
307
+ return response, errors
308
+ except Exception as error:
309
+ last_error = error
310
+ errors.append(operation | {"attempt": attempt, "result": _error_result(error)})
311
+ client.reset_session()
312
+ if last_error is None:
313
+ raise RuntimeError("worksheet list retry failed without an exception")
314
+ raise last_error
315
+
316
+
317
+ def _worksheet_exists(payload: Any, worksheet_name: str) -> bool:
318
+ for worksheet in list_worksheets_items(payload):
319
+ if not isinstance(worksheet, dict):
320
+ continue
321
+ value = worksheet.get("worksheet_name") or worksheet.get("sheet_name") or worksheet.get("name") or worksheet.get("title")
322
+ if value == worksheet_name:
323
+ return True
324
+ return False
325
+
326
+
327
+ def _is_uncertain_write_error(error: Exception) -> bool:
328
+ if isinstance(error, httpx.HTTPError):
329
+ return True
330
+ if isinstance(error, APIRequestError):
331
+ if error.status_code is None:
332
+ return True
333
+ return error.status_code in {408, 425, 429} or error.status_code >= 500
334
+ return True
335
+
336
+
337
+ def _reconcile_create_after_uncertain_failure(
338
+ client: MaybeAIClient,
339
+ target: ResolvedTarget,
340
+ plan: dict[str, Any],
341
+ *,
342
+ original_error: Exception,
343
+ failed_operation: str,
344
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any]]:
345
+ reconcile_operations: list[dict[str, Any]] = []
346
+ verify_operation = _verify_operation(target)
347
+
348
+ try:
349
+ verification, retry_errors = _execute_chart_list_with_retries(client, verify_operation, attempts=2)
350
+ reconcile_operations.extend(retry_errors)
351
+ reconcile_operations.append(verify_operation | {"result": verification, "reconcile": True})
352
+ if _verification_has_expected_charts(verification, plan):
353
+ return reconcile_operations, verification, {
354
+ "success": True,
355
+ "mode": "verified_existing",
356
+ "reason": str(original_error),
357
+ }
358
+ except Exception as read_error:
359
+ reconcile_operations.append(verify_operation | {"result": _error_result(read_error), "reconcile": True})
360
+
361
+ worksheet_operation = _worksheet_list_operation(target)
362
+ worksheet_response, worksheet_retry_errors = _execute_worksheet_list_with_retries(client, worksheet_operation, attempts=2)
363
+ reconcile_operations.extend(worksheet_retry_errors)
364
+ reconcile_operations.append(worksheet_operation | {"result": worksheet_response, "reconcile": True})
365
+ if not _worksheet_exists(worksheet_response, target.worksheet_name or plan["worksheet_name"]):
366
+ if failed_operation == "worksheet.create":
367
+ retry_operations, retry_verification, _retry_reconciliation = _execute_create_operations(
368
+ client,
369
+ target,
370
+ plan,
371
+ operations=_dashboard_operations(target, plan, create_worksheet=True),
372
+ allow_reconcile=False,
373
+ )
374
+ reconcile_operations.extend(operation | {"reconcile": True} for operation in retry_operations)
375
+ if _verification_has_expected_charts(retry_verification, plan):
376
+ return reconcile_operations, retry_verification, {
377
+ "success": True,
378
+ "mode": "retried_after_absent_worksheet",
379
+ "reason": str(original_error),
380
+ }
381
+ raise original_error
382
+
383
+ refresh_operations, refresh_verification, _existing_response = _execute_refresh_operations(client, target, plan)
384
+ reconcile_operations.extend(operation | {"reconcile": True} for operation in refresh_operations)
385
+ if _verification_has_expected_charts(refresh_verification, plan):
386
+ return reconcile_operations, refresh_verification, {
387
+ "success": True,
388
+ "mode": "refreshed_missing_charts",
389
+ "reason": str(original_error),
390
+ }
391
+ raise original_error
392
+
393
+
227
394
  def _execute_create_operations(
228
395
  client: MaybeAIClient,
229
396
  target: ResolvedTarget,
230
397
  plan: dict[str, Any],
231
398
  *,
232
399
  operations: list[dict[str, Any]],
233
- ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
400
+ allow_reconcile: bool = True,
401
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]:
234
402
  executed: list[dict[str, Any]] = []
235
403
  verification: dict[str, Any] | None = None
404
+ reconciliation: dict[str, Any] | None = None
236
405
  for operation in operations:
237
406
  try:
238
407
  if operation["name"] == "chart.list":
239
- response = _execute_chart_list(client, operation)
408
+ response, retry_errors = _execute_chart_list_with_retries(client, operation, attempts=2)
409
+ executed.extend(retry_errors)
240
410
  else:
241
411
  response = client.post_json(operation["endpoint"], operation["payload"])
242
412
  except Exception as error:
243
- if operation["name"] != "chart.create-config.batch" or not _is_batch_unsupported(error):
244
- raise
245
- executed.append(operation | {"result": _error_result(error), "fallback": "per-chart"})
246
- for item in plan["charts"]:
247
- fallback_operation = _create_chart_operation(target, item)
248
- response = client.post_json(fallback_operation["endpoint"], fallback_operation["payload"])
249
- executed.append(fallback_operation | {"result": response})
250
- continue
413
+ if operation["name"] == "chart.create-config.batch" and _is_batch_unsupported(error):
414
+ executed.append(operation | {"result": _error_result(error), "fallback": "per-chart"})
415
+ for item in plan["charts"]:
416
+ fallback_operation = _create_chart_operation(target, item)
417
+ try:
418
+ response = client.post_json(fallback_operation["endpoint"], fallback_operation["payload"])
419
+ except Exception as fallback_error:
420
+ if not _is_uncertain_write_error(fallback_error):
421
+ raise
422
+ executed.append(fallback_operation | {"result": _error_result(fallback_error), "uncertain": True})
423
+ if not allow_reconcile:
424
+ raise
425
+ reconcile_operations, verification, reconciliation = _reconcile_create_after_uncertain_failure(
426
+ client,
427
+ target,
428
+ plan,
429
+ original_error=fallback_error,
430
+ failed_operation=fallback_operation["name"],
431
+ )
432
+ executed.extend(reconcile_operations)
433
+ return executed, verification, reconciliation
434
+ executed.append(fallback_operation | {"result": response})
435
+ continue
436
+ if allow_reconcile and operation["name"] in {"worksheet.create", "chart.create-config.batch", "chart.create-config"} and _is_uncertain_write_error(error):
437
+ executed.append(operation | {"result": _error_result(error), "uncertain": True})
438
+ reconcile_operations, verification, reconciliation = _reconcile_create_after_uncertain_failure(
439
+ client,
440
+ target,
441
+ plan,
442
+ original_error=error,
443
+ failed_operation=operation["name"],
444
+ )
445
+ executed.extend(reconcile_operations)
446
+ return executed, verification, reconciliation
447
+ raise
251
448
  executed.append(operation | {"result": response})
252
449
  if operation["name"] == "chart.list":
253
450
  verification = response
254
- return executed, verification
451
+ return executed, verification, reconciliation
255
452
 
256
453
 
257
454
  def _execute_refresh_operations(
@@ -285,7 +482,8 @@ def _execute_refresh_operations(
285
482
  response = client.post_json(operation["endpoint"], operation["payload"])
286
483
  executed.append(operation | {"result": response})
287
484
 
288
- verification = _execute_chart_list(client, verify_operation)
485
+ verification, retry_errors = _execute_chart_list_with_retries(client, verify_operation, attempts=2)
486
+ executed.extend(retry_errors)
289
487
  executed.append(verify_operation | {"result": verification})
290
488
  return executed, verification, existing_response
291
489
 
@@ -502,13 +700,15 @@ def create_config(
502
700
  }
503
701
  else:
504
702
  client = MaybeAIClient(state)
505
- executed, verification = _execute_create_operations(client, target, plan, operations=operations)
703
+ executed, verification, reconciliation = _execute_create_operations(client, target, plan, operations=operations)
506
704
  result = {
507
705
  "success": True,
508
706
  "dry_run": False,
509
707
  "dashboard": summarize_dashboard_plan(plan),
510
708
  "style_summary": summarize_dashboard_plan_items(plan["charts"]),
511
709
  "interaction_validation": interaction_validation,
710
+ "reconciled": bool(reconciliation and reconciliation.get("success")),
711
+ "reconciliation": reconciliation,
512
712
  "operations": executed,
513
713
  "verification": verification or {"success": False},
514
714
  }
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import re
3
5
  from pathlib import Path
4
6
  from typing import Any, Optional
5
7
 
@@ -7,7 +9,8 @@ import typer
7
9
 
8
10
  from ..client import MaybeAIClient
9
11
  from ..config import CLIContext
10
- from ..errors import APIRequestError
12
+ from ..endpoints import get_endpoint_metadata
13
+ from ..errors import APIRequestError, UsageError
11
14
  from ..options import output_option, resolve_command_state, timeout_option, verbose_option
12
15
  from ..formatters import build_command_output, handle_cli_error, load_json_file, render_output
13
16
  from ..models.sheet import (
@@ -24,6 +27,7 @@ from ..models.sheet import (
24
27
  RecalculateFormulasRequest,
25
28
  ReadSheetRequest,
26
29
  UpdateRangeRequest,
30
+ UpdateDataKeepHeadersRequest,
27
31
  UpsertRowsRequest,
28
32
  )
29
33
  from ..resolver import resolve_target
@@ -114,6 +118,76 @@ def _read_verify(
114
118
  )
115
119
 
116
120
 
121
+ def _load_update_data(path: Path) -> list[dict[str, Any]]:
122
+ try:
123
+ raw_data = load_json_file(path)
124
+ except json.JSONDecodeError as error:
125
+ raise UsageError(f"--data must contain valid JSON: {error.msg}.") from error
126
+ except OSError as error:
127
+ raise UsageError(f"--data must point to a readable JSON file: {error}.") from error
128
+ if not isinstance(raw_data, list):
129
+ raise UsageError("--data must contain a JSON array of objects.")
130
+ if not raw_data:
131
+ raise UsageError("--data cannot be empty; clearing worksheet data is outside this command's scope.")
132
+ rows: list[dict[str, Any]] = []
133
+ for index, item in enumerate(raw_data):
134
+ if not isinstance(item, dict):
135
+ raise UsageError(f"--data item {index} must be a JSON object.")
136
+ if not item:
137
+ raise UsageError(f"--data item {index} cannot be an empty object.")
138
+ rows.append(item)
139
+ return rows
140
+
141
+
142
+ def _headers_from_response(response: Any) -> tuple[list[str], str]:
143
+ candidate = response
144
+ if isinstance(candidate, dict) and isinstance(candidate.get("result"), dict):
145
+ candidate = candidate["result"]
146
+ headers = candidate.get("headers") if isinstance(candidate, dict) else None
147
+ if not isinstance(headers, list) or not headers:
148
+ raise UsageError("The target worksheet has no readable headers.")
149
+ if all(isinstance(header, str) for header in headers):
150
+ return headers, _column_letters(len(headers))
151
+ if not all(
152
+ isinstance(header, dict)
153
+ and isinstance(header.get("name"), str)
154
+ and isinstance(header.get("cell"), str)
155
+ for header in headers
156
+ ):
157
+ raise UsageError("The target worksheet returned invalid header metadata.")
158
+ header_names: list[str] = []
159
+ last_column_index = 0
160
+ for header in headers:
161
+ match = re.match(r"^([A-Za-z]+)[1-9][0-9]*$", header["cell"])
162
+ if match is None:
163
+ raise UsageError("The target worksheet returned an invalid header cell.")
164
+ column_index = 0
165
+ for character in match.group(1).upper():
166
+ column_index = column_index * 26 + (ord(character) - ord("A") + 1)
167
+ last_column_index = max(last_column_index, column_index)
168
+ header_names.append(header["name"])
169
+ return header_names, _column_letters(last_column_index)
170
+
171
+
172
+ def _column_letters(column_count: int) -> str:
173
+ letters: list[str] = []
174
+ current = column_count
175
+ while current > 0:
176
+ current, remainder = divmod(current - 1, 26)
177
+ letters.append(chr(ord("A") + remainder))
178
+ return "".join(reversed(letters))
179
+
180
+
181
+ def _validate_update_keys(rows: list[dict[str, Any]], headers: list[str]) -> None:
182
+ known_headers = set(headers)
183
+ unknown_keys = sorted({key for row in rows for key in row if key not in known_headers})
184
+ if unknown_keys:
185
+ raise UsageError(
186
+ "--data contains keys that do not match worksheet headers: "
187
+ + ", ".join(unknown_keys)
188
+ )
189
+
190
+
117
191
  def _first_by_key(value: Any, keys: tuple[str, ...]) -> Any:
118
192
  if isinstance(value, dict):
119
193
  for key in keys:
@@ -704,6 +778,117 @@ def clear_range(
704
778
  handle_cli_error(error)
705
779
 
706
780
 
781
+ @app.command("update-data-keep-headers")
782
+ def update_data_keep_headers(
783
+ ctx: typer.Context,
784
+ doc_id: Optional[str] = typer.Option(None, "--doc-id", help="MaybeAI document ID."),
785
+ url: Optional[str] = typer.Option(None, "--url", help="MaybeAI workbook URL."),
786
+ uri: Optional[str] = typer.Option(None, "--uri", help="Resolved workbook URI."),
787
+ gid: Optional[int] = typer.Option(None, "--gid", help="Worksheet gid."),
788
+ worksheet_name: Optional[str] = typer.Option(None, "--worksheet-name", help="Worksheet name."),
789
+ data: Path = typer.Option(
790
+ ...,
791
+ "--data",
792
+ exists=True,
793
+ file_okay=True,
794
+ dir_okay=False,
795
+ readable=True,
796
+ resolve_path=True,
797
+ help="Path to a non-empty JSON array of objects keyed by existing worksheet headers.",
798
+ ),
799
+ start_row: int = typer.Option(2, "--start-row", min=2, help="First data row to replace; row 1 remains the header."),
800
+ preserve_formulas: bool = typer.Option(
801
+ True,
802
+ "--preserve-formulas/--no-preserve-formulas",
803
+ help="Preserve formula columns omitted from the first input object.",
804
+ ),
805
+ skip_recalculation: bool = typer.Option(
806
+ False,
807
+ "--skip-recalculation",
808
+ help="Skip rebuilding the workbook calculation chain.",
809
+ ),
810
+ dry_run: bool = typer.Option(False, "--dry-run", help="Validate and show the request without updating data."),
811
+ verify: bool = typer.Option(False, "--verify", help="Read back the header, updated rows, and one trailing row."),
812
+ output: Optional[str] = output_option(),
813
+ verbose: Optional[bool] = verbose_option(),
814
+ timeout: Optional[float] = timeout_option(),
815
+ ) -> None:
816
+ try:
817
+ if dry_run and verify:
818
+ raise UsageError("--dry-run and --verify cannot be used together.")
819
+ state: CLIContext = resolve_command_state(
820
+ ctx,
821
+ doc_id=doc_id,
822
+ url=url,
823
+ uri=uri,
824
+ gid=gid,
825
+ worksheet_name=worksheet_name,
826
+ output=output,
827
+ verbose=verbose,
828
+ timeout=timeout,
829
+ )
830
+ client = MaybeAIClient(state)
831
+ target = resolve_target(
832
+ state,
833
+ force_gid_uri=bool(state.gid is not None and not state.worksheet_name),
834
+ )
835
+ rows = _load_update_data(data)
836
+ headers_payload = ReadHeadersRequest(
837
+ uri=target.uri,
838
+ worksheet_name=target.worksheet_name,
839
+ )
840
+ headers_response = client.post_json(
841
+ "/api/v1/excel/read_headers",
842
+ headers_payload.model_dump(exclude_none=True),
843
+ )
844
+ headers, last_column = _headers_from_response(headers_response)
845
+ _validate_update_keys(rows, headers)
846
+
847
+ request = UpdateDataKeepHeadersRequest(
848
+ uri=target.uri,
849
+ worksheet_name=target.worksheet_name,
850
+ data=rows,
851
+ start_row=start_row,
852
+ preserve_formulas=preserve_formulas,
853
+ skip_recalculation=skip_recalculation,
854
+ )
855
+ endpoint = "/api/v1/excel/update_data_keep_headers"
856
+ payload = request.model_dump(exclude_none=True)
857
+ if dry_run:
858
+ response: dict[str, Any] = {
859
+ "success": True,
860
+ "dry_run": True,
861
+ "payload": payload,
862
+ }
863
+ else:
864
+ response = client.post_json(endpoint, payload)
865
+
866
+ verify_result: Any = None
867
+ if verify:
868
+ verify_result = _read_verify(
869
+ client,
870
+ uri=target.uri,
871
+ worksheet_name=target.worksheet_name,
872
+ cell_range=f"A1:{last_column}{start_row + len(rows)}",
873
+ )
874
+ render_output(
875
+ build_command_output(
876
+ endpoint=endpoint,
877
+ target=target,
878
+ result=response,
879
+ verify=verify_result,
880
+ metadata=(
881
+ get_endpoint_metadata("sheet.update-data-keep-headers")
882
+ if state.verbose
883
+ else None
884
+ ),
885
+ ),
886
+ state.output,
887
+ )
888
+ except Exception as error: # pragma: no cover - CLI boundary
889
+ handle_cli_error(error)
890
+
891
+
707
892
  @app.command("append")
708
893
  def append_rows(
709
894
  ctx: typer.Context,
@@ -98,6 +98,16 @@ COMMAND_ENDPOINTS: dict[str, EndpointMetadata] = {
98
98
  "range.write": _meta("range.write", "NOW", "POST", "/api/v1/excel/update_range", verify_supported=True),
99
99
  "range.clear": _meta("range.clear", "NOW", "POST", "/api/v1/excel/clear_range"),
100
100
  "table.headers": _meta("table.headers", "NOW", "POST", "/api/v1/excel/read_headers"),
101
+ "sheet.update-data-keep-headers": _meta(
102
+ "sheet.update-data-keep-headers",
103
+ "NOW",
104
+ "POST",
105
+ "/api/v1/excel/update_data_keep_headers",
106
+ destructive=True,
107
+ dry_run_supported=True,
108
+ verify_supported=True,
109
+ notes="Replaces worksheet data rows while retaining row 1; preserves formula columns by default.",
110
+ ),
101
111
  "table.append-rows": _meta("table.append-rows", "NOW", "POST", "/api/v1/excel/append_rows", verify_supported=True),
102
112
  "table.upsert-rows": _meta("table.upsert-rows", "NOW", "POST", "/api/v1/excel/update_range_by_lookup", verify_supported=True),
103
113
  "formula.read": _meta("formula.read", "NOW", "POST", "/api/v1/excel/read_sheet"),
@@ -60,6 +60,17 @@ class UpdateRangeRequest(BaseModel):
60
60
  worksheet_name: str | None = None
61
61
 
62
62
 
63
+ class UpdateDataKeepHeadersRequest(BaseModel):
64
+ model_config = ConfigDict(extra="forbid")
65
+
66
+ uri: str
67
+ data: list[dict[str, Any]]
68
+ worksheet_name: str | None = None
69
+ skip_recalculation: bool = False
70
+ preserve_formulas: bool = True
71
+ start_row: int = 2
72
+
73
+
63
74
  class ClearRangeRequest(BaseModel):
64
75
  model_config = ConfigDict(extra="forbid")
65
76