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.
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/PKG-INFO +38 -1
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/README.md +37 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/pyproject.toml +1 -1
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/dashboard.py +215 -15
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/sheet.py +186 -1
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/endpoints.py +10 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/sheet.py +11 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_cli.py +295 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_phase2_cli.py +385 -2
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/.gitignore +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/__init__.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/chart_config.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/chart_style_summary.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/cli.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/client.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/__init__.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/_phase2.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/cell.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/chart.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/column.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/db_table.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/excel_table.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/excel_worksheet.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/file.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/formula.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/history.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/image.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/media.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/pivot.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/range.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/raw.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/row.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/share.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/style.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/table.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/workbook.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/worksheet.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/config.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/dashboard_interactions.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/dashboard_plan.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/errors.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/formatters.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/__init__.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/share.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/models/workbook.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/options.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/protocol.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/resolver.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/table_resolver.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/update.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/build_fixtures.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/flat-records.xlsx +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/mixed-engine.xlsx +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/multi-table-layout.xlsx +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/rows.json +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/simple-grid.xlsx +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/fixtures/benchmarks/simple-values.json +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_benchmark_runner.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_dashboard_plan.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_endpoints.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_formatters.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_resolver.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_two_engine_cli.py +0 -0
- {maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/tests/test_update.py +0 -0
- {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.
|
|
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
|
{maybeai_sheet_cli-0.19.0 → maybeai_sheet_cli-0.19.2}/src/maybeai_sheet/commands/dashboard.py
RENAMED
|
@@ -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
|
-
|
|
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 =
|
|
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"]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 =
|
|
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 ..
|
|
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
|
|